Merge remote-tracking branch 'origin/master' into xtr/trajectory-timeline
# Conflicts: # apps/web/tests/code-mode-fixture.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # apps/web/tests/snapshots/navigation-panes/details-open.expected.md # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/fold-adapter.ts # packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
@@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -104,6 +104,28 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
// Live path: run mints an executing node, done settles it in the flow.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(6, 'cmd-live', 'plan'))
|
||||
let command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
|
||||
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
|
||||
command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
|
||||
|
||||
// Replay path (refresh): the same pair inside the history window folds identically.
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.commandRun(6, 'cmd-live', 'plan'),
|
||||
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -173,42 +195,6 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
|
||||
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
|
||||
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
|
||||
const { session } = await opened()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.todoWrite(6, listA))
|
||||
expect(session.getSnapshot().todos).toEqual(listA)
|
||||
feed(ev.todoWrite(7, listB))
|
||||
expect(session.getSnapshot().todos).toEqual(listB)
|
||||
// Window replay converges on the same last snapshot (history contains both writes).
|
||||
const replayed = makeSession()
|
||||
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
|
||||
await replayed.session.open()
|
||||
expect(replayed.session.getSnapshot().todos).toEqual(listB)
|
||||
})
|
||||
|
||||
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
|
||||
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
|
||||
// Cold open: the page window carries NO todo/write; the projection rides the response.
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
|
||||
await session.open()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// Paging an older window in must not clear the session-level projection.
|
||||
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
|
||||
await session.loadOlder()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// A later live write still overrides the seeded projection.
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
|
||||
})
|
||||
|
||||
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
|
||||
@@ -222,37 +208,6 @@ describe('live event path', () => {
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
|
||||
})
|
||||
|
||||
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
// The missed range contained a todo/write that the repulled page no longer
|
||||
// covers; the response's session-level projection is the only carrier.
|
||||
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
|
||||
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.history').length).toBe(2)
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(session.getSnapshot().todos).toEqual(current)
|
||||
})
|
||||
|
||||
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
|
||||
// Live write lands, then the host crashes before persisting it: the
|
||||
// authoritative log holds no todo/write, so the resync tail response
|
||||
// carries no projection — an omitted field on a tail request is the empty
|
||||
// list, not a missing carrier, and the rolled-back plan must disappear.
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('paging', () => {
|
||||
|
||||
Reference in New Issue
Block a user