refactor(apiproxy): read subagent history like session history

subagents.history drops its session-query read: a live child serves its
in-memory events with the registry watermark snapshot, a cold child goes
through inspectServable with a detached fold, and the retired
SESSION_QUERY_* arms disappear while every wire face stays put. The
design note records the shared read source.
This commit is contained in:
imccyu
2026-08-06 23:17:19 +08:00
parent ba791c0ac3
commit b9886d1615
6 changed files with 111 additions and 63 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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) | — | **零改动** |
## 考虑过的替代方案

View File

@@ -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) {

View File

@@ -70,7 +70,8 @@ export interface SubagentsApi {
): Promise<RpcResponse<SubagentCatalog>>
/**
* 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(

View File

@@ -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({