diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index fc666f437e..cb76aaf7b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.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/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 008e7907bd35e7341e0ce7cb8f8541435973c8ea -2026-08-06-subagent-list-identity-projection.zh.md: 0f5876485c6e204db73810eb09314505039b2ebb +2026-08-06-subagent-list-identity-projection.md: 0684ec284fbce161e37a9f7c7c57460d91c8d0d0 +2026-08-06-subagent-list-identity-projection.zh.md: ea5fb4329032e42bf394706032e8571d5a975e2e diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 008e7907bd..0684ec284f 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -127,7 +127,7 @@ Known boundary deviations (deliberately accepted, recorded with this note): - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). - An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. -Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral change is the apiproxy route segment: the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this. +Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). ### Change footprint @@ -135,9 +135,9 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir | --- | --- | --- | | subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration | | subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | -| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin` | +| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms retired with it | | tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | -| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | **Zero changes** — row shape and diagnostic handling unchanged | +| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | Types, row shape, and diagnostic handling **unchanged**; api/subagents.ts only reworded the `history` JSDoc to the dual arm | | core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** | ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index 0f5876485c..ea5fb43290 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -127,7 +127,7 @@ export type SubagentListEntry = - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 - 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 -消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上唯一动的是 apiproxy 路由段:`hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受。 +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 ### 改动落点 @@ -135,9 +135,9 @@ export type SubagentListEntry = | --- | --- | --- | | subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 | | subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | -| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin` | +| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役 | | tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | -| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | **零改动**——行形状与 diagnostic 处理不变 | +| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**;api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 | | core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** | ## 考虑过的替代方案 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 5b4e1e7b65..d9ed8115f2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1918,44 +1918,63 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro parentSessionId, childSessionId, mode, }, signal) if (verified.error !== undefined) return err(request, verified.error) - try { - const snapshot = await ctx.sessionQuery.readSession(childSessionId) - signal?.throwIfAborted() - if (snapshot.session.parentSession !== parentSessionId) { + // The generic-history data plane: an attached child serves its + // in-memory snapshot and the registry's live watermark projections; a + // cold child is one persistence inspection plus a detached fold. + let header: SessionHeader + let events: SessionEvent[] + let projections: SessionProjectionsBlock | undefined + const attached = ctx.sessions.get(childSessionId) + if (attached !== undefined) { + header = attached.header + events = [...attached.events] + projections = beforeSeq === undefined ? projectionsFor(ctx, attached) : undefined + } else { + try { + const inspected = await inspectServable(childSessionId) + header = inspected.meta + events = inspected.events + projections = beforeSeq === undefined + ? detachedProjectionsFor(ctx, inspected.events) + : undefined + } catch (error: unknown) { + if (signal?.aborted) { + return err(request, { + code: 'cancelled', + message: 'subagent history read was cancelled', + details: {}, + }) + } + if (error instanceof SessionNotFound) { + return err(request, { + code: 'subagent-not-found', + message: 'subagent disappeared during history read', + details: { parentSessionId, childSessionId }, + }) + } return err(request, { - code: 'subagent-unauthorized', - message: 'subagent parent changed during history read', - details: { childSessionId }, - }) - } - const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages) - const projections = beforeSeq === undefined - ? detachedProjectionsFor(ctx, snapshot.events) - : undefined - return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) - } catch (error: unknown) { - if (signal?.aborted - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { - return err(request, { - code: 'cancelled', - message: 'subagent history read was cancelled', + code: 'internal', + message: 'subagent history read failed', details: {}, }) } - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return err(request, { - code: 'subagent-not-found', - message: 'subagent disappeared during history read', - details: { parentSessionId, childSessionId }, - }) - } + } + if (signal?.aborted) { return err(request, { - code: 'internal', - message: 'subagent history read failed', + code: 'cancelled', + message: 'subagent history read was cancelled', details: {}, }) } + if (header.parentSession !== parentSessionId) { + return err(request, { + code: 'subagent-unauthorized', + message: 'subagent parent changed during history read', + details: { childSessionId }, + }) + } + const page = historyPage(ctx, events, beforeSeq, maxMessages) + return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) }, async prompt(request, signal) { diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index d1e0392eb1..4c251dca7c 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -70,7 +70,8 @@ export interface SubagentsApi { ): Promise> /** - * Reads one healthy catalog child's persisted raw log with ordinary + * Reads one healthy catalog child's transcript — the in-memory snapshot of + * a live child, the persisted log of a cold one — with ordinary * message-aligned pagination and render intents, without Agent activation. */ history( diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 6503fab3cc..691b97b7b4 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import { RpcId } from '../src/api/rpc.ts' import type { RpcRequest } from '../src/api/rpc.ts' @@ -21,7 +20,10 @@ function bench(options: { entries?: object[] followupError?: Error listError?: Error - readError?: Error + /** Persistence forgets the child entirely (the vanished-mid-read race). */ + storedChild?: false + /** Attach the child to the live session store instead of persistence only. */ + liveChild?: true historyParent?: SessionId } = {}) { const parent = { id: PARENT } @@ -49,25 +51,38 @@ function bench(options: { ) => options.followupError === undefined ? Promise.resolve('message-1') : Promise.reject(options.followupError)) - const readSession = vi.fn(() => options.readError === undefined - ? Promise.resolve({ - session: { - version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT, - } satisfies SessionHeader, - events: [ - { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } }, - ] as unknown as SessionEvent[], - }) - : Promise.reject(options.readError)) + const childHeader = { + version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT, + } satisfies SessionHeader + const childEvents = [ + { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } }, + ] as unknown as SessionEvent[] + const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents })) + const liveBlock = { values: {}, asOfSeq: 3 } + const coldBlock = { values: {}, asOfSeq: 0 } + const snapshot = vi.fn(() => liveBlock) + const restore = vi.fn(() => ({ snapshot: coldBlock })) const ctx = new Context() ctx.provide('agents', { get: getAgent }) ctx.provide('subagents', { listChildren, followup }) - ctx.provide('sessionQuery', { readSession }) + ctx.provide('sessions', { + get: (id: SessionId) => options.liveChild === true && id === CHILD + ? { id: CHILD, header: childHeader, events: childEvents } + : undefined, + }) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]), + inspect, + locate: () => undefined, + }) + // The gateway's own projection push feed subscribes at construction; the + // no-op disposer keeps that seam quiet while these tests pin history reads. + ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', }) - return { api, getAgent, listChildren, readSession, followup, parent } + return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } describe('subagent gateway', () => { @@ -113,7 +128,7 @@ describe('subagent gateway', () => { }) it('reads a healthy direct child without looking up or activating any Agent', async () => { - const { api, getAgent, readSession } = bench() + const { api, getAgent, inspect, restore } = bench() const response = await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10, })) @@ -121,27 +136,42 @@ describe('subagent gateway', () => { ok: true, value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, }) - expect(readSession).toHaveBeenCalledWith(CHILD) + expect(inspect).toHaveBeenCalledWith(CHILD) + expect(restore).toHaveBeenCalledTimes(1) expect(getAgent).not.toHaveBeenCalled() }) + it('serves a live child from the in-memory snapshot and the watermark projections', async () => { + const { api, inspect, snapshot, restore } = bench({ liveChild: true }) + const response = await api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + })) + expect(response.result).toMatchObject({ + ok: true, + value: { hasMore: false, projections: { asOfSeq: 3 } }, + }) + expect(snapshot).toHaveBeenCalledTimes(1) + expect(restore).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() + }) + it('reads one-shot history and rejects an address with the wrong mode', async () => { const oneShot = { kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', activity: 'inactive', hasChildren: false, } - const { api, readSession } = bench({ entries: [oneShot] }) + const { api, inspect } = bench({ entries: [oneShot] }) expect((await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot', }))).result).toMatchObject({ ok: true }) expect((await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } }) - expect(readSession).toHaveBeenCalledTimes(1) + expect(inspect).toHaveBeenCalledTimes(1) }) it('rejects a diagnostic address before reading history', async () => { - const { api, readSession } = bench({ entries: [ + const { api, inspect } = bench({ entries: [ { kind: 'diagnostic', id: CHILD, reason: 'unsupported' }, ] }) const response = await api.subagents.history(request({ @@ -154,7 +184,7 @@ describe('subagent gateway', () => { details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' }, }, }) - expect(readSession).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() }) it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { @@ -175,7 +205,7 @@ describe('subagent gateway', () => { expect((await history.api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }))).result).toMatchObject({ ok: false, error: expected }) - expect(history.readSession).not.toHaveBeenCalled() + expect(history.inspect).not.toHaveBeenCalled() const prompt = bench({ listError: listError() }) expect((await prompt.api.subagents.prompt(request({ @@ -220,9 +250,7 @@ describe('subagent gateway', () => { }) it('maps history disappearance and hides unexpected backend details', async () => { - const disappeared = bench({ - readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'), - }) + const disappeared = bench({ storedChild: false }) expect((await disappeared.api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', }))).result).toMatchObject({