fix(web): deduplicate subagent navigation

This commit is contained in:
Dudu-0223
2026-07-30 11:34:57 +08:00
committed by Tianyi Cui
parent 16ffd63115
commit f0ab04273d
53 changed files with 294 additions and 95 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 packages/client/runtime/README.md
README.md: ffee75c0e28cd39c4b80a2f93a2496f2a8a22092
README.zh.md: 85511bc191d64cb4954515a2ba1efdf912c3f937
README.md: 0f5b35b6fa4a92f083d5e20c354c19eaae1d5b70
README.zh.md: 67cdf6479ef2ba7156b7babb7249b51f8640b134

View File

@@ -56,7 +56,7 @@ Each resident `Session` owns a `modelSelection` snapshot containing the current
## Addressed subagent conversations
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
## Model Experience

View File

@@ -56,7 +56,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
## 已寻址的 subagent 对话
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间把地址与所选会话一同持久化。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活跃状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活动状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
## 模型体验

View File

@@ -18,6 +18,8 @@ export interface SessionListEntry {
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
blank: boolean
parentSessionId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
cwd?: string
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean

View File

@@ -134,8 +134,13 @@ export class SessionManager {
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
this.addresses.delete(sessionId)
this.sessions.get(sessionId)?.configureSubagent(undefined)
const address = this.addresses.get(sessionId)
this.sessions.get(sessionId)?.configureSubagent(
address,
address === undefined
? false
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
)
this.selected = sessionId
void this.refreshSubagents(sessionId)
this.notifier.notifyNow()
@@ -607,6 +612,7 @@ export class SessionManager {
this.mergeSummary({
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
@@ -724,7 +730,7 @@ export class SessionManager {
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
) return prev
this.entryCache.set(entry.sessionId, entry)
@@ -766,9 +772,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
? { parentSessionId: mutation.summary.parentSessionId } : {}),
...(existing.origin === undefined && mutation.summary.origin !== undefined
? { origin: mutation.summary.origin } : {}),
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.blank === existing.blank) return [...summaries]
&& filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
case 'remove':

View File

@@ -44,6 +44,8 @@ export interface SessionSummary {
displayTitle: string
cwd?: string
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
@@ -631,6 +633,7 @@ export class SessionsService implements ISessions {
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
}
}
if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) {
@@ -641,6 +644,7 @@ export class SessionsService implements ISessions {
id: current,
displayTitle: child.label,
parentId: currentAddress.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
waitingApproval: false,
blank: false,

View File

@@ -12,7 +12,13 @@ import { entries, plainTurn } from './event-script.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
type SummaryOver = Partial<{
updatedAt: number
running: boolean
blank: boolean
parentSessionId: SessionId
origin: 'subagent'
}>
function summary(sessionId: SessionId, over: SummaryOver = {}) {
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
@@ -273,10 +279,11 @@ describe('host frame routing', () => {
})
describe('subagent catalogs', () => {
it('selects only a catalog-discovered child and keeps its durable address across status frames', async () => {
it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
] as never[] }))
api.onSubagentList = () => Promise.resolve(ok({
entries: [{ kind: 'child', id: S2, label: 'worker', activity: 'running' }] as never[],
@@ -294,6 +301,26 @@ describe('subagent catalogs', () => {
address: { parentSessionId: S1, childSessionId: S2 },
parentAvailable: true,
})
// Clicking the same child through an ordinary list-selection path must not
// erase the catalog-derived address and fall back to session.* transport.
manager.select(S2)
expect(manager.getListSnapshot().currentAddress).toEqual({
parentSessionId: S1, childSessionId: S2,
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2 },
parentAvailable: true,
})
await manager.get(S2).open()
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: S1, childSessionId: S2, maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([
{ parentSessionId: S1, childSessionId: S2, content: [{ type: 'text', text: 'continue' }] },
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
const listCalls = api.callsOf('subagent.list').length
manager.handleHostEnvelope({
rpcId: 'child-complete' as never,
@@ -490,9 +517,17 @@ describe('remaining branches', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } })
manager.handleHostEnvelope({
rpcId: 'h2' as never,
payload: {
type: 'host/session-added', blank: true, sessionId: S2,
parentSessionId: S1, origin: 'subagent',
},
})
const items = manager.getListSnapshot().items
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
expect(items.find(e => e.sessionId === S2)).toMatchObject({
parentSessionId: S1, origin: 'subagent', depth: 1,
})
})
})

View File

@@ -28,7 +28,14 @@ function bench(): Bench {
}
/** Refresh the manager list from programmable rows and flush the microtask batch. */
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
type FeedRow = {
id: string
cwd?: string
parentId?: string
origin?: 'subagent'
running?: boolean
blank?: boolean
}
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
b.api.onList = () => Promise.resolve(ok({
@@ -36,6 +43,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
...(r.origin !== undefined ? { origin: r.origin } : {}),
})),
}) as never)
await b.svc.refresh()
@@ -51,12 +59,14 @@ describe('list store projection', () => {
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
{ id: 's2', parentId: 's1', origin: 'subagent', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s2')]).toMatchObject({
displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true,
})
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})

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 packages/client/ui-subagent/README.md
README.md: 06cfc368577a41b405336025e75e61998b2051ae
README.zh.md: 64b096ad4804694a9b0d7d3c8c26a8f3f867bf23
README.md: c69211bd6f84b6e09760ff47d5a319b1ef2ce28c
README.zh.md: 43776fbc32a5bd369e837779787cdc82ceb0f749

View File

@@ -8,6 +8,8 @@ The header action reads `subagentsByParent` and session summaries through the st
An addressed child with no exact live parent elects the read-only composer entry and explains the recovery path. A child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; this package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar.
The `@` source remains deliberately separate and inert. Candidates are zero-RPC running children from `ctx.sessions.list`; picking one inserts literal `@label ` text, and the codec projects `@label`. It has no command-adjudication hooks and does not resolve labels into continuation addresses.
## Model Experience
@@ -29,5 +31,4 @@ Append-only. This package never edits earlier request tokens.
## Known Limitations and Deferred Work
- **The catalog has coarse liveness only** — it cannot show durable outcome, elapsed time, exact Activation state, or a correct cancel button.
- **The sidebar still contains child sessions** — complete de-duplication needs a scalable durable classifier that does not hide ordinary forks.
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.

View File

@@ -8,6 +8,8 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可
已寻址 child 没有确切的存活 parent 时,会选中只读编辑器配置项并说明恢复路径。parent 存活时,child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
普通侧边栏会省略带 subagent origin 的 Session 行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
`@` source 仍然刻意保持独立且惰性。候选是从 `ctx.sessions.list` 零 RPC 得到的运行中 child;pick 会插入字面文本 `@label `,codec 投影为 `@label`。它不参与命令裁决,也不会把 label 解析成继续执行地址。
## 模型体验
@@ -29,5 +31,4 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可
## 已知限制与暂缓事项
- **目录只有粗粒度存活状态**:它不能显示持久化结果、耗时、确切的 Activation 状态或正确的取消按钮。
- **侧边栏仍包含 child Session**:完全去重需要可扩展的持久化分类器,且不得误隐藏普通 fork。
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。

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 packages/client/ui-workspace/README.md
README.md: 2854c678fd93d56267d0aec8515455b77d4bcbf1
README.zh.md: 51db6cacfe83d176a6cc68b01fe3dd91acf49e25
README.md: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607
README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b

View File

@@ -14,6 +14,8 @@ The Session row's Fork action forks at the source's last completed turn, increme
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state.
## Model Experience
None, as the picker is browser chrome; nothing here reaches a model request.

View File

@@ -14,6 +14,8 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。
## 模型体验
无。选择器属于浏览器 chrome;这里没有任何内容进入模型请求。

View File

@@ -92,11 +92,14 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
/**
* Ordinary sessions are visible; among blank sessions, only the current one
* is visible; archived sessions are visible nowhere (their accounting slots
* remain, so unarchiving restores position).
* is visible. Subagent children use their parent header catalog; archived
* sessions are visible nowhere, while their accounting slots remain so
* unarchiving restores position.
*/
function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet<SessionId>): boolean {
return !archived.has(session.id) && (!session.blank || session.id === current)
return session.origin !== 'subagent'
&& !archived.has(session.id)
&& (!session.blank || session.id === current)
}
/**

View File

@@ -69,6 +69,22 @@ describe('deriveGroups', () => {
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
it('hides subagent-origin sessions without hiding ordinary forks', () => {
const parent = summary('parent', 1)
const fork = { ...summary('fork', 2), parentId: parent.id }
const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
const sessions = { ...list(parent, fork, subagent), current: subagent.id }
const groups = deriveGroups(
sessions,
[workspace('first', ['parent', 'fork', 'subagent'])],
noArchive,
view(['first']),
)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([parent.id, fork.id])
expect(groups[0]!.sessionCount).toBe(2)
})
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
const parent = summary('parent', 1)
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
@@ -143,6 +159,17 @@ describe('deriveFlat', () => {
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
})
it('hides subagent-origin rows but keeps ordinary forks', () => {
const parent = summary('parent', 1)
const fork = { ...summary('fork', 2), parentId: parent.id }
const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
const rows = deriveFlat(
{ ...list(parent, fork, subagent), current: subagent.id },
noArchive,
)
expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])

View File

@@ -1821,7 +1821,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 delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | 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?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'CreateGoalRequest',
@@ -1829,7 +1829,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}',
},
{
name: 'CredentialInfo',
@@ -2461,7 +2461,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionHeader',
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}',
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}',
},
{
name: 'SessionId',

View File

@@ -48,9 +48,9 @@ export interface CreateAgentOptions {
readonly sessionId: SessionId
/**
* Session creation metadata: validated absolute `cwd`, `parentSession`
* fork lineage, the `seedLength` seed boundary, and the `delegationDepth`
* recursion budget. Mirrors the
* `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of
* fork lineage, the `seedLength` seed boundary, the coarse `origin`
* classification, and the `delegationDepth` recursion budget. Mirrors the
* `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth` fields of
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
* `createdAt`, used when reconstructing a persisted session, is deliberately
* excluded — a factory caller never sets it). This is durable session data,
@@ -61,6 +61,7 @@ export interface CreateAgentOptions {
readonly cwd?: string
readonly parentSession?: SessionId
readonly seedLength?: number
readonly origin?: 'subagent'
readonly delegationDepth?: number
}
/**

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 packages/core/session/README.md
README.md: 4730cac913e949d642d049a5c53ab2dd47e10627
README.zh.md: 12aa1625d7b0568196cd788c2a25cfb869d8780d
README.md: d78dc5bcfe1df2edd01280208f3859eb1b2d6763
README.zh.md: 40c58a539d5027f2619b5b2102b94e76f2c73e23

View File

@@ -12,7 +12,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `origin`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; it returns `true` when at least one listener participated and `false` for an empty snapshot, while unpublished, detached, and stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
@@ -43,7 +43,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
- `session.seq`, `session.id` — current sequence and readonly typed identity.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth`). `origin: 'subagent'` is a coarse product classification, not a continuation capability. Construction validates the durable record and requires its id to match `session.id`.
### Lossless JSON utilities
@@ -89,7 +89,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Metadata types (`types.ts`)
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points

View File

@@ -12,7 +12,7 @@
### 公共 API
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`origin` 和 `delegationDepth`。
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败;至少一个监听器参与时返回 `true`,监听器快照为空时返回 `false`,而未发布、已脱离和陈旧的对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
@@ -43,7 +43,7 @@
- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。
- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
- `session.seq`、`session.id`:当前序号和只读类型化身份。
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth`)。`origin: 'subagent'` 是粗粒度产品分类,不代表具备继续执行能力。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
### 无损 JSON 工具
@@ -89,7 +89,7 @@
### 元数据类型(`types.ts`)
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
### 扩展点

View File

@@ -138,6 +138,9 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
throw new Error('session header seedLength must be a non-negative safe integer')
}
if (record.origin !== undefined && record.origin !== 'subagent') {
throw new Error('session header origin must be "subagent"')
}
if (record.delegationDepth !== undefined
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
throw new Error('session header delegationDepth must be a non-negative safe integer')
@@ -837,6 +840,7 @@ export class SessionStore extends Service {
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
...meta?.origin === undefined ? {} : { origin: meta.origin },
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
}
return new Session(sessionId, seed, header)

View File

@@ -59,6 +59,11 @@ export interface SessionHeader {
* boundary lets resume and replay distinguish parent history from child work.
*/
readonly seedLength?: number
/**
* Coarse product classification for a session created as a subagent child.
* This is presentation metadata, not proof that the child is continuable.
*/
readonly origin?: 'subagent'
/**
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
* for a subagent child. Persisted so a recursion budget survives restart and
@@ -84,6 +89,7 @@ export interface CreateSessionOptions {
readonly parentSession?: SessionId
readonly createdAt?: number
readonly seedLength?: number
readonly origin?: 'subagent'
readonly delegationDepth?: number
}
}

View File

@@ -1251,15 +1251,16 @@ describe('SessionStore', () => {
})
})
it('attaches delegationDepth from meta to the header', async () => {
it('attaches subagent origin and delegationDepth from meta to the header', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('delegated-child'), {
meta: { parentSession: SessionId('parent'), delegationDepth: 2 },
meta: { parentSession: SessionId('parent'), origin: 'subagent', delegationDepth: 2 },
})
expect(session.header).toMatchObject({
id: 'delegated-child',
parentSession: 'parent',
origin: 'subagent',
delegationDepth: 2,
})
})
@@ -1278,6 +1279,7 @@ describe('SessionStore', () => {
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { origin: 'fork' }, error: /origin must be "subagent"/ },
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },

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 packages/host/apiproxy/README.md
README.md: abca1e9b209891b152ff7f1a58fadb9f91e65839
README.zh.md: dca3924af940a99fa99afb283f37a84570d1ad90
README.md: 15a8a6f80dc3f4c37950e85a902f5c53e83cfcac
README.zh.md: 6c61d8e2a4c44e9c1b8bbec1555c36f0cd39c1e2

View File

@@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.

View File

@@ -22,7 +22,7 @@
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。

View File

@@ -283,6 +283,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
running,
blank: sessionBlank(session),
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
...session.header.origin === undefined ? {} : { origin: session.header.origin },
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
}
}
@@ -318,6 +319,7 @@ async function summarizeCold(
// cold session is served as not-blank (its log holds its conversation).
blank: false,
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
...meta.origin === undefined ? {} : { origin: meta.origin },
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
filters those out (legacy logs are not served); the conditional mirrors
summarize() shape. */
@@ -2340,6 +2342,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// has run no turn yet, so this is constantly true in practice.
blank: sessionBlank(session),
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
...session.header.origin === undefined ? {} : { origin: session.header.origin },
// cwd rides the frame so the client list needs no refresh to group the new session.
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
}))

