fix(client): preserve pending waits across reconnect

This commit is contained in:
Turtle
2026-08-04 18:52:11 +08:00
parent 00d4349eb4
commit 3a54694d28
17 changed files with 219 additions and 64 deletions

View File

@@ -76,8 +76,9 @@ describe('runtime client apply', () => {
})
await Promise.resolve()
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
// Mux and generation-lifecycle sinks route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onDisconnected?.()
bench.sinks?.onConnected?.()
})

View File

@@ -887,7 +887,7 @@ describe('pending-interaction list status', () => {
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
})
it('classifies ordinary questions and plan reviews, then clears by question rpcId', () => {
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({
@@ -903,7 +903,11 @@ describe('pending-interaction list status', () => {
payload: {
type: 'question/requested',
sessionId: S1,
questions: [{ id: 'plan', question: 'Approve?', detail: '# Plan', intent: { kind: 'plan-review', approve: 'Approve' } }],
questions: [{
id: 'plan', question: 'Approve?', detail: '# Plan',
options: [{ label: 'Approve' }, { label: 'Refuse' }],
intent: { kind: 'plan-review', approve: 'Approve' },
}],
},
})
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review')
@@ -911,6 +915,28 @@ describe('pending-interaction list status', () => {
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
})
it.each([
['missing detail', {}],
['multi-select', { detail: '# Plan', multiSelect: true }],
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
manager.handleMuxEnvelope({
rpcId: 'q-plan' as never,
payload: {
type: 'question/requested', sessionId: S1,
questions: [{
id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }],
intent: { kind: 'plan-review', approve: 'Approve' },
...over,
}],
},
})
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
})
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
@@ -930,19 +956,30 @@ describe('pending-interaction list status', () => {
expect(manager.getListSnapshot().items).toHaveLength(0)
})
it('drops stale status at generation death before replay re-adds live interactions', () => {
const manager = new SessionManager(new FakeApiClient())
it('drops stale waits at generation death and preserves replay arriving before onConnected', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
const session = manager.get(S1)
await session.open()
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
expect(session.getSnapshot().pending).toMatchObject([{ key: 'a:ra' }])
// Generation death clears (resolved-while-disconnected questions send no frame)…
manager.handleDisconnected()
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
expect(session.getSnapshot().pending).toEqual([])
// …and a replayed frame arriving before onConnected (stream open precedes
// the readiness handshake) survives the later handleConnected untouched.
// the readiness handshake) survives the later resync untouched.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleConnected()
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
expect(session.getSnapshot().pending).toMatchObject([{ key: 'a:ra' }])
})
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {

View File

@@ -1098,16 +1098,19 @@ describe('remaining branches', () => {
})
describe('resync', () => {
it('rebuilds the window and clears pending; cold instances no-op', async () => {
it('rebuilds the window without clearing a fresh-generation wait; cold instances no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
session.handleDisconnected()
expect(session.getSnapshot().pending).toEqual([])
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')])
await session.resync()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames
expect(snapshot.pending).toMatchObject([{ key: 'a:ra', kind: 'approval' }])
expect(snapshot.nodes).toHaveLength(4)
const cold = makeSession()