feat(web): add subagent conversation transport
This commit is contained in:
@@ -132,6 +132,19 @@ export class FakeApiClient implements IApiClient {
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
|
||||
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
|
||||
= () => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
|
||||
@@ -272,6 +272,87 @@ describe('host frame routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent catalogs', () => {
|
||||
it('selects only a catalog-discovered child and keeps its durable address across status frames', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [
|
||||
summary(S1),
|
||||
] as never[] }))
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{ kind: 'child', id: S2, label: 'worker', activity: 'running' }] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2 })
|
||||
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2,
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2 },
|
||||
parentAvailable: true,
|
||||
})
|
||||
const listCalls = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-complete' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S2, running: false },
|
||||
})
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
|
||||
kind: 'child', id: S2, activity: 'inactive',
|
||||
})
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot()).toMatchObject({
|
||||
removed: false,
|
||||
subagent: { address: { parentSessionId: S1, childSessionId: S2 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('refetches debounced membership only while the parent catalog is open', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.setSubagentCatalogOpen(S1, true)
|
||||
await Promise.resolve()
|
||||
const baseline = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-again' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
|
||||
manager.setSubagentCatalogOpen(S1, false)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-closed' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
const api = new FakeApiClient()
|
||||
@@ -435,6 +516,19 @@ describe('connected generation', () => {
|
||||
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads the durable parent address for a restored child selection', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const address = { parentSessionId: S1, childSessionId: S2 }
|
||||
const manager = new SessionManager(api, S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
|
||||
})
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual(address)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
@@ -580,6 +581,33 @@ describe('paging', () => {
|
||||
})
|
||||
|
||||
describe('prompt and cancel errors', () => {
|
||||
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, content: [{ type: 'text', text: '继续' }] },
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
expect(session.getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: PARENT, childSessionId: SID },
|
||||
parentAvailable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
// The blank → engaging edge fires before the RPC settles: the first-send
|
||||
|
||||
Reference in New Issue
Block a user