fix(web): synchronize subagent navigation state

This commit is contained in:
imccyu
2026-08-02 01:38:28 +08:00
committed by Tianyi Cui
parent b94d2f9c1d
commit 23680e838b
25 changed files with 462 additions and 106 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: e4b8e1777ad3a01144dfe9da0a81f83e0f4e2b6a
README.zh.md: 5aa53ce1ec680579b44f0b2859104a1eda7471d3
README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c
README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a

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 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 an origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its 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; the Host baseline and `host/session-status` both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. An origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its 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` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight`host/session-status` 就地翻转已列 child 的粗粒度活动状态,按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 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-flightHost 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
## 模型体验

View File

@@ -58,6 +58,7 @@ export interface SubagentCatalogSnapshot extends SubagentCatalog {
interface CatalogInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
}
type SessionListMutation =
@@ -288,6 +289,7 @@ export class SessionManager {
if (existing !== undefined) return existing.promise
const previous = this.catalogs.get(parentSessionId)
const expandableRows = new Set<SessionId>()
const activityRows = new Map<SessionId, 'running' | 'inactive'>()
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
@@ -301,7 +303,7 @@ export class SessionManager {
if (result.ok) {
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withExpandableRows(result.value.entries, expandableRows),
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
state: 'ready',
error: null,
})
@@ -311,7 +313,9 @@ export class SessionManager {
}
} else {
this.catalogs.set(parentSessionId, {
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: result.error,
@@ -320,7 +324,9 @@ export class SessionManager {
} catch (error: unknown) {
const folded = transportError<never>(error)
this.catalogs.set(parentSessionId, {
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
@@ -330,7 +336,7 @@ export class SessionManager {
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows })
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows })
return operation
}
@@ -651,19 +657,22 @@ export class SessionManager {
return
}
case 'host/session-removed': {
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
if (this.addresses.has(frame.sessionId)) {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
this.recordMutation(durableSubagent
? { kind: 'status', sessionId: frame.sessionId, running: false }
: { kind: 'remove', sessionId: frame.sessionId })
this.updateCatalogActivity(frame.sessionId, false)
if (durableSubagent) {
// An Activation detaching is not durable child deletion:
// keep the addressed conversation usable and return its catalog row
// to the inactive state.
// keep its lineage and conversation while returning it to idle.
this.sessions.get(frame.sessionId)?.handleRunning(false)
this.updateCatalogActivity(frame.sessionId, false)
} else {
this.sessions.get(frame.sessionId)?.handleRemoved()
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
return
}
case 'host/session-status': {
@@ -725,11 +734,14 @@ export class SessionManager {
this.catalogDebounce.set(parentSessionId, timer)
}
/** Flip a listed child's coarse activity in place from the shared Host frame. */
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
const activity = running ? 'running' as const : 'inactive' as const
for (const inflight of this.catalogInflight.values()) {
inflight.activityRows.set(childSessionId, activity)
}
let changed = false
for (const [parentSessionId, catalog] of this.catalogs) {
const activity = running ? 'running' as const : 'inactive' as const
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
const entries = catalog.entries.map((entry) => {
@@ -764,14 +776,22 @@ export class SessionManager {
if (changed) this.notifier.markDirty()
}
/** Fold request-local positive row mutations into one catalog result before publication. */
private withExpandableRows(
/** Fold request-local row mutations into one catalog result before publication. */
private withCatalogMutations(
entries: SubagentCatalog['entries'],
expandableRows: ReadonlySet<SessionId>,
activityRows: ReadonlyMap<SessionId, 'running' | 'inactive'>,
): SubagentCatalog['entries'] {
return entries.map(entry => entry.kind === 'child' && expandableRows.has(entry.id)
? { ...entry, hasChildren: true }
: entry)
return entries.map((entry) => {
if (entry.kind !== 'child') return entry
const activity = activityRows.get(entry.id)
if (!expandableRows.has(entry.id) && activity === undefined) return entry
return {
...entry,
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
...activity === undefined ? {} : { activity },
}
})
}
private buildListSnapshot(): SessionListSnapshot {

View File

@@ -625,13 +625,15 @@ export class SessionsService implements ISessions {
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
const child = subagentsByParent[address.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === childId)
if (child?.kind !== 'child') break
const displayTitle = child.label ?? childId
const summary = byId[childId]
if (summary === undefined) {
byId[childId] = {
id: childId,
displayTitle: child.label ?? childId,
displayTitle,
parentId: address.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
@@ -639,8 +641,11 @@ export class SessionsService implements ISessions {
blank: false,
updatedAt: 0,
}
} else if (summary.displayTitle !== displayTitle) {
byId[childId] = { ...summary, displayTitle }
}
if (byId[address.parentSessionId] !== undefined) break
const parent = byId[address.parentSessionId]
if (parent !== undefined && parent.origin !== 'subagent') break
address = this.manager.navigationAddress(address.parentSessionId)
}
}

View File

@@ -341,6 +341,9 @@ describe('subagent catalogs', () => {
rpcId: 'child-detached' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
origin: 'subagent', parentSessionId: S1, running: false,
})
expect(manager.get(S2).getSnapshot()).toMatchObject({
removed: false,
subagent: {
@@ -467,6 +470,65 @@ describe('subagent catalogs', () => {
{ kind: 'child', id: S1, hasChildren: false },
])
})
it('replays status frames over an older in-flight catalog response', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api)
const refresh = manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'child-stopped' as never,
payload: { type: 'host/session-status', sessionId: S1, running: false },
})
manager.handleHostEnvelope({
rpcId: 'child-started' as never,
payload: { type: 'host/session-status', sessionId: S2, running: true },
})
response.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
activity: 'running', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'started',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, activity: 'inactive' },
{ kind: 'child', id: S2, activity: 'running' },
])
})
it('marks a detached catalog child inactive without requiring a selected address', async () => {
const api = new FakeApiClient()
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(S1)
manager.handleHostEnvelope({
rpcId: 'child-detached' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
{ kind: 'child', id: S2, activity: 'inactive' },
])
})
})
describe('remaining branches', () => {

View File

@@ -362,6 +362,45 @@ describe('slot-store scope prune hook', () => {
})
describe('catalog-addressed navigation', () => {
it('uses catalog labels for a listed addressed route', 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', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
})
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {