From b270b3ef9f816c97facc5025bc535667ccf7e396 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:18:34 +0800 Subject: [PATCH 01/41] fix(web): run a trailing catalog refresh for coalesced membership changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshSubagents` single-flights per catalog owner: a request arriving while a pull is in flight returns the in-flight promise and is silently coalesced into it. The in-flight response was requested before the triggering change, so it can never contain that change — a debounced membership refresh (50ms after `host/session-added`) firing during a slow pull therefore lost the new child, and the catalog stayed stale until an unrelated trigger (reselection, menu reopen, reconnect). Mark the owner stale on coalescing and re-arm one trailing pull in the settlement `finally`, so every membership change observed during a pull is carried by a follow-up refresh exactly once. Bounded: the trailing pull only runs when a refresh request was actually coalesced, and a new coalescing during the trailing pull re-marks the same set. Adds a fake-timer regression test: a `host/session-added` debounce firing mid-pull yields exactly two `subagent.list` calls and the catalog eventually contains the new child. --- .../runtime/src/client/sessions/manager.ts | 17 +++++- packages/client/runtime/tests/manager.spec.ts | 58 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1019327df5..3b841ae0b2 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -101,6 +101,8 @@ export class SessionManager { private readonly addresses = new Map() private readonly catalogs = new Map() private readonly catalogInflight = new Map() + /** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */ + private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() @@ -286,7 +288,16 @@ export class SessionManager { */ refreshSubagents(parentSessionId: SessionId): Promise { const existing = this.catalogInflight.get(parentSessionId) - if (existing !== undefined) return existing.promise + if (existing !== undefined) { + // A refresh requested while a pull is in flight must not be silently + // coalesced into it: the in-flight response was requested before the + // triggering change (a membership frame or an opened menu), so it can + // never contain that change. Queue one trailing refresh that runs after + // the pull settles; without it the change stays invisible until an + // unrelated later trigger (reselection, menu reopen, reconnect). + this.catalogStale.add(parentSessionId) + return existing.promise + } const previous = this.catalogs.get(parentSessionId) const expandableRows = new Set() const activityRows = new Map() @@ -333,6 +344,10 @@ export class SessionManager { }) } finally { this.catalogInflight.delete(parentSessionId) + // Re-arm the trailing pull before the dirty notify: the response the + // caller observed predates the stale-marking change, so the follow-up + // refresh is the only carrier of that change. + if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId) this.notifier.markDirty() } })() diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 6ada34de7f..2c1b8c259d 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -529,6 +529,64 @@ describe('subagent catalogs', () => { { kind: 'child', id: S2, activity: 'inactive' }, ]) }) + + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { + vi.useFakeTimers() + try { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + const second = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + manager.setSubagentCatalogOpen(root, true) + const refresh = manager.refreshSubagents(root) + + // A membership frame arrives while the pull is in flight; the debounced + // refresh it schedules fires 50ms later and is coalesced into the pull — + // which was requested before the new child existed. The stale mark must + // queue one trailing pull carrying the change. + manager.handleHostEnvelope({ + rpcId: 'child-added' as never, + payload: { + type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false, + }, + }) + await vi.advanceTimersByTimeAsync(50) + api.onSubagentList = () => second.promise + first.resolve(ok({ + entries: [{ + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + await refresh + // The trailing pull is already in flight (kicked synchronously in finally). + second.resolve(ok({ + entries: [ + { + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }, + { + kind: 'child', id: S2, mode: 'continuable', label: 'new child', + activity: 'inactive', hasChildren: false, + }, + ] as never[], + parentAvailable: true, + })) + await second.promise + + expect(api.callsOf('subagent.list')).toHaveLength(2) + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, label: 'older' }, + { kind: 'child', id: S2, label: 'new child' }, + ]) + } finally { + vi.useRealTimers() + } + }) }) describe('remaining branches', () => { From 8431dbead35b934148df806665e1d9af923ed727 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:19:08 +0800 Subject: [PATCH 02/41] fix(web): invalidate catalog availability when the owning parent is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A removed session can no longer be the delivery owner of its continuable children, but the `host/session-removed` handler only reconciled the removed row's own activity. `parentAvailable` was updated exclusively from `refreshSubagents` success, and removal schedules no catalog refresh — so after the parent's Activation detaches, an addressed child kept a writable editor against a dead continuation owner until an unrelated refresh (or forever, for a closed menu). Flip `parentAvailable` to false on the owned catalog and push `handleSubagentParentAvailable(false)` to every addressed child Session at removal time, matching the refresh path's notification. New Session instances already read `parentAvailable` from the catalog, so they inherit the invalidated state. Adds a regression test: removing the catalog's owning parent flips the snapshot's `parentAvailable` and notifies the addressed child instance. --- .../runtime/src/client/sessions/manager.ts | 13 ++++++++++ packages/client/runtime/tests/manager.spec.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 3b841ae0b2..1c186059de 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -688,6 +688,19 @@ export class SessionManager { this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) + // The removed session can no longer be the delivery owner of its + // catalog: invalidate availability immediately. Removal schedules no + // catalog refresh, and without this an addressed child keeps a + // writable editor against a dead continuation owner until an + // unrelated refresh (or forever, for a closed menu). + const ownedCatalog = this.catalogs.get(frame.sessionId) + if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { + this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false }) + } + for (const [childId, address] of this.addresses) { + if (address.parentSessionId !== frame.sessionId) continue + this.sessions.get(childId)?.handleSubagentParentAvailable(false) + } return } case 'host/session-status': { diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2c1b8c259d..5d12498edd 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -587,6 +587,30 @@ describe('subagent catalogs', () => { vi.useRealTimers() } }) + + it('invalidates catalog availability when the owning parent is removed', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + api.onSubagentList = () => Promise.resolve(ok({ + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(api) + await manager.refreshSubagents(root) + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) + + manager.handleHostEnvelope({ + rpcId: 'parent-removed' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) }) describe('remaining branches', () => { From b2187cabf6b29a77ad2178b12abab45a93d6fd94 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:19:15 +0800 Subject: [PATCH 03/41] fix(cli): keep the core-web overlay at its documented two-tool surface The opt-in `core-web.cordis.yml` profile promises "exactly persistent `bash` plus `str_replace_editor`" (its header comment and `apps/cli/ README.md`), but the base registration of `tool-subagent-list-agents` (added with the durable child catalog) was not disabled by the overlay, so the profile actually exposed `bash`, `str_replace_editor`, and `list_agents`. The assembled snapshot was updated to accept the third tool, which ratified the contract break instead of fixing it. Disable `tool-subagent-list-agents` in the overlay and restore the snapshot's expected tool registry to the documented two tools. --- apps/cli/config/core-web.cordis.yml | 3 +++ apps/web/tests/core-web-profile.snapshot.ts | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 2a5205cd0d..6b31c8f424 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -28,6 +28,9 @@ - id: tool-subagent-control disabled: true +- id: tool-subagent-list-agents + disabled: true + - id: tool-subagent disabled: true diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts index 5f3334c0e7..58f2a34858 100644 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -72,7 +72,6 @@ describe('core Web profile', () => { "tools": [ "bash", "str_replace_editor", - "list_agents", ], } `) From 4b2fa3317ed557a4c5edb3dc47a6f490ce746d5b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:14 +0800 Subject: [PATCH 04/41] perf(host): scan the own-suffix for a subagent descriptor without copying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasSubagentDescriptor` sliced the whole own-suffix events array on every Agent-bound RPC — including each `session.prompt` and `sessions.models` call on long transcripts — and `ensureSession` rescans the same suffix after creation. Replace the slice-then-some with an indexed loop from the seed boundary, so the classification is a plain O(suffix) read with no allocation. --- packages/host/apiproxy/src/api-proxy.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fd4626e573..70661bb027 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1017,8 +1017,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Whether the session's own suffix carries the durable subagent discriminator. */ function hasSubagentDescriptor(session: Pick): boolean { - const ownStart = session.header.seedLength ?? 0 - return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor') + const events = session.events + // Indexed scan from the own-suffix start: slicing copies the whole suffix + // on every Agent-bound RPC, including each `session.prompt` on long + // transcripts. + for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) { + if (events[index]?.type === 'subagent/descriptor') return true + } + return false } /** From 56e252bed35cec9b304af92f389ecd01ac357e20 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:20 +0800 Subject: [PATCH 05/41] fix(host): fence the agentFor live fast path on the agent's own session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentFor` fenced subagent ownership through the attached session store (`ctx.sessions.get`) and only then returned a live registered agent. A registered agent whose session is ever absent from the attached store — an invariant nothing in this package guarantees — would therefore be handed out through generic Host routing unfenced, bypassing subagent delivery entirely. Fence `live.session` directly whenever a live agent exists, and keep the attached-store check only for the not-live durable classification. `ensureSession`'s race `.catch` already fences `live.session`; this makes the fast path the same check instead of an asymmetric weaker one. --- packages/host/apiproxy/src/api-proxy.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 70661bb027..96e040d2a2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1106,6 +1106,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (error instanceof SubagentSessionOwnership) { return { error: subagentOwnershipError(error.sessionId) } } + // A concurrent parent `enter()` can win the identity between the + // pre-resume published re-check and `ctx.agents.resume` publication; + // the ID-collision rejection falls through here. Re-classify that + // raced published winner into the stable ownership error, mirroring + // ensureSession's `.catch`. + const live = ctx.agents.get(sessionId) + if (live !== undefined && hasSubagentOwner(live.session, live)) { + return { error: subagentOwnershipError(sessionId) } + } + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { + return { error: subagentOwnershipError(sessionId) } + } // The internal details slot is contractually {}; the reason rides the message. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } } From 468fd29e51f160135d6e2b0f30c335d5a8fbb3cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:28 +0800 Subject: [PATCH 06/41] fix(host): classify a raced cold-resume ID collision as agent-busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a generic `agentFor` cold resume loses the identity to a parent's concurrent `enter()` — the collision rejection arrives from `ctx.agents.resume` publication after the pre-resume re-check — the error fell through to the `internal` mapping. Clients retrying then see a transient-looking internal failure instead of the stable ownership error that `ensureSession`'s `.catch` already produces for the exact same published-winner case. Mirror that re-classification in `agentFor`'s resume error path: after the typed errors, re-check the registry and attached store and answer `agent-busy` when the raced winner is subagent-owned. Adds a regression test whose resume mock publishes the subagent winner before throwing the ID-collision error. --- packages/host/apiproxy/src/api-proxy.ts | 8 ++-- .../apiproxy/tests/api-proxy-cold.spec.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 96e040d2a2..517688de34 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2253,9 +2253,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, commands: { - // Both methods address one session's agent (agentFor keeps its - // resume-on-miss: clients only send a sessionId for a published - // session, and resume restores an existing entity). + // Both methods address one session's agent. agentFor resumes on miss + // and fences every subagent-owned identity with `agent-busy`; the + // api/commands.ts module contract owns that fence's wording, so this + // comment only notes the routing shape: clients send a sessionId for a + // published session, and resume restores an existing entity. async list(request) { // Missing service = the deployment omitted dsh-commands from its // composition, not an empty catalog: fail loud instead of serving []. diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 1e5b095778..7eeeba3af6 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -338,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => { } } }) + + it('classifies a raced cold-resume ID collision as agent-busy', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('race-resume') + const meta: SessionHeader = header('race-resume', 1000) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + locate: () => undefined, + } as never) + // The raced winner: a live parent-owned subagent publishes the identity + // while the generic cold resume is in flight, so the resume collides. + const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) + const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent + ctx.agents.register(parent) + const childSession = ctx.sessions.create(sessionId, { + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, + }) + const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + // The parent's `enter()` wins the identity between the pre-resume + // re-check and publication; the generic resume then collides. + ctx.agents.register(child) + throw new Error('session id already published') + }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const models = await api.sessions.models(request({ sessionId })) + expect(models.result.ok).toBe(false) + if (!models.result.ok) { + expect(models.result.error).toMatchObject({ + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }) + } + }) }) From c68c3dbb43e2b9137a37a3f0d8b9daab154123a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:41 +0800 Subject: [PATCH 07/41] fix(host): check subagent ownership before cwd conflict in ensureSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit-id adoption of a cold session-backed subagent under a *different* cwd answered `session-conflict` because the cwd check ran before the persistence inspection classified the identity. The api/commands.ts contract states explicit-id `session.create` adoption rejects session-backed subagents with `agent-busy` — ownership is an identity property, so it must win regardless of the requested workspace. Reorder the stored-session branch to inspect and classify ownership first, then enforce the cwd match, making the response match the documented contract. --- packages/host/apiproxy/src/api-proxy.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 517688de34..501390e488 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1066,12 +1066,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { - const attached = ctx.sessions.get(sessionId) const live = ctx.agents.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, live)) { + if (live !== undefined) { + // Fence the live agent's own session rather than trusting a + // "registered ⇒ attached-store" invariant: a registered subagent whose + // session is ever absent from the attached store must still not be + // handed out through generic Host routing (ensureSession's `.catch` + // already fences `live.session`; this is the same check on the fast path). + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } + } + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } } - if (live !== undefined) return { agent: live } let resume = resumes.get(sessionId) if (resume === undefined) { resume = (async () => { From e81267945abc04887f3ea68f6525c36d97b0a4e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:54 +0800 Subject: [PATCH 08/41] docs(host): refresh the stale agentFor resume-on-miss comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commands entry's inline comment described the old routing shape ("clients only send a sessionId for a published session") without the ownership fence that agentFor now applies on every path — the fence's contract home is the api/commands.ts module JSDoc, so trim the duplicate and point at the routing shape only, keeping one home per fact. --- packages/host/apiproxy/src/api-proxy.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 501390e488..0827762a8a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1202,13 +1202,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ? undefined : (await persistence.list()).find(header => header.id === sessionId) if (persistence !== undefined && stored !== undefined) { - if (stored.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, stored.cwd) - } const inspected = await persistence.inspect(sessionId) + // Ownership first: explicit-id adoption of a session-backed + // subagent must answer `agent-busy` regardless of the requested + // cwd (the api/commands.ts contract), not a cwd conflict. if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { throw new SubagentSessionOwnership(sessionId) } + if (inspected.meta.cwd !== cwd) { + throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) + } return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, From cb835c7ea98345d51508b944f57c252c7c55e503 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:23:29 +0800 Subject: [PATCH 09/41] fix(acp): keep per-session teardown failure reasons in the aggregate log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection-close teardown path threw a bare `AggregateError` whose message counts the failed sessions, and its only production consumer logs through `String(error)` — which renders the message alone. Compared with the previous `Promise.all` behavior, every actual disposal failure reason disappeared from operational logs. Join the per-session reasons into the aggregate message, matching the subagent seam's own aggregate disposal messages, and pin the reason in the dispose spec's warning assertion. --- packages/acp/acp/src/index.ts | 10 +++++++++- packages/acp/acp/tests/dispose.spec.ts | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 0a2f7f7f68..7af26b594a 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -368,7 +368,15 @@ export function apply(ctx: Context, config: AcpConfig): void { if (result.status === 'rejected') failures.push(result.reason as unknown) } if (failures.length > 0) { - throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`) + // The only production consumer logs this error through `String`, which + // renders the message alone — without the joined reasons, per-session + // disposal failures would vanish from operational logs. Join them like + // the subagent seam's own aggregate disposal messages. + const detail = failures.map(failure => String(failure)).join('; ') + throw new AggregateError( + failures, + `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, + ) } })() return quiescing diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 5f6b5babd5..0eea014eeb 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -131,7 +131,8 @@ describe('ACP connection ownership', () => { releaseSecond.resolve(undefined) await vi.waitFor(() => { - expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true) + expect(warnings.some(warning => + warning.includes('ACP agent teardown failed for 1 session(s): Error: first session cleanup failed'))).toBe(true) expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined() }) From 2a3a8ff66d294eb442a4163b13fd3b4cd57b29cf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:24:08 +0800 Subject: [PATCH 10/41] docs(subagent): mark the superseded flush-required clause in the intent-named note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-27 intent-named operations note still declared that a continuable provider requires `flush()` to resolve `true` at its final result boundary and maps `false`/rejection to `DURABILITY_FAILED`. The activation-based record (2026-07-28-continuable-subagent-conversations) superseded that contract: the manager awaits the final flush as a best-effort barrier and deliberately ignores the boolean, because listener participation cannot identify a persistence backend. Active notes are the current source of truth — sync both sides of the bilingual pair by marking the old clause superseded with a link to the record that replaced it. --- .../2026-07-27-intent-named-subagent-continuation-operations.md | 2 +- ...26-07-27-intent-named-subagent-continuation-operations.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 5029d8335f..00340ac534 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -18,7 +18,7 @@ The durability boundary also exposed both `SessionStore.flush()` and `flushRequi Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. -`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. +`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. **Superseded** by the activation-based record [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md): the continuation manager awaits the final `flush()` as a best-effort barrier and deliberately ignores the boolean, because listener participation cannot identify a persistence backend; a rejection is logged without changing the lifecycle result or host-drain outcome. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 0785730c19..58af4f2dc9 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -18,7 +18,7 @@ Status: implemented 调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。 +`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.zh.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 ## 已考虑的替代方案 From 42ee4e22debcbfcc1234c89b5c710ecece8d9ae5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:27:55 +0800 Subject: [PATCH 11/41] fix(subagent): validate setup transactions before agent publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `materialize` ran `setupTransaction.assertIntact()` only after `ctx.agents.create()/resume()` resolved — but the factory publishes `session/created` (and the persistence backend writes the descriptor seed) inside that call, and `rollbackUnpublished()` only disposes the live handle; the persistence seam has no delete. A setup contribution revoked during construction therefore left a durable ghost: `startContinuable()` rejected with `ACTIVATION_SETUP_REVOKED` and returned no child id, yet `list_agents` surfaced a persisted `continuable` child whose log carries a valid descriptor — so a later `send_message` could cold-resume a child the deployment had explicitly refused to establish. Move the validation into the creation callback, before the factory can publish: `assertIntact()` then rejects the create/resume call itself, so no session is ever persisted for a rejected child. Commit the batch in the same callback so a later contribution removal releases the installation instead of invalidating a child already being established (live revocation, matching the resident semantics). Pins the rollback regression test to assert that no `session/created` is ever announced for the rejected child (the parent is created before the listener registers), in addition to the existing registry assertion. --- packages/subagent/subagent/src/continuation.ts | 16 +++++++++++++--- .../tests/tool-subagent-report.spec.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index d21f235589..bc4833bbd0 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -804,6 +804,16 @@ export class SubagentContinuationManager { const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) setupTransaction = this.setupRegistry.apply(childCtx) + // Validate and freeze the batch inside the creation callback, before the + // factory can publish the session: a revoked contribution must reject + // the create/resume call pre-publication, so no persisted session is + // ever left behind for a child the manager rejects — rollback only + // disposes the live handle, and the persistence seam has no delete, so + // a post-publication rejection would leave a resumable ghost child. + // Committing here also means a later contribution removal releases the + // installation instead of invalidating a child already being established. + setupTransaction.assertIntact() + setupTransaction.commit() } const observer = this.host.observeActivation(provider, childId, parent) const { create } = inputs @@ -842,7 +852,6 @@ export class SubagentContinuationManager { try { inputs.signal.throwIfAborted() this.assertAdmitting(parent) - setupTransaction.assertIntact() this.acquireOwnership(parent, childId) // Every accepted id leaves the inbox exactly once, through dequeue or // discard. Clearing it there is what lets `stateOf()` distinguish a truly @@ -860,8 +869,9 @@ export class SubagentContinuationManager { for (const item of items) activation.accepted.delete(item.message.id) this.wake(activation) }) - // Resident setup revokes live from here instead of invalidating creation. - setupTransaction.commit() + // Setup already validated and committed inside the creation callback; + // revocations from here on are immediate live revocation, never + // creation invalidation. // Publish the start edge before any turn can run, so observers see this // epoch before its first request. observer.start(handle.agent) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 31837afa9d..20e751530f 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -327,6 +327,15 @@ describe('dsh-tool-subagent-report', () => { return dispose }) + // No session may be announced for the rejected child: the setup + // validation must reject inside the creation callback, before the factory + // publishes — a post-publication rejection would persist a resumable + // ghost that `list_agents` surfaces and `send_message` can resurrect. + // The parent was created inside setup(), so any later announcement is the + // rejected child's. + const announced: SessionId[] = [] + const listener = (session: { id: SessionId }): void => { announced.push(session.id) } + const removeListener = ctx.on('session/created', listener) await expect(ctx.subagents.startContinuable({ provider: 'spawn', label: 'racing child', @@ -336,6 +345,8 @@ describe('dsh-tool-subagent-report', () => { }, signal: testSignal, })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(announced).toEqual([]) expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) From 879a623095345df5e117bf9512cb33bf32c4a0aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:28:48 +0800 Subject: [PATCH 12/41] fix(subagent): cover the scope-disposal effect registration with setup rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `childCtx.effect()` that routes scope disposal into `releaseChild` was registered after the install loop's try/catch, so a hypothetical throw from the registration itself (effect() rejects only on an inactive fiber, which a live unpublished scope cannot be) would leak the just-installed batch — neither the setup-rollback catch nor `releaseChild` would release it. Move the registration inside the try so the existing rollback path covers it; no observable behavior change. --- packages/subagent/subagent/src/activation-setup-registry.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index c0fc84552c..dca194f113 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -123,6 +123,10 @@ export class SubagentActivationSetupRegistry { // Dispose that escaped record and invalidate the provisioning batch. if (isRemoved(registration)) this.release(installation) } + // Register the scope-disposal release inside the same try so the + // setup-rollback catch also covers a hypothetical effect-registration + // throw; today effect() cannot reject on a live unpublished scope. + childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') } catch (error: unknown) { // Keep the installer failure authoritative, but attempt every rollback. try { @@ -133,7 +137,6 @@ export class SubagentActivationSetupRegistry { } throw error } - childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') return { assertIntact: () => { if (!state.invalidated) return From 98ccbade7edefa3c4e19d7a1ba30319a055df044 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:28:50 +0800 Subject: [PATCH 13/41] fix(subagent): drop the dead reportDelivery destructure default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply()` resolved the deployment config through schemastery's `Config()`, which always fills the schema default (`quiet`, pinned by the config test), so the `= 'quiet'` destructure fallback was dead at runtime on every path — and as a defaulted parameter it formed a branch no test could ever exercise against the per-file coverage gate. Remove the fallback and let the schema be the single home of the default. --- packages/subagent/tool-subagent-report/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index e83d65f830..6f6160dc85 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -88,7 +88,7 @@ export function installReportTool( * @param config - deployment scheduling policy. */ export function apply(ctx: Context, config: Config = {}): void { - const { reportDelivery = 'quiet' } = Config(config) + const { reportDelivery } = Config(config) ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx, reportDelivery)) } From 5da2ac58357bfdb6164e79af3f907a54889f0514 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:29:02 +0800 Subject: [PATCH 14/41] docs(subagent): state that per-activation knobs are not restored on cold resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descriptor deliberately snapshots a curated composition field set rather than the merge-extensible `AgentOptions`, and it already names the per-activation exclusions (`outputSchema`). `maxTokens` is the same class of property — it budgets one activation, and on cold resume there is no parent to inherit a limit from, so the resumed activation runs under the deployment defaults. Spell that out in the module contract so the fallback is a documented decision instead of a silent surprise for deployments that set explicit child token limits. --- packages/subagent/subagent/src/descriptor.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 22acbecbdd..4cec72e658 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -12,6 +12,10 @@ * omits `subagentDepth` — cold resume trusts the persisted header's * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs * to one activation's result contract rather than durable child composition. + * Per-activation knobs such as `maxTokens` are omitted for the same reason as + * `outputSchema`: they budget one activation and, on cold resume, no parent + * exists to inherit them from, so the resumed activation runs under the + * deployment defaults rather than restoring a stale budget. * * @module @deepseek-ai/dsh-subagent/descriptor */ From 31149473240ee2a92b68ae55347a1756e17b5941 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:31:54 +0800 Subject: [PATCH 15/41] docs(subagent): correct report acceptance semantics for closing parents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool README claimed a "missing, disposed, or closing parent" fails the call — but acceptance is governed by the parent's registry presence: `resolveReportParent` only rejects when the durable parent id is absent from the registry, so a host-owned parent already in disposal but still registered still accepts (the pinned host-disposing-parent behavior). The claim misled callers into treating disposal state as a delivery signal. Restate the contract in both languages: absence from the registry is the only `PARENT_UNAVAILABLE` case, and a failed tool call does not prove non-delivery — a later `tools/post-execute` veto can fail a call whose report was already accepted, so the durable child transcript remains the recovery source. Adds a regression test pinning acceptance into a host-disposing but still-registered parent, and rejection after disposal settles. --- .../subagent/tool-subagent-report/README.md | 2 +- .../subagent/tool-subagent-report/README.zh.md | 2 +- .../tests/tool-subagent-report.spec.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index e15b8b5d58..5e3947c6a6 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. -A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A missing, disposed, or closing parent fails the call with `direct parent is not live; report was not delivered`; the service performs no injection, parent cold resume, or offline mailbox write, so the durable child transcript remains the recovery source. +A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — acceptance is governed by registry presence, so a parent already in host-owned disposal but still registered still accepts. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). `reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 0c41bc9c1e..bb008f5b0b 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -4,7 +4,7 @@ 可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级不存在、已 dispose(资源释放)或正在关闭时,本次调用会失败并返回 `direct parent is not live; report was not delivered`;服务不会执行注入、父级冷恢复或离线 mailbox 写入,因此持久化子级 transcript(文本记录)仍是恢复真源。 +子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。接受与否由父级在注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 `reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 20e751530f..4d44204fd6 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -350,6 +350,23 @@ describe('dsh-tool-subagent-report', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) + it('accepts a report into a host-disposing but still-registered parent', async () => { + const { ctx } = await setup() + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('disposing-parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const { child } = await startChild(ctx, parentHandle.agent) + // Host-owned disposal starts asynchronously; the parent stays registered + // until quiescence, and registry presence — not disposal state — is the + // acceptance gate (pins the README contract). + const disposing = parentHandle.dispose() + const accepted = await callReport(ctx, child, 'during-close') + expect(accepted.isError).toBe(false) + await disposing + expect((await callReport(ctx, child, 'after-close')).isError).toBe(true) + }) + it('keeps the namespace plugin shape and validates its default', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-report') From 902b46b86bc2402df644474b4dffec7ccd5cac62 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:37:54 +0800 Subject: [PATCH 16/41] feat(web): localize the subagent catalog and read-only composer copy The catalog action (diagnostics, relative times, loading/error/retry, mode and activity labels, branch toggles, descendant counts, tree aria) and the read-only composer were hardcoded to Simplified Chinese, so an English-locale session rendered mixed-language UI. Register a `subagent` locale namespace (zh source of truth + en dictionary), declare it on both slot registrations, thread the locale `t` seat through the components, and mount the locale service in the plugin specs. The UI spec's zh assertions now run against the real dictionary through a `t` stub that interpolates `{name}` params exactly like the locale service. --- .../src/client/SubagentCatalogAction.tsx | 72 +++++++++++-------- .../src/client/SubagentReadOnlyComposer.tsx | 15 ++-- .../client/ui-subagent/src/client/index.ts | 14 +++- .../client/ui-subagent/src/client/locales.ts | 67 +++++++++++++++++ .../ui-subagent/tests/browser-plugin.spec.ts | 5 +- .../tests/conversation-ui.spec.tsx | 22 +++++- 6 files changed, 153 insertions(+), 42 deletions(-) create mode 100644 packages/client/ui-subagent/src/client/locales.ts diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index c23c82d77b..359827780f 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -7,7 +7,8 @@ import type { import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './SubagentCatalogAction.module.css' @@ -23,7 +24,7 @@ export interface SubagentCatalogInjected { /** Full props for the session-header catalog action. */ export type SubagentCatalogActionProps = - PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected + PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale interface CatalogRowsProps { parentSessionId: SessionId @@ -39,11 +40,14 @@ interface CatalogRowsProps { closeCatalog: () => void } -function diagnosticReason(entry: Extract): string { +function diagnosticReason( + entry: Extract, + t: TranslateNS, +): string { switch (entry.reason) { - case 'corrupt': return '会话记录损坏' - case 'unsupported': return '子代理记录版本不受支持' - case 'unavailable': return '会话记录暂不可用' + case 'corrupt': return t('diagnostic.corrupt') + case 'unsupported': return t('diagnostic.unsupported') + case 'unavailable': return t('diagnostic.unavailable') } } @@ -54,18 +58,22 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { } /** Compact trailing activity time for a catalog row. */ -function relativeTime(updatedAt: number | undefined, now: number): string | undefined { +function relativeTime( + updatedAt: number | undefined, + now: number, + t: TranslateNS, +): string | undefined { if (updatedAt === undefined) return undefined const minute = 60_000 const hour = 60 * minute const day = 24 * hour const diff = Math.max(0, now - updatedAt) - if (diff < minute) return '刚刚' - if (diff < hour) return `${Math.floor(diff / minute)}分钟` - if (diff < day) return `${Math.floor(diff / hour)}小时` - if (diff < 30 * day) return `${Math.floor(diff / day)}天` - if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月` - return `${Math.floor(diff / (365 * day))}年` + if (diff < minute) return t('time.justNow') + if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) }) + if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) }) + if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) }) + if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) }) + return t('time.years', { n: Math.floor(diff / (365 * day)) }) } /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ @@ -98,28 +106,30 @@ function CatalogLoadingRows({ parentSessionId, summaries, level, + t, }: { parentSessionId: SessionId summaries: Readonly> level: number + t: TranslateNS }) { const children = Object.values(summaries).filter(summary => ( summary.origin === 'subagent' && summary.parentId === parentSessionId )) - if (children.length === 0) return
正在加载子代理…
+ if (children.length === 0) return
{t('loading.label')}
return children.map(summary => (
- 正在加载子代理… + {t('loading.label')}
@@ -129,8 +139,8 @@ function CatalogLoadingRows({ /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, catalog, catalogs, summaries, expanded, level, now, - openChild, refresh, toggleBranch, closeCatalog, -}: CatalogRowsProps) { + openChild, refresh, toggleBranch, closeCatalog, t, +}: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 return ( <> @@ -139,24 +149,25 @@ function CatalogRows({ parentSessionId={parentSessionId} summaries={summaries} level={level} + t={t} /> )} {catalog.state === 'error' && (
- {catalog.error?.message ?? '无法加载子代理'} + {catalog.error?.message ?? t('load.error')}
)} {catalog.entries.map((entry) => { if (entry.kind === 'diagnostic') { - const reason = diagnosticReason(entry) + const reason = diagnosticReason(entry, t) return (
value !== undefined) .join(' · ') - const time = relativeTime(summary?.updatedAt, now) + const time = relativeTime(summary?.updatedAt, now, t) const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -235,7 +246,7 @@ function CatalogRows({ type="button" tabIndex={-1} className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`} - aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`} + aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })} onClick={toggle} > @@ -262,6 +273,7 @@ function CatalogRows({ parentSessionId={entry.id} summaries={summaries} level={level + 1} + t={t} /> ) : ( @@ -277,6 +289,7 @@ function CatalogRows({ refresh={refresh} toggleBranch={toggleBranch} closeCatalog={closeCatalog} + t={t} /> )}
@@ -294,7 +307,7 @@ function CatalogRows({ * @returns The action only after a non-empty catalog arrives. */ export function SubagentCatalogAction({ - sessionId, useSessions, openChild, refresh, setCatalogOpen, + sessionId, useSessions, openChild, refresh, setCatalogOpen, t, }: SubagentCatalogActionProps) { const catalogs = useSessions(state => state.subagentsByParent) const summaries = useSessions(state => state.byId) @@ -419,7 +432,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`} + aria-label={t(descendants.running ? 'count.running' : 'count.total', { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -431,11 +444,11 @@ export function SubagentCatalogAction({ {descendants.running && } - {descendantCount} 个子代理 + {t('count.total', { count: descendantCount })} {open && ( -
+
{ changeOpen(false) }} + t={t} />
)} diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 0cc2699bd8..158e8cb77b 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -1,4 +1,5 @@ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ @@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch { /** Full chain props after the read-only subagent selector accepts the owner currency. */ export type SubagentReadOnlyComposerProps = - PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } + PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale /** * Explain why the normal composer is unavailable for an addressed child. @@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps = * @returns A read-only composer replacement. */ export function SubagentReadOnlyComposer({ - matched, -}: Pick) { + matched, t, +}: Pick) { const oneShot = matched.reason === 'one-shot' return (
- {oneShot ? '一次性子代理记录' : '此子代理暂时只读'} + {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {oneShot - ? '一次性任务不支持后续消息,可在这里查看完整执行记录。' - : '父会话当前不在线,重新打开父会话后即可继续发送消息。'} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 239ffd4335..31579dc258 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type SubagentKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Subagent catalog and read-only composer copy. */ + 'subagent': SubagentKey + } +} export type { SubagentCatalogActionProps, SubagentCatalogInjected, @@ -27,7 +36,7 @@ export type { } from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ -export const inject = ['slash', 'sessions', 'conversation', 'slots'] +export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') const sessions = ctx.sessions // Child labels live on the session list (parentId lineage + displayTitle), // not the conversation snapshot — the list store is the zero-RPC candidate feed. @@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void { name: 'conversation.session.header.actions', id: 'subagent-catalog', order: 10, + locale: NS, inject: catalogActions, }, SubagentCatalogAction), 'ui-subagent: lazy descendant catalog action', @@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.composer', priority: -10, + locale: NS, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), 'ui-subagent: read-only addressed composer', diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts new file mode 100644 index 0000000000..2ecf1be4f5 --- /dev/null +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -0,0 +1,67 @@ +/** `subagent` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'subagent' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'diagnostic.corrupt': '会话记录损坏', + 'diagnostic.unsupported': '子代理记录版本不受支持', + 'diagnostic.unavailable': '会话记录暂不可用', + 'time.justNow': '刚刚', + 'time.minutes': '{n}分钟', + 'time.hours': '{n}小时', + 'time.days': '{n}天', + 'time.months': '{n}个月', + 'time.years': '{n}年', + 'loading.label': '正在加载子代理…', + 'loading.aria': '正在加载子代理', + 'load.error': '无法加载子代理', + 'retry': '重试', + 'mode.oneShot': '一次性', + 'mode.continuable': '可继续', + 'activity.running': '正在运行', + 'activity.inactive': '当前未运行', + 'branch.collapse': '收起 {label} 的下级子代理', + 'branch.expand': '展开 {label} 的下级子代理', + 'count.total': '{count} 个子代理', + 'count.running': '{count} 个子代理,正在运行', + 'tree.aria': '子代理会话', + 'readonly.oneShot.title': '一次性子代理记录', + 'readonly.title': '此子代理暂时只读', + 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', + 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'diagnostic.corrupt': 'corrupted session record', + 'diagnostic.unsupported': 'unsupported subagent record version', + 'diagnostic.unavailable': 'session record temporarily unavailable', + 'time.justNow': 'just now', + 'time.minutes': '{n}m', + 'time.hours': '{n}h', + 'time.days': '{n}d', + 'time.months': '{n}mo', + 'time.years': '{n}y', + 'loading.label': 'Loading subagents…', + 'loading.aria': 'Loading subagents', + 'load.error': 'Unable to load subagents', + 'retry': 'Retry', + 'mode.oneShot': 'one-shot', + 'mode.continuable': 'continuable', + 'activity.running': 'running', + 'activity.inactive': 'not running', + 'branch.collapse': 'Collapse {label} descendants', + 'branch.expand': 'Expand {label} descendants', + 'count.total': '{count} subagents', + 'count.running': '{count} subagents running', + 'tree.aria': 'Subagent sessions', + 'readonly.oneShot.title': 'One-shot subagent record', + 'readonly.title': 'This subagent is read-only for now', + 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', + 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', +} + +/** Key domain of the `subagent` namespace (zh is the source of truth). */ +export type SubagentKey = keyof typeof zh diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 0db157a5a7..d2332cb3af 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,6 +18,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) { ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -111,7 +113,7 @@ const req = (query: string) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots']) + expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale']) }) it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { @@ -119,6 +121,7 @@ describe('apply', () => { await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 6eb9f881c0..2e90893244 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -7,7 +7,10 @@ import type { import { SubagentCatalogAction, type SubagentCatalogActionProps, } from '../src/client/SubagentCatalogAction.tsx' -import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx' +import { + SubagentReadOnlyComposer, type SubagentReadOnlyComposerProps, +} from '../src/client/SubagentReadOnlyComposer.tsx' +import { zh, type SubagentKey } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -63,12 +66,22 @@ function props( function useSessions(select: (snapshot: SessionListState) => T): T { return select(state) } + // The zh dictionary is the source of truth for this spec's assertions: + // the stub interpolates `{name}` params like the locale service does. + const t = ((key: SubagentKey, params?: Record): string => { + let text = zh[key] + for (const [name, value] of Object.entries(params ?? {})) { + text = text.replaceAll(`{${name}}`, String(value)) + } + return text + }) as SubagentCatalogActionProps['t'] return { sessionId: PARENT, useSessions, openChild: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), + t, } as unknown as SubagentCatalogActionProps } @@ -453,13 +466,16 @@ describe('SubagentCatalogAction', () => { }) describe('SubagentReadOnlyComposer', () => { + // The zh dictionary is the source of truth for this spec's assertions. + const t = ((key: SubagentKey): string => zh[key]) as SubagentReadOnlyComposerProps['t'] + it('explains the exact missing-parent recovery path', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) it('explains that one-shot histories never accept follow-ups', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') }) }) From df01ed926a7b8198ee009c3543a3f3afc0551341 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:39:35 +0800 Subject: [PATCH 17/41] fix(subagent): type the schema-resolved reportDelivery shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config() applies the schemastery default at runtime, but its return type keeps the input's optional field, so assert the resolved shape at the seam — keeping the dead fallback branch gone. --- packages/client/ui-subagent/tests/conversation-ui.spec.tsx | 2 +- packages/subagent/tool-subagent-report/src/index.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 2e90893244..1687b1ffc1 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -69,7 +69,7 @@ function props( // The zh dictionary is the source of truth for this spec's assertions: // the stub interpolates `{name}` params like the locale service does. const t = ((key: SubagentKey, params?: Record): string => { - let text = zh[key] + let text: string = zh[key] for (const [name, value] of Object.entries(params ?? {})) { text = text.replaceAll(`{${name}}`, String(value)) } diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index 6f6160dc85..962d8cf382 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -88,7 +88,10 @@ export function installReportTool( * @param config - deployment scheduling policy. */ export function apply(ctx: Context, config: Config = {}): void { - const { reportDelivery } = Config(config) + // Config() applies the schema default ('quiet') at runtime; the schemastery + // return type keeps the input's optional shape, so assert the resolved + // shape here — no runtime fallback exists or is wanted. + const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery } ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx, reportDelivery)) } From 8bba72639ad48931c5c2c2ccf14b4f09bdcd078d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:48:45 +0800 Subject: [PATCH 18/41] chore(docs): refresh the persistence catalog after the descriptor doc edit The maxTokens contract sentences added lines above the `subagent/descriptor` declaration, shifting its source anchor from line 32 to 36; regenerate the catalog so the source link stays accurate. --- docs/persistence-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0aa81a7939..53502cc905 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -531,7 +531,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:32`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:36`](../packages/subagent/subagent/src/descriptor.ts) ### `todo/*` From e55d3e96d971b31143636c754603521b6780da84 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:50:24 +0800 Subject: [PATCH 19/41] docs(subagent): re-record bilingual pairs after the stack end-result doc edits Two pairs needed their confirmed-consistent state refreshed: the intent-named note's supersession clause (zh link normalized to the shared `.md` target, since the pairing contract requires identical link targets) and the report README's acceptance-semantics rewrite (both sides edited). Re-record both pairs so the translation-pairing gate passes. --- ...27-intent-named-subagent-continuation-operations.i18n.yaml | 4 ++-- ...-07-27-intent-named-subagent-continuation-operations.zh.md | 2 +- packages/subagent/tool-subagent-report/README.i18n.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index ad2e2c40bd..659cebef8f 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 5029d8335f699e99e67c6027b7d1666880db4724 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 0785730c1934a192380af41f3ad88f95a2747cf7 +2026-07-27-intent-named-subagent-continuation-operations.md: 00340ac53443741e9857cb238ddf95bced504c7f +2026-07-27-intent-named-subagent-continuation-operations.zh.md: 3184a066de98fb442cb5e2d305191419c27f278c diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 58af4f2dc9..3184a066de 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -18,7 +18,7 @@ Status: implemented 调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.zh.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 +`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 ## 已考虑的替代方案 diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 849f6de67c..50bdb0ae5f 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md -README.md: e15b8b5d5881fd7b6868995fec22048a605f4c7e -README.zh.md: 0c41bc9c1e5aa4d728789b064f2d00c8da8ca6c8 +README.md: 5e3947c6a6e65b15040ab8e0852db347130d5fa0 +README.zh.md: bb008f5b0b5ebf0c117d9a4c49a769210726d458 From daf955480416b5c4667ea7b3ff5461d6be162bff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:55:10 +0800 Subject: [PATCH 20/41] refactor(subagent): scope the setup transaction to the creation callback The setup validation and commit moved into the callback, so the outer definite-assignment slot and its type import are no longer needed; declare the transaction as a callback-local const. --- packages/subagent/subagent/src/continuation.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index bc4833bbd0..82f66d08cb 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -42,7 +42,6 @@ import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequ import type { ActivationObserver } from './lifecycle.ts' import { SubagentError } from './error.ts' import type SubagentActivationSetupRegistry from './activation-setup-registry.ts' -import type { ActivationSetupTransaction } from './activation-setup-registry.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { @@ -800,10 +799,9 @@ export class SubagentContinuationManager { // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() - let setupTransaction!: ActivationSetupTransaction const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) - setupTransaction = this.setupRegistry.apply(childCtx) + const setupTransaction = this.setupRegistry.apply(childCtx) // Validate and freeze the batch inside the creation callback, before the // factory can publish the session: a revoked contribution must reject // the create/resume call pre-publication, so no persisted session is From 5c98cbd8f62c7c865f833a1d9608e2e954443b10 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:36:54 +0800 Subject: [PATCH 21/41] test(web): run the subagent-conversation e2e against the locale-aware copy The ui-subagent catalog and read-only composer copy moved from hardcoded Chinese to the locale-aware `subagent` namespace, so an en-US headless browser now renders English. The e2e's selectors and goldens still asserted the old hardcoded Chinese strings, leaving the scenario unable to find the catalog trigger. Convert the selectors to the default (en-US) render and re-record the catalog goldens (ui, tree, nested) in English. The locale-aware parts of the remaining goldens were already English (recorded under the en-US default), so sidebar and fork are untouched. --- .../subagent-conversation/nested.expected.md | 4 +- .../subagent-conversation/tree.expected.md | 12 +++--- .../subagent-conversation/ui.expected.md | 4 +- apps/web/tests/subagent-conversation.e2e.ts | 40 +++++++++---------- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md index 7d7595c7a4..9f7c0f23f7 100644 --- a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md @@ -14,5 +14,5 @@ - button "Branch into a new conversation": - img - status: - - strong: 此子代理暂时只读 - - text: 父会话当前不在线,重新打开父会话后即可继续发送消息。 + - strong: This subagent is read-only for now + - text: The parent session is offline; reopen it to continue sending messages. diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 12520a65c4..88dd7ec974 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ -- tree "子代理会话": - - treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚" [expanded] [level=1]: - - button "收起 event-sourcing researcher 的下级子代理": +- tree "Subagent sessions": + - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running just now" [expanded] [level=1]: + - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚 + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running just now - group: - - treeitem "example editor 可继续 · 当前未运行 刚刚" [level=2] - - treeitem "event-sourcing reviewer 一次性 · 当前未运行 刚刚" [level=1] + - treeitem "example editor continuable · not running just now" [level=2] + - treeitem "event-sourcing reviewer one-shot · not running just now" [level=1] diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 6513bb60e3..3a9b03fffe 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,8 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] - - button "1 个子代理": - - text: 1 个子代理 + - button "1 subagents": + - text: 1 subagents - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index e71b54e0d9..83ce78f3e4 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -217,13 +217,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const catalogButton = page.getByRole('button', { name: /个子代理/ }) + const catalogButton = page.getByRole('button', { name: /subagents/ }) await catalogButton.waitFor({ timeout: 15_000 }) await catalogButton.click() - const catalogTree = page.getByRole('tree', { name: '子代理会话' }) + const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 }) await catalogTree.press('Escape') - await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) }, 120_000) @@ -241,26 +241,26 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() expect(await page.getByRole('button', { - name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`, + name: `Expand ${ONE_SHOT_LABEL} descendants`, }).count()).toBe(0) - await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click() + await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( page, - '[role="tree"][aria-label="子代理会话"]', + '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE) - await page.getByRole('tree', { name: '子代理会话' }).press('Escape') + await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape') }) it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll( () => page.getByText(INITIAL_PROMPT, { exact: true }).count(), @@ -302,18 +302,18 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe('running') const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button').first().click() - const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' }) + const runningTrigger = page.getByRole('button', { name: '3 subagents running' }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) await runningTrigger.click() await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*正在运行`), + name: new RegExp(`${LABEL}.*running`), }).waitFor({ timeout: 10_000 }) await ended await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*当前未运行`), + name: new RegExp(`${LABEL}.*not running`), }).waitFor({ timeout: 10_000 }) - expect(await page.getByRole('button', { name: '3 个子代理' }) + expect(await page.getByRole('button', { name: '3 subagents' }) .locator('[data-state="ongoing"]').count()).toBe(0) await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1) @@ -331,9 +331,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) - await page.getByRole('button', { name: '1 个子代理' }).click() + await page.getByRole('button', { name: '1 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() - await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor() + await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) @@ -352,9 +352,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem') .last() await parentSession.click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click() - await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor() + await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() }) @@ -363,7 +363,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('tree', { name: 'Sessions' }) .getByRole('treeitem', { name: /Ask a research subagent to/ }) .click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() const forkResponse = page.waitForResponse(response => @@ -389,7 +389,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup')) const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.locator('textarea:enabled').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -406,7 +406,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() const input = page.locator('textarea:enabled').first() await input.waitFor() From 295e56b61ec64ea366a215869085e0e580e6efd6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:46:51 +0800 Subject: [PATCH 22/41] fix(web): keep removal-time availability invalidation across an in-flight pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `host/session-removed` invalidation flipped the owned catalog and addressed children to `parentAvailable:false`, but a `subagent.list` pull already in flight was requested before the removal and its ok-response carries the pre-removal `parentAvailable:true` — the response then overwrote both the catalog and every addressed child, resurrecting the writable-editor-against-a-dead-continuation-owner bug the invalidation closes, with no refresh scheduled to converge afterwards. Mark the owner stale when a pull is in flight at removal time, so one trailing refresh runs after the in-flight response settles and the post-removal host truth lands. Adds a regression test: removal mid-pull, stale ok response, trailing pull, final state stays unavailable on the catalog and the addressed child. --- .../runtime/src/client/sessions/manager.ts | 5 +++ packages/client/runtime/tests/manager.spec.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1c186059de..2d20875adb 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -688,6 +688,11 @@ export class SessionManager { this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) + // A pull already in flight was requested before this removal and can + // carry the pre-removal parentAvailable:true, which would resurrect + // the writable editor this invalidation just closed. Queue one + // trailing refresh so the post-removal host truth converges. + if (this.catalogInflight.has(frame.sessionId)) this.catalogStale.add(frame.sessionId) // The removed session can no longer be the delivery owner of its // catalog: invalidate availability immediately. Removal schedules no // catalog refresh, and without this an addressed child keeps a diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 5d12498edd..2d456a9c34 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -588,6 +588,44 @@ describe('subagent catalogs', () => { } }) + it('does not let a stale in-flight pull resurrect a removed parent\'s availability', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const child = () => ({ + kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', + activity: 'inactive' as const, hasChildren: false, + }) + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + const refresh = manager.refreshSubagents(root) + first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await refresh + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + + // The removal lands while a second pull is in flight: the invalidation + // must survive the pre-removal ok response, so one trailing pull runs. + const mid = deferred>>() + api.onSubagentList = () => mid.promise + const midRefresh = manager.refreshSubagents(root) + manager.handleHostEnvelope({ + rpcId: 'parent-removed-mid-pull' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + const trailing = deferred>>() + api.onSubagentList = () => trailing.promise + mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await midRefresh + trailing.resolve(ok({ entries: [child()] as never[], parentAvailable: false })) + await trailing.promise + + const rootCalls = api.callsOf('subagent.list') + .filter((call: { parentSessionId: SessionId }) => call.parentSessionId === root) + expect(rootCalls).toHaveLength(3) + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) + it('invalidates catalog availability when the owning parent is removed', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId From fb6ccdff04cfd39bc8ad2fbb6cc7035e39ee1930 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:47:12 +0800 Subject: [PATCH 23/41] docs(subagent): scope report acceptance to parent resolution, not delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README claimed "acceptance is governed by registry presence" as a universal statement, but `sendReport` translates a registered parent's send rejection into the same PARENT_UNAVAILABLE code — registry presence governs parent *resolution*, while acceptance additionally depends on the parent's log still admitting appends. Soften both languages to the precise contract and re-record the pair. --- packages/subagent/tool-subagent-report/README.i18n.yaml | 4 ++-- packages/subagent/tool-subagent-report/README.md | 2 +- packages/subagent/tool-subagent-report/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 50bdb0ae5f..389a16e620 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md -README.md: 5e3947c6a6e65b15040ab8e0852db347130d5fa0 -README.zh.md: bb008f5b0b5ebf0c117d9a4c49a769210726d458 +README.md: c1cff4d023e35ff246e592f58b0c85c8a10f327a +README.zh.md: 167a6338e8db9fbb5efce7037392f7c75e48f116 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index 5e3947c6a6..c1cff4d023 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. -A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — acceptance is governed by registry presence, so a parent already in host-owned disposal but still registered still accepts. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). +A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). `reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index bb008f5b0b..167a6338e8 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -4,7 +4,7 @@ 可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。接受与否由父级在注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 +子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 `reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 From d7a70f6efa376ee4adac97ea717bc7994d79a038 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:47:24 +0800 Subject: [PATCH 24/41] fix(host): hand a raced plain-agent winner back from agentFor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raced-collision catch mirrored only the subagent-owned half of ensureSession's `.catch`: a concurrent plain-agent publish winning the identity still fell through to `internal`, where ensureSession returns the winner. Mirror in full — classify a subagent-owned winner as `agent-busy`, return a clean plain-agent winner directly. --- packages/host/apiproxy/src/api-proxy.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0827762a8a..14f03b3773 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1114,14 +1114,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (error instanceof SubagentSessionOwnership) { return { error: subagentOwnershipError(error.sessionId) } } - // A concurrent parent `enter()` can win the identity between the - // pre-resume published re-check and `ctx.agents.resume` publication; - // the ID-collision rejection falls through here. Re-classify that - // raced published winner into the stable ownership error, mirroring - // ensureSession's `.catch`. + // A concurrent publish can win the identity between the pre-resume + // re-check and `ctx.agents.resume` publication; the ID-collision + // rejection falls through here. Mirror ensureSession's `.catch` in + // full: classify a subagent-owned winner into the stable ownership + // error, and hand a clean plain-agent winner straight back. const live = ctx.agents.get(sessionId) - if (live !== undefined && hasSubagentOwner(live.session, live)) { - return { error: subagentOwnershipError(sessionId) } + if (live !== undefined) { + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } } const attached = ctx.sessions.get(sessionId) if (attached !== undefined && hasSubagentOwner(attached, undefined)) { From c8b2e709886cdb1d30b096aa95ac5ecb39915f3b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:47:54 +0800 Subject: [PATCH 25/41] build(web): declare the locale dependency for ui-subagent The client plugin now consumes `ctx.locale` (dictionary registration plus the slot `t` seat), but the package graph did not know it: no `dshClient.inject` entry, no peer/devDependency, no tsconfig project reference. Mirror the ui-conversation convention so the dependency graph, HMR/preflight metadata, and standalone packaging all recognize the `@deepseek-ai/dsh-client-locale` seam. --- packages/client/ui-subagent/package.json | 3 +++ packages/client/ui-subagent/tsconfig.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index efec3bc047..a3e753d91b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", @@ -40,6 +41,7 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -49,6 +51,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 395281ce6d..84c310fc56 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../runtime" }, From d004c694f1cef0e972985a3a0852790a4ed9a4c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:48:50 +0800 Subject: [PATCH 26/41] test(web): narrow the stale-pull assertion to the root catalog's calls --- packages/client/runtime/tests/manager.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2d456a9c34..c37f0b88a1 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -620,7 +620,7 @@ describe('subagent catalogs', () => { await trailing.promise const rootCalls = api.callsOf('subagent.list') - .filter((call: { parentSessionId: SessionId }) => call.parentSessionId === root) + .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) expect(rootCalls).toHaveLength(3) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) From e2982e0fcc38b305b937848171ffabed334e8fc4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:50:33 +0800 Subject: [PATCH 27/41] chore(deps): record the ui-subagent locale devDependency in the lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1d8d15c03..d81b237b7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1879,6 +1879,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From dabb710ab3ba03415d90e1bbf4378b1079dd7795 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:52:39 +0800 Subject: [PATCH 28/41] chore(docs): refresh the module graph for the ui-subagent locale edge --- docs/module-graph.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 2af5dc62ea..6687a583c1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -832,6 +832,7 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation pkg_client_ui_subagent --> pkg_client_ui_primitives @@ -1217,7 +1218,7 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | From dbe053fe082f799102a6f73c72011b9937099eb4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:58:22 +0800 Subject: [PATCH 29/41] refactor(host): share the fenced-live-agent resolution between agentFor paths The live fast-path fence and the raced-collision catch duplicated the same subagent-ownership classification, tripping the duplication gate. Extract `fencedLiveAgent` so both paths resolve one live identity through the fence identically. --- packages/host/apiproxy/src/api-proxy.ts | 34 ++++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 14f03b3773..a84e1d5c47 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1065,17 +1065,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return inspected } - async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { + /** + * Resolve one live registered identity through the subagent-ownership + * fence: subagent-owned agents answer `agent-busy`, plain agents pass. + * Fences the live agent's own session rather than trusting a + * "registered ⇒ attached-store" invariant — a registered subagent whose + * session is ever absent from the attached store must still not be handed + * out through generic Host routing. `undefined` means no live agent. + */ + function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined { const live = ctx.agents.get(sessionId) - if (live !== undefined) { - // Fence the live agent's own session rather than trusting a - // "registered ⇒ attached-store" invariant: a registered subagent whose - // session is ever absent from the attached store must still not be - // handed out through generic Host routing (ensureSession's `.catch` - // already fences `live.session`; this is the same check on the fast path). - if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } - return { agent: live } - } + if (live === undefined) return undefined + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } + } + + async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced const attached = ctx.sessions.get(sessionId) if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } @@ -1119,11 +1126,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // rejection falls through here. Mirror ensureSession's `.catch` in // full: classify a subagent-owned winner into the stable ownership // error, and hand a clean plain-agent winner straight back. - const live = ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } - return { agent: live } - } + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced const attached = ctx.sessions.get(sessionId) if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } From b54381f3e744453d975d131654e28b9fdade8f4c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:09:05 +0800 Subject: [PATCH 30/41] fix(agent): commit mutable setup at publication Agent setup may await while a mutable contribution registry changes. The previous subagent path validated and committed its provisioning batch inside the setup callback. A revocation queued after that callback returned therefore treated the installation as resident and released it, even though AgentLoop had not published the child yet. AgentLoop could then admit and announce a child whose required capability had already disappeared. Introduce AgentSetupCommit as the optional synchronous result of create and resume setup. AgentLoop now awaits setup, invokes that commit with no intervening asynchronous boundary, and only then enters the Session and Agent registries. A commit failure follows the existing private-transaction rollback, so neither identity is published and the caller can reuse the id. Keep continuable-subagent installations provisional until this publication commit. Contribution removal still releases every installation immediately, but now marks an unpublished batch invalid so its commit rejects with ACTIVATION_SETUP_REVOKED. Once the commit succeeds, later removal remains ordinary live revocation. Cover create and resume ordering, resume commit rejection and identity reuse, and an assembled microtask revocation that leaves only the parent Agent and Session. Update the public JSDoc, architecture flow, package contracts, current Agent Notes, Chinese counterparts, pairing records, and generated Cordis API to describe the new boundary. Validated with the four focused Agent/subagent test files (91 tests), the isolated assembled regression, targeted TypeScript project builds, generated Cordis API freshness, export JSDoc verification, scoped translation pairing, Markdown wrapping, and Mermaid parsing. --- .../2026-07-08-agent-scope-contexts.i18n.yaml | 6 +- .../2026-07-08-agent-scope-contexts.md | 10 ++-- .../2026-07-08-agent-scope-contexts.zh.md | 10 ++-- ...continuable-subagent-report-tool.i18n.yaml | 4 +- ...-07-30-continuable-subagent-report-tool.md | 8 ++- ...-30-continuable-subagent-report-tool.zh.md | 8 ++- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 +++- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 6 +- packages/core/agent-loop/README.zh.md | 6 +- packages/core/agent-loop/src/index.ts | 8 ++- packages/core/agent-loop/tests/resume.spec.ts | 35 ++++++++++++ .../agent-loop/tests/scope-lifecycle.spec.ts | 8 +++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 6 +- packages/core/agent/README.zh.md | 6 +- packages/core/agent/src/index.ts | 57 +++++++++++++------ .../subagent/src/activation-setup-registry.ts | 31 ++++------ .../subagent/subagent/src/continuation.ts | 20 ++----- .../tests/activation-setup-registry.spec.ts | 7 +-- .../tool-subagent-report/README.i18n.yaml | 4 +- .../subagent/tool-subagent-report/README.md | 1 - .../tool-subagent-report/README.zh.md | 1 - .../tests/tool-subagent-report.spec.ts | 28 +++++++++ 28 files changed, 198 insertions(+), 106 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 67955d377d..cdee6ab259 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 -2026-07-08-agent-scope-contexts.zh.md: 35e725e43d402b048daf12c3b4be384b3fd2d2ce +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +2026-07-08-agent-scope-contexts.md: 5e09bdbcae1e57e6b65eb7d1720a6e7a7f758a9f +2026-07-08-agent-scope-contexts.zh.md: 4714045f28e0386a3a53b53437d063462e75a9f1 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md index e4c076189a..5e09bdbcae 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -108,11 +108,11 @@ A listener registered with `{ global: true }` deliberately bypasses contextual a ### Creation publishes last and disposal revokes last -`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. +`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, synchronously invoke its optional `AgentSetupCommit`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. The commit lets mutable provisioning revalidate at the exact publication boundary after every setup await; a throw rolls the private transaction back before either identity is announced, while revocation after a successful commit is ordinary live teardown. An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal. -If loading, setup, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. +If loading, setup, the optional setup commit, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. `AgentHandle.dispose()` reverses the boundary. It deactivates creation or driving, waits for synchronous publication to unwind, stops and drains the driver and final session flushes, detaches the agent and session, and finally disposes the scope. Repeated or racing disposal requests join one completion promise. @@ -122,12 +122,14 @@ The calling Cordis context and the concrete AgentLoop factory are structural co- flowchart TB request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] privateWorld --> setup["Await composition through agent.ctx"] - setup --> admission["Admit final session and agent entries"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> admission["Admit final session and agent entries"] admission --> publish["Announce lifecycle and start the driver"] publish --> live["Return AgentHandle"] privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] setup -->|"failure, cancellation, or owner loss"| rollback + setupCommit -->|"revalidation failure or owner loss"| rollback admission -->|"duplicate or owner loss"| rollback publish -->|"listener failure or owner loss"| rollback live -->|"handle or owner disposal"| quiesce["Stop and drain work"] @@ -166,6 +168,6 @@ Parentage describes lifetime and conversation lineage, not a universal merge pol ## Consequences -Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup is atomic from an observer's perspective, and teardown preserves local behavior until work stops. +Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup and its optional publication commit are atomic from an observer's perspective, and teardown preserves local behavior until work stops. The cost is explicit subject selection, asynchronous programmatic creation, and service-specific scope adoption. Flat registration scope is intentionally not authority, and subagent composition controls remain a separate feature rather than hidden scope semantics. diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 35e725e43d..4714045f28 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -108,11 +108,11 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 ### 创建最后发布,dispose 最后撤销 -`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。 +`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,同步调用其可选的 `AgentSetupCommit`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。该提交操作让可变的配置状态在所有 setup 的 await 均结算后,于确切的发布边界重新校验;若其抛出异常,则会在公告任何一个身份前回滚私有事务,而成功提交后的撤销属于普通的实时拆卸。 可选的创建信号仅在创建或恢复挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。 -如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 +如果加载、setup、可选的 setup 提交、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 `AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷写,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 @@ -122,12 +122,14 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 flowchart TB request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] privateWorld --> setup["Await composition through agent.ctx"] - setup --> admission["Admit final session and agent entries"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> admission["Admit final session and agent entries"] admission --> publish["Announce lifecycle and start the driver"] publish --> live["Return AgentHandle"] privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] setup -->|"failure, cancellation, or owner loss"| rollback + setupCommit -->|"revalidation failure or owner loss"| rollback admission -->|"duplicate or owner loss"| rollback publish -->|"listener failure or owner loss"| rollback live -->|"handle or owner disposal"| quiesce["Stop and drain work"] @@ -166,6 +168,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 ## 后果 -贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 +贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看,setup 及其可选的发布提交是原子的,拆除则保留本地行为直到工作停止。 代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限,subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index 53d40cf299..54966549ba 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md -2026-07-30-continuable-subagent-report-tool.md: 24922cfe88084bb0f9fea8c9363980a224b875da -2026-07-30-continuable-subagent-report-tool.zh.md: bb0b1847f157dba6116526851194cf1228e52e49 +2026-07-30-continuable-subagent-report-tool.md: 8324f1fa08f7dace6153712575e7e70a07ee9344 +2026-07-30-continuable-subagent-report-tool.zh.md: e7599c1d85328e83c7773718101b59e763a1e37f diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index 24922cfe88..8324f1fa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -54,7 +54,7 @@ The first version provides no durable mailbox, idempotency key, delivery receipt The subagent seam adds `registerContinuableSetup(contribution): () => void`, backed by `SubagentActivationSetupRegistry`. Each synchronous contribution receives the unpublished child context and returns the disposer for its installation. The continuation manager first applies base child composition, then current contributions in registration order through the same setup closure used for fresh creation and cold resume. -The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. A throwing or concurrently revoked contribution rejects before Activation publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. +The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. Applying a batch returns the Agent setup commit that revalidates provisioning after every setup await and immediately before Agent publication. A throwing or concurrently revoked contribution therefore rejects before either Agent or Session publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. This seam keeps the continuation manager unaware of tool names. The report package installs only `report`; `@deepseek-ai/dsh-tool-subagent-control` independently installs parent-side `send_message` and `list_agents`. A deployment can install either direction, both, or neither. Providers remain data-only, durable descriptors do not snapshot report availability or delivery mode, and cold resume uses the deployment's current contributions and policy. @@ -94,6 +94,10 @@ Mutating or cold-resuming an absent parent requires a new durable addressing, au A result-bearing wrapper makes one report or one turn appear terminal and recreates the lifetime mismatch that continuable Activations removed. Explicit repeatable sends need no intermediate execution object. +### Validate setup after Agent creation + +A post-creation revocation check can reject the Activation only after the Agent and Session have been published. Disposing the returned handle removes the live objects but cannot delete persistence through the current seam, leaving a resumable child that the continuation manager said was never established. Returning an `AgentSetupCommit` instead lets the Agent factory perform the same mutable-state check synchronously at its publication boundary. + ## Consequences - A continuable in-process child exposes exactly one scope-local `report` schema only while the report package's contribution is installed; unrelated Agents never expose it. @@ -112,5 +116,3 @@ The acceptance boundary is weaker than durable end-to-end delivery. A crash can Wakeup mode can amplify model work when nested children report frequently. Deployment ownership and a quiet default limit but do not remove that risk. Registry presence is the parent liveness signal. A host-owned parent whose `AgentHandle.dispose()` has started but has not yet unwound its scope can still accept and append a report that it will not act on in this process. Closing that gap requires an Agent-level disposal-start signal rather than subagent-layer inference. - -The final setup-revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, after lower-level Agent and Session publication. Revocation in this window rolls back the handle and prevents the subagent Activation start edge but may leave a persisted Session. Moving the cutoff before lower-level publication requires a future Agent-creation setup transaction seam. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index bb0b1847f1..e7599c1d85 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -54,7 +54,7 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以 subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 `SubagentActivationSetupRegistry` 支撑。每个同步贡献都会接收尚未发布的 child 上下文,并返回其安装的 disposer。继续执行管理器首先应用基础 child 组合,然后通过同一个用于首次创建与冷恢复的设置闭包,按注册顺序应用当前贡献。 -注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。某项贡献抛出异常或被并发撤销时,会在 Activation 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 +注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与 Session 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report`;`@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message` 和 `list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。 @@ -94,6 +94,10 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, 承载结果的包装层会让一次报告或一个轮次看似具有终止性,并重新引入可继续 Activation 已经移除的生命周期不匹配。显式、可重复的发送无需中间执行对象。 +### 在 Agent 创建后校验 setup + +创建完成后的撤销检查只能在 Agent 与 Session 均已发布后拒绝 Activation。对返回的 handle 执行 dispose 会移除实时对象,但当前 seam 无法删除持久化内容,因此会留下一个仍可恢复的 child,而继续执行管理器却判定它从未建立。改为返回 `AgentSetupCommit`,Agent 工厂便可在自身的发布边界同步执行同一项可变状态检查。 + ## 影响 - 只有安装 report 包贡献时,可继续进程内 child 才会恰好暴露一个作用域局部 `report` schema;无关 Agent 永远不会暴露该 schema。 @@ -112,5 +116,3 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, wakeup 模式可能在嵌套 child 频繁报告时放大模型工作量。由部署所有者控制并默认静默,可以限制该风险,但无法完全消除。 注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未展开其作用域,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。 - -最终 setup 撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时底层 Agent 和 Session 已经发布。在该窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。若要把截止点移到底层发布之前,需要未来提供 Agent 创建 setup 事务 seam。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 576ef05d32..a0d59b2993 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d -architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f +architecture.md: add76b6016ae5243115e88173971468a2c93287f +architecture.zh.md: 595f14566cac4301a0b52ded1140511cab948d90 diff --git a/docs/architecture.md b/docs/architecture.md index 6aa942ba27..add76b6016 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Creation without an id mints `-session-`; `sessionId` resumes o ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -139,7 +139,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Scope -Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication and may return a synchronous commit that the factory invokes immediately before registry entry, after every setup await. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c8aaa68527..595f14566c 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -76,7 +76,7 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委 ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -139,7 +139,7 @@ idle inject: ### Agent 作用域 -每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 +每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合,并可返回一个同步提交操作;所有 setup 的 await 均完成后,工厂会在进入注册表前立即调用该操作。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 ## 状态 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 96b876a0eb..4744710ee3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a2a1bf3c5d..52610d78ad 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1607,6 +1607,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', }, + { + name: 'AgentSetup', + declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', + }, + { + name: 'AgentSetupCommit', + declaration: 'export interface AgentSetupCommit {\n commit(): void;\n}', + }, { name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', @@ -1833,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -2337,7 +2345,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'SandboxEnforcement', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 6e9e63a22f..00643acbbc 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6 -README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817 +README.md: f146ea3d2cc379303514286c5cfd2833397f23ed +README.zh.md: 767fbd420709be05e9a8f85dc8ec6fe03ae6aa66 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1662b1076c..f146ea3d2c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. @@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 2fca32a02f..767fbd4207 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 +创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 @@ -20,8 +20,8 @@ `AgentLoop` 还实现 `AgentFactory` seam,并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent: -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),使用同一 id 注册 agent,重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup,再执行受回滚保护的发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),在同一 id 下重建历史,针对全新且尚未发布的 agent 作用域等待 setup,调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent(该路径会丢弃 handle)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 1342fbd885..54b8f5c957 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -558,7 +558,10 @@ export class AgentLoop extends Service implements AgentFactory { const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal) const published = (async () => { try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId) + const setupCommit = await raceAbort( + options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId, + ) + setupCommit?.commit() return prepared.publish('startup') } catch (error: unknown) { await prepared.dispose() @@ -617,7 +620,8 @@ export class AgentLoop extends Service implements AgentFactory { }) const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + setupCommit?.commit() return prepared.publish('resume') } catch (error: unknown) { await prepared.dispose() diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 7844aebd73..0bb7ce7d2e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + order.push('setup:commit') + }, + } }, }) @@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', @@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('resume setup commit rejection publishes nothing and releases the identity', async () => { + const sessionId = SessionId('resume-setup-commit-reject') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + await expect(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + setup: () => ({ + commit: () => { throw new Error('resume setup commit failed') }, + }), + })).rejects.toThrow('resume setup commit failed') + + expect(published).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + const retry = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await retry.dispose() + await ctx.fiber.dispose() + }) + it('owner unload aborts resume setup and cannot publish after the callback settles', async () => { const sessionId = SessionId('resume-setup-owner-unload') const root = await persistSession(sessionId) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5f4e51784a..2420d20e8f 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => { setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() + order.push('setup:commit') + }, + } }, }) await setupStarted.promise @@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => { expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 78473df4a7..1461dcf6da 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734 -README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170 +README.md: 5f9fdc44794a562c2b0da39e1a43a01aa19c451b +README.zh.md: f088b8c354f0aec1b51d7ff8ae706ddc49265a20 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8a60283521..5f9fdc4479 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves. `AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. @@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index ffa71ea987..f088b8c354 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 `AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 @@ -39,8 +39,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上,消费方(UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow,也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。 - `ctx.agents.setFactory(factory: AgentFactory): () => void`:注册创建工厂(循环在构造时调用)。第二个工厂会导致抛出;dispose 时清空槽位。 -- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 -- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 +- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,调用其可选的同步提交,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 +- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 `AgentHandle = { agent: Agent; dispose(): Promise }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent,从存储中移除其会话,最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index d5c19aae3e..66dee4efb7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -36,6 +36,27 @@ declare module 'cordis' { } } +/** + * Synchronous finalizer returned by unpublished Agent setup when its + * contributions need validation at the exact publication commit point. + */ +export interface AgentSetupCommit { + /** + * Validate and commit the prepared setup immediately before publication. + * @throws when publication must roll the unpublished Agent back. + */ + commit(): void +} + +/** + * Compose an unpublished Agent scope and optionally return its publication commit. + * @param agentCtx - unpublished Agent scope. + * @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication. + */ +export type AgentSetup = ( + agentCtx: Context, +) => AgentSetupCommit | Promise | void + /** * Options for programmatically creating an agent through the registry factory * ({@link AgentRegistry.create}). The caller supplies the single live @@ -80,17 +101,21 @@ export interface CreateAgentOptions { * Creation-time composition of the agent's scoped world. The factory awaits * setup after minting `agentCtx` but BEFORE inserting or announcing either * the session or agent, so observers can never see a partially configured - * world. Everything registered through `agentCtx` (scoped tools, prompt - * sections/variables, `restrict()`, listeners, awaited child plugins) exists - * before `session/created`, `agent/created`, `agent/session-start`, and the - * first prompt assembly. A throw/rejection or owner disposal rolls the scope - * back without publishing either id. + * world. Setup may return an {@link AgentSetupCommit}; the factory invokes its + * synchronous `commit()` after every setup await settles and immediately + * before registry publication. This lets mutable provisioning revalidate at + * the exact publication boundary. Everything registered through `agentCtx` + * (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited + * child plugins) exists before `session/created`, `agent/created`, + * `agent/session-start`, and the first prompt assembly. A setup + * throw/rejection, commit throw, or owner disposal rolls the scope back + * without publishing either id. * * **Setup composes, it never drives**: the callback is trusted same-process * code and receives the full scoped context, so this is a contract rather * than a runtime restriction. Drive the agent only after creation resolves. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -108,12 +133,12 @@ export interface ResumeAgentOptions { * Resume-time composition of the agent's fresh scoped world. Persistence is * loaded first; the factory then mints `agentCtx` and awaits setup while the * reconstructed session and agent remain unpublished. The callback has the - * same trusted composition-only contract as - * {@link CreateAgentOptions.setup}: all registrations exist before either - * creation announcement, and rejection or owner disposal rolls the - * transaction back without publishing either id. + * same trusted composition-only contract and optional synchronous + * publication commit as {@link CreateAgentOptions.setup}: all registrations + * exist before either creation announcement, and rejection, commit failure, + * or owner disposal rolls the transaction back without publishing either id. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -144,9 +169,9 @@ export interface AgentHandle { export interface AgentFactory { /** * Create a new agent on a caller-supplied session id. Async because creation - * awaits unpublished setup, inserts both session and agent, emits their - * creation notifications in order, emits `agent/session-start`, and only - * then starts the loop. The sequence is + * awaits unpublished setup, invokes its optional synchronous commit, inserts + * both session and agent, emits their creation notifications in order, emits + * `agent/session-start`, and only then starts the loop. The sequence is * rollback-covered, but notifications delivered before a later listener * failure remain observable; every agent or session creation announcement * that began is paired by `agent/disposed` or `session/disposed` during @@ -165,8 +190,8 @@ export interface AgentFactory { * Load a persisted session and resume an agent on it. Async because it awaits * both `ctx.sessionPersistence.load` and the optional unpublished setup * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication follows the same ordered boundary as - * {@link createAgent}. + * `sessionPersistence`). Publication follows the same setup-commit and + * ordered boundary as {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index dca194f113..3e8ef4fe61 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -12,6 +12,7 @@ */ import type { Context } from 'cordis' +import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { SubagentError } from './error.ts' @@ -47,17 +48,6 @@ interface TransactionState { invalidated: boolean } -/** Package-private setup transaction consumed by the continuation manager. */ -export interface ActivationSetupTransaction { - /** - * Reject a batch invalidated by revocation before publication. - * @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation. - */ - assertIntact(): void - /** Promote this batch to resident installations. */ - commit(): void -} - /** Re-read mutable removal state after a contribution may have revoked itself. */ function isRemoved(registration: Registration): boolean { return registration.removed @@ -95,9 +85,9 @@ export class SubagentActivationSetupRegistry { /** * Install every live contribution into one unpublished child context. * @param childCtx - the child's unpublished scoped context. - * @returns the provisioning transaction. + * @returns the provisioning commit consumed at Agent publication. */ - apply(childCtx: Context): ActivationSetupTransaction { + apply(childCtx: Context): AgentSetupCommit { const state: TransactionState = { installations: [], invalidated: false } try { for (const registration of [...this.registrations]) { @@ -138,15 +128,14 @@ export class SubagentActivationSetupRegistry { throw error } return { - assertIntact: () => { - if (!state.invalidated) return - throw new SubagentError( - 'a continuable-subagent setup contribution was revoked while this child was being built; ' - + 'the child was not established', - 'ACTIVATION_SETUP_REVOKED', - ) - }, commit: () => { + if (state.invalidated) { + throw new SubagentError( + 'a continuable-subagent setup contribution was revoked while this child was being built; ' + + 'the child was not established', + 'ACTIVATION_SETUP_REVOKED', + ) + } for (const installation of state.installations) installation.transaction = undefined }, } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 82f66d08cb..f690cab6cf 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -20,6 +20,7 @@ import type { Agent, AgentHandle, AgentOptions, + AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' @@ -799,19 +800,9 @@ export class SubagentContinuationManager { // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() - const setup = (childCtx: Context): void => { + const setup = (childCtx: Context): AgentSetupCommit => { applyChildComposition(childCtx, inputs.composition) - const setupTransaction = this.setupRegistry.apply(childCtx) - // Validate and freeze the batch inside the creation callback, before the - // factory can publish the session: a revoked contribution must reject - // the create/resume call pre-publication, so no persisted session is - // ever left behind for a child the manager rejects — rollback only - // disposes the live handle, and the persistence seam has no delete, so - // a post-publication rejection would leave a resumable ghost child. - // Committing here also means a later contribution removal releases the - // installation instead of invalidating a child already being established. - setupTransaction.assertIntact() - setupTransaction.commit() + return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) const { create } = inputs @@ -867,9 +858,8 @@ export class SubagentContinuationManager { for (const item of items) activation.accepted.delete(item.message.id) this.wake(activation) }) - // Setup already validated and committed inside the creation callback; - // revocations from here on are immediate live revocation, never - // creation invalidation. + // Agent creation committed setup at its publication boundary; + // revocations from here on are immediate live revocation. // Publish the start edge before any turn can run, so observers see this // epoch before its first request. observer.start(handle.agent) diff --git a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts index 059befe236..6353f486c5 100644 --- a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts +++ b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts @@ -19,8 +19,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(child.ctx) expect(order).toEqual(['first', 'second']) - expect(() => { transaction.assertIntact() }).not.toThrow() - transaction.commit() + expect(() => { transaction.commit() }).not.toThrow() expect(order).toEqual(['first', 'second']) }) @@ -68,7 +67,7 @@ describe('SubagentActivationSetupRegistry', () => { remove() expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/) + expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/) }) it('catches a contribution revoked inside its own installer', () => { @@ -82,7 +81,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(childContext().ctx) expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked/) + expect(() => { transaction.commit() }).toThrow(/revoked/) }) it('attempts every contribution-removal disposer before reporting failures', () => { diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 389a16e620..869dee55bf 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-report/README.md -README.md: c1cff4d023e35ff246e592f58b0c85c8a10f327a -README.zh.md: 167a6338e8db9fbb5efce7037392f7c75e48f116 +README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58 +README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index c1cff4d023..cd73154dfb 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -58,7 +58,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del ## Known Limitations and Deferred Work -- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication. - **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary. - **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report. - **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 167a6338e8..4b31bed48e 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -58,7 +58,6 @@ ## 已知限制与暂缓事项 -- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口,需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。 - **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由延续管理器拥有的父级,管理器的准入边界会在整棵子树拆卸期间拒绝该上报。 - **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。 - **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。 diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 4d44204fd6..84f33646bb 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -350,6 +350,34 @@ describe('dsh-tool-subagent-report', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) + it('rolls back materialization when setup revocation lands before publication', async () => { + const { ctx, parent } = await setup({ load: false }) + const self: { revoke?: () => void } = {} + let installed = false + self.revoke = ctx.subagents.registerContinuableSetup(() => { + installed = true + queueMicrotask(() => { self.revoke?.() }) + return () => { installed = false } + }) + const announced: SessionId[] = [] + const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) }) + + await expect(ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'revoked child', + request: { + prompt: [{ type: 'text', text: 'revoked child' }], + parent, + }, + signal: testSignal, + })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(installed).toBe(false) + expect(announced).toEqual([]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) + expect(ctx.sessions.list()).toEqual([parent.session]) + }) + it('accepts a report into a host-disposing but still-registered parent', async () => { const { ctx } = await setup() const parentHandle = await ctx.agents.create({ From 069f2644fffd3ea81d997ce5d35a02afecfcbfa0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:11:34 +0800 Subject: [PATCH 31/41] fix(web): preserve removal across stale catalog pulls The earlier removal fix invalidated parentAvailable immediately and queued a trailing subagent.list request, but it still applied the already in-flight success verbatim. That stale success reopened the composer and became the trailing request baseline. If the trailing request failed, its error snapshot preserved parentAvailable:true indefinitely. Record a false-only parent availability override on the exact in-flight catalog request when the owner removal frame arrives. Successful and failed responses now replay that request-local invalidation before publishing a snapshot, and addressed child Sessions receive the same effective value. The trailing request therefore starts from a false baseline and a later transport or business failure cannot resurrect the removed parent. Strengthen the regression to assert the catalog and selected child remain read-only immediately after a stale parentAvailable:true success, then fail the trailing pull and assert the error snapshot remains unavailable. The complete SessionManager test file passes all 39 tests, and the client runtime TypeScript project builds cleanly. --- .../runtime/src/client/sessions/manager.ts | 31 ++++++++++++++----- packages/client/runtime/tests/manager.spec.ts | 14 +++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 2d20875adb..96b790abd0 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -59,6 +59,8 @@ interface CatalogInflight { readonly promise: Promise readonly expandableRows: Set readonly activityRows: Map + /** Removal-time invalidation replayed over the response this request predates. */ + parentAvailableOverride: false | undefined } type SessionListMutation = @@ -312,22 +314,26 @@ export class SessionManager { try { const { result } = await this.api.subagents.list({ parentSessionId }) if (result.ok) { + const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? result.value.parentAvailable this.catalogs.set(parentSessionId, { ...result.value, entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows), + parentAvailable, state: 'ready', error: null, }) for (const [childId, address] of this.addresses) { if (address.parentSessionId !== parentSessionId) continue - this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable) + this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable) } } else { this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: result.error, }) @@ -338,7 +344,8 @@ export class SessionManager { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: folded.ok ? null : folded.error, }) @@ -351,7 +358,12 @@ export class SessionManager { this.notifier.markDirty() } })() - this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows }) + this.catalogInflight.set(parentSessionId, { + promise: operation, + expandableRows, + activityRows, + parentAvailableOverride: undefined, + }) return operation } @@ -690,9 +702,14 @@ export class SessionManager { if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect - // the writable editor this invalidation just closed. Queue one - // trailing refresh so the post-removal host truth converges. - if (this.catalogInflight.has(frame.sessionId)) this.catalogStale.add(frame.sessionId) + // the writable editor this invalidation just closed. Replay false over + // that response and queue one trailing refresh so the post-removal + // host truth converges. + const inflightCatalog = this.catalogInflight.get(frame.sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(frame.sessionId) + } // The removed session can no longer be the delivery owner of its // catalog: invalidate availability immediately. Removal schedules no // catalog refresh, and without this an addressed child keeps a diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index c37f0b88a1..8ec3df98d8 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -588,7 +588,7 @@ describe('subagent catalogs', () => { } }) - it('does not let a stale in-flight pull resurrect a removed parent\'s availability', async () => { + it('keeps removal invalidation across a stale success and failed trailing pull', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId const child = () => ({ @@ -616,8 +616,16 @@ describe('subagent catalogs', () => { api.onSubagentList = () => trailing.promise mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await midRefresh - trailing.resolve(ok({ entries: [child()] as never[], parentAvailable: false })) - await trailing.promise + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + + trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} })) + await vi.waitFor(() => { + expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ + state: 'error', + parentAvailable: false, + }) + }) const rootCalls = api.callsOf('subagent.list') .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) From 7b40fd54196b37221ffa9c710ce22e07da7b9f8c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:13:28 +0800 Subject: [PATCH 32/41] perf(web): trail only membership-invalidated catalogs refreshSubagents previously treated every overlapping caller as proof that the in-flight response was stale. Selection, menu opening, and reconnect paths can legitimately request the same catalog concurrently without any host mutation, so those reads were coalesced and then followed by an unnecessary second RPC. Restore ordinary in-flight coalescing at the public refresh boundary. The debounced host/session-added path now owns the membership-specific stale mark: if its timer fires during an older pull, it queues one trailing request; otherwise it starts the refresh directly. Parent removal keeps its separate explicit invalidation and trailing-refresh path. Add a regression proving two overlapping reads share one Promise and issue one RPC. Rework the membership test to start from a restored selected parent, so only the host membership frame can request the trailing pull instead of the test priming the stale bit with an unrelated duplicate read. Validated with both focused catalog cases, all 40 SessionManager tests, and the client runtime TypeScript project build. --- .../runtime/src/client/sessions/manager.ts | 20 +++++++++---------- packages/client/runtime/tests/manager.spec.ts | 19 ++++++++++++++++-- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 96b790abd0..afbbe9e6ad 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -290,16 +290,7 @@ export class SessionManager { */ refreshSubagents(parentSessionId: SessionId): Promise { const existing = this.catalogInflight.get(parentSessionId) - if (existing !== undefined) { - // A refresh requested while a pull is in flight must not be silently - // coalesced into it: the in-flight response was requested before the - // triggering change (a membership frame or an opened menu), so it can - // never contain that change. Queue one trailing refresh that runs after - // the pull settles; without it the change stays invisible until an - // unrelated later trigger (reselection, menu reopen, reconnect). - this.catalogStale.add(parentSessionId) - return existing.promise - } + if (existing !== undefined) return existing.promise const previous = this.catalogs.get(parentSessionId) const expandableRows = new Set() const activityRows = new Map() @@ -774,11 +765,18 @@ export class SessionManager { for (const session of this.sessions.values()) void session.resync() } - /** Debounce membership refetches while one parent catalog is open. */ + /** Debounce membership refetches while one parent catalog is selected or open. */ private scheduleCatalogRefresh(parentSessionId: SessionId): void { if (this.catalogDebounce.has(parentSessionId)) return const timer = setTimeout(() => { this.catalogDebounce.delete(parentSessionId) + // The in-flight response predates the membership frame that scheduled + // this callback. Queue one post-settlement pull instead of treating an + // ordinary overlapping read as evidence that catalog membership changed. + if (this.catalogInflight.has(parentSessionId)) { + this.catalogStale.add(parentSessionId) + return + } void this.refreshSubagents(parentSessionId) }, 50) this.catalogDebounce.set(parentSessionId, timer) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 8ec3df98d8..f6d7baf318 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -530,6 +530,22 @@ describe('subagent catalogs', () => { ]) }) + it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + + const refresh = manager.refreshSubagents(root) + expect(manager.refreshSubagents(root)).toBe(refresh) + api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + first.resolve(ok({ entries: [], parentAvailable: true })) + await refresh + + expect(api.callsOf('subagent.list')).toHaveLength(1) + }) + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { vi.useFakeTimers() try { @@ -538,8 +554,7 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api) - manager.setSubagentCatalogOpen(root, true) + const manager = new SessionManager(api, root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced From ac88b6e4c79df1a8e109009a02b63a7955bbf469 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:14:46 +0800 Subject: [PATCH 33/41] fix(acp): retain nested teardown diagnostics ACP waits for every owned Agent disposal and throws one AggregateError when any Session teardown fails. The aggregate message embedded each rejected value with String(failure) because the connection-close logger itself renders only the outer error message. String preserves only an Error name and message, so causes and AggregateError members disappeared from the operational warning. Render each per-session rejection with the existing errorChain diagnostic helper before joining it into the outer message. The original rejected values remain in AggregateError.errors for programmatic inspection, while the message now carries cause chains and nested aggregate members through the String-based logger. Exercise a disposal failure containing both AggregateError members and a nested cause, while retaining the existing barrier that proves the second Session finishes disposal before any warning is emitted. All 10 ACP disposal tests pass and the ACP TypeScript project builds cleanly. --- packages/acp/acp/src/index.ts | 11 +++++------ packages/acp/acp/tests/dispose.spec.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 7af26b594a..b88322012c 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' import Schema from 'schemastery' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import { AgentSideConnection, ndJsonStream, @@ -368,11 +368,10 @@ export function apply(ctx: Context, config: AcpConfig): void { if (result.status === 'rejected') failures.push(result.reason as unknown) } if (failures.length > 0) { - // The only production consumer logs this error through `String`, which - // renders the message alone — without the joined reasons, per-session - // disposal failures would vanish from operational logs. Join them like - // the subagent seam's own aggregate disposal messages. - const detail = failures.map(failure => String(failure)).join('; ') + // The production consumer logs this AggregateError through `String`, + // which renders only its message. Embed every per-session diagnostic, + // including nested causes and aggregate members, in that message. + const detail = failures.map(failure => errorChain(failure)).join('; ') throw new AggregateError( failures, `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 0eea014eeb..b4b1eaeef2 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -96,7 +96,7 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) - it('awaits every owned session disposal before reporting one failure', async () => { + it('awaits every owned session disposal and reports nested failure reasons', async () => { harness = await makeBridgeHarness() const create = harness.ctx.agents.create.bind(harness.ctx.agents) const releaseSecond = Promise.withResolvers() @@ -110,7 +110,10 @@ describe('ACP connection ownership', () => { if (created++ === 0) { handle.dispose = async () => { await originalDispose() - throw new Error('first session cleanup failed') + throw new AggregateError([ + new Error('scope cleanup failed', { cause: new Error('sqlite busy') }), + new Error('hook cleanup failed'), + ], 'first session cleanup failed') } } else { handle.dispose = async () => { @@ -132,7 +135,10 @@ describe('ACP connection ownership', () => { releaseSecond.resolve(undefined) await vi.waitFor(() => { expect(warnings.some(warning => - warning.includes('ACP agent teardown failed for 1 session(s): Error: first session cleanup failed'))).toBe(true) + warning.includes( + 'ACP agent teardown failed for 1 session(s): ' + + 'first session cleanup failed [scope cleanup failed: sqlite busy; hook cleanup failed]', + ))).toBe(true) expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined() }) From 3fc4142c04f5fd3f060266f5cb4c63dc870cb7d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:17:34 +0800 Subject: [PATCH 34/41] fix(web): pluralize singular subagent counts The localized catalog exposed one count.total and one count.running string for every cardinality. The English dictionary therefore rendered both the visible trigger and its accessibility label as 1 subagents, and the assembled Web golden had begun preserving that grammar error. Split both count families into explicit one and other keys, following the existing client locale convention. SubagentCatalogAction selects the pair from the effective descendant count; English uses subagent for one and subagents otherwise, while Chinese keeps its unchanged classifier text under the same key domain. Add a component regression proving a single running descendant selects both singular keys. Update the real Web E2E locator and keyless assembled aria golden from 1 subagents to 1 subagent. Both ui-subagent test files pass all 28 tests and the package TypeScript project builds cleanly. --- .../subagent-conversation/ui.expected.md | 4 ++-- apps/web/tests/subagent-conversation.e2e.ts | 2 +- .../src/client/SubagentCatalogAction.tsx | 6 ++++-- .../client/ui-subagent/src/client/locales.ts | 12 ++++++++---- .../ui-subagent/tests/conversation-ui.spec.tsx | 18 ++++++++++++++++++ 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 3a9b03fffe..14b397d9b4 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,8 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] - - button "1 subagents": - - text: 1 subagents + - button "1 subagent": + - text: 1 subagent - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 83ce78f3e4..904fcfcd09 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -331,7 +331,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) - await page.getByRole('button', { name: '1 subagents' }).click() + await page.getByRole('button', { name: '1 subagent' }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 359827780f..14f8169d82 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -324,6 +324,8 @@ export function SubagentCatalogAction({ // The catalog can arrive before the session-list baseline; never undercount // the already-visible direct rows during that short bootstrap window. const descendantCount = Math.max(healthy.length, descendants.count) + const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other' + const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other' const observeCatalog = (parentSessionId: SessionId, next: boolean): void => { if (next) observedCatalogs.current.add(parentSessionId) @@ -432,7 +434,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={t(descendants.running ? 'count.running' : 'count.total', { count: descendantCount })} + aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -444,7 +446,7 @@ export function SubagentCatalogAction({ {descendants.running && } - {t('count.total', { count: descendantCount })} + {t(totalCountKey, { count: descendantCount })} {open && ( diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 2ecf1be4f5..86562534b0 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -24,8 +24,10 @@ export const zh = { 'activity.inactive': '当前未运行', 'branch.collapse': '收起 {label} 的下级子代理', 'branch.expand': '展开 {label} 的下级子代理', - 'count.total': '{count} 个子代理', - 'count.running': '{count} 个子代理,正在运行', + 'count.total.one': '{count} 个子代理', + 'count.total.other': '{count} 个子代理', + 'count.running.one': '{count} 个子代理,正在运行', + 'count.running.other': '{count} 个子代理,正在运行', 'tree.aria': '子代理会话', 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', @@ -54,8 +56,10 @@ export const en: Record = { 'activity.inactive': 'not running', 'branch.collapse': 'Collapse {label} descendants', 'branch.expand': 'Expand {label} descendants', - 'count.total': '{count} subagents', - 'count.running': '{count} subagents running', + 'count.total.one': '{count} subagent', + 'count.total.other': '{count} subagents', + 'count.running.one': '{count} subagent running', + 'count.running.other': '{count} subagents running', 'tree.aria': 'Subagent sessions', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 1687b1ffc1..5649b2b257 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -167,6 +167,24 @@ describe('SubagentCatalogAction', () => { expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false) }) + it('selects singular count keys for one descendant', () => { + const base = props(catalog({ + entries: [{ + kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }], + }), {}, { + [CHILD]: { + ...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true, + }, + }) + const translate = vi.fn(base.t) + render() + + expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 }) + expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 }) + }) + it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => { const input = props(catalog()) render() From f91b4b2cdcd4c09d7d53cd8263594110eb5b0477 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:18:31 +0800 Subject: [PATCH 35/41] docs(subagent): correct cold-resume parent contract The descriptor module claimed that no parent exists during cold resume, using that as the reason maxTokens cannot be inherited. Continuable follow-up actually requires and authorizes the exact live direct parent before it loads the descriptor and materializes the child, so the stated lifecycle fact was false. Keep the real persistence decision explicit: per-activation budgets are not durable descriptor composition. Cold resume reconstructs child options only from the curated durable fields, so it deliberately neither restores the establishing budget nor inherits the live parent current transient budget; the resumed provider and model route defaults apply. This is a contract-only correction with no runtime change. The exported JSDoc gate remains clean. --- packages/subagent/subagent/src/descriptor.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 4cec72e658..55ba73dcc4 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -13,9 +13,10 @@ * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs * to one activation's result contract rather than durable child composition. * Per-activation knobs such as `maxTokens` are omitted for the same reason as - * `outputSchema`: they budget one activation and, on cold resume, no parent - * exists to inherit them from, so the resumed activation runs under the - * deployment defaults rather than restoring a stale budget. + * `outputSchema`: they budget one activation. Cold resume requires the exact + * live parent for authorization but reconstructs child options only from the + * durable descriptor, so it neither restores the prior budget nor inherits + * the parent's current one; the resumed route's defaults apply instead. * * @module @deepseek-ai/dsh-subagent/descriptor */ From ab4120ac967077296eef886e81edc13fd4d417f9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:24:43 +0800 Subject: [PATCH 36/41] docs(subagent): keep intent note current The PR appended a superseded warning to one obsolete durability clause while leaving the same active decision record with mutually incompatible claims about Task-backed continuations, provider resume dispatch, and persistence guarantees. Because implemented Agent Notes are current authority rather than a review-history log, readers could still derive an API and ownership model that no longer exists. Rewrite the affected decision, alternatives, and consequences in place around the activation-based implementation: ordinary starts remain holder-owned one-shot runs; continuable starts return durable child and accepted message identities; the manager owns materialization, follow-up/report routing, cold resume, and teardown; providers only contribute detached first-create data through prepareContinuable; and flush participation is observable but is not proof that a persistence backend stored state. Keep the English and Chinese records equivalent, move the Chinese dispose glossary to its new first use, and refresh the pairing sidecar. This commit changes documentation authority only; it does not change runtime behavior. Validated with the scoped translation-pairing writer and checker, verify-md-wrap, verify-agent-note-format, verify-agent-note-classification, and git diff --cached --check. --- ...-subagent-continuation-operations.i18n.yaml | 4 ++-- ...t-named-subagent-continuation-operations.md | 18 +++++++++--------- ...amed-subagent-continuation-operations.zh.md | 18 +++++++++--------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index 659cebef8f..f0587a9b39 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 00340ac53443741e9857cb238ddf95bced504c7f -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 3184a066de98fb442cb5e2d305191419c27f278c +2026-07-27-intent-named-subagent-continuation-operations.md: e74d62b7582e92f8e5ce68327a677259c8453d24 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: dae4dd37fa9950f0b8d1ba6ec5c46b99977a7201 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 00340ac534..e74d62b758 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) -The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, retains its bare `Agent` parameter as exact live-direct-parent authority, and replaces provider `resume` dispatch with `prepareContinuable`. +The current activation-based realization is owned by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). It retains the `followup` operation this record names, returns the accepted `MessageId`, uses the bare `Agent` parameter as exact live-direct-parent authority, and limits provider participation in continuable children to `prepareContinuable`. ## Problem @@ -14,25 +14,25 @@ The durability boundary also exposed both `SessionStore.flush()` and `flushRequi ## Decision -`SubagentService` exposes three execution intents: `start(name, request)` for an ordinary holder-owned run, `startContinuable(spec)` for a durable Task-backed child, and `followup(parent, childId, content, { source, signal })` for later content. The last verb matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-activation capability. The model-facing tool keeps its stable `send_message` name and delegates routing to `followup()`. +`SubagentService` separates four execution intents: `start(name, request)` returns an ordinary holder-owned one-shot run; `startContinuable(spec)` establishes a durable child and returns its id plus the accepted initial `MessageId`; `followup(parent, childId, content, { source, signal })` sends later parent content; and `reportFrom(child, content, { delivery, signal })` sends selected child content to its direct parent. `followup` matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-run capability. The model-facing tools keep their stable `send_message` and `report` names and delegate routing to the corresponding intent methods. -Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. +Caller and provider requests are distinct. `SubagentStartRequest` contains caller-supplied one-shot data; `ResolvedSubagentStartRequest` adds the service-resolved descriptor before `SubagentProvider.start()`. For continuable creation, the manager passes a `ContinuableCreateRequest` to optional `SubagentProvider.prepareContinuable()` and receives detached creation data only. `SubagentService.resume()` and provider resume dispatch are absent: the continuation manager loads the descriptor, authorizes the parent, and owns Agent materialization, prompt delivery, cold resume, and teardown. -`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. **Superseded** by the activation-based record [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md): the continuation manager awaits the final `flush()` as a best-effort barrier and deliberately ignores the boolean, because listener participation cannot identify a persistence backend; a rejection is logged without changing the lifecycle result or host-drain outcome. +`SessionStore.flush(session)` is the single durability barrier and returns `Promise`. It resolves `true` after at least one scoped listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Participation cannot identify whether a selected persistence backend stored the state. Ordinary checkpoints may ignore the boolean; the continuation manager also treats its final flush as a best-effort barrier, deliberately ignores participation, logs rejection, and still disposes the child and releases ownership. ## Alternatives considered -**Keep public provider resume dispatch.** No production caller outside the continuation manager owns the descriptor lookup, direct-parent authorization, Task cancellation, and activation association needed to call it safely. A public method would expose resolved implementation data without a valid independent intent. +**Keep public provider resume dispatch.** No production caller outside the continuation manager owns descriptor lookup, direct-parent authorization, Agent materialization, Activation ownership, and child-first teardown. A public method would expose resolved implementation data without a valid independent intent; providers instead contribute detached first-creation data through `prepareContinuable` and never participate in cold resume. **Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route. **Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable. -**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned run or immediate child/Task identities. Separate intent methods preserve the ownership and timing distinction without a return union. +**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned one-shot run or immediate durable child and message identities. Separate intent methods preserve the ownership and timing distinction without a return union. ## Consequences -- The Cordis service catalog contains only caller operations; provider reconstruction remains extensible through `SubagentProvider.resume?()` without exposing its resolved request as a service method. +- The Cordis service catalog contains only caller operations; a provider can opt into continuable first creation through `SubagentProvider.prepareContinuable?()` without receiving Agent lifecycle authority or a public resume operation. - Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics. -- Session durability has one barrier operation. Callers that require a backend must inspect its participation result rather than selecting a second dispatch method. -- The `send_message` schema, route results, Task ownership, durable event vocabulary, and model-visible transcript remain unchanged. +- Session durability has one barrier operation. Its participation result remains observable, but no continuable-child path treats arbitrary listener participation as proof that a persistence backend stored the state. +- The `send_message` and `report` schemas, accepted message identities, `AgentHandle` ownership, durable event vocabulary, and model-visible transcript follow the activation-based realization linked above. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 3184a066de..dae4dd37fa 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 -本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,保留裸 `Agent` 参数作为准确的实时直属父级权限,并以 `prepareContinuable` 替换提供方 `resume` 派发。 +当前基于 Activation 的实现由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)负责。它保留本记录命名的 `followup` 操作,返回已接受的 `MessageId`,使用裸 `Agent` 参数作为确切的在线直属父级权限,并将提供方对可继续 child 的参与限制为 `prepareContinuable`。 ## 问题 @@ -14,25 +14,25 @@ Status: implemented ## 决策 -`SubagentService` 公开三种执行意图:`start(name, request)` 用于普通的、由持有方负责的 run;`startContinuable(spec)` 用于具备持久性且由 Task 支撑的 child;`followup(parent, childId, content, { source, signal })` 用于投递后续内容。最后一个动词与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的激活提供 steering(中途引导)。面向模型的工具保留稳定的 `send_message` 名称,并将路由委托给 `followup()`。 +`SubagentService` 分离四种执行意图:`start(name, request)` 返回普通的、由持有方负责的 one-shot run;`startContinuable(spec)` 建立持久化 child,并返回其 id 与已接受的初始 `MessageId`;`followup(parent, childId, content, { source, signal })` 发送后续 parent 内容;`reportFrom(child, content, { delivery, signal })` 将选定的 child 内容发送给其直接 parent。`followup` 与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的 run 提供 steering。面向模型的工具保留稳定的 `send_message` 与 `report` 名称,并将路由委托给对应的意图方法。 -调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 +调用方请求与提供方请求相互分离。`SubagentStartRequest` 包含调用方提供的 one-shot 数据;`ResolvedSubagentStartRequest` 会在调用 `SubagentProvider.start()` 前加入由服务解析的描述符。创建可继续 child 时,管理器将 `ContinuableCreateRequest` 传给可选的 `SubagentProvider.prepareContinuable()`,且只接收分离的创建数据。`SubagentService.resume()` 与提供方恢复分发均不存在:继续执行管理器加载描述符、对 parent 进行鉴权,并负责 Agent 实体化、提示词投递、冷恢复与 teardown。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 +`SessionStore.flush(session)` 是唯一的持久性屏障,并返回 `Promise`。至少一个作用域内监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。参与结果无法表明所选的持久化后端是否已经存储状态。普通检查点可以忽略该布尔值;继续执行管理器同样将最终 flush 视为 best-effort 屏障,有意忽略参与结果,记录拒绝日志,并仍会对 child 执行 dispose(资源释放)并释放所有权。 ## 已考虑的替代方案 -**保留公开的提供方恢复分发。** 继续执行管理器之外没有任何生产调用方负责安全调用所需的描述符查找、直接 parent 鉴权、Task 取消与激活关联。公开方法会暴露已解析的实现数据,但并不存在与之对应的合理独立调用意图。 +**保留公开的提供方恢复分发。** 继续执行管理器之外,没有任何生产调用方同时负责安全调用所需的描述符查找、直接 parent 鉴权、Agent 实体化、Activation 所有权与 child-first teardown。公开方法会暴露已解析的实现数据,却没有合理的独立调用意图;提供方改为通过 `prepareContinuable` 贡献分离的首次创建数据,且从不参与冷恢复。 **在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。 **保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。 -**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 run 就绪后返回,要么立即返回 child 和 Task 标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 +**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 one-shot run 就绪后返回,要么立即返回持久化 child 与消息标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 ## 影响 -- Cordis 服务目录只包含调用方操作;提供方的重建能力仍可通过 `SubagentProvider.resume?()` 扩展,同时不会将已解析的请求暴露为服务方法。 +- Cordis 服务目录只包含调用方操作;提供方可以通过 `SubagentProvider.prepareContinuable?()` 选择参与可继续 child 的首次创建,但不会获得 Agent 生命周期权限或公开恢复操作。 - 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。 -- 会话持久性只保留一个屏障操作。需要后端参与的调用方必须检查参与结果,而不是选择第二种分发方法。 -- `send_message` schema、路由结果、Task 所有权、持久化事件词汇与模型可见的 transcript(文本记录)保持不变。 +- 会话持久性只有一个屏障操作。参与结果仍可观测,但任何可继续 child 路径都不会将任意监听器参与视为持久化后端已存储状态的证明。 +- `send_message` 与 `report` schema、已接受的消息标识、`AgentHandle` 所有权、持久化事件词汇与模型可见的 transcript(文本记录)遵循上文链接的基于 Activation 的实现。 From a24f9b06e7e57bb62e58a6ef3f3cf071f2f8277b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:25:39 +0800 Subject: [PATCH 37/41] refactor(subagent): drop speculative effect rollback The PR moved child scope-effect registration into the contribution-installation try/catch solely to cover a hypothetical throw, while documenting that Context.effect cannot reject for the live unpublished scope passed to apply. The change therefore added control-flow and rollback implications for a failure mode the API does not expose, without changing observable behavior. Keep the rollback boundary focused on contribution installers, which are the operations that can actually fail and leave recorded installations to unwind. Register the child-scope cleanup effect immediately after that boundary, as before; its disposer still converges with contribution removal through the registry idempotence rules. This is a behavior-preserving removal of unnecessary code. The focused activation-setup-registry suite passes all 11 tests, the subagent TypeScript project checks cleanly, and the staged diff passes whitespace validation. --- packages/subagent/subagent/src/activation-setup-registry.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index 3e8ef4fe61..5681e89863 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -113,10 +113,6 @@ export class SubagentActivationSetupRegistry { // Dispose that escaped record and invalidate the provisioning batch. if (isRemoved(registration)) this.release(installation) } - // Register the scope-disposal release inside the same try so the - // setup-rollback catch also covers a hypothetical effect-registration - // throw; today effect() cannot reject on a live unpublished scope. - childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') } catch (error: unknown) { // Keep the installer failure authoritative, but attempt every rollback. try { @@ -127,6 +123,7 @@ export class SubagentActivationSetupRegistry { } throw error } + childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') return { commit: () => { if (state.invalidated) { From e4663cb10bf0288abc3d5cc3914499f1bb137e45 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:28:23 +0800 Subject: [PATCH 38/41] test(web): share the subagent locale translator The localized catalog spec introduced two package-local translation stubs: one manually looped over interpolation parameters and the other indexed the Chinese dictionary directly. That duplicates framework test plumbing and can drift from the shared lookup, fallback, and placeholder semantics used by the rest of the client suites. Use makeTranslate from dsh-client-test-runtime as the single Chinese translator for both catalog and read-only composer assertions. Record the test-only workspace dependency in the ui-subagent manifest and lockfile; no production dependency or runtime bundle edge is added. This removes twelve lines of local translation behavior while preserving the same Chinese assertions and exercising the shared interpolation path. Both ui-subagent test files pass with all 28 tests, the package TypeScript project checks cleanly, and the staged diff passes formatting, lint, and whitespace hooks. --- packages/client/ui-subagent/package.json | 1 + .../tests/conversation-ui.spec.tsx | 20 ++++--------------- pnpm-lock.yaml | 3 +++ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index a3e753d91b..6dc3f9bd7d 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -53,6 +53,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 5649b2b257..ded344145f 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,16 +1,15 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { SubagentCatalogAction, type SubagentCatalogActionProps, } from '../src/client/SubagentCatalogAction.tsx' -import { - SubagentReadOnlyComposer, type SubagentReadOnlyComposerProps, -} from '../src/client/SubagentReadOnlyComposer.tsx' -import { zh, type SubagentKey } from '../src/client/locales.ts' +import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx' +import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -20,6 +19,7 @@ afterEach(() => { const PARENT = 'parent' as SessionId const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId +const t: SubagentCatalogActionProps['t'] = makeTranslate(zh) function catalog(over: Partial = {}): SubagentCatalogSnapshot { return { @@ -66,15 +66,6 @@ function props( function useSessions(select: (snapshot: SessionListState) => T): T { return select(state) } - // The zh dictionary is the source of truth for this spec's assertions: - // the stub interpolates `{name}` params like the locale service does. - const t = ((key: SubagentKey, params?: Record): string => { - let text: string = zh[key] - for (const [name, value] of Object.entries(params ?? {})) { - text = text.replaceAll(`{${name}}`, String(value)) - } - return text - }) as SubagentCatalogActionProps['t'] return { sessionId: PARENT, useSessions, @@ -484,9 +475,6 @@ describe('SubagentCatalogAction', () => { }) describe('SubagentReadOnlyComposer', () => { - // The zh dictionary is the source of truth for this spec's assertions. - const t = ((key: SubagentKey): string => zh[key]) as SubagentReadOnlyComposerProps['t'] - it('explains the exact missing-parent recovery path', () => { render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d81b237b7c..c7053f6738 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1885,6 +1885,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation From a54eadf2f17e3835f6e4a806b6d03b3aef6a5ce3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:30:01 +0800 Subject: [PATCH 39/41] test(web): pin the subagent snapshot to English The subagent conversation scenario asserts English role names and compares English accessibility goldens, but it opened a raw Playwright page while the rest of the English Web scenarios use the shared bootstrap that writes dsh.locale before client initialization. Once the subagent surface became localized, the raw page left those assertions dependent on ambient browser or persisted locale selection. Create the page through newEnglishPage so the product sees an explicit English preference before boot, while preserving the standard 1680 by 1000 viewport. Chinese-surface scenarios continue to bypass this helper and advertise their own locale explicitly. A fresh library build and production Vite build completed successfully. The focused assembled subagent-conversation Web suite then passed all 8 keyless replay tests against the updated singular-copy golden, and the staged diff passes formatting, lint, and whitespace hooks. --- apps/web/tests/subagent-conversation.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 904fcfcd09..14080dca6f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -15,7 +15,7 @@ import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) @@ -77,7 +77,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = paceMs: 25, }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) page.on('request', (request) => { const path = new URL(request.url()).pathname if (path.startsWith('/api/')) apiCalls.push(path) From da80b0e5e62359d4972d1732e7488798f9fd4287 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:34:43 +0800 Subject: [PATCH 40/41] chore(docs): refresh the descriptor catalog pointer Correcting the cold-resume module contract added one JSDoc line above SubagentDescriptorData, but the generated persistence catalog still linked the durable subagent/descriptor event payload to descriptor.ts line 36. That left a dead source pointer and made the repository documentation gate fail even though the catalog content itself was otherwise current. Regenerate docs/persistence-catalog.md so its source link follows the declaration to line 37. This is a generated-reference correction only: it does not change the durable event vocabulary, payload shape, or runtime behavior. Validated with pnpm run verify-persistence-catalog and git diff --cached --check; the generator reports the catalog is up to date. --- docs/persistence-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 53502cc905..b3ac5aae70 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -531,7 +531,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:36`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) ### `todo/*` From 711a33ea8dbe69c852d6364f665dedf830818b9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:22:39 +0800 Subject: [PATCH 41/41] fix(web): keep known subagent chooser visible --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 6 +-- ...026-07-27-web-subagent-conversations.zh.md | 6 +-- .../stale-catalog.expected.md | 3 ++ apps/web/tests/subagent-conversation.e2e.ts | 54 +++++++++++++++++++ .../src/client/SubagentCatalogAction.tsx | 19 +++++-- .../tests/conversation-ui.spec.tsx | 26 +++++++++ 7 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index bd780ca60d..dc59a0ccb1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 34acb1410cf6316bca2980ed012046ffab9623f6 -2026-07-27-web-subagent-conversations.zh.md: 5dcd7025c5cd03fed34266834795de1f2b630648 +2026-07-27-web-subagent-conversations.md: b959fd35a4f5e2a6fa68deed8776ccbae86a0647 +2026-07-27-web-subagent-conversations.zh.md: dc0297d92acb5ab05dcdc5c682fd0a7fe2a2a18a diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 34acb1410c..b959fd35a4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -37,7 +37,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The header action is absent only after a complete empty direct-catalog response. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. @@ -102,8 +102,8 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 5dcd7025c5..dc0297d92a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -37,7 +37,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品契约 -只有在完整的直接目录响应为空后,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 @@ -102,8 +102,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 diff --git a/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md new file mode 100644 index 0000000000..bd2a1b7918 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md @@ -0,0 +1,3 @@ +- tree "Subagent sessions": + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 14080dca6f..53e61d829f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) +const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url)) @@ -239,6 +240,59 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed') }) + it('keeps known descendants reachable across a stale empty catalog response', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog')) + const pattern = '**/api/subagent.list' + let firstClaimed = false + let emptyDelivered = false + let trailingRequested = false + let releaseCatalog = (): void => {} + const catalogHeld = new Promise((resolve) => { releaseCatalog = resolve }) + await page.route(pattern, async (route) => { + if (firstClaimed) { + const response = await route.fetch() + trailingRequested = true + await catalogHeld + await route.fulfill({ response }) + return + } + firstClaimed = true + const response = await route.fetch() + const body = await response.json() as { + result: { ok: true; value: { entries: unknown[] } } | { ok: false } + } + if (body.result.ok) body.result.value.entries = [] + await route.fulfill({ response, json: body }) + emptyDelivered = true + }) + + const warningStart = tripwire.warnings.length + try { + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + await page.getByRole('button', { name: '3 subagents' }).click() + await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true) + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor() + expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2) + await compareOrRefreshGolden( + STALE_CATALOG_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), + MODE, + ) + releaseCatalog() + await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 }) + await tree.press('Escape') + } finally { + releaseCatalog() + await page.unroute(pattern) + } + }) + it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) await page.getByRole('button', { name: '3 subagents' }).click() diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 14f8169d82..4953b89591 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -304,7 +304,7 @@ function CatalogRows({ /** * Render the current session's direct catalog and lazily expanded descendants. * @param props - session standard props plus catalog navigation actions. - * @returns The action only after a non-empty catalog arrives. + * @returns The action while the catalog is pending or summaries establish descendants. */ export function SubagentCatalogAction({ sessionId, useSessions, openChild, refresh, setCatalogOpen, t, @@ -326,6 +326,18 @@ export function SubagentCatalogAction({ const descendantCount = Math.max(healthy.length, descendants.count) const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other' const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other' + // Session summaries can announce membership before the descriptor-backed catalog catches up. + // Keep that entry point visible through disabled loading rows; only catalog rows are navigable. + const summaryBackedLoading = descendants.count > 0 + && (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0)) + const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading + ? { + entries: [], + parentAvailable: catalog?.parentAvailable ?? false, + state: 'loading', + error: null, + } + : catalog const observeCatalog = (parentSessionId: SessionId, next: boolean): void => { if (next) observedCatalogs.current.add(parentSessionId) @@ -390,7 +402,8 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) - const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0) + const visible = presentedCatalog !== undefined + && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) useEffect(() => { if (visible || !open) return setOpen(false) @@ -453,7 +466,7 @@ export function SubagentCatalogAction({
{ expect(failed.refresh).toHaveBeenCalledWith(PARENT) }) + it('keeps known descendants reachable while their catalog is absent or stale-empty', () => { + const second = 'child-2' as SessionId + const summaries = { + [CHILD]: { + ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const, + }, + [second]: { + ...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true, + }, + } + const absent = props(undefined, {}, summaries) + const view = render() + + const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' }) + fireEvent.click(trigger) + expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true) + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(absent.openChild).not.toHaveBeenCalled() + + const staleEmpty = props(catalog({ entries: [] }), {}, summaries) + view.rerender() + expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy() + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(staleEmpty.openChild).not.toHaveBeenCalled() + }) + it('renders empty loading and fallback error states without focusable rows', async () => { const loading = props(catalog({ entries: [], state: 'loading' })) const view = render()