fix(web): deduplicate subagent navigation
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: ffee75c0e28cd39c4b80a2f93a2496f2a8a22092
|
||||
README.zh.md: 85511bc191d64cb4954515a2ba1efdf912c3f937
|
||||
README.md: 0f5b35b6fa4a92f083d5e20c354c19eaae1d5b70
|
||||
README.zh.md: 67cdf6479ef2ba7156b7babb7249b51f8640b134
|
||||
|
||||
@@ -56,7 +56,7 @@ Each resident `Session` owns a `modelSelection` snapshot containing the current
|
||||
|
||||
## Addressed subagent conversations
|
||||
|
||||
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
|
||||
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
|
||||
|
||||
## 已寻址的 subagent 对话
|
||||
|
||||
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间把地址与所选会话一同持久化。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活跃状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
|
||||
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活动状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface SessionListEntry {
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
|
||||
@@ -134,8 +134,13 @@ export class SessionManager {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
this.addresses.delete(sessionId)
|
||||
this.sessions.get(sessionId)?.configureSubagent(undefined)
|
||||
const address = this.addresses.get(sessionId)
|
||||
this.sessions.get(sessionId)?.configureSubagent(
|
||||
address,
|
||||
address === undefined
|
||||
? false
|
||||
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
|
||||
)
|
||||
this.selected = sessionId
|
||||
void this.refreshSubagents(sessionId)
|
||||
this.notifier.notifyNow()
|
||||
@@ -607,6 +612,7 @@ export class SessionManager {
|
||||
this.mergeSummary({
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
@@ -724,7 +730,7 @@ export class SessionManager {
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
@@ -766,9 +772,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
|
||||
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
||||
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
...(existing.origin === undefined && mutation.summary.origin !== undefined
|
||||
? { origin: mutation.summary.origin } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
||||
&& filled.blank === existing.blank) return [...summaries]
|
||||
&& filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface SessionSummary {
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
running: boolean
|
||||
/** An approval question is pending on this session (sidebar amber-dot state). */
|
||||
waitingApproval: boolean
|
||||
@@ -631,6 +633,7 @@ export class SessionsService implements ISessions {
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) {
|
||||
@@ -641,6 +644,7 @@ export class SessionsService implements ISessions {
|
||||
id: current,
|
||||
displayTitle: child.label,
|
||||
parentId: currentAddress.parentSessionId,
|
||||
origin: 'subagent',
|
||||
running: child.activity === 'running',
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user