fix(web): preserve subagent navigation and fork grouping

This commit is contained in:
imccyu
2026-08-02 12:51:11 +08:00
committed by Tianyi Cui
parent 8c9cd4c15d
commit 130410bb98
18 changed files with 296 additions and 62 deletions
@@ -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
@@ -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
@@ -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 树,支持懒加载式 ArrowRightArrowLeft 展开与折叠、线性 ArrowUpArrowDown 导航、HomeEnd、Escape 以及焦点恢复。
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRightArrowLeft 展开与折叠、线性 ArrowUpArrowDown 导航、HomeEnd、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。
## 后果
@@ -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"
@@ -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: 父会话当前不在线,重新打开父会话后即可继续发送消息。
+51 -4
View File
@@ -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' })
@@ -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
@@ -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<SessionId, SessionSummary>
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<SessionId>()
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<SessionId, SessionSummary>): void {
void byId
private pruneScopes(): void {
for (const [id, record] of this.scopes) {
if (this.eligible(id)) continue
if (id === this.watched) {
@@ -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', () => {
@@ -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()
@@ -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<SessionListState>({
@@ -203,7 +206,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, retargetWorkspace, session, slotCalls,
view, chat, sink, retargetWorkspace, session, slotCalls, open,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -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]')
@@ -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',
+2 -2
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: 5755850e77dbd247b8808d907d813aecf2a650aa
README.zh.md: 02048fdbf2eaca3458b34d508731dfc9df1e90d3
README.md: c4e99c4240d2e728840ab67a29552ee39726fe62
README.zh.md: c3166ede6babb885ac43b2fef91c55a3b843b122
+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -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`
+27 -3
View File
@@ -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<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
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)
+2 -1
View File
@@ -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<RpcResponse<{ sessionId: SessionId }>>
@@ -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<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
}
async function composed(): Promise<Context> {
async function composed(workspaces: readonly Workspace[] = []): Promise<Context> {
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<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
@@ -49,8 +50,14 @@ async function composed(): Promise<Context> {
/** 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<void>>()
.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 }))