SNA

앱에 SNA 내장하기

실제 앱 안에 SNA를 내장할 때 쓰는 제품 통합 패턴입니다.

SNA는 일회성 chat endpoint가 아니라 local/cloud agent runtime으로 앱 안에 내장할 수 있습니다. 제품이 project, tab, document, permission, background workflow를 agent 주변에 가지고 있을 때 아래 패턴이 유용합니다.

1. 앱 세션과 SNA 세션을 분리하기

제품에는 보통 자체 session 또는 project row가 있습니다. 그 row를 사용자에게 보이는 객체로 유지하고, 현재 붙어 있는 SNA session id를 별도 필드로 저장합니다.

type AppSession = {
  // Product가 소유하는 안정적인 identity입니다. Rename/archive/fork는 이 id를 기준으로 처리합니다.
  id: string;

  // User-facing label입니다. Runtime process label과 분리해두면 migration이 쉬워집니다.
  name: string;

  // Local project가 없을 수도 있으므로 nullable로 둡니다.
  projectPath: string | null;

  // 현재 붙어 있는 SNA head입니다. Runtime switch나 fork 때 바뀔 수 있습니다.
  snaSessionId: string | null;

  // Local SNA와 cloud SNA instance를 같은 product model로 다루기 위한 field입니다.
  snaInstanceId: "local" | string;
};

이 분리 덕분에 앱은 runtime process를 제품 객체로 착각하지 않고 rename, archive, move, fork를 처리할 수 있습니다. 앱 세션의 identity를 유지한 채 local SNA에서 cloud SNA로 옮기는 것도 가능합니다.

한 앱 세션이 시간이 지나며 여러 SNA session head를 만들 수 있다면 link table을 두는 패턴이 다루기 쉽습니다.

CREATE TABLE session_sna_links (
  session_id TEXT NOT NULL,
  sna_session_id TEXT NOT NULL,
  instance_id TEXT NOT NULL DEFAULT 'local',
  source TEXT,
  created_at INTEGER NOT NULL,
  PRIMARY KEY (session_id, sna_session_id)
);

2. SNA instance마다 SnaClient 하나 사용하기

WebSocket state는 connection 단위입니다. 앱이 local SNA와 cloud SNA instance를 동시에 다룰 수 있다면 instance마다 client를 cache하고, event는 하나의 listener set으로 fan-in합니다.

import { SnaClient } from "@sna-sdk/client";

// Instance id마다 client를 하나만 만듭니다. WebSocket 중복 연결을 막는 역할입니다.
const clients = new Map<string, SnaClient>();

type SnaConnection = {
  // Local embedded SNA라면 startSnaServer()가 반환한 값을 사용합니다.
  baseUrl: string;
  authToken: string;
};

// UI는 특정 client가 아니라 app-level listener set에 subscribe합니다.
const eventListeners = new Set<(event: unknown) => void>();
const permissionListeners = new Set<(event: unknown) => void>();

function installAggregator(client: SnaClient) {
  // Runtime event를 하나의 app event bus로 모읍니다.
  client.agent.onEvent((event) => {
    for (const listener of eventListeners) listener(event);
  });

  // Permission request도 client별로 들어오므로 같은 방식으로 fan-in합니다.
  client.agent.onPermissionRequest((event) => {
    for (const listener of permissionListeners) listener(event);
  });
}

export function snaFor(instanceId: string, connection: SnaConnection): SnaClient {
  // 이미 연결된 instance라면 같은 client를 재사용합니다.
  const existing = clients.get(instanceId);
  if (existing) return existing;

  const client = new SnaClient(connection);
  clients.set(instanceId, client);
  installAggregator(client);

  // connect와 permission subscription은 client 생성 직후 한 번만 수행합니다.
  client.connect();
  client.agent.subscribePermissions().catch(() => {});
  return client;
}

export function onAgentEventAll(listener: (event: unknown) => void) {
  // React hook에서 cleanup으로 바로 쓸 수 있도록 unsubscribe 함수를 반환합니다.
  eventListeners.add(listener);
  return () => eventListeners.delete(listener);
}

핵심은 UI component가 특정 WebSocket이 아니라 앱 레벨 aggregator에 subscribe한다는 점입니다. 나중에 cloud instance가 추가되어도 UI를 remount하지 않고 해당 client를 aggregator에 붙일 수 있습니다.

3. Command는 host를 통하고 event는 SNA로 직접 받기

제품 앱에서는 보통 경로를 이렇게 분리하면 다루기 쉽습니다.

PathRoute이유
Lifecycle commandrenderer → app host HTTP → SNA HTTPHost가 앱 DB에서 config를 만들고, cloud instance를 깨우고, model selection을 reconcile하고, 제품 context를 주입할 수 있습니다.
Event streamrenderer → SNA WebSocket모든 token을 앱 서버로 proxy하지 않고 UI가 낮은 latency로 runtime event를 받습니다. SNA auth token은 host의 신뢰할 수 있는 채널로 전달합니다.

Renderer에는 raw SNA call보다 domain wrapper를 노출하는 식으로 경계를 잡습니다.

export async function ensureAgentAlive(snaSessionId: string, isNewSession = false) {
  // Renderer는 SNA id를 받더라도 product session으로 다시 resolve합니다.
  const appSession = sessionStore.findBySnaId(snaSessionId);

  // Host endpoint가 cwd, model, cloud wake, context injection 같은 product logic을 소유합니다.
  await appFetch("/agent/ensure", {
    method: "POST",
    body: JSON.stringify({
      appSessionId: appSession.id,
      snaSessionId,
      isNewSession,
    }),
  });
}

export async function sendMessage(snaSessionId: string, message: string) {
  // Send도 host를 거치면 pending model/runtime change를 user-turn 경계에서 적용할 수 있습니다.
  return appFetch("/agent/send", {
    method: "POST",
    body: JSON.stringify({ snaSessionId, message }),
  });
}

export function subscribeAgent(snaSessionId: string) {
  // Event stream은 token latency를 줄이기 위해 SNA WebSocket에 직접 붙습니다.
  return clientForSnaSession(snaSessionId).agent.subscribe(snaSessionId, { since: 0 });
}

Component는 ensureAgentAlive, sendMessage, subscribeAgent 같은 제품 verb만 알면 됩니다.

4. /agent/ensure를 idempotent하게 만들기

Start-vs-resume 판단은 host가 소유하게 두면 흐름이 단순해집니다. UI가 runtime history 유무, cloud wake 필요 여부, remote container 안에서 유효한 cwd를 판단하지 않게 두면 안정적입니다.

async function ensureAgent({ appSessionId, requestedSnaId, isNewSession }) {
  // App session을 기준으로 local/cloud SNA instance를 고릅니다.
  // `instance`는 SnaClient connection을 감싼 host-owned wrapper입니다.
  const instance = snaForAppSession(appSessionId);
  await wakeIfCloudInstance(instance);

  // 요청 id가 없으면 product DB에 저장된 SNA id를 쓰거나 새로 만듭니다.
  const snaSessionId = requestedSnaId ?? await loadOrCreateSnaId(appSessionId);
  if (await instance.isAlive(snaSessionId)) {
    return { status: "already_alive", snaSessionId };
  }

  // Agent config는 host에서 조립합니다. UI가 model args나 cwd를 직접 만들지 않습니다.
  const { config, cwd } = await buildAgentConfig(appSessionId, snaSessionId);
  await instance.createSession({
    id: snaSessionId,
    label: config.label ?? appSessionId,
    cwd,
    meta: debugMode ? { langfuseTrace: true } : undefined,
  });

  const hasHistory = await instance.getMessages(snaSessionId, { limit: 1 })
    .then((res) => res.ok && res.data.messages.length > 0)
    .catch(() => false);

  // 새 session이거나 history가 없으면 start, 기존 head라면 resume으로 복구합니다.
  return isNewSession || !hasHistory
    ? instance.startAgent(snaSessionId, { ...config, force: true, cwd })
    : instance.resumeAgent(snaSessionId, config);
}

이 endpoint는 tab focus, reload recovery, send preflight에서 반복 호출되어도 안전해야 합니다.

5. 제품 context는 user-turn 경계에서 주입하기

Metadata를 별도 model turn으로 보내기보다, 사용자 message를 forwarding하기 직전에 context를 만들고 그 message에 제품 소유 tag를 prepend/append하면 runtime history가 깨끗합니다.

async function sendAgentMessage({ snaSessionId, message }) {
  let finalMessage = message;

  // Pending model/runtime 선택은 실제 user turn 직전에 reconcile합니다.
  await reconcileSelectedModel(snaSessionId);

  // 한 번만 소비되어야 하는 system metadata는 user turn에 붙여 보냅니다.
  const pendingMeta = consumePendingSystemMeta(snaSessionId);
  if (pendingMeta) finalMessage = `${pendingMeta}\n${finalMessage}`;

  // Retrieval context도 현재 user message 기준으로 계산해야 stale context가 줄어듭니다.
  const retrievalContext = await maybeBuildRetrievalContext(snaSessionId, finalMessage);
  if (retrievalContext) finalMessage += `\n${retrievalContext}`;

  // 최종적으로 agent가 보는 것은 context가 붙은 실제 user turn입니다.
  return snaForSnaSession(snaSessionId).sendMessage(snaSessionId, finalMessage);
}

이렇게 하면 runtime history가 깨끗하게 유지됩니다. Start/resume 이후 agent가 처음 보는 것은 앱의 현재 world로 보강된 실제 사용자 turn입니다.

6. 비용 큰 상태 변경은 send 시점까지 미루기

Context folding, model change, runtime switch 같은 pending operation은 다음 user-turn 경계에서 적용합니다. 사용자가 보낸 직후 model이 응답하기 전의 자연스러운 대기 시간이 kill/resume/replay 비용을 가려줍니다.

