From 130410bb98593ffc45d203c42049cb8c76f4bfc9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 1 Aug 2026 15:35:50 +0800 Subject: [PATCH] fix(web): preserve subagent navigation and fork grouping --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 4 +- ...026-07-27-web-subagent-conversations.zh.md | 4 +- .../subagent-conversation/fork.expected.md | 6 ++ .../subagent-conversation/nested.expected.md | 18 +++++ apps/web/tests/subagent-conversation.e2e.ts | 55 +++++++++++++-- .../runtime/src/client/sessions/manager.ts | 8 +-- .../runtime/src/client/sessions/service.ts | 64 +++++++++-------- .../runtime/tests/sessions-service.spec.ts | 65 +++++++++++++++-- .../client/skeleton/ConversationSession.tsx | 3 +- .../ui-conversation/tests/skeleton.spec.tsx | 15 +++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 30 +++++++- packages/host/apiproxy/src/api/sessions.ts | 3 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 69 +++++++++++++++++-- 18 files changed, 296 insertions(+), 62 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/fork.expected.md create mode 100644 apps/web/tests/snapshots/subagent-conversation/nested.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 5a8c9b997f..d8b35fae96 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: 947f5a33fbb6db19a1e88bacecb557ae6d7b80f0 -2026-07-27-web-subagent-conversations.zh.md: 407e8d626846c71dec17ff0901b7169bd886c314 +2026-07-27-web-subagent-conversations.md: 7087bc982b99ef0e967f47dd1d28612fba6aed8c +2026-07-27-web-subagent-conversations.zh.md: 732fd757bbe06f81ce7b4fa0919e8c7d18590196 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 947f5a33fb..7087bc982b 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 @@ -41,7 +41,7 @@ The header action count includes healthy `kind: 'child'` entries and excludes di `running` means the logical child record is live in the session corpus; `inactive` means it exists only in persistence. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `host/session-status` updates known activity in place. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`; 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. -Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, breadcrumbs, and live mux reconciliation reuse the ordinary conversation machinery. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. +Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior. @@ -104,7 +104,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - 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, subagent-parent expandability flips, and membership refresh. - jsdom tests pin 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 expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. -- Sidebar tests pin `origin: 'subagent'` filtering without hiding ordinary forks. +- 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 407e8d6268..732fd757bb 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 @@ -41,7 +41,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 `running` 表示逻辑 child 记录存活于会话语料库中;`inactive` 表示它只存在于持久化存储中。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`host/session-status` 会就地更新已知活动状态。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 -选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title、面包屑导航与实时 mux 归并都会复用普通对话机制。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 +选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。 @@ -104,7 +104,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转、subagent parent 可展开性翻转与成员刷新。 - jsdom 测试固定混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 - 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 -- 侧边栏测试固定 `origin: 'subagent'` 过滤,同时不隐藏普通 fork。 +- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 diff --git a/apps/web/tests/snapshots/subagent-conversation/fork.expected.md b/apps/web/tests/snapshots/subagent-conversation/fork.expected.md new file mode 100644 index 0000000000..3d4a1f7612 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/fork.expected.md @@ -0,0 +1,6 @@ +- tree "Sessions": + - treeitem "workspace 2 sessions" [expanded]: + - img + - text: workspace 2 sessions + - treeitem "Explain event sourcing in one now" [selected] + - treeitem "Ask a research subagent to now" diff --git a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md new file mode 100644 index 0000000000..7d7595c7a4 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md @@ -0,0 +1,18 @@ +- banner: + - navigation "Session hierarchy": + - button "Ask a research subagent to" + - text: / + - button "event-sourcing researcher" + - text: / + - button "example editor" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Give one concrete event sourcing example. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- status: + - strong: 此子代理暂时只读 + - text: 父会话当前不在线,重新打开父会话后即可继续发送消息。 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 840a5220b0..56035d7d55 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -17,9 +17,11 @@ import { import { connectFreshWorkspace, saveFailureShot } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) -const UI_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', 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 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)) const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' const ONE_SHOT_LABEL = 'event-sourcing reviewer' @@ -252,7 +254,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (scaffold.ctx.agents.get(childId) !== undefined) { throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`) } - await page.getByRole('heading', { name: LABEL }).waitFor() + const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) + await hierarchy.getByRole('button', { name: LABEL, disabled: true }).waitFor() const sidebar = await captureStableAria( page, '[role="tree"][aria-label="Sessions"]', @@ -275,7 +278,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = resolveEnded() }) }) - const input = page.locator('textarea:enabled').first() + const input = page.getByRole('textbox', { name: 'Message the agent' }) await input.fill(FOLLOWUP) await input.press('Enter') await ended @@ -287,11 +290,28 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('matches the settled addressed-conversation aria golden and stays clean', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria')) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) + 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('treeitem', { name: new RegExp(NESTED_LABEL) }).click() + await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor() + const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) + const crumbs = await hierarchy.getByRole('button').allTextContents() + expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) + expect(scaffold.ctx.agents.get(childId)).toBeUndefined() + expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() + await compareOrRefreshGolden( + UNAVAILABLE_GRANDCHILD_EXPECTED, + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), + MODE, + ) + }) + it('opens a one-shot child as permanently read-only history', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot')) const parentSession = page.getByRole('tree', { name: 'Sessions' }) @@ -304,6 +324,33 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() }) + it('places an ordinary fork from a subagent beside its workspace-owning ancestor', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-fork')) + await page.getByRole('tree', { name: 'Sessions' }) + .getByRole('treeitem', { name: /Ask a research subagent to/ }) + .click() + await page.getByRole('button', { name: '2 个子代理' }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() + const forkResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/session.fork') + await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() + const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } } + expect(forkReceipt.result).toMatchObject({ ok: true }) + await expect.poll( + () => page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').count(), + { timeout: 15_000 }, + ).toBe(3) + expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0) + const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) + expect(await hierarchy.getByRole('button').count()).toBe(1) + await compareOrRefreshGolden( + FORK_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + }) + it('cold-resumes the original subagent while its ordinary fork stays active', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup')) const sessions = page.getByRole('tree', { name: 'Sessions' }) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index a077c6ac9e..fa2a8c4a46 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -127,14 +127,14 @@ export class SessionManager { // ---- Selection ---- /** - * Select a listed Session. - * @param sessionId - listed Session id. + * Select a listed Session or a retained catalog-addressed child. + * @param sessionId - listed or catalog-addressed Session id. */ select(sessionId: SessionId): void { - if (!this.summaries.some(summary => summary.sessionId === sessionId)) { + const address = this.addresses.get(sessionId) + if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) { throw new Error(`sessions.select: unknown session ${sessionId}`) } - const address = this.addresses.get(sessionId) this.sessions.get(sessionId)?.configureSubagent( address, address === undefined diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 6f360eb2c6..42c3de31e2 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -65,7 +65,9 @@ export interface SessionSummary { * sidebar highlighting and SessionProvider share one fact source). */ export interface SessionListState { + /** Host-list order; addressed breadcrumb-only rows are excluded. */ ids: SessionId[] + /** Host rows plus the current addressed subagent route used by navigation. */ byId: Record current: SessionId | undefined /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ @@ -309,9 +311,8 @@ export class SessionsService implements ISessions { } /** - * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere. - * @param id - session id (must exist in the list store). + * Select a listed or retained catalog-addressed session as current. + * @param id - listed or addressed session id. */ open(id: SessionId): void { this.manager.select(id) @@ -563,9 +564,9 @@ export class SessionsService implements ISessions { } /** - * Breadcrumb feed: walk parentId links inside the list store. + * Breadcrumb feed: walk subagent parent links inside the list store. * @param id - session id. - * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk). + * @returns The ordinary owner plus its subagent route, or only the requested ordinary/fork session. */ ancestry(id: SessionId): SessionSummary[] { const { byId } = this.list.getSnapshot() @@ -575,6 +576,7 @@ export class SessionsService implements ISessions { const summary: SessionSummary | undefined = byId[cursor] if (summary === undefined || chain.includes(summary)) break chain.unshift(summary) + if (summary.origin !== 'subagent') break cursor = summary.parentId } return chain @@ -582,10 +584,9 @@ export class SessionsService implements ISessions { /** * Lazily mint the scope + binding for an eligible session. Eligibility and - * prune share one predicate (decision 12): listed on the host — a scope is - * born when its session enters the client's view (list mirror row from the - * baseline pull, a create() echo, or the session-added frame) and dies with - * the prune when the row leaves. + * prune share one predicate (decision 12): listed on the host or selected + * through a retained subagent address. Breadcrumb-only ancestors remain + * summary data and do not keep scopes alive. */ private resolve(id: SessionId): ScopeRecord | undefined { const existing = this.scopes.get(id) @@ -609,9 +610,10 @@ export class SessionsService implements ISessions { return record } - /** The one aliveness predicate shared by scope mint and prune: host-listed. */ + /** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */ private eligible(id: SessionId): boolean { - return this.list.getSnapshot().byId[id] !== undefined + const { ids, current } = this.list.getSnapshot() + return current === id || ids.includes(id) } /** Project the manager's list snapshot into the store (title derivation is display-only). */ @@ -636,20 +638,29 @@ export class SessionsService implements ISessions { ...(entry.origin !== undefined ? { origin: entry.origin } : {}), } } - if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) { - const child = subagentsByParent[currentAddress.parentSessionId]?.entries - .find(entry => entry.kind === 'child' && entry.id === current) - if (child?.kind === 'child') { - byId[current] = { - id: current, - displayTitle: child.label ?? current, - parentId: currentAddress.parentSessionId, - origin: 'subagent', - running: child.activity === 'running', - waitingApproval: false, - blank: false, - updatedAt: 0, + if (current !== undefined && currentAddress !== undefined) { + const seen = new Set() + let address: SubagentAddress | undefined = currentAddress + while (address !== undefined && !seen.has(address.childSessionId)) { + const childId = address.childSessionId + seen.add(childId) + if (byId[childId] === undefined) { + const child = subagentsByParent[address.parentSessionId]?.entries + .find(entry => entry.kind === 'child' && entry.id === childId) + if (child?.kind !== 'child') break + byId[childId] = { + id: childId, + displayTitle: child.label ?? childId, + parentId: address.parentSessionId, + origin: 'subagent', + running: child.activity === 'running', + waitingApproval: false, + blank: false, + updatedAt: 0, + } } + if (byId[address.parentSessionId] !== undefined) break + address = this.manager.subagentAddress(address.parentSessionId) } } const persisted = this.selection.getSnapshot().sessionId @@ -668,12 +679,11 @@ export class SessionsService implements ISessions { }) } this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress }) - this.pruneScopes(byId) + this.pruneScopes() } /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ - private pruneScopes(byId: Record): void { - void byId + private pruneScopes(): void { for (const [id, record] of this.scopes) { if (this.eligible(id)) continue if (id === this.watched) { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 8391fc8e76..61efe6e1ed 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -362,18 +362,73 @@ describe('slot-store scope prune hook', () => { }) describe('ancestry', () => { - it('walks parentId links root-first including self; broken links stop the walk', async () => { + it('walks only subagent lineage and includes its first ordinary owner', async () => { const b = bench() await feedList(b, [ { id: 'root', cwd: '/w/app' }, - { id: 'mid', parentId: 'root' }, - { id: 'leaf', parentId: 'mid' }, - { id: 'orphan', parentId: 'ghost' }, + { id: 'fork', parentId: 'root' }, + { id: 'child', parentId: 'fork', origin: 'subagent' }, + { id: 'grandchild', parentId: 'child', origin: 'subagent' }, + { id: 'orphan', parentId: 'ghost', origin: 'subagent' }, ]) - expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf']) + expect(b.svc.ancestry(sid('fork')).map(s => s.id)).toEqual(['fork']) + expect(b.svc.ancestry(sid('child')).map(s => s.id)).toEqual(['fork', 'child']) + expect(b.svc.ancestry(sid('grandchild')).map(s => s.id)).toEqual(['fork', 'child', 'grandchild']) expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan']) expect(b.svc.ancestry(sid('ghost'))).toEqual([]) }) + + it('retains a cold nested subagent route without retaining ancestor scopes', async () => { + const b = bench() + b.api.onSubagentList = (payload) => { + const { parentSessionId } = payload as { parentSessionId: SessionId } + if (parentSessionId === sid('root')) { + return Promise.resolve(ok({ + entries: [{ + kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', + activity: 'inactive', hasChildren: true, + }] as never[], + parentAvailable: true, + })) + } + if (parentSessionId === sid('child')) { + return Promise.resolve(ok({ + entries: [{ + kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: false, + })) + } + return Promise.resolve(ok({ entries: [], parentAvailable: false })) + } + await feedList(b, [ + { id: 'root' }, + { id: 'child', parentId: 'root', origin: 'subagent' }, + { id: 'grandchild', parentId: 'child', origin: 'subagent' }, + ]) + await b.svc.refreshSubagents(sid('root')) + b.svc.openSubagent({ + parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable', + }) + await b.svc.refreshSubagents(sid('child')) + b.svc.openSubagent({ + parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable', + }) + + await feedList(b, [{ id: 'root' }]) + const list = b.svc.list.getSnapshot() + expect(list.ids).toEqual([sid('root')]) + expect(b.svc.ancestry(sid('grandchild')).map(summary => summary.id)) + .toEqual([sid('root'), sid('child'), sid('grandchild')]) + expect(b.svc.binding(sid('child'))).toBeUndefined() + + b.svc.open(sid('child')) + expect(b.svc.list.getSnapshot().current).toBe(sid('child')) + expect(b.svc.subagentAddress(sid('child'))).toEqual({ + parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable', + }) + }) }) describe('create', () => { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index b20f6464eb..df0e95d1d0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -17,6 +17,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session const summary: SessionSummary | undefined = list.byId[cursor] if (summary === undefined || chain.includes(summary)) break chain.unshift(summary) + if (summary.origin !== 'subagent') break cursor = summary.parentId } return chain @@ -24,7 +25,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, wrapActiveBody, t + renderSlot, views, bindDraftMirror, open, wrapActiveBody, t, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 69f94a99fc..eff655d229 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -87,6 +87,8 @@ function mount( summaryBlank?: boolean /** Drop the session's summary row entirely (a session the list has not caught up with). */ omitSummaryRow?: boolean + /** Classify the selected child as a subagent instead of an ordinary fork. */ + summaryOrigin?: 'subagent' } = {}, ) { const root = sid('root') @@ -94,6 +96,7 @@ function mount( const childRow = { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2, + ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }), } const listed = options.omitSummaryRow !== true const sessions = createSnapshotStore({ @@ -203,7 +206,7 @@ function mount( } const view = render() return { - view, chat, sink, retargetWorkspace, session, slotCalls, + view, chat, sink, retargetWorkspace, session, slotCalls, open, pickerOwner: () => pickerOwner, rerender: () => { view.rerender() }, } @@ -218,10 +221,18 @@ describe('ConversationRoot resident composer', () => { expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).toHaveBeenCalledWith('ordinary revised') - expect(b.view.getByRole('heading', { name: 'Child', level: 1 })).toBeTruthy() + expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) + it('shows hierarchy only for subagents and opens their ordinary owner', () => { + const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' }) + const root = b.view.getByRole('button', { name: 'Root' }) + expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(root) + expect(b.open).toHaveBeenCalledWith(sid('root')) + }) + it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => { const b = mount(conversationSnapshot()) const host = b.view.container.querySelector('[data-conversation-scroll]') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 11efc9d7f2..a2a1bf3c5d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2725,7 +2725,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentListEntry', - declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', + declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', }, { name: 'SubagentProvider', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index fce1033f67..cca0f0da89 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 5755850e77dbd247b8808d907d813aecf2a650aa -README.zh.md: 02048fdbf2eaca3458b34d508731dfc9df1e90d3 +README.md: c4e99c4240d2e728840ab67a29552ee39726fe62 +README.zh.md: c3166ede6babb885ac43b2fef91c55a3b843b122 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5755850e77..c4e99c4240 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,7 +16,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until an Agent-bound ordinary-session operation attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -`session.fork` reads its source from attached state or persistence inspection without acquiring an Agent, then maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published ordinary child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace when present. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. +`session.fork` reads its source from attached state or persistence inspection without acquiring an Agent, then maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published ordinary child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace, or the nearest workspace-owning ancestor when the source is a subagent. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 02048fdbf2..c3166ede6b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,7 +16,7 @@ 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到某项绑定到 Agent 的普通会话操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -`session.fork` 会从已附加状态或持久化检查中读取源会话而不获取 Agent,再将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的普通子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再在源会话属于 Workspace 时加入该 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 +`session.fork` 会从已附加状态或持久化检查中读取源会话而不获取 Agent,再将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的普通子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace;若源会话是 subagent,则改为附加到最近拥有 Workspace 的祖先。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a869be700e..82102a8f9d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1125,6 +1125,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } } + /** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */ + async function forkWorkspace(source: Pick): Promise { + const workspaces = ctx.workspace.list() + const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) + if (direct !== undefined || source.header.origin !== 'subagent') return direct + + const lineage = await ctx.sessionQuery.traceSession(source.id) + for (const ancestor of lineage.ancestors) { + const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id)) + if (workspace !== undefined) return workspace + } + return undefined + } + /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */ async function historyStateFor( sessionId: SessionId, @@ -1762,6 +1776,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // right after the boundary turn. let cut = boundary.seq + 1 while (cut < events.length && events[cut]?.type !== 'turn/start') cut++ + let workspace: Workspace | undefined + try { + workspace = await forkWorkspace(source) + } catch (error: unknown) { + return err(request, { + code: 'internal', + message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`, + details: {}, + }) + } const childId = `session-${randomUUID()}` as SessionId try { await ctx.agents.create({ @@ -1782,9 +1806,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - // Keep the child in the source's Workspace so the list nests it under - // its parent; the child is already published if the attach fails. - const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id)) + // An ordinary source keeps its direct Workspace. A subagent source is + // not listed there, so its ordinary fork joins the nearest owning + // ancestor instead. The child is already published if attach fails. if (workspace !== undefined) { try { await workspace.attachSession(childId) diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 78643ed343..99e725482e 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -274,7 +274,8 @@ export interface SessionsApi { * target and `parentSessionId` lineage; the seed prefix carries the source * title. Reading the source uses attached state or persistence inspection * without acquiring an Agent. Workspace attachment follows the source - * directly when it belongs to one. + * directly, or the nearest + * workspace-owning ancestor when the source is a subagent. */ fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index d0226d81e6..e653bafb4e 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -10,6 +10,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { Workspace } from '@deepseek-ai/dsh-workspace' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' @@ -21,13 +22,13 @@ function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload } } -async function composed(): Promise { +async function composed(workspaces: readonly Workspace[] = []): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - ctx.provide('workspace', { list: () => [] } as never) + ctx.provide('workspace', { list: () => workspaces } as never) ctx.agents.setFactory({ createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise => { const session = ctx.sessions.create(options.sessionId, { @@ -49,8 +50,14 @@ async function composed(): Promise { /** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */ type Tail = 'none' | 'open' | 'aborted' -function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session { - const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } }) +function liveAgent( + ctx: Context, + id: string, + turns: number, + tail: Tail = 'none', + lineage: { parentSession?: SessionId; origin?: 'subagent' } = {}, +): Session { + const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } }) for (let turn = 1; turn <= turns; turn++) { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', createUserMessage({ @@ -94,6 +101,51 @@ describe('sessions.fork', () => { await ctx.fiber.dispose() }) + it('attaches a subagent fork to its nearest workspace-owning ancestor', async () => { + const accounted: SessionId[] = [] + const attachSession = vi.fn<(sessionId: SessionId) => Promise>() + .mockResolvedValue(undefined) + const workspace = { + sessionIds: accounted, + attachSession, + } as unknown as Workspace + const ctx = await composed([workspace]) + const owner = liveAgent(ctx, 'session-owner', 1) + accounted.push(owner.id) + const child = liveAgent(ctx, 'session-child', 1, 'none', { + parentSession: owner.id, + origin: 'subagent', + }) + const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', { + parentSession: child.id, + origin: 'subagent', + }) + ctx.provide('sessionQuery', { + traceSession: vi.fn(() => Promise.resolve({ + target: { header: grandchild.header, live: true, persisted: false }, + ancestors: [ + { header: child.header, live: true, persisted: false }, + { header: owner.header, live: true, persisted: false }, + ], + descendants: [], + complete: true, + root: { header: owner.header, live: true, persisted: false }, + })), + } as never) + + const response = await api(ctx).sessions.fork(request({ sessionId: grandchild.id })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) return + expect(attachSession).toHaveBeenCalledWith(response.result.value.sessionId) + expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({ + parentSession: grandchild.id, + cwd: '/proj', + }) + expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined() + await ctx.fiber.dispose() + }) + it('forks a persisted subagent without resuming its Agent', async () => { const ctx = await composed() const sourceId = sid('session-cold-subagent') @@ -121,6 +173,15 @@ describe('sessions.fork', () => { list: () => Promise.resolve([header]), inspect: () => Promise.resolve({ meta: header, events }), } as never) + ctx.provide('sessionQuery', { + traceSession: () => Promise.resolve({ + target: { header, live: false, persisted: true }, + ancestors: [], + descendants: [], + complete: true, + root: { header, live: false, persisted: true }, + }), + } as never) const resume = vi.spyOn(ctx.agents, 'resume') const response = await api(ctx).sessions.fork(request({ sessionId: sourceId }))