fix(web): deduplicate subagent navigation

This commit is contained in:
Dudu-0223
2026-07-30 11:34:57 +08:00
committed by Tianyi Cui
parent 16ffd63115
commit f0ab04273d
53 changed files with 294 additions and 95 deletions

View File

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

View File

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