안전한 순서는 다음과 같습니다.

  1. Active SNA head를 resolve합니다.
  2. Pending fork/fold operation을 적용합니다.
  3. 선택된 model/runtime을 reconcile합니다.
  4. 최종 message context를 만듭니다.
  5. sendMessage를 호출합니다.
  6. User message가 persist된 뒤 session 변경을 broadcast합니다.

User message가 저장되기 전에 새 SNA head를 broadcast하면 renderer가 optimistic user message가 없는 timeline에 먼저 resubscribe할 수 있습니다.

7. Permission은 모든 client에서 subscribe하기

Permission subscription은 WebSocket별입니다. Permission dialog가 mount된 뒤 cloud client가 생성될 수 있다면, 새 client에도 subscribe해야 합니다.

export async function subscribePermissions() {
  await Promise.all(
    [...clients.values()].map((client) =>
      client.agent.subscribePermissions().catch(() => {}),
    ),
  );
}

export function respondPermission(snaSessionId: string, approved: boolean) {
  return clientForSnaSession(snaSessionId)
    .agent
    .respondPermission(snaSessionId, approved);
}

이 처리가 없으면 cloud instance의 agent가 tool decision을 기다리는데 UI는 local SNA에만 subscribe한 상태가 될 수 있습니다.

8. React subscription은 app root에 유지하기

SNA event bridge는 chat surface보다 위에 mount해두면 route unmount에 안전합니다. Chat pane, route, side panel은 agent가 streaming 중일 때도 unmount될 수 있습니다. Root-level hook이 WebSocket listener를 유지하고 event를 store로 전달하면 안정적입니다.

function AppShell() {
  // App boot 때 product sessions를 먼저 hydrate합니다.
  const hydrateSessions = useSessionStore((state) => state.hydrate);
  const sessions = useSessionStore((state) => state.sessions);

  // Permission dialog는 현재 route뿐 아니라 모든 known session을 볼 수 있어야 합니다.
  const sessionIds = useMemo(
    () => sessions.flatMap((session) => session.snaSessionId ? [session.snaSessionId] : []),
    [sessions],
  );

  // 이 hook은 route보다 오래 살아야 하므로 app root에서 호출합니다.
  useAgentEventStream();
  const { pending, clearPending } = usePermissionRequests(sessionIds);

  useEffect(() => {
    // SNA connection lifecycle도 root component가 소유합니다.
    hydrateSessions();
    connectSna();
    return () => disconnectSna();
  }, [hydrateSessions]);

  return (
    <>
      <AppRoutes />
      {pending[0] && (
        <PermissionDialog
          request={pending[0]}
          onDone={() => clearPending(pending[0].sessionId)}
        />
      )}
    </>
  );
}

Event hook은 한 번만 listener를 등록하고, session list가 바뀔 때 보이는/알려진 SNA session들을 subscribe해야 합니다.

function useAgentEventStream() {
  const sessions = useSessionStore((state) => state.sessions);
  const register = useMessageStore((state) => state.register);
  const handleEvent = useMessageStore((state) => state.handleEvent);

  useEffect(() => {
    // Listener는 한 번만 설치하고, 들어오는 event는 store에 정규화해서 넣습니다.
    return onAgentEvent(({ session, cursor, event, isHistory }) => {
      handleEvent(session, { cursor, event, isHistory });
    });
  }, [handleEvent]);

  useEffect(() => {
    const cleanups = sessions
      .filter((session) => session.snaSessionId)
      .map((session) => {
        // Product session id와 SNA session id의 mapping을 store에 알려줍니다.
        register(session.snaSessionId!, session.id);

        // Mount 시 최근 event를 tail로 복구하고 이후 stream을 이어받습니다.
        subscribeAgent(session.snaSessionId!, { since: 0, tail: 200 }).catch(() => {});

        // Session list가 바뀌면 이전 subscription을 정리합니다.
        return () => unsubscribeAgent(session.snaSessionId!).catch(() => {});
      });

    return () => cleanups.forEach((cleanup) => cleanup());
  }, [sessions, register]);
}

Permission UI도 같은 이유로 root에 mount하는 편이 안전합니다. 이것은 현재 선택된 chat panel의 자식 상태가 아니라 제품 상태입니다.

언제 쓰나

상황이 패턴 사용?
제품 DB가 없는 단일 CLI wrapper보통은 필요 없습니다. SNA를 직접 호출하는 편이 단순합니다.
Local SNA와 renderer UI가 있는 Electron 앱잘 맞습니다. Command와 event path를 분리하면 좋습니다.
Local + cloud SNA instance를 함께 쓰는 앱잘 맞습니다. Client registry와 event aggregator가 필요합니다.
Retrieval, project, policy context를 주입하는 앱잘 맞습니다. Send-time context building을 host에 모으면 다루기 쉽습니다.

목차