SNA

アプリに SNA を組み込む

実アプリに SNA を組み込むときの product integration pattern。

SNA は一回限りの chat endpoint としてだけでなく、local / cloud 対応の agent runtime としてアプリ内に組み込めます。Product が project、tab、document、permission、background workflow を agent の周辺に持つ場合、次の pattern が役立ちます。

1. App session と SNA session を分ける

Product には通常、独自の session または project row があります。その row を user-facing object として維持し、現在接続している SNA session id を別 field に保存します。

type AppSession = {
  // Product が所有する安定した identity です。Rename/archive/fork はこの id を基準にします。
  id: string;

  // User-facing label です。Runtime process label とは分けておくと移行しやすいです。
  name: string;

  // Local project がまだない session もあるため nullable にします。
  projectPath: string | null;

  // 現在接続している SNA head です。Runtime switch、fold、fork で変わることがあります。
  snaSessionId: string | null;

  // Local SNA と cloud SNA instance を同じ product model で扱うための field です。
  snaInstanceId: "local" | string;
};

この分離により、app は runtime process を product object と混同せずに rename、archive、move、fork を扱えます。App session の identity を保ったまま local SNA から cloud SNA instance へ移すこともできます。

1 つの app session が時間とともに複数の 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 を 1 つ持つ

WebSocket state は connection 単位です。App が local SNA と cloud SNA instance を同時に扱うなら、instance ごとに client を cache し、event は 1 つの listener set に fan-in します。

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

// Instance id ごとに client を 1 つだけ作り、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 を 1 つの product event bus に集めます。
  client.agent.onEvent((event) => {
    for (const listener of eventListeners) listener(event);
  });

  // Permission request も connection ごとに届くため、同じように 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 effect の cleanup としてそのまま使える unsubscribe を返します。
  eventListeners.add(listener);
  return () => eventListeners.delete(listener);
}

重要なのは、UI component が特定の WebSocket ではなく app-level aggregator に subscribe することです。あとから cloud instance が増えても、UI を remount せずにその client を aggregator に参加させられます。

3. Command は host 経由、event は SNA から直接受ける

Product app では、path を次のように分けると扱いやすいです。

PathRoute理由
Lifecycle commandrenderer → app host HTTP → SNA HTTPHost が app DB から config を作り、cloud instance を起こし、model selection を reconcile し、product context を注入できます。
Event streamrenderer → SNA WebSocketすべての token を app server 経由にせず、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 は host 側で 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 は ensureAgentAlivesendMessagesubscribeAgent という product verb だけを知っていれば済みます。

4. /agent/ensure を idempotent にする

Start-vs-resume の判断は host が持つと責任範囲が明確になります。UI が runtime history の有無、cloud wake の必要性、remote container 内で有効な cwd を判断しない形にすると安定します。

async function ensureAgent({ appSessionId, requestedSnaId, isNewSession }) {
  // Product session を基準に local/cloud SNA instance を選びます。
  // `instance` は SnaClient connection を包む host-owned wrapper です。
  const instance = snaForAppSession(appSessionId);
  await wakeIfCloudInstance(instance);

  // Request に id がなければ product DB に保存された SNA id を使うか、新しく作ります。
  const snaSessionId = requestedSnaId ?? await loadOrCreateSnaId(appSessionId);
  if (await instance.isAlive(snaSessionId)) {
    return { status: "already_alive", snaSessionId };
  }

  // Agent config は host で組み立て、renderer は domain-level に保ちます。
  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);

  // 新規または history のない head は start、既存 history がある head は resume します。
  return isNewSession || !hasHistory
    ? instance.startAgent(snaSessionId, { ...config, force: true, cwd })
    : instance.resumeAgent(snaSessionId, config);
}

この endpoint は tab focus、reload recovery、send preflight から何度呼ばれても安全な形にしておくと扱いやすいです。

5. Product context は user-turn 境界で注入する

Metadata は独立した model turn として送るより、User message を forward する直前に context を作り、その message に product-owned tag を prepend / append する形が扱いやすいです。

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

  // Pending model/runtime selection は実際の 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 が見るのは、product context が付いた最終 user turn です。
  return snaForSnaSession(snaSessionId).sendMessage(snaSessionId, finalMessage);
}

これにより runtime history はきれいに保たれます。Start/resume のあと agent が最初に見るのは、app の現在の world で補強された実際の user turn です。

6. 重い状態変更は send まで遅延する

Context folding、model change、runtime switch などの pending operation があるなら、次の user-turn 境界で適用します。User が送信した直後、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 change を 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 します。Chat pane、route、side panel は agent が streaming 中でも unmount されることがあります。Root-level hook が WebSocket listener を維持し、event を store に流し込む構成が安定します。

function AppShell() {
  // Product session hydration は app boot の責務として root に置きます。
  const hydrateSessions = useSessionStore((state) => state.hydrate);
  const sessions = useSessionStore((state) => state.sessions);

  // Permission UI は現在 route だけでなく、既知の全 session を見られると安全です。
  const sessionIds = useMemo(
    () => sessions.flatMap((session) => session.snaSessionId ? [session.snaSessionId] : []),
    [sessions],
  );

  // Event bridge は route/chat pane より上に置くと streaming 中の unmount に強くなります。
  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 を復元し、その後の 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 の子 state ではなく、product state です。

いつ使うか

状況この pattern を使うか
Product DB のない単一 CLI wrapper通常は不要です。SNA を直接呼ぶ方が単純です。
Local SNA と renderer UI を持つ Electron appよく合います。Command path と event path を分けると扱いやすいです。
Local + cloud SNA instance を同時に扱う appよく合います。Client registry と event aggregator が必要です。
Retrieval、project、policy context を注入する appよく合います。Send-time context building を host に集約すると扱いやすいです。

目次