| |
| |
| |
| |
| |
|
|
| import type { |
| AssistantMessage, |
| HistoryMessage, |
| InteractionMessage, |
| ServerMessage, |
| StepMessage, |
| SystemMessage, |
| TaskMessage, |
| ToolCallMessage, |
| TurnMessage, |
| UserMessage, |
| } from '@moonshot-ai/kap-server/protocol'; |
| import { describe, expect, it, vi } from 'vitest'; |
|
|
| import type { WsLike } from '../channel/wsLike'; |
| import { fetchFullHistory, fetchHistoryPage } from './api'; |
| import { ChatChannel } from './channel'; |
| import { projectPlans } from './plan'; |
| import { |
| ChatStore, |
| newestTerminalStepId, |
| oldestTurnId, |
| recoverLoadedWindow, |
| type TimelineEntry, |
| } from './store'; |
| import { ChatWs } from './ws'; |
|
|
| |
|
|
| const T0 = Date.parse('2026-01-01T00:00:00.000Z'); |
| let tick = 0; |
|
|
| function ts(offsetMs?: number): number { |
| tick += 1; |
| return T0 + tick * 1000 + (offsetMs ?? 0); |
| } |
|
|
| const base = { session_id: 's1', agent_id: 'main' } as const; |
|
|
| function turnMsg(n: number, status: 'running' | 'completed' = 'completed', at?: number): TurnMessage { |
| return { |
| type: 'turn', |
| ...base, |
| timestamp: at ?? ts(), |
| turn_id: `t${n}`, |
| ordinal: n, |
| status, |
| origin: { kind: 'user' }, |
| }; |
| } |
|
|
| function stepMsg( |
| stepId: string, |
| status: StepMessage['status'] = 'completed', |
| at?: number, |
| ): StepMessage { |
| const turnId = stepId.split('.')[0] ?? 't1'; |
| const ordinal = Number(stepId.split('.')[1] ?? '1'); |
| return { |
| type: 'step', |
| ...base, |
| timestamp: at ?? ts(), |
| step_id: stepId, |
| turn_id: turnId, |
| ordinal, |
| status, |
| }; |
| } |
|
|
| function userMsg(stepId: string, text: string, at?: number): UserMessage { |
| const turnId = stepId.split('.')[0] ?? 't1'; |
| return { |
| type: 'user', |
| ...base, |
| timestamp: at ?? ts(), |
| message_id: `${stepId}.u0`, |
| turn_id: turnId, |
| text: [{ type: 'text', text, meta: {} }], |
| status: 'read', |
| }; |
| } |
|
|
| function assistantMsg( |
| stepId: string, |
| text: string, |
| status: 'streaming' | 'completed' = 'completed', |
| at?: number, |
| ): AssistantMessage { |
| const turnId = stepId.split('.')[0] ?? 't1'; |
| return { |
| type: 'assistant', |
| ...base, |
| timestamp: at ?? ts(), |
| message_id: `${stepId}.a0`, |
| turn_id: turnId, |
| step_id: stepId, |
| status, |
| text, |
| }; |
| } |
|
|
| function toolCallMsg( |
| stepId: string, |
| id: string, |
| overrides: Partial<ToolCallMessage> = {}, |
| ): ToolCallMessage { |
| const turnId = stepId.split('.')[0] ?? 't1'; |
| return { |
| type: 'tool_call', |
| ...base, |
| timestamp: ts(), |
| tool_call_id: id, |
| turn_id: turnId, |
| step_id: stepId, |
| name: 'Bash', |
| status: 'running', |
| ...overrides, |
| }; |
| } |
|
|
| function systemMsg( |
| subtype: SystemMessage['subtype'], |
| systemId: string, |
| payload?: unknown, |
| ): SystemMessage { |
| return { |
| type: 'system', |
| ...base, |
| timestamp: ts(), |
| system_id: systemId, |
| subtype, |
| payload, |
| } as SystemMessage; |
| } |
|
|
| function interactionMsg(id: string, toolCallId?: string): InteractionMessage { |
| return { |
| type: 'interaction', |
| ...base, |
| timestamp: ts(), |
| interaction_id: id, |
| kind: 'approval', |
| status: 'pending', |
| tool_call_id: toolCallId, |
| }; |
| } |
|
|
| function taskMsg(id: string, status: TaskMessage['status'] = 'running'): TaskMessage { |
| return { |
| type: 'task', |
| ...base, |
| timestamp: ts(), |
| task_id: id, |
| kind: 'shell', |
| status, |
| detached: false, |
| output_tail: '', |
| }; |
| } |
|
|
| function undoMsg(systemId: string, removedIds: readonly string[]): SystemMessage { |
| return systemMsg('undo', systemId, { removed_ids: [...removedIds] }); |
| } |
|
|
| function entryKeys(entries: readonly TimelineEntry[]): string[] { |
| return entries.map((entry) => entry.key); |
| } |
|
|
| function makeStore(): ChatStore { |
| return new ChatStore({ notifyIntervalMs: 0 }); |
| } |
|
|
| function okEnvelope(data: unknown) { |
| return { code: 0, msg: 'success', data, request_id: 'r1' }; |
| } |
|
|
| function fakeFetch(envelope: unknown) { |
| const calls: { url: string; init?: RequestInit }[] = []; |
| const fetchImpl = (async (url: string | URL, init?: RequestInit) => { |
| calls.push({ url: String(url), init }); |
| return { json: async () => envelope }; |
| }) as unknown as typeof fetch; |
| return { calls, fetchImpl }; |
| } |
|
|
| class FakeWs implements WsLike { |
| static OPEN = 1; |
| static instances: FakeWs[] = []; |
| readyState = 1; |
| readonly sent: string[] = []; |
| private readonly listeners = new Map<string, ((event: never) => void)[]>(); |
|
|
| constructor( |
| readonly url: string, |
| readonly protocols?: string | string[], |
| ) { |
| FakeWs.instances.push(this); |
| } |
|
|
| static reset(): void { |
| FakeWs.instances = []; |
| } |
|
|
| send(data: string): void { |
| this.sent.push(data); |
| } |
|
|
| close(): void { |
| this.emit('close'); |
| } |
|
|
| addEventListener(type: string, listener: (event: never) => void): void { |
| const list = this.listeners.get(type) ?? []; |
| list.push(listener); |
| this.listeners.set(type, list); |
| } |
|
|
| emit(type: string, event?: unknown): void { |
| for (const listener of this.listeners.get(type) ?? []) listener(event as never); |
| } |
|
|
| open(): void { |
| this.emit('open'); |
| } |
|
|
| serverFrame(frame: unknown): void { |
| this.emit('message', { data: JSON.stringify(frame) }); |
| } |
|
|
| sentFrames(): Record<string, unknown>[] { |
| return this.sent.map((data) => JSON.parse(data) as Record<string, unknown>); |
| } |
|
|
| hello(): void { |
| this.serverFrame({ |
| type: 'hello', |
| protocol_version: '3', |
| server_id: 'srv', |
| capabilities: ['step_replay_v1'], |
| }); |
| } |
| } |
|
|
| function makeWs(handlers: Partial<ConstructorParameters<typeof ChatWs>[0]['handlers']> = {}) { |
| const seen = { |
| messages: [] as ServerMessage[], |
| acks: [] as { code: number; msg?: string }[], |
| protocolErrors: [] as { code: number; msg: string }[], |
| invalid: 0, |
| reconnects: 0, |
| }; |
| const ws = new ChatWs({ |
| url: 'http://h:1', |
| token: 'tok', |
| sessionId: 's1', |
| agentIds: ['main'], |
| WebSocketImpl: FakeWs, |
| reconnectDelayMs: 1, |
| handlers: { |
| onMessage: (message) => { |
| seen.messages.push(message); |
| handlers.onMessage?.(message); |
| }, |
| onAck: (code, msg) => { |
| seen.acks.push({ code, msg }); |
| handlers.onAck?.(code, msg); |
| }, |
| onProtocolError: (code, msg) => { |
| seen.protocolErrors.push({ code, msg }); |
| handlers.onProtocolError?.(code, msg); |
| }, |
| onInvalidFrame: () => { |
| seen.invalid += 1; |
| handlers.onInvalidFrame?.(null); |
| }, |
| onReconnectScheduled: () => { |
| seen.reconnects += 1; |
| handlers.onReconnectScheduled?.(0); |
| }, |
| }, |
| }); |
| return { ws, seen }; |
| } |
|
|
| |
|
|
| describe('fetchHistoryPage', () => { |
| const pageData = { |
| messages: [turnMsg(1)], |
| has_more: false, |
| in_flight: { turn_id: 't1', step_id: 't1.2' }, |
| }; |
|
|
| it('requests the endpoint with cursor params and bearer auth, unwraps the envelope', async () => { |
| const { calls, fetchImpl } = fakeFetch(okEnvelope(pageData)); |
| const page = await fetchHistoryPage({ |
| baseUrl: 'http://h:1', |
| token: 'tok', |
| sessionId: 's 1', |
| agentId: 'main', |
| beforeTurn: 't5', |
| pageSize: 50, |
| fetchImpl, |
| }); |
| expect(calls).toHaveLength(1); |
| expect(calls[0]!.url).toContain('/api/v1/sessions/s%201/history?'); |
| expect(calls[0]!.url).toContain('agent_id=main'); |
| expect(calls[0]!.url).toContain('before_turn=t5'); |
| expect(calls[0]!.url).toContain('page_size=50'); |
| expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' }); |
| expect(page.messages).toHaveLength(1); |
| expect(page.inFlight).toEqual({ turn_id: 't1', step_id: 't1.2' }); |
| }); |
|
|
| it('sends after_step and omits unset cursors', async () => { |
| const { calls, fetchImpl } = fakeFetch(okEnvelope({ messages: [], has_more: false })); |
| await fetchHistoryPage({ |
| baseUrl: 'http://h:1', |
| sessionId: 's1', |
| agentId: 'main', |
| afterStep: 't1.3', |
| fetchImpl, |
| }); |
| expect(calls[0]!.url).toContain('after_step=t1.3'); |
| expect(calls[0]!.url).not.toContain('before_turn'); |
| expect(calls[0]!.init?.headers).toEqual({}); |
| }); |
|
|
| it('throws on a non-zero envelope code', async () => { |
| const { fetchImpl } = fakeFetch({ code: 40401, msg: 'session not found', data: null }); |
| await expect( |
| fetchHistoryPage({ baseUrl: 'http://h:1', sessionId: 's9', agentId: 'main', fetchImpl }), |
| ).rejects.toThrow('session not found'); |
| }); |
|
|
| it('throws when the payload fails schema validation', async () => { |
| const { fetchImpl } = fakeFetch(okEnvelope({ messages: 'nope' })); |
| await expect( |
| fetchHistoryPage({ baseUrl: 'http://h:1', sessionId: 's1', agentId: 'main', fetchImpl }), |
| ).rejects.toThrow('unexpected response shape'); |
| }); |
|
|
| it('fetchFullHistory pages before_turn to the beginning and returns timeline order', async () => { |
| const pages: Record<string, unknown> = { |
| newest: okEnvelope({ messages: [turnMsg(3), stepMsg('t3.1')], has_more: true }), |
| 't3': okEnvelope({ messages: [turnMsg(1), turnMsg(2)], has_more: false }), |
| }; |
| const calls: string[] = []; |
| const fetchImpl = (async (url: string | URL) => { |
| const text = String(url); |
| calls.push(text); |
| const before = /before_turn=([^&]+)/.exec(text)?.[1]; |
| const envelope = before === undefined ? pages['newest'] : (pages[before] ?? okEnvelope({ messages: [], has_more: false })); |
| return { json: async () => envelope }; |
| }) as unknown as typeof fetch; |
| const messages = await fetchFullHistory({ |
| baseUrl: 'http://h:1', |
| sessionId: 's1', |
| agentId: 'main', |
| pageSize: 2, |
| fetchImpl, |
| }); |
| expect(calls).toHaveLength(3); |
| expect(calls[1]).toContain('before_turn=t3'); |
| expect(calls[2]).toContain('before_turn=t1'); |
| expect(messages.map((m) => ('turn_id' in m ? m.turn_id : ''))).toEqual(['t1', 't2', 't3', 't3']); |
| }); |
| }); |
|
|
| |
|
|
| describe('ChatWs', () => { |
| it('connects with the bearer subprotocol and subscribes after the server hello', () => { |
| FakeWs.reset(); |
| makeWs(); |
| const sock = FakeWs.instances[0]!; |
| expect(sock.url).toBe('ws://h:1/api/v3/ws'); |
| expect(sock.protocols).toEqual(['kimi-code.bearer.tok']); |
| sock.open(); |
| expect(sock.sent).toHaveLength(0); |
| sock.hello(); |
| expect(sock.sentFrames()[0]).toEqual({ |
| type: 'subscribe', |
| id: 1, |
| session_id: 's1', |
| agent_ids: ['main'], |
| }); |
| }); |
|
|
| it('fires onAck on the subscribe ack and forwards entity messages', () => { |
| FakeWs.reset(); |
| const { seen } = makeWs(); |
| const sock = FakeWs.instances[0]!; |
| sock.open(); |
| sock.hello(); |
| sock.serverFrame({ type: 'ack', id: 1, code: 0 }); |
| expect(seen.acks).toEqual([{ code: 0 }]); |
| sock.serverFrame(turnMsg(1, 'running')); |
| sock.serverFrame({ |
| type: 'session.state', |
| session_id: 's1', |
| timestamp: ts(), |
| status: 'idle', |
| }); |
| expect(seen.messages.map((m) => m.type)).toEqual(['turn', 'session.state']); |
| }); |
|
|
| it('surfaces protocol error frames and ignores acks for other ids', () => { |
| FakeWs.reset(); |
| const { seen } = makeWs(); |
| const sock = FakeWs.instances[0]!; |
| sock.open(); |
| sock.hello(); |
| sock.serverFrame({ type: 'ack', id: 99, code: 0 }); |
| expect(seen.acks).toHaveLength(0); |
| sock.serverFrame({ type: 'error', code: 1008, msg: 'slow consumer' }); |
| expect(seen.protocolErrors).toEqual([{ code: 1008, msg: 'slow consumer' }]); |
| }); |
|
|
| it('ignores unknown future message types but reports malformed known ones', () => { |
| FakeWs.reset(); |
| const { seen } = makeWs(); |
| const sock = FakeWs.instances[0]!; |
| sock.open(); |
| sock.hello(); |
| sock.serverFrame({ type: 'turn.supercharged', whatever: true }); |
| sock.serverFrame({ type: 'turn', turn_id: 42 }); |
| expect(seen.messages).toHaveLength(0); |
| expect(seen.invalid).toBe(1); |
| }); |
|
|
| it('re-subscribes after a drop and fires onAck per subscribe', async () => { |
| FakeWs.reset(); |
| const { seen } = makeWs(); |
| const first = FakeWs.instances[0]!; |
| first.open(); |
| first.hello(); |
| first.serverFrame({ type: 'ack', id: 1, code: 0 }); |
| expect(seen.acks).toHaveLength(1); |
| first.emit('close'); |
| await vi.waitFor(() => { |
| expect(FakeWs.instances.length).toBeGreaterThan(1); |
| }); |
| const second = FakeWs.instances[1]!; |
| second.open(); |
| second.hello(); |
| expect(second.sentFrames()[0]).toMatchObject({ type: 'subscribe', id: 2 }); |
| second.serverFrame({ type: 'ack', id: 2, code: 0 }); |
| expect(seen.acks).toHaveLength(2); |
| }); |
|
|
| it('stays closed after close()', () => { |
| FakeWs.reset(); |
| const { ws } = makeWs(); |
| FakeWs.instances[0]!.open(); |
| ws.close(); |
| expect(FakeWs.instances).toHaveLength(1); |
| }); |
| }); |
|
|
| |
|
|
| describe('ChatStore', () => { |
| it('upserts entities by (type, id) and replaces in place', () => { |
| const store = makeStore(); |
| store.applyLive(turnMsg(1, 'running')); |
| store.applyLive(stepMsg('t1.1', 'running')); |
| store.applyLive(turnMsg(1, 'completed')); |
| const state = store.getState(); |
| expect(entryKeys(state.entries)).toEqual(['turn:t1', 'step:t1.1']); |
| const turn = state.entries[0]!.message as TurnMessage; |
| expect(turn.status).toBe('completed'); |
| }); |
|
|
| it('skips an upsert whose timestamp is older than the held entity', () => { |
| const store = makeStore(); |
| store.applyLive(assistantMsg('t1.1', 'hello world', 'streaming', Date.parse('2026-01-01T00:00:10.000Z'))); |
| store.applyLive(assistantMsg('t1.1', 'hel', 'streaming', Date.parse('2026-01-01T00:00:05.000Z'))); |
| const held = store.getState().entries[0]!.message as AssistantMessage; |
| expect(held.text).toBe('hello world'); |
| }); |
|
|
| it('appends deltas to the held entity and drops orphan deltas', () => { |
| const store = makeStore(); |
| store.applyLive({ |
| type: 'assistant.delta', |
| ...base, |
| timestamp: ts(), |
| message_id: 't1.1.a0', |
| text: 'orphan', |
| }); |
| expect(store.getState().entries).toHaveLength(0); |
| store.applyLive(assistantMsg('t1.1', '', 'streaming')); |
| store.applyLive({ |
| type: 'assistant.delta', |
| ...base, |
| timestamp: ts(), |
| message_id: 't1.1.a0', |
| text: 'hel', |
| }); |
| store.applyLive({ |
| type: 'assistant.delta', |
| ...base, |
| timestamp: ts(), |
| message_id: 't1.1.a0', |
| text: 'lo', |
| }); |
| const held = store.getState().entries[0]!.message as AssistantMessage; |
| expect(held.text).toBe('hello'); |
| }); |
|
|
| it('treats an entity arrival after deltas as the authoritative whole', () => { |
| const store = makeStore(); |
| store.applyLive(assistantMsg('t1.1', '', 'streaming')); |
| store.applyLive({ |
| type: 'assistant.delta', |
| ...base, |
| timestamp: ts(), |
| message_id: 't1.1.a0', |
| text: 'partial', |
| }); |
| store.applyLive(assistantMsg('t1.1', 'partial but authoritative', 'completed')); |
| const held = store.getState().entries[0]!.message as AssistantMessage; |
| expect(held.text).toBe('partial but authoritative'); |
| expect(held.status).toBe('completed'); |
| }); |
|
|
| it('appends tool_call deltas to input_text and patches tool.progress', () => { |
| const store = makeStore(); |
| store.applyLive(toolCallMsg('t1.1', 'call_1', { input_text: '' })); |
| store.applyLive({ |
| type: 'tool_call.delta', |
| ...base, |
| timestamp: ts(), |
| tool_call_id: 'call_1', |
| input_text: '{"command"', |
| }); |
| store.applyLive({ |
| type: 'tool_call.delta', |
| ...base, |
| timestamp: ts(), |
| tool_call_id: 'call_1', |
| input_text: ':"ls"}', |
| }); |
| store.applyLive({ |
| type: 'tool.progress', |
| ...base, |
| timestamp: ts(), |
| tool_call_id: 'call_1', |
| progress: { kind: 'stdout', text: 'file.txt' }, |
| }); |
| const held = store.getState().entries[0]!.message as ToolCallMessage; |
| expect(held.input_text).toBe('{"command":"ls"}'); |
| expect(held.progress).toEqual({ kind: 'stdout', text: 'file.txt' }); |
| }); |
|
|
| it('truncates the removed turn subtree on system(undo) and keeps the marker', () => { |
| const store = makeStore(); |
| store.applyLive(turnMsg(1)); |
| store.applyLive(stepMsg('t1.1')); |
| store.applyLive(assistantMsg('t1.1', 'first')); |
| store.applyLive(turnMsg(2)); |
| store.applyLive(stepMsg('t2.1')); |
| store.applyLive(toolCallMsg('t2.1', 'call_1')); |
| store.applyLive(undoMsg('sys-undo-1', ['t2'])); |
| const state = store.getState(); |
| expect(entryKeys(state.entries)).toEqual([ |
| 'turn:t1', |
| 'step:t1.1', |
| 'assistant:t1.1.a0', |
| 'system:sys-undo-1', |
| ]); |
| }); |
|
|
| it('cascades undo to interactions anchored at removed tool calls', () => { |
| const store = makeStore(); |
| store.applyLive(turnMsg(1)); |
| store.applyLive(toolCallMsg('t1.1', 'call_1')); |
| store.applyLive(interactionMsg('ix-1', 'call_1')); |
| store.applyLive(interactionMsg('ix-2', 'call_other')); |
| store.applyLive(undoMsg('sys-undo-1', ['t1'])); |
| expect([...store.getState().interactions.keys()]).toEqual(['ix-2']); |
| }); |
|
|
| it('empties the timeline on system(clear)', () => { |
| const store = makeStore(); |
| store.applyLive(turnMsg(1)); |
| store.applyLive(stepMsg('t1.1')); |
| store.applyLive(assistantMsg('t1.1', 'gone')); |
| store.applyLive(systemMsg('clear', 'sys-clear-1', { removed_ids: ['t1', 't1.1', 't1.1.a0'] })); |
| expect(entryKeys(store.getState().entries)).toEqual(['system:sys-clear-1']); |
| }); |
|
|
| it('upserts state entities into their own maps and ignores global messages', () => { |
| const store = makeStore(); |
| store.applyLive(interactionMsg('ix-1', 'call_1')); |
| store.applyLive(taskMsg('task-1')); |
| store.applyLive({ |
| type: 'todo', |
| ...base, |
| timestamp: ts(), |
| todo_id: 'todo', |
| items: [{ title: 'x', status: 'pending' }], |
| }); |
| store.applyLive({ |
| type: 'session.state', |
| session_id: 's1', |
| timestamp: ts(), |
| status: 'running', |
| }); |
| store.applyLive({ |
| type: 'workspace', |
| timestamp: ts(), |
| subtype: 'updated', |
| workspace: { |
| id: 'wd_test_0123456789ab', |
| root: '/tmp', |
| name: 'tmp', |
| created_at: new Date(ts()).toISOString(), |
| last_opened_at: new Date(ts()).toISOString(), |
| session_count: 1, |
| }, |
| }); |
| const state = store.getState(); |
| expect(state.interactions.get('ix-1')?.status).toBe('pending'); |
| expect(state.tasks.get('task-1')?.kind).toBe('shell'); |
| expect(state.todos.get('todo')?.items).toHaveLength(1); |
| expect(state.sessionState?.status).toBe('running'); |
| expect(state.entries).toHaveLength(0); |
| }); |
|
|
| it('replace installs the page as the window and keeps entries newer than the page', () => { |
| const store = makeStore(); |
| store.applyLive(turnMsg(9, 'running', Date.parse('2026-01-01T00:00:09.000Z'))); |
| store.applyLive(turnMsg(1, 'completed', Date.parse('2026-01-01T00:00:01.000Z'))); |
| store.applyHistoryPage( |
| [turnMsg(1, 'completed', Date.parse('2026-01-01T00:00:01.500Z')), stepMsg('t1.1', 'completed', Date.parse('2026-01-01T00:00:02.000Z'))], |
| 'replace', |
| ); |
| expect(entryKeys(store.getState().entries)).toEqual(['turn:t1', 'step:t1.1', 'turn:t9']); |
| }); |
|
|
| it('prepend inserts older pages ahead of the window and dedupes by key', () => { |
| const store = makeStore(); |
| store.applyHistoryPage([turnMsg(3)], 'replace'); |
| store.applyHistoryPage([turnMsg(1), turnMsg(2), turnMsg(3)], 'prepend'); |
| expect(entryKeys(store.getState().entries)).toEqual(['turn:t1', 'turn:t2', 'turn:t3']); |
| }); |
|
|
| it('tail upserts the catch-up slice in page order', () => { |
| const store = makeStore(); |
| store.applyHistoryPage([turnMsg(1), stepMsg('t1.1')], 'replace'); |
| store.applyHistoryPage( |
| [assistantMsg('t1.1', 'tail'), turnMsg(2), stepMsg('t2.1', 'running')], |
| 'tail', |
| ); |
| expect(entryKeys(store.getState().entries)).toEqual([ |
| 'turn:t1', |
| 'step:t1.1', |
| 'assistant:t1.1.a0', |
| 'turn:t2', |
| 'step:t2.1', |
| ]); |
| }); |
|
|
| it('applies a system(undo) inside a history page like a live one', () => { |
| const store = makeStore(); |
| store.applyLive(turnMsg(1)); |
| store.applyLive(turnMsg(2)); |
| store.applyHistoryPage([undoMsg('sys-undo-1', ['t2'])], 'tail'); |
| expect(entryKeys(store.getState().entries)).toEqual(['turn:t1', 'system:sys-undo-1']); |
| }); |
| }); |
|
|
| |
|
|
| describe('recoverLoadedWindow', () => { |
| const pageOf = (items: HistoryMessage[], hasMore: boolean): HistoryMessage[] => items; |
|
|
| it('pages backwards until the previous oldest turn is re-covered', async () => { |
| const store = makeStore(); |
| store.applyHistoryPage([turnMsg(4), turnMsg(5), turnMsg(6)], 'replace'); |
| store.setHasMoreOlder(true); |
| const fetched: string[] = []; |
| await recoverLoadedWindow( |
| store, |
| 't2', |
| async (beforeTurn) => { |
| fetched.push(beforeTurn); |
| store.setHasMoreOlder(beforeTurn !== 't2'); |
| return beforeTurn === 't4' ? [turnMsg(2), turnMsg(3)] : []; |
| }, |
| () => false, |
| ); |
| expect(fetched).toEqual(['t4']); |
| expect(oldestTurnId(store.getState().entries)).toBe('t2'); |
| expect(newestTerminalStepId(store.getState().entries)).toBeUndefined(); |
| }); |
|
|
| it('stops when there is no older history left, even if the anchor is gone', async () => { |
| const store = makeStore(); |
| store.applyHistoryPage([turnMsg(5)], 'replace'); |
| store.setHasMoreOlder(true); |
| const fetched: string[] = []; |
| await recoverLoadedWindow( |
| store, |
| 't1', |
| async (beforeTurn) => { |
| fetched.push(beforeTurn); |
| store.setHasMoreOlder(false); |
| return pageOf([], false); |
| }, |
| () => false, |
| ); |
| expect(fetched).toEqual(['t5']); |
| }); |
| }); |
|
|
| describe('ChatChannel', () => { |
| function scriptedFetch(script: { noCursor: unknown[]; afterStep?: Record<string, readonly unknown[]> }) { |
| const calls: string[] = []; |
| let noCursorIndex = 0; |
| const fetchImpl = (async (url: string | URL) => { |
| const text = String(url); |
| calls.push(text); |
| const after = /after_step=([^&]+)/.exec(text)?.[1]; |
| let envelope: unknown; |
| if (after !== undefined) { |
| envelope = okEnvelope({ messages: [...(script.afterStep?.[after] ?? [])], has_more: false }); |
| } else { |
| envelope = script.noCursor[Math.min(noCursorIndex, script.noCursor.length - 1)]; |
| noCursorIndex += 1; |
| } |
| return { json: async () => envelope }; |
| }) as unknown as typeof fetch; |
| return { calls, fetchImpl }; |
| } |
|
|
| function makeChannel(fetchImpl: typeof fetch): { channel: ChatChannel; sock: FakeWs } { |
| FakeWs.reset(); |
| const channel = new ChatChannel({ |
| baseUrl: 'http://h:1', |
| token: 'tok', |
| sessionId: 's1', |
| agentId: 'main', |
| pageSize: 50, |
| WebSocketImpl: FakeWs, |
| fetchImpl, |
| notifyIntervalMs: 0, |
| }); |
| return { channel, sock: FakeWs.instances[0]! }; |
| } |
|
|
| it('serializes the initial refresh with the ack catch-up behind one queue', async () => { |
| const newest = okEnvelope({ messages: [turnMsg(1), stepMsg('t1.1')], has_more: false }); |
| const { calls, fetchImpl } = scriptedFetch({ noCursor: [newest] }); |
| let releaseFirst: () => void = () => {}; |
| const gate = new Promise<void>((resolve) => { |
| releaseFirst = resolve; |
| }); |
| let first = true; |
| const gatedFetch = (async (url: string | URL, init?: RequestInit) => { |
| if (first) { |
| first = false; |
| await gate; |
| } |
| return fetchImpl(url, init); |
| }) as unknown as typeof fetch; |
| const { channel, sock } = makeChannel(gatedFetch); |
| channel.start(); |
| sock.open(); |
| sock.hello(); |
| sock.serverFrame({ type: 'ack', id: 1, code: 0 }); |
| releaseFirst(); |
| await vi.waitFor(() => { |
| expect(calls).toHaveLength(3); |
| }); |
| const restEntries = channel.trail.getEntries().filter((e) => e.kind === 'rest'); |
| expect(restEntries.filter((e) => e.mode === 'replace')).toHaveLength(1); |
| expect(channel.trail.getEntries().some((e) => e.kind === 'event' && e.event === 'catchup-refresh')).toBe(false); |
| expect(calls.filter((url) => !url.includes('after_step='))).toHaveLength(2); |
| expect(calls[1]).toContain('after_step=t1.1'); |
| expect(newestTerminalStepId(channel.store.getState().entries)).toBe('t1.1'); |
| channel.close(); |
| }); |
|
|
| it('probes the newest page for the anchor step or turn before falling back to a refresh', async () => { |
| const first = okEnvelope({ messages: [turnMsg(1), stepMsg('t1.1')], has_more: false }); |
| const probeWithTurn = okEnvelope({ messages: [systemMsg('notice', 'sys_n1'), turnMsg(1)], has_more: false }); |
| const alive = scriptedFetch({ noCursor: [first, probeWithTurn] }); |
| const aliveChannel = makeChannel(alive.fetchImpl); |
| aliveChannel.channel.start(); |
| aliveChannel.sock.open(); |
| aliveChannel.sock.hello(); |
| aliveChannel.sock.serverFrame({ type: 'ack', id: 1, code: 0 }); |
| await vi.waitFor(() => { |
| expect(aliveChannel.channel.store.getState().entries.length).toBeGreaterThan(0); |
| }); |
| await vi.waitFor(() => { |
| expect(alive.calls).toHaveLength(3); |
| }); |
| expect( |
| aliveChannel.channel.trail.getEntries().some((e) => e.kind === 'event' && e.event === 'catchup-refresh'), |
| ).toBe(false); |
| expect(aliveChannel.channel.trail.getEntries().filter((e) => e.kind === 'rest' && e.mode === 'replace')).toHaveLength(1); |
| aliveChannel.channel.close(); |
|
|
| const movedOn = okEnvelope({ messages: [turnMsg(2), stepMsg('t2.1')], has_more: false }); |
| const gone = scriptedFetch({ noCursor: [first, movedOn] }); |
| const goneChannel = makeChannel(gone.fetchImpl); |
| goneChannel.channel.start(); |
| goneChannel.sock.open(); |
| goneChannel.sock.hello(); |
| goneChannel.sock.serverFrame({ type: 'ack', id: 1, code: 0 }); |
| await vi.waitFor(() => { |
| expect( |
| goneChannel.channel.trail.getEntries().some((e) => e.kind === 'event' && e.event === 'catchup-refresh'), |
| ).toBe(true); |
| }); |
| await vi.waitFor(() => { |
| expect(newestTerminalStepId(goneChannel.channel.store.getState().entries)).toBe('t2.1'); |
| }); |
| goneChannel.channel.close(); |
| }); |
| }); |
|
|
| |
|
|
| describe('projectPlans', () => { |
| const planCall = (id: string, overrides: Partial<ToolCallMessage> = {}): ToolCallMessage => |
| toolCallMsg('t1.1', id, { name: 'ExitPlanMode', status: 'done', ...overrides }); |
|
|
| it('derives plan content and review from the linked approval interaction', () => { |
| const messages: HistoryMessage[] = [ |
| turnMsg(1), |
| planCall('call_plan', { approval_id: 'ix-1' }), |
| { |
| type: 'interaction', |
| ...base, |
| timestamp: ts(), |
| interaction_id: 'ix-1', |
| kind: 'approval', |
| status: 'approved', |
| tool_call_id: 'call_plan', |
| request: { |
| tool_name: 'ExitPlanMode', |
| action: 'review', |
| tool_input_display: { |
| kind: 'plan_review', |
| plan: '# The Plan\n\nDo the thing.', |
| path: '/tmp/plans/foo.md', |
| options: [{ label: 'Approach A', description: 'fast' }], |
| }, |
| }, |
| response: { decision: 'approved', selected_label: 'Approach A', feedback: 'looks good' }, |
| }, |
| ]; |
| const plans = projectPlans(messages); |
| expect(plans).toEqual([ |
| { |
| toolCallId: 'call_plan', |
| turnId: 't1', |
| source: 'interaction', |
| plan: '# The Plan\n\nDo the thing.', |
| path: '/tmp/plans/foo.md', |
| options: [{ label: 'Approach A', description: 'fast' }], |
| review: { state: 'approved', selectedOption: 'Approach A', feedback: 'looks good' }, |
| }, |
| ]); |
| }); |
|
|
| it('falls back to the tool call display, then to the output body', () => { |
| const fromDisplay = projectPlans([ |
| planCall('call_display', { |
| display: { kind: 'plan_review', plan: '# Draft', path: '/tmp/draft.md' }, |
| }), |
| ]); |
| expect(fromDisplay[0]).toMatchObject({ source: 'display', plan: '# Draft', path: '/tmp/draft.md' }); |
| const fromOutput = projectPlans([ |
| planCall('call_output', { |
| output: 'Plan saved to: /tmp/out.md\n## Approved Plan:\n# Final', |
| }), |
| ]); |
| expect(fromOutput[0]).toMatchObject({ source: 'output', plan: '# Final', path: '/tmp/out.md' }); |
| }); |
|
|
| it('filters by tool_call_id and ignores non-ExitPlanMode calls', () => { |
| const messages: HistoryMessage[] = [ |
| planCall('call_a', { display: { kind: 'plan_review', plan: '# A' } }), |
| toolCallMsg('t1.1', 'call_bash', { name: 'Bash', status: 'done' }), |
| planCall('call_b', { display: { kind: 'plan_review', plan: '# B' } }), |
| ]; |
| expect(projectPlans(messages, 'call_b').map((p) => p.toolCallId)).toEqual(['call_b']); |
| expect(projectPlans(messages).map((p) => p.toolCallId)).toEqual(['call_a', 'call_b']); |
| }); |
| }); |
|
|