アプリに 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 を次のように分けると扱いやすいです。
| Path | Route | 理由 |
|---|---|---|
| Lifecycle command | renderer → app host HTTP → SNA HTTP | Host が app DB から config を作り、cloud instance を起こし、model selection を reconcile し、product context を注入できます。 |
| Event stream | renderer → 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 は ensureAgentAlive、sendMessage、subscribeAgent という 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 のコストを隠せます。
安全な順序は次の通りです。
- Active SNA head を resolve します。
- Pending fork / fold operation を適用します。
- 選択された model / runtime を reconcile します。
- 最終 message context を作ります。
sendMessageを呼びます。- 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 に集約すると扱いやすいです。 |