From 9ebb40b84b404b3568a53cd6fab1b966e2c82c74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:28:04 +0800 Subject: [PATCH] fix: keep SDK subagent notifications local --- .../2026-06-20-unify-agent-and-session-id.md | 2 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 8 +++++++- packages/ui/jsonrpc/tests/server.spec.ts | 16 +++++----------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md index 10521447e0..bb77717e3b 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -28,7 +28,7 @@ The config-driven path keeps `agents[].id` as a stable configuration label, not - Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. - The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend uses the child server's returned session id as its run id; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC caches only disposable-child parent lineage. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend uses the child server's returned session id as its run id; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC caches only local disposable-child parent lineage while leaving remote runs outside its local-session notification pair. - The config-driven resume-or-create policy is explicit and covered across a durable restart. - A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 933023a2d9..2324227ff7 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches only parent lineage because the child may be disposed before `subagent/end`. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches only parent lineage because the child may be disposed before `subagent/end`. Runs from remote providers are not reported through this local-session notification pair because they create no local `session/created`/`subagent.started` edge. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index cfa083c394..3c803a6c8c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -104,8 +104,14 @@ export class HarnessSdkServer { })) this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { const agent = this.ctx.agents.get(info.id) - const parentSessionId = this.subagentParents.get(info.id) ?? agent?.session.header.parentSession + const cachedParentSessionId = this.subagentParents.get(info.id) this.subagentParents.delete(info.id) + // This protocol reports LOCAL child sessions, paired with the + // session/created-driven subagent.started notification above. A remote + // provider may use a real remote SessionId for its run, but that session + // does not exist in this harness and therefore has no paired start event. + if (cachedParentSessionId === undefined && agent === undefined) return + const parentSessionId = cachedParentSessionId ?? agent?.session.header.parentSession this.transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 433119e046..e7f591b88f 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -303,7 +303,7 @@ describe('HarnessSdkServer', () => { } }) - it('falls back to live lineage and treats the shared id as the child session id', async () => { + it('falls back to live lineage and ignores runs without a local child session', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) let parentHandle: AgentHandle | undefined @@ -367,16 +367,10 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }, }) - expect(transport.notifications).toContainEqual({ - method: 'subagent.finished', - params: { - provider: 'fork', - agentId: 'missing-child-agent', - childSessionId: 'missing-child-agent', - status: 'error', - stopReason: 'error', - }, - }) + expect(transport.notifications.some(n => + n.method === 'subagent.finished' + && n.params?.agentId === 'missing-child-agent', + )).toBe(false) await server.shutdown() } finally {