Merge branch 'master' into worktree/web-session-titles

This commit is contained in:
Tianyi Cui
2026-07-23 20:51:17 +08:00
30 changed files with 795 additions and 106 deletions

View File

@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -93,8 +93,10 @@ export class FakeApiClient implements IApiClient {
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
respond(message: ClientResponse): Promise<RpcReceipt> {
return this.record('respond', message, this.onRespond(message))
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */

View File

@@ -34,7 +34,7 @@ describe('instances', () => {
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
const session = manager.get(S1)
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
// Buffer cleared: a second instantiation of another id gets nothing.
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
@@ -48,7 +48,7 @@ describe('instances', () => {
}
const pending = manager.get(S1).getSnapshot().pending
expect(pending).toHaveLength(32)
expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })

View File

@@ -251,6 +251,36 @@ describe('pending interactions', () => {
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
})
it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } })
const receipt = await wait.respond({
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
})
expect(receipt).toEqual({ accepted: true })
expect(api.callsOf('respond')).toEqual([{
type: 'client-response', rpcId: 'rq-answer',
result: {
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
},
}])
})
it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
.toThrow('already settled')
expect(api.callsOf('respond')).toEqual([])
})
})
describe('remaining branches', () => {
@@ -355,7 +385,7 @@ describe('remaining branches', () => {
session.handleMuxEnvelope('ra' as never, {
type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
})
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } })
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
@@ -568,6 +598,22 @@ describe('resync', () => {
expect(cold.api.calls).toEqual([]) // never opened: no traffic
})
it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const before = session.getSnapshot().pending[0]!
await session.resync()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const after = session.getSnapshot().pending[0]!
expect(after).not.toBe(before)
expect(after.key).toBe(before.key)
// Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
})
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()