View File

@@ -65,7 +65,14 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
/** HostFrame union (payload slot of a host-stream ServerRequest). */
export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
z.object({
type: z.literal('host/session-added'),
sessionId: sessionIdSchema,
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
origin: z.literal('subagent').optional(),
cwd: z.string().optional(),
}),
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),

View File

@@ -90,9 +90,9 @@ export type MuxFrame =
| { type: 'stream/error'; error: RpcError }
/**
* Host stream frames. session-added carries the lineage anchor, the project
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
* refresh to learn); the frame fires at session/created, so blank is
* Host stream frames. session-added carries the lineage anchor, product
* origin, project cwd, and blank bit (the list-summary fields a client cannot
* wait for a refresh to learn); the frame fires at session/created, so blank is
* constantly true — clients flip it on the session's first
* `host/session-status(running:true)` (a blank session never runs), and a
* reconnecting client takes `session.list`'s summary.blank as authoritative.
@@ -106,7 +106,14 @@ export type MuxFrame =
* workspace-changed — `workspace.list` re-baselines it on reconnect).
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
| {
type: 'host/session-added'
sessionId: SessionId
blank: boolean
parentSessionId?: SessionId
origin?: 'subagent'
cwd?: string
}
| { type: 'host/session-removed'; sessionId: SessionId }
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }

View File

@@ -53,6 +53,7 @@ export const sessionSummarySchema = z.object({
running: z.boolean(),
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
origin: z.literal('subagent').optional(),
cwd: z.string().optional(),
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
}) as unknown as z.ZodType<Wire<SessionSummary>>

View File

@@ -153,6 +153,8 @@ export interface SessionSummary {
blank: boolean
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
parentSessionId?: SessionId
/** Coarse durable origin used by navigation surfaces; never proves resumability. */
origin?: 'subagent'
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
cwd?: string
/**

View File

@@ -42,7 +42,7 @@ describe('sessions.list cold merge', () => {
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent') }),
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('session-c', 1500),
]
// Structural fake of the persistence face list() consumes: list + locate.
@@ -74,6 +74,7 @@ describe('sessions.list cold merge', () => {
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(b?.origin).toBe('subagent')
expect(c?.updatedAt).toBe(1500)
})
})

View File

@@ -364,6 +364,36 @@ describe('session creation and Workspace membership', () => {
})
describe('Host Workspace increments', () => {
it('projects subagent origin in attached summaries and creation increments', async () => {
const { api, ctx } = await harness()
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const pending = nextHostFrame(stream)
const childId = SessionId('session-subagent-child')
ctx.sessions.create(childId, {
meta: {
cwd: '/tmp',
parentSession: SessionId('session-parent'),
origin: 'subagent',
},
})
expect(await pending).toMatchObject({
payload: {
type: 'host/session-added',
sessionId: childId,
parentSessionId: 'session-parent',
origin: 'subagent',
},
})
expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
)
abort.abort()
})
it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])

View File

@@ -141,7 +141,13 @@ describe('sessions domain schemas', () => {
expect(sessionIdSchema.parse('s1')).toBe('s1')
expect(() => sessionIdSchema.parse('')).toThrow()
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
expect(sessionSummarySchema.parse({
sessionId: 's1', updatedAt: 1, running: true, blank: false,
parentSessionId: 'p', origin: 'subagent', cwd: '/x',
})).toMatchObject({ origin: 'subagent', cwd: '/x' })
expect(() => sessionSummarySchema.parse({
sessionId: 's1', updatedAt: 1, running: false, blank: false, origin: 'fork',
})).toThrow()
// blank is mandatory: a summary without it fails the parse.
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
const event = sessionEventSchema.parse({
@@ -498,7 +504,7 @@ describe('events frame schemas', () => {
it('accepts every host frame branch', () => {
const frames = [
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p', origin: 'subagent' },
{ type: 'host/session-added', sessionId: 's', blank: true },
{ type: 'host/session-removed', sessionId: 's' },
{ type: 'host/session-status', sessionId: 's', running: true },
@@ -512,6 +518,9 @@ describe('events frame schemas', () => {
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
expect(() => hostFrameSchema.parse({
type: 'host/session-added', sessionId: 's', blank: true, origin: 'fork',
})).toThrow()
})
})

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 packages/subagent/subagent-inprocess/README.md
README.md: ea6443e9a862c03ce4e43b32d5c1b1d8996f670f
README.zh.md: fb5710a363faa828d0c7252723cc552094eb3eb2
README.md: 61e5c8381fcd8972815129a8a2171fcf7d864113
README.zh.md: 1caf43427229afcd7083f929adbaa083a1d1ac2e

View File

@@ -10,7 +10,7 @@ This package is the shared run driver for the two in-process providers' one-shot
The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it together with `origin: 'subagent'` in the child session header. Origin is a coarse product-navigation classifier; the later descriptor remains lifecycle and continuation authority.
2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and a one-shot `agent/step` contribution that appends the resolved `subagent/descriptor` event after the initial `turn/start` and before the first request.
3. Publish the child, retain the returned `AgentHandle`, and return its holder-owned run. The run's `result` drives one task with `child.followup(prompt)` followed by `child.whenIdle()`.
4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output.

View File

@@ -10,7 +10,7 @@
驱动器按以下顺序运行:
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并与 `origin: 'subagent'` 一同持久化到子 agent 会话 header。origin 是粗粒度产品导航分类器;后续描述符仍是生命周期与继续执行的权威依据。
2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制、结构化输出运行时,以及一次性的 `agent/step` contribution;该 contribution 会在初始 `turn/start` 之后、首次请求之前追加已解析的 `subagent/descriptor` 事件。
3. 发布子 agent,保留返回的 `AgentHandle`,并返回由持有方拥有的 run。该 run 的 `result` 会通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。

View File

@@ -175,13 +175,16 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('persists the child depth in its session header', async () => {
it('persists the child origin and depth in its session header', async () => {
const { ctx, parent } = await setup([textResponse('child answer')])
const run = await startInProcessRun(request(parent), {})
await run.result
// The recursion budget is durable session data, not only runtime options —
// a depth that lived only in AgentOptions would reset to 0 on resume.
expect(ctx.agents.get(run.id)!.session.header.delegationDepth).toBe(1)
expect(ctx.agents.get(run.id)!.session.header).toMatchObject({
origin: 'subagent',
delegationDepth: 1,
})
await run.dispose()
})

View File

@@ -71,8 +71,9 @@ export function resolveChildAgentOptions(
/**
* Build the child session's durable creation metadata: the parent's workspace,
* its direct lineage, the recursion budget that must survive persistence, and
* the seed boundary that separates inherited parent history from child work.
* its direct lineage, coarse product origin, the recursion budget that must
* survive persistence, and the seed boundary that separates inherited parent
* history from child work.
* @param parent - the delegating parent agent.
* @param childDepth - the resolved delegation depth to persist.
* @param lineageSeedLength - how many leading events came from the parent's log.
@@ -87,6 +88,7 @@ export function childSessionMeta(
return {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
origin: 'subagent',
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,
...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {},