From 6b782b018927139ea07e5907488b0b948d90c813 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:54:34 +0800 Subject: [PATCH 1/2] fix: bind stdio to its exact fresh identity --- docs/config-catalog.md | 4 +- .../2026-06-20-unify-agent-and-session-id.md | 2 +- packages/core/agent-loop/README.md | 5 ++- packages/core/agent-loop/src/index.ts | 11 +++-- .../tests/config-session-id.spec.ts | 30 +++++++++++++ packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/src/index.ts | 7 ++- packages/ui/stdio-agent/src/stdio-chat.ts | 32 +++++--------- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 13 ++++++ .../ui/stdio-agent/tests/stdio-chat.spec.ts | 44 +++++-------------- 10 files changed, 88 insertions(+), 62 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 94d613bc4e..66791edb0a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -119,6 +119,8 @@ export interface Config { agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string + /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -645,7 +647,7 @@ export interface Config { Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/stdio-agent/src/index.ts:64`](../packages/ui/stdio-agent/src/index.ts) +Source: [`packages/ui/stdio-agent/src/index.ts:65`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` 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 032eae4f7d..404ba83636 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 @@ -16,7 +16,7 @@ Session itself repeated the same fact as `Session.id` and `Session.header.id`. C An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process subagent creation uses the child session id; and `Session.id` derives from `header.id`. A remote ACP run has no local agent/session pair: it keeps one parent-minted lifecycle id while the child server's wire-local session id remains private to ACP calls. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between local ids are gone. -The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start normally mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; a coupled app may pre-mint and pass the exact fresh `sessionId`, while `resumeSessionId` supplies the exact combined identity to load and register. The two exact-id inputs are mutually exclusive. Stdio uses this narrow escape hatch so its config-created agent and UI share one opaque identity instead of guessing from a prefix. Logs may use the stable label while all live and durable lookups use the one `SessionId`. `agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index f40577bb95..246c9cdc27 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity. +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary; an app may instead supply an exact fresh `sessionId` when another coupled component must bind to it. `resumeSessionId` loads and registers the exact persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): @@ -33,6 +33,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo interface Config { agents: Array<{ id: string // required stable label; prefixes fresh combined ids + sessionId?: string // optional exact identity for a fresh session model?: string resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session @@ -40,7 +41,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` and optional `sessionId` apply only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index dbe503ff6a..0041b984d1 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -326,6 +326,8 @@ export interface Config { agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string + /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -341,6 +343,7 @@ export class AgentLoop extends Service implements AgentFactory { static Config = z.object({ agents: z.array(z.object({ id: z.string().required(), + sessionId: z.string(), model: z.string(), cwd: z.string(), resumeSessionId: z.string(), @@ -360,12 +363,14 @@ export class AgentLoop extends Service implements AgentFactory { ctx.systemPrompt.variable('model', context => context.agent?.options.model) ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) - for (const { id, cwd, resumeSessionId, ...options } of config.agents) { + for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) { if (resumeSessionId === undefined || resumeSessionId === '') { - const sessionId = SessionId(`${id}-session-${randomUUID()}`) - this.create(sessionId, options, cwd === undefined ? {} : { cwd }) + this.create(sessionId ?? SessionId(`${id}-session-${randomUUID()}`), options, cwd === undefined ? {} : { cwd }) continue } + if (sessionId !== undefined) { + throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`) + } ctx.effect(() => { const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 134ea68c32..543335e5fd 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -24,7 +24,37 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } +async function makeCoreContext(): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + return ctx +} + describe('config-driven session id', () => { + it('accepts one exact fresh id and rejects it alongside a resume id', async () => { + const exact = await makeCoreContext() + await exact.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }], + }) + expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact') + await exact.fiber.dispose() + + const conflicting = await makeCoreContext() + await expect(conflicting.plugin(AgentLoop, { + agents: [{ + id: 'main', + sessionId: SessionId('fresh'), + resumeSessionId: SessionId('persisted'), + model: 'mock', + }], + })).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive') + await conflicting.fiber.dispose() + }) + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index d7d30ee49d..b509e46781 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id; the UI binds to that fresh-id namespace, or to the exact `resumeSessionId` for a resumed run, and never selects unrelated registry roots. Resumed sessions keep the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; the UI's `main` text is only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. ## The bin diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 2eca0cceb3..03f3d49555 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -39,6 +39,7 @@ */ import type { Context } from 'cordis' +import { randomUUID } from 'node:crypto' import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' @@ -108,6 +109,8 @@ export const Config: z = z.object({ * a leaf concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { + const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId + const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) ctx.plugin(ConsoleExporter) ctx.plugin(agentCore, { ...config.persona !== undefined ? { persona: config.persona } : {}, @@ -117,7 +120,7 @@ export function apply(ctx: Context, config: Config): void { id: 'main', model: config.model, cwd: process.cwd(), - ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, + ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, }], ...config.skills !== undefined ? { skills: config.skills } : {}, }) @@ -126,6 +129,6 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(toolAskUser) ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', - ...config.resumeSessionId !== undefined ? { resumeSessionId: config.resumeSessionId } : {}, + sessionId, }) } diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 17a11168db..93c8f9b4f1 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -36,13 +36,13 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - /** Exact persisted session id the app configured for resume; absent selects the app's fresh `main-session-*` identity. */ - resumeSessionId?: string + /** Exact shared agent/session identity this app instance created or resumed. */ + sessionId?: string } export const Config: z = z.object({ welcome: z.string().default('ready.'), - resumeSessionId: z.string(), + sessionId: z.string(), }) /** @@ -98,26 +98,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // Bind only to this app's configured top-level agent. Fresh runs own the - // `main-session-*` namespace; resumed runs own the exact persisted id. The - // registry's runtime-root relation excludes subagents without confusing it - // with durable parentSession lineage. Keeping the matching candidates also - // covers HMR's publish-new-before-dispose-old ordering without ever falling - // through to an unrelated root owned by another app or test fixture. - const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId - const matchesConfiguredIdentity = (agent: Agent): boolean => resumeSessionId === undefined - ? agent.id.startsWith('main-session-') - : agent.id === resumeSessionId - const configuredRoots = new Set(ctx.agents.roots().filter(matchesConfiguredIdentity)) - let target: Agent | undefined = [...configuredRoots].at(-1) + // Bind only to the exact identity this app passed to its config-created + // agent. Session ids are opaque: neither a prefix nor registry order can + // identify ownership. The root check rejects a child that somehow preempts + // the configured id; later recreation under the same id supports loop HMR. + const matchesConfiguredIdentity = (agent: Agent): boolean => + agent.id === config.sessionId && ctx.agents.roots().includes(agent) + let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === config.sessionId) ctx.on('agent/created', (agent) => { - if (!matchesConfiguredIdentity(agent) || !ctx.agents.roots().includes(agent)) return - configuredRoots.add(agent) - target ??= agent + if (matchesConfiguredIdentity(agent)) target = agent }) ctx.on('agent/disposed', (agent) => { - configuredRoots.delete(agent) - if (target === agent) target = [...configuredRoots].at(-1) + if (target === agent) target = undefined }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 5c5c0d2667..9a7bf10bad 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -93,6 +93,19 @@ describe('dsh-stdio-agent app', () => { await ctx.fiber.dispose() }) + it('normalizes an empty resume id to a fresh exact app identity', async () => { + const ctx = await mount({ + model: 'mock', + resumeSessionId: '', + persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume', + skills: await isolatedSkillsConfig(), + }) + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/) + expect(agent?.id).toBe(agent?.session.id) + await ctx.fiber.dispose() + }) + it('defaults persistenceRoot and welcome when omitted', async () => { // Direct apply (NOT via ctx.plugin, which validates+defaults the config // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 2ebc51e14e..f1ca914525 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -74,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } } -const CONFIG: Config = { welcome: 'hi there', resumeSessionId: 'main' } +const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() @@ -199,7 +199,7 @@ describe('createStdioChat rendering', () => { }) it('accepts a lineage-bearing configured agent created after the UI installs', async () => { - const { ctx, input } = await setup({ welcome: 'hi there', resumeSessionId: 'resumed' }) + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' }) const unrelated = makeAgent('unrelated') ctx.agents.register(unrelated) const resumed = makeAgent('resumed') @@ -247,33 +247,21 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[main turn 1] ') }) - it('retargets a surviving root when HMR publishes it before disposing the old root', async () => { - const { ctx, input } = await setup({ welcome: 'hi there' }) - const oldRoot = makeAgent('main-session-old') - const child = makeAgent('child') - ;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id - const replacement = makeAgent('main-session-replacement') - const lateChild = makeAgent('late-child') + it('retargets only the exact identity after loop HMR recreation', async () => { + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' }) + const oldRoot = makeAgent('main-session-fixed') + const prefixCollision = makeAgent('main-session-unrelated') const disposeOld = ctx.agents.register(oldRoot) - const disposeChild = ctx.agents.enter(child, oldRoot) - ctx.agents.announce(child) - ctx.agents.register(replacement) - const disposeLateChild = ctx.agents.enter(lateChild, replacement) - ctx.agents.announce(lateChild) - - // The replacement's created edge arrived while oldRoot was still targeted. - // A replacement-owned child then arrived even later. Once oldRoot is - // removed, runtime ownership still identifies replacement as the only - // surviving root instead of selecting either newer child by insertion order. + ctx.agents.register(prefixCollision) disposeOld() + const replacement = makeAgent('main-session-fixed') + ctx.agents.register(replacement) + input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) - expect(child.sent).toEqual([]) - expect(lateChild.sent).toEqual([]) + expect(prefixCollision.sent).toEqual([]) expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) - disposeLateChild() - disposeChild() }) it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => { @@ -740,7 +728,7 @@ describe('createStdioChat input', () => { }) it('drives the exact app-configured resumed session', async () => { - const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: 'worker' }) + const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) const agent = makeAgent('worker') ctx.agents.register(agent) input.feed('hi') @@ -748,14 +736,6 @@ describe('createStdioChat input', () => { expect(agent.sent).toHaveLength(1) }) - it('treats an empty resume session id as a fresh configured identity', async () => { - const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: '' }) - const agent = makeAgent('main-session-fresh') - ctx.agents.register(agent) - input.feed('hi') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toHaveLength(1) - }) }) describe('createStdioChat EOF exit', () => { From a8c0e8a03c35b1303d7780b8215e482809c32d6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:55:15 +0800 Subject: [PATCH 2/2] test: cover successful ACP cleanup failure --- .../support/acp-snapshot/tests/harness.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index de764cfd8a..23b4192a4d 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -274,6 +274,21 @@ describe('runScenario', () => { expect(failures[1]).toBe(cleanupFailure) }) + it('reports cleanup failure after an otherwise successful scenario', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({}) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).message).toBe('snapshot cleanup failed') + expect((failure as AggregateError).errors as unknown[]).toEqual([cleanupFailure]) + }) + it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ rejectExtraDirs: true }) const result = await runScenario(