Merge remote-tracking branch 'origin/master' into xtr/trajectory-virtual-scroll
# Conflicts: # apps/web/tests/chat-scroll-fixture.ts # packages/client/runtime/README.i18n.yaml
This commit is contained in:
@@ -40,6 +40,7 @@ describe('instances', () => {
|
||||
const manager = new SessionManager(api)
|
||||
// Uninstantiated: approval buffers, plain session/event drops.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
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', payload: { approvalId: 'ap1' } }])
|
||||
@@ -47,16 +48,26 @@ describe('instances', () => {
|
||||
expect(manager.get(S2).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
|
||||
it('retains every live answerable request and compacts resolutions before instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
}
|
||||
const pending = manager.get(S1).getSnapshot().pending
|
||||
expect(pending).toHaveLength(32)
|
||||
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: `r${i}` as never,
|
||||
payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' },
|
||||
})
|
||||
}
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
expect(manager.get(S1).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('drops buffered answerable requests on session removal', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
// 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 } })
|
||||
@@ -862,48 +873,102 @@ describe('connected generation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
describe('pending-interaction list status', () => {
|
||||
it('tracks approval requests through replay and resolution without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
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({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q2' as never,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: S1,
|
||||
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')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } })
|
||||
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 } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
it('drops stale status at generation death before replay re-adds live interactions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected 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]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
|
||||
* laziness, synchronous notifyNow, and unsubscribe.
|
||||
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
|
||||
* no-listener laziness, synchronous notifyNow, and unsubscribe.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Notifier } from '../src/client/sessions/notifier.ts'
|
||||
|
||||
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Notifier', () => {
|
||||
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
|
||||
const order: string[] = []
|
||||
@@ -60,6 +64,57 @@ describe('Notifier', () => {
|
||||
expect(rebuilds).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses frame-dirty changes into one cumulative frame publication', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const order: string[] = []
|
||||
const notifier = new Notifier(() => order.push('rebuild'))
|
||||
notifier.subscribe(() => order.push('notify'))
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
|
||||
expect(order).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
frames.shift()!(0)
|
||||
expect(order).toEqual(['rebuild', 'notify'])
|
||||
})
|
||||
|
||||
it('lets a structural microtask publication supersede a pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markDirty()
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching when animation frames are unavailable', async () => {
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
expect(notifications).toBe(0)
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('unsubscribed listeners stop receiving notifications', async () => {
|
||||
let calls = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* enough.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -20,6 +20,10 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
@@ -42,6 +46,11 @@ describe('open', () => {
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.hasMore).toBe(true)
|
||||
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
|
||||
expect(snapshot.turnTimings.get(3)).toEqual({
|
||||
startTime: 1_700_000_000_010,
|
||||
endTime: 1_700_000_000_015,
|
||||
})
|
||||
expect(snapshot.turnEnds.get(3)).toBe(15)
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -163,6 +172,40 @@ describe('live event path', () => {
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const { session } = await opened()
|
||||
const published: Array<string | null> = []
|
||||
session.subscribe(() => {
|
||||
const block = session.getSnapshot().partial?.blocks[0]
|
||||
published.push(block?.kind === 'text' ? block.text : null)
|
||||
})
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
|
||||
feed(ev.chunkStart(6, 1))
|
||||
feed(ev.chunkText(7, 1, '累'))
|
||||
feed(ev.chunkText(8, 1, '计'))
|
||||
expect(published).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计'])
|
||||
|
||||
feed(ev.chunkText(9, 1, '完成'))
|
||||
feed(ev.assistant(10, 1, '累计完成'))
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual(['累计', null])
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计', null])
|
||||
})
|
||||
|
||||
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -215,11 +258,22 @@ describe('live event path', () => {
|
||||
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
const retryStart = retryTurn.find(event =>
|
||||
event.type === 'turn/start' && event.data.trigger.kind === 'retry')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include a retry turn/start')
|
||||
const retryEnd = retryTurn.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
|
||||
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
|
||||
expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({
|
||||
startTime: retryStart.time,
|
||||
endTime: retryEnd.time,
|
||||
})
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
|
||||
expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1216,6 +1270,7 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.turnTimings).toBe(before.turnTimings)
|
||||
expect(after.turnEnds).toBe(before.turnEnds)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
|
||||
Reference in New Issue
Block a user