diff --git a/apps/kimi-inspect/AGENTS.md b/apps/kimi-inspect/AGENTS.md index 15782d128da..aa973f7459e 100644 --- a/apps/kimi-inspect/AGENTS.md +++ b/apps/kimi-inspect/AGENTS.md @@ -14,7 +14,7 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views: The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx` — Audit / Agent / State / Session tabs) across two of them: -- `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan`) plus the agent Service panels. +- `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — deriving the reviewed plan of one ExitPlanMode tool call, or every plan of the agent, from the message stream: a full `GET /sessions/{id}/history` read via `src/transcript/api.ts`'s `fetchFullHistory` + client-side `projectPlans` in `src/transcript/plan.ts`) plus the agent Service panels. - `State` tab — every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`. The **Session scope** lives in the same right dock as the `Session` tab (`src/components/SessionPane.tsx`, embedded by `RightPanel`) with two sub-tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`). @@ -33,12 +33,12 @@ The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, d ## Chat view -The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses. +The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **message protocol v3** surface and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses. -Full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library). +Persisted state comes from `GET /api/v1/sessions/{id}/history` only (client in `src/transcript/api.ts`): the initial load reads the newest page (default 500 messages, replace mode), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view (prepend mode; a short or empty page ends paging — the response deliberately carries no has-more flag), and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library). -`/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). +`/api/v3/ws` (client in `src/transcript/ws.ts`) is the live channel: server `hello` → `subscribe {id, session_id, agent_ids: [agent]}` → `ack` → recovery payload (in-flight entities + pending interactions + running tasks + todo + `session.state`) → live traffic, heartbeat at the WS protocol level. The store (`src/transcript/store.ts`) applies recovery and live messages through one idempotent path — entity messages upsert by (type, own id) with content fields authoritative, the delta family (`assistant.delta` / `thinking.delta` / `tool_call.delta`) appends by id, `tool.progress` patches the entity, `system(undo/clear)` truncates the timeline by `payload.removed_ids` (subtree included, linked interactions cascaded), and `interaction` / `task` / `todo` / `session.state` upsert their own single-source maps; an upsert older than the held entity's `timestamp` is skipped. Notifications are throttled trailing-edge so the per-token delta stream does not re-render per token. Every subscribe ack (initial and every reconnect) triggers an `after_step` catch-up from the newest terminal step; an empty catch-up whose anchor vanished (undo/clear while away) falls back to a full refresh. All of this is orchestrated by `ChatChannel` (`src/transcript/channel.ts`) — no buffering, no cursors beyond the two REST page cursors, no reset frames. ## Transcript audit panel -The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. +The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST history page (request + replace/prepend/tail mode), every WS message (entity/delta/state as applied), channel events (subscribe ack, reconnect, catch-up fallback, protocol errors), and prompt/cancel actions — with the resulting immutable `ChatState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view (the flat entity timeline plus the interaction/task/todo/session.state entities), and the raw Event payload. diff --git a/apps/kimi-inspect/package.json b/apps/kimi-inspect/package.json index c347485a4d7..ee37ebc9497 100644 --- a/apps/kimi-inspect/package.json +++ b/apps/kimi-inspect/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@moonshot-ai/agent-core-v2": "workspace:^", - "@moonshot-ai/transcript": "workspace:^", + "@moonshot-ai/kap-server": "workspace:^", "@tanstack/react-query": "^5.74.4", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/apps/kimi-inspect/src/audit/audit.test.ts b/apps/kimi-inspect/src/audit/audit.test.ts index 0043ec73bac..fca9d483e03 100644 --- a/apps/kimi-inspect/src/audit/audit.test.ts +++ b/apps/kimi-inspect/src/audit/audit.test.ts @@ -3,27 +3,57 @@ * and tail-preserving truncation used by the chat view's audit panel. */ -import { EMPTY_AGENT_STATE, type AgentState, type TranscriptTurn } from '@moonshot-ai/transcript'; +import type { StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol'; import { describe, expect, it } from 'vitest'; +import { EMPTY_CHAT_STATE, type ChatState } from '../transcript/store'; import { diffValue, type DiffNode } from './diff'; import { serializeState } from './serialize'; import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail'; import { tailTrunc } from './truncate'; -function turnItem(n: number): TranscriptTurn { +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +let tick = 0; + +function ts(): string { + tick += 1; + return new Date(T0 + tick * 1000).toISOString(); +} + +function turnMsg(n: number, state: 'running' | 'completed' = 'completed'): TurnMessage { return { - kind: 'turn', - turnId: `t${n}`, + type: 'turn', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + turn_id: `t${n}`, ordinal: n, - state: 'completed', + state, origin: { kind: 'user' }, - steps: [], }; } -function stateWith(items: readonly TranscriptTurn[]): AgentState { - return { ...EMPTY_AGENT_STATE, items }; +function stepMsg(stepId: string, state: 'running' | 'completed'): StepMessage { + return { + type: 'step', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + step_id: stepId, + turn_id: stepId.split('.')[0] ?? 't1', + ordinal: Number(stepId.split('.')[1] ?? '1'), + state, + }; +} + +function stateWithTimeline(items: readonly (TurnMessage | StepMessage)[]): ChatState { + return { + ...EMPTY_CHAT_STATE, + entries: items.map((message) => ({ + key: message.type === 'turn' ? `turn:${message.turn_id}` : `step:${message.step_id}`, + message, + })), + }; } // ---------------------------------------------------------------- diff @@ -53,8 +83,10 @@ describe('diffValue', () => { }); it('matches entity arrays by id instead of index', () => { - const prev = [turnItem(1), turnItem(2)]; - const next = [turnItem(1), { ...turnItem(2), state: 'running' as const }, turnItem(3)]; + const t1 = turnMsg(1); + const t2 = turnMsg(2); + const prev = [t1, t2]; + const next = [t1, { ...t2, state: 'running' as const }, turnMsg(3)]; const node = diffValue(prev, next); expect(node.children?.get('t1')?.status).toBe('unchanged'); expect(node.children?.get('t2')?.status).toBe('modified'); @@ -66,18 +98,11 @@ describe('diffValue', () => { expect(node.children?.get('t3')?.status).toBe('added'); }); - it('keys steps by stepId (not their shared turnId) so siblings never collide', () => { - const step = (id: string, state: 'running' | 'completed') => ({ - kind: 'step' as const, - stepId: id, - turnId: 't1', - ordinal: 1, - state, - frames: [], - }); + it('keys steps by step_id (not their shared turn_id) so siblings never collide', () => { + const done = stepMsg('t1.1', 'completed'); const node = diffValue( - [step('t1.1', 'completed'), step('t1.2', 'completed')], - [step('t1.1', 'completed'), step('t1.2', 'running')], + [done, stepMsg('t1.2', 'completed')], + [done, stepMsg('t1.2', 'running')], ); expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']); expect(node.children?.get('t1.1')?.status).toBe('unchanged'); @@ -85,7 +110,8 @@ describe('diffValue', () => { }); it('marks removed array elements by id', () => { - const node = diffValue([turnItem(1), turnItem(2)], [turnItem(2)]); + const t2 = turnMsg(2); + const node = diffValue([turnMsg(1), t2], [t2]); expect(node.children?.get('t1')).toMatchObject({ status: 'removed' }); expect(node.children?.get('t2')?.status).toBe('unchanged'); }); @@ -105,48 +131,71 @@ describe('diffValue', () => { expect(diffValue([1], { 0: 1 }).status).toBe('modified'); }); - it('diffs two serialized states with meta changes visible (goal/plan fields)', () => { - const prev = serializeState(stateWith([turnItem(1)])); - const nextState: AgentState = { - ...stateWith([turnItem(1)]), - meta: { + it('diffs two serialized states with session.state changes visible', () => { + const base = stateWithTimeline([turnMsg(1)]); + const prev = serializeState(base); + const nextState: ChatState = { + ...base, + sessionState: { + type: 'session.state', + session_id: 's1', + timestamp: ts(), + busy: true, + main_turn_active: true, + activity: 'turn', goal: { objective: 'ship it', status: 'active' }, - modes: { plan: { reviewPath: '/tmp/plan.md' } }, + modes: { plan: { review_path: '/tmp/plan.md' } }, }, }; const node: DiffNode = diffValue(prev, serializeState(nextState)); - expect(node.children?.get('items')?.status).toBe('unchanged'); - const meta = node.children?.get('meta'); - expect(meta?.status).toBe('modified'); - expect(meta?.children?.get('goal')?.status).toBe('added'); - // Whole-subtree add: `modes` was absent before, so the block (plan - // included) is marked added without descending into children. - expect(meta?.children?.get('modes')?.status).toBe('added'); - expect(meta?.children?.get('modes')?.children).toBeUndefined(); + expect(node.children?.get('timeline')?.status).toBe('unchanged'); + const sessionState = node.children?.get('sessionState'); + expect(sessionState?.status).toBe('added'); + expect(sessionState?.children).toBeUndefined(); }); }); // ---------------------------------------------------------------- serialize describe('serializeState', () => { - it('turns maps into sorted plain objects and sets into arrays', () => { - const state: AgentState = { - ...EMPTY_AGENT_STATE, + it('turns maps into sorted plain objects and flattens the timeline', () => { + const state: ChatState = { + ...EMPTY_CHAT_STATE, + entries: stateWithTimeline([turnMsg(1)]).entries, tasks: new Map([ [ 'b-task', - { taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' }, + { + type: 'task', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + task_id: 'b-task', + kind: 'shell', + state: 'running', + detached: false, + output_tail: '', + }, ], [ 'a-task', - { taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' }, + { + type: 'task', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + task_id: 'a-task', + kind: 'tool', + state: 'completed', + detached: false, + output_tail: '', + }, ], ]), - pendingInteractions: new Set(['z', 'a']), }; const out = serializeState(state); - expect(Object.keys(out.tasks as Record)).toEqual(['a-task', 'b-task']); - expect(out.pendingInteractions).toEqual(['a', 'z']); + expect(Object.keys(out.tasks)).toEqual(['a-task', 'b-task']); + expect(out.timeline.map((m) => (m.type === 'turn' ? m.turn_id : ''))).toEqual(['t1']); expect(out.hasMoreOlder).toBe(false); }); }); @@ -171,37 +220,20 @@ describe('tailTrunc', () => { // ---------------------------------------------------------------- trail describe('AuditTrail', () => { - const page = { - items: [turnItem(1)], - hasMoreOlder: false, - tasks: [], - interactions: [], - attachments: [], - todos: [], - meta: {}, - pendingInteractions: [], - }; - it('records entries with increasing indices, timestamps, and state references', () => { const trail = new AuditTrail(); - const s1 = stateWith([turnItem(1)]); - const s2 = stateWith([turnItem(1), turnItem(2)]); - trail.recordRest({ pageSize: 30 }, 'replace', page, s1); - trail.recordOps([{ op: 'turn.upsert', turn: turnItem(2) }], 'live', '2026-01-01T00:00:00Z', s2); + const s1 = stateWithTimeline([turnMsg(1)]); + const s2 = stateWithTimeline([turnMsg(1), turnMsg(2)]); + trail.recordRest({ pageSize: 500 }, 'replace', 1, { turn_id: 't1', step_id: 't1.1' }, s1); + trail.recordWs(turnMsg(2, 'running'), s2); trail.recordEvent('prompt', 'hello', s2); - trail.recordReset( - { items: [], tasks: [], interactions: [], attachments: [], todos: [], prompts: [], meta: {} }, - false, - undefined, - s2, - ); const entries = trail.getEntries(); - expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ops', 'event', 'reset']); - expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3]); + expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ws', 'event']); + expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2]); expect(entries[0]!.state).toBe(s1); expect(entries[1]!.state).toBe(s2); - expect(entries[1]).toMatchObject({ delivery: 'live', envelopeAt: '2026-01-01T00:00:00Z' }); + expect(entries[0]).toMatchObject({ mode: 'replace', messageCount: 1 }); expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' }); expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe( true, @@ -215,18 +247,18 @@ describe('AuditTrail', () => { const unsubscribe = trail.subscribe(() => { notified += 1; }); - trail.recordEvent('cancel', undefined, EMPTY_AGENT_STATE); - trail.recordEvent('gap', undefined, EMPTY_AGENT_STATE); + trail.recordEvent('cancel', undefined, EMPTY_CHAT_STATE); + trail.recordEvent('ack', undefined, EMPTY_CHAT_STATE); expect(notified).toBe(2); unsubscribe(); - trail.recordEvent('resync', undefined, EMPTY_AGENT_STATE); + trail.recordEvent('reconnect', undefined, EMPTY_CHAT_STATE); expect(notified).toBe(2); }); it('drops the oldest entries beyond the cap while indices keep increasing', () => { const trail = new AuditTrail(); for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) { - trail.recordEvent('prompt', `p${i}`, EMPTY_AGENT_STATE); + trail.recordEvent('prompt', `p${i}`, EMPTY_CHAT_STATE); } const entries = trail.getEntries(); expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES); diff --git a/apps/kimi-inspect/src/audit/diff.ts b/apps/kimi-inspect/src/audit/diff.ts index 613d7ebe6e6..13c1af72e1b 100644 --- a/apps/kimi-inspect/src/audit/diff.ts +++ b/apps/kimi-inspect/src/audit/diff.ts @@ -1,13 +1,14 @@ /** - * Structural diff over serialized `AgentState` values (see `serialize.ts`). + * Structural diff over serialized `ChatState` values (see `serialize.ts`). * * The audit panel diffs two adjacent, immutable states. Because the store * is copy-on-write, untouched subtrees share references — the reference * equality fast path below collapses them to `unchanged` without walking. * - * Arrays of transcript entities are matched by their id field (turnId, - * stepId, frameId, …) rather than by index, so an upsert in the middle of - * the timeline does not turn into a cascade of spurious modifications. + * Arrays of protocol entities are matched by their id field (turn_id, + * step_id, message_id, tool_call_id, …) rather than by index, so an upsert + * in the middle of the timeline does not turn into a cascade of spurious + * modifications. */ export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified'; @@ -27,21 +28,22 @@ export interface DiffNode { } /** - * Id fields checked in priority order — MOST SPECIFIC FIRST. A step carries - * both `turnId` and `stepId`, and a frame can carry `taskId` alongside its - * `frameId`; matching the wrong one mislabels the node and, worse, collides - * siblings in the children map (two steps of one turn both keyed `t1`). + * Id fields checked in priority order — MOST SPECIFIC FIRST. An interaction + * carries both `interaction_id` and `tool_call_id`, a tool call can carry + * `task_id` / `todo_id` alongside its `tool_call_id`, and every timeline + * entity carries `turn_id`; matching the wrong one mislabels the node and, + * worse, collides siblings in the children map (two tool calls of one task + * both keyed by that task id). */ const ID_FIELDS = [ - 'frameId', - 'stepId', - 'interactionId', - 'attachmentId', - 'todoId', - 'markerId', - 'refId', - 'turnId', - 'taskId', + 'message_id', + 'interaction_id', + 'tool_call_id', + 'task_id', + 'todo_id', + 'system_id', + 'step_id', + 'turn_id', ] as const; function elementId(element: unknown): string | undefined { diff --git a/apps/kimi-inspect/src/audit/serialize.ts b/apps/kimi-inspect/src/audit/serialize.ts index ddcbecea3c8..1b2768b52e6 100644 --- a/apps/kimi-inspect/src/audit/serialize.ts +++ b/apps/kimi-inspect/src/audit/serialize.ts @@ -1,48 +1,43 @@ /** - * Serialize an `AgentState` into a plain, JSON-shaped object for the audit + * Serialize a `ChatState` into a plain, JSON-shaped object for the audit * panel's state tree and structural diff. Maps become key-sorted plain - * objects (stable display order), Sets become sorted arrays; everything - * else is passed through by reference (state is immutable, so sharing is - * safe and keeps the reference-equality fast path in `diffValue` useful). + * objects (stable display order); everything else is passed through by + * reference (state is immutable, so sharing is safe and keeps the + * reference-equality fast path in `diffValue` useful). */ import type { - AgentState, - TranscriptAttachment, - TranscriptInteraction, - TranscriptItem, - TranscriptMeta, - TranscriptTask, - TranscriptTodo, -} from '@moonshot-ai/transcript'; + InteractionMessage, + SessionStateMessage, + TaskMessage, + TodoMessage, +} from '@moonshot-ai/kap-server/protocol'; -/** Plain-object view of an `AgentState` (Maps/Sets unwrapped). */ -export interface SerializedAgentState { - readonly items: readonly TranscriptItem[]; - readonly tasks: Record; - readonly interactions: Record; - readonly attachments: Record; - readonly todos: Record; - readonly meta: TranscriptMeta; - readonly pendingInteractions: readonly string[]; +import type { ChatState, TimelineMessage } from '../transcript/store'; + +/** Plain-object view of a `ChatState` (Maps unwrapped). */ +export interface SerializedChatState { + readonly timeline: readonly TimelineMessage[]; + readonly interactions: Record; + readonly tasks: Record; + readonly todos: Record; + readonly sessionState: SessionStateMessage | undefined; readonly hasMoreOlder: boolean; } function mapToSortedObject(map: ReadonlyMap): Record { const out: Record = {}; - for (const key of [...map.keys()].sort()) out[key] = map.get(key) as V; + for (const key of [...map.keys()].toSorted()) out[key] = map.get(key) as V; return out; } -export function serializeState(state: AgentState): SerializedAgentState { +export function serializeState(state: ChatState): SerializedChatState { return { - items: state.items, - tasks: mapToSortedObject(state.tasks), + timeline: state.entries.map((entry) => entry.message), interactions: mapToSortedObject(state.interactions), - attachments: mapToSortedObject(state.attachments), + tasks: mapToSortedObject(state.tasks), todos: mapToSortedObject(state.todos), - meta: state.meta, - pendingInteractions: [...state.pendingInteractions].sort(), + sessionState: state.sessionState, hasMoreOlder: state.hasMoreOlder, }; } diff --git a/apps/kimi-inspect/src/audit/trail.ts b/apps/kimi-inspect/src/audit/trail.ts index efed4cfd2b7..d3096ddad03 100644 --- a/apps/kimi-inspect/src/audit/trail.ts +++ b/apps/kimi-inspect/src/audit/trail.ts @@ -1,21 +1,17 @@ /** - * Audit trail for the chat view's transcript channel. + * Audit trail for the chat view's message-protocol channel. * - * A pure observer: the chat pipeline (REST loads, WS frames, user actions) - * calls the `record*` methods AFTER applying each step to the real - * `TranscriptChatStore`, passing the resulting immutable `AgentState` - * reference. Replaying the trail is therefore free — every entry already - * holds the exact state the store had at that point, ready for the - * timeline slider and the structural diff. + * A pure observer: the chat pipeline (REST history loads, WS messages, user + * actions) calls the `record*` methods AFTER applying each step to the real + * `ChatStore`, passing the resulting immutable `ChatState` reference. + * Replaying the trail is therefore free — every entry already holds the + * exact state the store had at that point, ready for the timeline slider + * and the structural diff. */ -import type { - AgentState, - AgentTranscriptSnapshot, - TranscriptOperation, -} from '@moonshot-ai/transcript'; +import type { ServerMessage } from '@moonshot-ai/kap-server/protocol'; -import type { TranscriptPage } from '../transcript/api'; +import type { ChatState } from '../transcript/store'; export const AUDIT_TRAIL_MAX_ENTRIES = 5000; @@ -25,53 +21,52 @@ interface AuditEntryBase { /** Local record time (ISO). */ readonly at: string; /** Store state right after this entry was applied (immutable reference). */ - readonly state: AgentState; + readonly state: ChatState; /** One-line summary for the timeline list. */ readonly summary: string; } export interface RestAuditEntry extends AuditEntryBase { readonly kind: 'rest'; - readonly request: { readonly beforeTurn?: string | undefined; readonly pageSize: number }; - readonly appliedAs: 'replace' | 'prepend'; - readonly page: TranscriptPage; -} - -export interface OpsAuditEntry extends AuditEntryBase { - readonly kind: 'ops'; - /** Envelope timestamp (server send time) when present. */ - readonly envelopeAt?: string | undefined; - readonly ops: readonly TranscriptOperation[]; - /** live = applied immediately; buffered = held during a REST refresh; flushed = replayed after one; catchup = fetched via the ops catch-up endpoint after a seq gap. */ - readonly delivery: 'live' | 'buffered' | 'flushed' | 'catchup'; + readonly request: { + readonly beforeTurn?: string | undefined; + readonly afterStep?: string | undefined; + readonly pageSize: number; + }; + /** replace = newest page (initial/refresh); prepend = older page; tail = after_step catch-up. */ + readonly mode: 'replace' | 'prepend' | 'tail'; + readonly messageCount: number; + readonly inFlight?: { turn_id: string; step_id: string } | undefined; } -export interface ResetAuditEntry extends AuditEntryBase { - readonly kind: 'reset'; - readonly envelopeAt?: string | undefined; - readonly snapshot: AgentTranscriptSnapshot; - readonly hasMoreOlder: boolean; +export interface WsAuditEntry extends AuditEntryBase { + readonly kind: 'ws'; + /** The raw server message as applied to the store (entity, delta, or state). */ + readonly message: ServerMessage; } export interface EventAuditEntry extends AuditEntryBase { readonly kind: 'event'; - readonly event: 'ack-refresh' | 'resync' | 'gap' | 'prompt' | 'cancel'; + readonly event: + | 'ack' + | 'ack-error' + | 'reconnect' + | 'catchup-refresh' + | 'protocol-error' + | 'invalid-frame' + | 'prompt' + | 'cancel' + | 'older-error'; readonly detail?: string | undefined; } -export type AuditEntry = RestAuditEntry | OpsAuditEntry | ResetAuditEntry | EventAuditEntry; +export type AuditEntry = RestAuditEntry | WsAuditEntry | EventAuditEntry; type DistributiveOmit = T extends unknown ? Omit : never; /** Entry payload accepted by `push` (index/at are filled in there). */ type AuditEntryInput = DistributiveOmit; -function summarizeOps(ops: readonly TranscriptOperation[]): string { - const counts = new Map(); - for (const op of ops) counts.set(op.op, (counts.get(op.op) ?? 0) + 1); - return [...counts.entries()].map(([name, n]) => (n > 1 ? `${name}×${n}` : name)).join(', '); -} - export class AuditTrail { private entryList: AuditEntry[] = []; private nextIndex = 0; @@ -91,68 +86,61 @@ export class AuditTrail { recordRest( request: RestAuditEntry['request'], - appliedAs: RestAuditEntry['appliedAs'], - page: TranscriptPage, - state: AgentState, + mode: RestAuditEntry['mode'], + messageCount: number, + inFlight: RestAuditEntry['inFlight'], + state: ChatState, ): void { - const cursor = request.beforeTurn !== undefined ? `?before_turn=${request.beforeTurn}` : ''; + const cursor = + request.beforeTurn !== undefined + ? `?before_turn=${request.beforeTurn}` + : request.afterStep !== undefined + ? `?after_step=${request.afterStep}` + : ''; + const flight = inFlight !== undefined ? ` (in_flight ${inFlight.step_id})` : ''; this.push({ kind: 'rest', request, - appliedAs, - page, + mode, + messageCount, + inFlight, state, - summary: `GET transcript${cursor} → ${page.items.length} items (${appliedAs})`, + summary: `GET history${cursor} → ${messageCount} messages (${mode})${flight}`, }); } - recordOps( - ops: readonly TranscriptOperation[], - delivery: OpsAuditEntry['delivery'], - envelopeAt: string | undefined, - state: AgentState, - ): void { + recordWs(message: ServerMessage, state: ChatState): void { this.push({ - kind: 'ops', - ops, - delivery, - envelopeAt, + kind: 'ws', + message, state, - summary: `${ops.length} ops (${summarizeOps(ops)}) [${delivery}]`, - }); - } - - recordReset( - snapshot: AgentTranscriptSnapshot, - hasMoreOlder: boolean, - envelopeAt: string | undefined, - state: AgentState, - ): void { - this.push({ - kind: 'reset', - snapshot, - hasMoreOlder, - envelopeAt, - state, - summary: `reset snapshot (${snapshot.items.length} items) — ignored by chat store`, + summary: summarizeMessage(message), }); } recordEvent( event: EventAuditEntry['event'], detail: string | undefined, - state: AgentState, + state: ChatState, ): void { const label = - event === 'ack-refresh' - ? 'subscribe ack → REST refresh' - : event === 'resync' - ? 'resync_required → REST refresh' - : event === 'gap' - ? 'append gap → REST refresh' - : event === 'prompt' - ? 'prompt sent' - : 'cancel sent'; + event === 'ack' + ? 'subscribe ack → after_step catch-up' + : event === 'ack-error' + ? 'subscribe ack error' + : event === 'reconnect' + ? 'socket dropped → reconnecting' + : event === 'catchup-refresh' + ? 'catch-up anchor gone → full refresh' + : event === 'protocol-error' + ? 'protocol error frame' + : event === 'invalid-frame' + ? 'invalid frame (server bug)' + : event === 'prompt' + ? 'prompt sent' + : event === 'cancel' + ? 'cancel sent' + : 'older-page load failed'; this.push({ kind: 'event', event, @@ -173,3 +161,38 @@ export class AuditTrail { for (const listener of this.listeners) listener(); } } + +function summarizeMessage(message: ServerMessage): string { + switch (message.type) { + case 'turn': + return `turn ${message.turn_id} (${message.state})`; + case 'step': + return `step ${message.step_id} (${message.state})`; + case 'user': + return `user ${message.message_id}`; + case 'assistant': + case 'thinking': + return `${message.type} ${message.message_id} (${message.status})`; + case 'assistant.delta': + case 'thinking.delta': + return `${message.type} ${message.message_id} +${message.text.length}ch`; + case 'tool_call': + return `tool_call ${message.name} ${message.tool_call_id} (${message.state})`; + case 'tool_call.delta': + return `tool_call.delta ${message.tool_call_id} +${message.input_text.length}ch`; + case 'tool.progress': + return `tool.progress ${message.tool_call_id} (${message.progress.kind})`; + case 'system': + return `system(${message.subtype}) ${message.system_id}`; + case 'interaction': + return `interaction ${message.interaction_id} (${message.kind}/${message.state})`; + case 'task': + return `task ${message.task_id} (${message.kind}/${message.state})`; + case 'todo': + return `todo ${message.todo_id} (${message.items.length} items)`; + case 'session.state': + return `session.state (${message.activity}${message.busy ? ', busy' : ''})`; + default: + return message.type; + } +} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index 9d9933f1212..394e578e012 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -1,58 +1,52 @@ /** * Main view — the conversation of the active session + agent, rendered from - * the transcript surface (`/api/v1`): + * the message protocol (`/api/v3/ws` + `GET /api/v1/sessions/{id}/history`): * - * - FULL state comes from the REST transcript API only: the initial load - * reads the newest page, a full refresh re-reads from the tail backwards - * until the previously loaded window is re-covered, and "Load earlier - * turns" pages further with a `before_turn` cursor. - * - The WS channel (`/api/v1/ws`) is a DELTA channel only: `transcript.ops` - * at `delta` grade; `transcript.reset` snapshots are ignored. Ops are - * buffered while a REST refresh is in flight and flushed onto the fresh - * pages — idempotent upserts and offset-placed appends make that converge. - * - Loss signals (`resync_required`, append gap, socket reconnect) trigger - * a full REST refresh; nothing is resynced from the socket itself. + * - Persisted state comes from the REST history endpoint only: the initial + * load reads the newest page, a full refresh re-reads it and re-covers + * the previously loaded window, and "load earlier" pages further with a + * `before_turn` cursor. + * - The WS channel carries the recovery payload and all live traffic; both + * are applied to the store through the same idempotent replace-by-id + * path (delta family appended by id, entity content authoritative), so + * there is no reset/buffer/cursor machinery. + * - Every subscribe ack (initial and reconnect) triggers an `after_step` + * catch-up from the newest terminal step; an empty catch-up whose + * anchor vanished (undo/clear while away) falls back to a full refresh. * - * Rendering is turn-granular (turn → step → frame) and typed entirely by the - * transcript data model. Prompts/cancels go through the `IAgentPromptService` - * / `IAgentLoopService` channels - * over the debug RPC surface (`/api/v1/debug`); the running indicator - * derives from transcript state (`meta.activity` / running turns). + * Rendering groups the flat timeline by turn (system markers stay + * standalone) and is typed entirely by the protocol schemas + * (`@moonshot-ai/kap-server/protocol`). Prompts/cancels go through the + * `IAgentPromptService` / `IAgentLoopService` channels over the debug RPC + * surface (`/api/v1/debug`); the running indicator derives from + * `session.state`. */ import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; -import { - ISessionQuestionService, - type QuestionItem, - type QuestionRequest, -} from '@moonshot-ai/agent-core-v2/session/question/question'; -import { - EMPTY_AGENT_STATE, - itemId, - type AgentState, - type NoticeFrame, - type ToolCallFrame, - type TranscriptAttachment, - type TranscriptFrame, - type TranscriptInteraction, - type TranscriptItem, - type TranscriptMarker, - type TranscriptOperation, - type TranscriptTask, - type TranscriptTaskRef, - type TranscriptTurn, - type TranscriptUsage, - type TurnOrigin, - type TurnState, -} from '@moonshot-ai/transcript'; +import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; +import type { + AssistantMessage, + InteractionMessage, + InteractionQuestionItem, + SessionStateMessage, + StepMessage, + SystemMessage, + TaskMessage, + ThinkingMessage, + TodoMessage, + ToolCallMessage, + TurnMessage, + UserMessage, +} from '@moonshot-ai/kap-server/protocol'; import { createContext, useCallback, useContext, useEffect, useLayoutEffect, + useMemo, useRef, useState, useSyncExternalStore, @@ -61,20 +55,13 @@ import { import { AuditTrail } from '../audit/trail'; import { useConnection } from '../connection'; import type { SearchHit } from '../search/api'; +import { ChatChannel } from '../transcript/channel'; import { - fetchTranscriptAttachment, - fetchTranscriptOps, - fetchTranscriptPage, - TRANSCRIPT_PAGE_SIZE, -} from '../transcript/api'; -import { - createCoalescedRunner, + EMPTY_CHAT_STATE, hasTurnId, - oldestTurnId, - recoverLoadedWindow, - TranscriptChatStore, + type ChatState, + type TimelineEntry, } from '../transcript/store'; -import { TranscriptWs } from '../transcript/ws'; import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui'; import { ChatSearchBar } from './ChatSearchBar'; @@ -97,251 +84,71 @@ export interface ChatJump { readonly nonce: number; } -interface TranscriptChannel { - /** Null until the effect has created the store (pre-ready / no session). */ - readonly store: TranscriptChatStore | null; - readonly state: AgentState; +interface ChatChannelState { + /** Null until the effect has created the channel (pre-ready / no session). */ + readonly channel: ChatChannel | null; + readonly state: ChatState; /** Records every step that built the store (audit panel data source). */ readonly trail: AuditTrail | null; /** True once the initial REST page load succeeded. */ readonly loaded: boolean; - /** Set when the initial/refresh load failed (e.g. server without transcript). */ + /** Set when the initial/refresh load failed. */ readonly loadError: unknown; } /** - * Owns the store, the REST load/refresh pipeline, and the WS delta - * subscription for one (sessionId, agentId) pair. + * Owns the channel (store + REST + WS) for one (sessionId, agentId) pair. */ -function useTranscriptChannel( +function useChatChannel( sessionId: string | null, agentId: string, ready: boolean, captureAnchor: () => void, -): TranscriptChannel { +): ChatChannelState { const { baseUrl, config } = useConnection(); const token = config.token.trim(); - const [channel, setChannel] = useState<{ store: TranscriptChatStore; trail: AuditTrail } | null>( - null, - ); + const [channel, setChannel] = useState(null); const [loaded, setLoaded] = useState(false); const [loadError, setLoadError] = useState(null); useEffect(() => { if (!ready || sessionId === null) return; - const store = new TranscriptChatStore(); - const trail = new AuditTrail(); const authToken = token === '' ? undefined : token; - let disposed = false; - /** While a REST reload / catch-up is in flight, WS ops are buffered, then flushed. */ - let fetching = true; - let buffer: TranscriptOperation[] = []; - /** Max batch seq seen while buffering (folded into the watermark on flush). */ - let bufferedSeq: number | undefined; - /** - * Op-batch watermark: the store is known to include every batch with - * seq <= lastSeq. Sourced from REST page watermarks and applied batch - * seqs; `undefined` until a sequenced server provides one (legacy - * servers never do — every recovery then falls back to full refreshes). - */ - let lastSeq: number | undefined; - /** Cursor of the in-flight recover fetch, paired with `onPageApplied`. */ - let recoverBefore: string | undefined; - /** True once the initial page load succeeded (gates reset-driven catch-up). */ - let seeded = false; - - const noteSeq = (seq: number | undefined): void => { - if (seq === undefined) return; - lastSeq = lastSeq === undefined ? seq : Math.max(lastSeq, seq); - }; - - const flushBuffer = (): void => { - fetching = false; - if (buffer.length > 0) { - const flushed = buffer; - store.applyOps(flushed); - trail.recordOps(flushed, 'flushed', undefined, store.getState()); - noteSeq(bufferedSeq); - } - buffer = []; - bufferedSeq = undefined; - }; - - /** Page (re)load body shared by the full refresh and the catch-up fallback. */ - const reloadPages = async (): Promise => { - // The window's oldest turn is the re-cover anchor: after a refresh the - // server window may have shifted, and only re-loading up to THIS turn - // preserves the previously loaded history. - const prevOldest = oldestTurnId(store.getState().items); - if (prevOldest !== undefined) captureAnchor(); - const newest = await fetchTranscriptPage({ - baseUrl, - token: authToken, - sessionId, - agentId, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - if (disposed) return; - store.applyPage(newest, { replace: true }); - trail.recordRest({ pageSize: TRANSCRIPT_PAGE_SIZE }, 'replace', newest, store.getState()); - lastSeq = newest.seq; - // Re-cover the previously loaded window for refreshes (a no-op on the - // initial load, where there is no previous oldest turn). - await recoverLoadedWindow( - store, - prevOldest, - (beforeTurn) => { - recoverBefore = beforeTurn; - return fetchTranscriptPage({ - baseUrl, - token: authToken, - sessionId, - agentId, - beforeTurn, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - }, - () => disposed, - (page) => { - trail.recordRest( - { beforeTurn: recoverBefore, pageSize: TRANSCRIPT_PAGE_SIZE }, - 'prepend', - page, - store.getState(), - ); - }, - ); - if (!disposed) { - seeded = true; - setLoaded(true); - setLoadError(null); - } - }; - - /** Full-state (re)load: the legacy recovery path and the initial load. */ - const refresh = createCoalescedRunner(async (): Promise => { - fetching = true; - buffer = []; - bufferedSeq = undefined; - try { - await reloadPages(); - } catch (error) { - if (!disposed) setLoadError(error); - } finally { - flushBuffer(); - } - }); - - /** - * Targeted catch-up: fetch exactly the op batches after our watermark - * (`GET .../transcript/ops?since_seq=`). Falls back to a full page - * reload on a legacy server (no seq / endpoint missing), a journal that - * no longer covers the gap (`complete: false`), or a fetch failure. - */ - const catchUp = createCoalescedRunner(async (): Promise => { - if (lastSeq === undefined) { - refresh(); - return; - } - fetching = true; - buffer = []; - bufferedSeq = undefined; - try { - const res = await fetchTranscriptOps({ - baseUrl, - token: authToken, - sessionId, - agentId, - sinceSeq: lastSeq, - }); - if (disposed) return; - if (!res.complete) { - await reloadPages(); - } else { - for (const batch of res.batches) { - store.applyOps(batch.ops); - trail.recordOps(batch.ops, 'catchup', undefined, store.getState()); - } - noteSeq(res.latestSeq); - } - } catch { - try { - await reloadPages(); - } catch (error) { - if (!disposed) setLoadError(error); - } - } finally { - flushBuffer(); - } - }); - - const ws = new TranscriptWs({ - url: baseUrl, + const next = new ChatChannel({ + baseUrl, token: authToken, sessionId, agentId, - getSince: () => lastSeq, - handlers: { - onOps: (aid, ops, meta) => { - if (aid !== agentId) return; - if (fetching) { - buffer.push(...ops); - if (meta?.seq !== undefined) { - bufferedSeq = Math.max(bufferedSeq ?? 0, meta.seq); - } - trail.recordOps(ops, 'buffered', meta?.at, store.getState()); - return; - } - // Seq gap: the store is behind by at least one batch. Catch up - // point-to-point instead of applying on a stale base (appends are - // offset-placed and would surface a gap anyway). - if (meta?.seq !== undefined && lastSeq !== undefined && meta.seq > lastSeq + 1) { - catchUp(); - return; - } - store.applyOps(ops); - trail.recordOps(ops, 'live', meta?.at, store.getState()); - noteSeq(meta?.seq); - }, - onReset: (_aid, snapshot, hasMoreOlder, meta) => { - trail.recordReset(snapshot, hasMoreOlder, meta?.at, store.getState()); - // Sequenced mode only: a reset after seeding means the server could - // not replay from our `transcript_since` cursor (journal truncated) - // — catch up, which itself falls back to a full reload when the seq - // window is gone. On legacy servers (no watermark) resets are - // routine per-subscribe noise and stay ignored, as before. - if (seeded && lastSeq !== undefined) catchUp(); - }, - onResyncRequired: () => { - trail.recordEvent('resync', undefined, store.getState()); - catchUp(); - }, - onReconnected: () => { - trail.recordEvent('ack-refresh', undefined, store.getState()); - catchUp(); - }, + onWillReplace: captureAnchor, + onLoaded: () => { + setLoaded(true); + setLoadError(null); + }, + onLoadError: (error) => { + setLoadError(error); }, }); - store.onGap = () => { - trail.recordEvent('gap', undefined, store.getState()); - catchUp(); - }; - setChannel({ store, trail }); + setChannel(next); setLoaded(false); setLoadError(null); - refresh(); + next.start(); return () => { - disposed = true; - ws.close(); + next.close(); setChannel(null); }; }, [sessionId, agentId, ready, baseUrl, token, captureAnchor]); const state = useSyncExternalStore( channel?.store.subscribe ?? noopSubscribe, - () => channel?.store.getState() ?? EMPTY_AGENT_STATE, + () => channel?.store.getState() ?? EMPTY_CHAT_STATE, ); - return { store: channel?.store ?? null, state, trail: channel?.trail ?? null, loaded, loadError }; + return { + channel, + state, + trail: channel?.trail ?? null, + loaded, + loadError, + }; } export function ChatView({ @@ -365,7 +172,7 @@ export function ChatView({ /** Hands an in-chat search hit up to the app shell (agent switch + jump). */ onOpenSearchHit?: ((hit: SearchHit) => void) | undefined; }) { - const { klient, baseUrl, config } = useConnection(); + const { klient } = useConnection(); const [input, setInput] = useState(''); const [sendError, setSendError] = useState(null); const [loadingOlder, setLoadingOlder] = useState(false); @@ -383,13 +190,13 @@ export function ChatView({ if (el !== null) anchorRef.current = el.scrollHeight - el.scrollTop; }, []); - const { store, state, trail, loaded, loadError } = useTranscriptChannel( + const { channel, state, trail, loaded, loadError } = useChatChannel( sessionId, agentId, ready, captureAnchor, ); - const items = state.items; + const entries = state.entries; // The audit panel is rendered by the app shell's right dock; report the // trail (null while no channel exists) so it can subscribe to it there. @@ -402,7 +209,7 @@ export function ChatView({ // step (or the turn card) and flash it briefly. A turn that never appears // (cut by an undo) degrades to no scroll. useEffect(() => { - if (jump === null || jump === undefined || !loaded || store === null || sessionId === null) { + if (jump === null || jump === undefined || !loaded || channel === null || sessionId === null) { return; } if (jump.turnId === undefined) { @@ -410,38 +217,27 @@ export function ChatView({ return; } let cancelled = false; + const isCancelled = (): boolean => cancelled; const turnId = jump.turnId; const stepId = jump.stepId; void (async () => { stickBottomRef.current = false; - const token = config.token.trim(); - let recoverBefore: string | undefined; - await recoverLoadedWindow( - store, - turnId, - (beforeTurn) => { - recoverBefore = beforeTurn; - return fetchTranscriptPage({ - baseUrl, - token: token === '' ? undefined : token, - sessionId, - agentId, - beforeTurn, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - }, - () => cancelled, - (page) => { - trail?.recordRest( - { beforeTurn: recoverBefore, pageSize: TRANSCRIPT_PAGE_SIZE }, - 'prepend', - page, - store.getState(), - ); - }, - ); + const store = channel.store; + try { + while ( + !hasTurnId(store.getState().entries, turnId) && + store.getState().hasMoreOlder && + !isCancelled() + ) { + const before = store.getState().entries.length; + await channel.loadOlder(); + if (store.getState().entries.length === before) break; + } + } catch { + // A failed older-page load leaves the window as-is; degrade to no scroll. + } if (cancelled) return; - if (!hasTurnId(store.getState().items, turnId)) { + if (!hasTurnId(store.getState().entries, turnId)) { onJumpHandled?.(); return; } @@ -464,7 +260,7 @@ export function ChatView({ return () => { cancelled = true; }; - }, [jump, loaded, store, sessionId, agentId, baseUrl, config, trail, onJumpHandled]); + }, [jump, loaded, channel, sessionId, onJumpHandled]); // The flash highlight clears itself after a short moment. useEffect(() => { @@ -482,7 +278,7 @@ export function ChatView({ return; } if (stickBottomRef.current) el.scrollTop = el.scrollHeight; - }, [items]); + }, [entries]); const onScroll = () => { const el = scrollRef.current; @@ -491,32 +287,20 @@ export function ChatView({ }; const loadOlder = async () => { - if (sessionId === null || loadingOlder || store === null) return; - const oldest = oldestTurnId(items); - if (oldest === undefined) return; + if (channel === null || loadingOlder) return; captureAnchor(); setLoadingOlder(true); setOlderError(null); try { - const token = config.token.trim(); - const page = await fetchTranscriptPage({ - baseUrl, - token: token === '' ? undefined : token, - sessionId, - agentId, - beforeTurn: oldest, - pageSize: TRANSCRIPT_PAGE_SIZE, - }); - store.applyPage(page); - trail?.recordRest( - { beforeTurn: oldest, pageSize: TRANSCRIPT_PAGE_SIZE }, - 'prepend', - page, - store.getState(), - ); + await channel.loadOlder(); } catch (error) { anchorRef.current = null; setOlderError(error); + trail?.recordEvent( + 'older-error', + error instanceof Error ? error.message : String(error), + channel.store.getState(), + ); } finally { setLoadingOlder(false); } @@ -534,8 +318,8 @@ export function ChatView({ const root = scrollRef.current; if (sentinel === null || root === null || olderError !== null) return; const observer = new IntersectionObserver( - (entries) => { - if (entries.some((entry) => entry.isIntersecting)) void loadOlderRef.current(); + (observed) => { + if (observed.some((entry) => entry.isIntersecting)) void loadOlderRef.current(); }, { root, rootMargin: '400px 0px 0px 0px' }, ); @@ -545,19 +329,25 @@ export function ChatView({ }; }, [hasMoreOlder, loaded, olderError, loadingOlder]); - const running = - state.meta.activity === 'turn' || - items.some((item) => item.kind === 'turn' && item.state === 'running'); + const running = state.sessionState?.busy === true || isAnyTurnRunning(entries); + const pendingCount = [...state.interactions.values()].filter( + (interaction) => interaction.state === 'pending', + ).length; - // Interactions render inline at their anchor tool frame; entities without - // an anchor (or whose anchor frame is outside the loaded window) collect - // here and render floating at the bottom. - const anchoredToolCallIds = collectToolCallIds(items); + // Interactions render inline at their anchor tool call; entities without + // an anchor (or whose anchor is outside the loaded window) collect here + // and render floating at the bottom. Unanchored tasks (no tool call + // references them, e.g. shell-command tasks) do the same. + const anchoredToolCallIds = useMemo(() => collectToolCallIds(entries), [entries]); const unanchoredInteractions = [...state.interactions.values()].filter( (interaction) => - interaction.toolCallId === undefined || !anchoredToolCallIds.has(interaction.toolCallId), + interaction.tool_call_id === undefined || !anchoredToolCallIds.has(interaction.tool_call_id), + ); + const anchoredTaskIds = useMemo(() => collectTaskIds(entries), [entries]); + const unanchoredTasks = [...state.tasks.values()].filter( + (task) => !anchoredTaskIds.has(task.task_id), ); - const latestTodo = [...state.todos.values()].at(-1); + const latestTodo = latestTodoOf(state.todos); const send = async () => { if (sessionId === null || input.trim() === '' || running) return; @@ -608,8 +398,9 @@ export function ChatView({ {sessionId} agent: {agentId} {running ? turn running : idle} - {state.pendingInteractions.size > 0 ? ( - {state.pendingInteractions.size} pending + {pendingCount > 0 ? {pendingCount} pending : null} + {state.sessionState !== undefined ? ( + ) : null} @@ -642,63 +433,25 @@ export function ChatView({
- Failed to load the transcript — the server may be too old to expose the transcript - API. + Failed to load the session history — the server may be too old to expose the + history API.
) : null} - {items.length === 0 && loadError === null ? ( + {entries.length === 0 && loadError === null ? (
{loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'}
) : null} {latestTodo !== undefined && latestTodo.items.length > 0 ? ( -
-
todo (latest)
- {latestTodo.items.map((entry, i) => ( -
- - {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} - - - {entry.title} - -
- ))} -
+ ) : null} - {items.map((item) => ( - // Native virtual screen: the browser skips layout/paint for - // off-screen items and remembers their last rendered size - // (`auto` in contain-intrinsic-size), so long transcripts stay - // cheap without a windowing library. -
- -
- ))} + {unanchoredInteractions.map((interaction) => ( - + + ))} + {unanchoredTasks.map((task) => ( + ))} @@ -736,447 +489,458 @@ export function ChatView({ ); } -// ---------------------------------------------------------------- items +// ---------------------------------------------------------------- timeline -function ItemView({ - item, - tasks, +type RenderItem = + | { + readonly kind: 'group'; + readonly turnId: string; + readonly turn: TurnMessage | undefined; + readonly items: readonly TimelineEntry[]; + } + | { readonly kind: 'system'; readonly key: string; readonly message: SystemMessage }; + +function groupTimeline(entries: readonly TimelineEntry[]): RenderItem[] { + interface GroupDraft { + turn?: TurnMessage; + items: TimelineEntry[]; + } + const drafts = new Map(); + const order: ( + | { kind: 'group'; turnId: string } + | { kind: 'system'; key: string; message: SystemMessage } + )[] = []; + for (const entry of entries) { + const message = entry.message; + if (message.type === 'system') { + order.push({ kind: 'system', key: entry.key, message }); + continue; + } + let draft = drafts.get(message.turn_id); + if (draft === undefined) { + draft = { items: [] }; + drafts.set(message.turn_id, draft); + order.push({ kind: 'group', turnId: message.turn_id }); + } + if (message.type === 'turn') draft.turn = message; + draft.items.push(entry); + } + return order.map((item) => + item.kind === 'system' + ? item + : { + kind: 'group', + turnId: item.turnId, + turn: drafts.get(item.turnId)?.turn, + items: drafts.get(item.turnId)?.items ?? [], + }, + ); +} + +function Timeline({ + items, interactions, - attachments, + tasks, flash, }: { - item: TranscriptItem; - tasks: ReadonlyMap; - interactions: ReadonlyMap; - attachments: ReadonlyMap; - /** The jump target being flashed, if any. */ + items: readonly TimelineEntry[]; + interactions: ReadonlyMap; + tasks: ReadonlyMap; flash?: { turnId: string; stepId?: string | undefined } | null | undefined; }) { - switch (item.kind) { - case 'turn': - return ( - - ); - case 'marker': - return ; - case 'taskref': - return ; + const renderItems = useMemo(() => groupTimeline(items), [items]); + return ( + <> + {renderItems.map((item) => + item.kind === 'system' ? ( + // Native virtual screen: the browser skips layout/paint for + // off-screen items and remembers their last rendered size. +
+ +
+ ) : ( +
+ +
+ ), + )} + + ); +} + +function isAnyTurnRunning(entries: readonly TimelineEntry[]): boolean { + return entries.some( + (entry) => entry.message.type === 'turn' && entry.message.state === 'running', + ); +} + +function collectToolCallIds(entries: readonly TimelineEntry[]): Set { + const ids = new Set(); + for (const entry of entries) { + if (entry.message.type === 'tool_call') ids.add(entry.message.tool_call_id); } + return ids; } -function collectToolCallIds(items: readonly TranscriptItem[]): Set { +function collectTaskIds(entries: readonly TimelineEntry[]): Set { const ids = new Set(); - for (const item of items) { - if (item.kind !== 'turn') continue; - for (const step of item.steps) { - for (const frame of step.frames) { - if (frame.kind === 'tool') ids.add(frame.toolCallId); - } + for (const entry of entries) { + if (entry.message.type === 'tool_call' && entry.message.task_id !== undefined) { + ids.add(entry.message.task_id); } } return ids; } -function turnStateTone(state: TurnState): 'neutral' | 'green' | 'amber' | 'red' { - switch (state) { - case 'running': - return 'amber'; - case 'completed': - return 'green'; - case 'failed': - return 'red'; - default: - return 'neutral'; +function latestTodoOf(todos: ReadonlyMap): TodoMessage | undefined { + let latest: TodoMessage | undefined; + for (const todo of todos.values()) { + if (latest === undefined || todo.timestamp > latest.timestamp) latest = todo; } + return latest; } -function usageText(usage: TranscriptUsage): string { - const parts: string[] = []; - if (usage.inputTokens !== undefined) parts.push(`in ${usage.inputTokens}`); - if (usage.outputTokens !== undefined) parts.push(`out ${usage.outputTokens}`); - if (usage.cachedTokens !== undefined) parts.push(`cached ${usage.cachedTokens}`); - if (usage.cost !== undefined) parts.push(`$${usage.cost.toFixed(4)}`); - return parts.join(' / '); -} +// ---------------------------------------------------------------- turn group -function TurnView({ +function TurnGroupView({ + turnId, turn, - tasks, + items, interactions, - attachments, + tasks, flash, }: { - turn: TranscriptTurn; - tasks: ReadonlyMap; - interactions: ReadonlyMap; - attachments: ReadonlyMap; - /** The jump target being flashed, if any. */ + turnId: string; + turn: TurnMessage | undefined; + items: readonly TimelineEntry[]; + interactions: ReadonlyMap; + tasks: ReadonlyMap; flash?: { turnId: string; stepId?: string | undefined } | null | undefined; }) { - const turnFlashed = flash?.turnId === turn.turnId && flash.stepId === undefined; + const turnFlashed = flash?.turnId === turnId && flash.stepId === undefined; return (
- {turn.turnId} - {turn.origin.kind} - {turn.state} - {turn.startedAt !== undefined ? ( - - {relTime(Date.parse(turn.startedAt))} - - ) : null} - {turn.usage !== undefined ? ( - {usageText(turn.usage)} - ) : null} + {turnId} + {turn !== undefined ? ( + <> + {turn.origin.kind} + {turn.state} + {turn.started_at !== undefined ? ( + + {relTime(Date.parse(turn.started_at))} + + ) : null} + {turn.usage !== undefined ? ( + + {turnUsageText(turn.usage)} + + ) : null} + + ) : ( + turn header outside the window + )}
- {turn.prompt !== undefined && turn.prompt !== '' ? ( - + {turn?.attachment_ids !== undefined && turn.attachment_ids.length > 0 ? ( + ) : null} - {turn.attachmentIds !== undefined && turn.attachmentIds.length > 0 ? ( - - ) : null} - {turn.steps.map((step) => ( -
- {step.frames.map((frame) => ( - - ))} - {step.state === 'interrupted' ? ( -
step interrupted
- ) : null} -
+ {items.map((entry) => ( + ))}
); } -function TurnPrompt({ origin, prompt }: { origin: TurnOrigin; prompt: string }) { - if (origin.kind === 'user') { - return ( -
-
- {prompt} -
-
- ); +function turnUsageText(usage: NonNullable): string { + const parts: string[] = []; + if (usage.input_tokens !== undefined) parts.push(`in ${usage.input_tokens}`); + if (usage.output_tokens !== undefined) parts.push(`out ${usage.output_tokens}`); + if (usage.cached_tokens !== undefined) parts.push(`cached ${usage.cached_tokens}`); + if (usage.cost !== undefined) parts.push(`$${usage.cost.toFixed(4)}`); + return parts.join(' / '); +} + +function TimelineEntryView({ + entry, + interactions, + tasks, + flash, +}: { + entry: TimelineEntry; + interactions: ReadonlyMap; + tasks: ReadonlyMap; + flash?: { turnId: string; stepId?: string | undefined } | null | undefined; +}) { + const message = entry.message; + switch (message.type) { + case 'turn': + return null; + case 'step': + return ; + case 'user': + return ; + case 'assistant': + return ; + case 'thinking': + return ; + case 'tool_call': + return ; + case 'system': + return ; } +} + +function StepRow({ step, flashed }: { step: StepMessage; flashed: boolean }) { return ( -
- {prompt} +
+ {step.step_id} + + {step.state} + + {step.retry !== undefined ? ( + + retry {step.retry.failed_attempt}→{step.retry.next_attempt}/{step.retry.max_attempts}:{' '} + {step.retry.error_name} + + ) : null} + {step.finish_reason !== undefined ? finish: {step.finish_reason} : null} + {step.usage !== undefined ? ( + + in {step.usage.input_other + step.usage.input_cache_read + step.usage.input_cache_creation}{' '} + / out {step.usage.output} + + ) : null} + {step.end_reason !== undefined ? {step.end_reason} : null} + {step.end_message !== undefined ? {step.end_message} : null}
); } -function MarkerView({ marker }: { marker: TranscriptMarker }) { +// ---------------------------------------------------------------- messages + +function UserMessageView({ message }: { message: UserMessage }) { + const isUserInput = message.origin === undefined; return ( -
-
-
- {marker.marker} - {marker.at !== undefined ? {relTime(Date.parse(marker.at))} : null} -
+
+
+ {message.message_id} + {message.origin !== undefined ? ( + + {message.origin.kind === 'cron' ? `cron ${message.origin.cron_id}` : 'channel'} + + ) : null} + {message.steered_at !== undefined ? steered : null} + {message.status === 'running' ? queued : null}
- {marker.payload !== undefined ? : null} + {isUserInput ? ( +
+
+ {message.text} +
+
+ ) : ( +
+ {message.text} +
+ )} + {message.attachment_ids !== undefined && message.attachment_ids.length > 0 ? ( + + ) : null} + {message.skill_activations !== undefined && message.skill_activations.length > 0 ? ( +
+ {message.skill_activations.map((skill) => ( + + skill: {skill.skill_name} + + ))} +
+ ) : null}
); } -function TaskRefView({ - item, - task, -}: { - item: TranscriptTaskRef; - task: TranscriptTask | undefined; -}) { - const failed = - task !== undefined && - (task.state === 'failed' || task.state === 'timed_out' || task.state === 'lost'); +function AssistantMessageView({ message }: { message: AssistantMessage }) { return ( -
-
- - task{task !== undefined ? `: ${task.kind}` : ''} - - {task?.description ?? item.taskId} - {task !== undefined ? ( - - {task.state} - {task.detached ? ' (detached)' : ''} - - ) : null} +
+
+ {message.text} + {message.status === 'streaming' ? : null}
- {task !== undefined && task.outputTail !== '' ? ( -
-          {task.outputTail}
-        
- ) : null}
); } -// ---------------------------------------------------------------- frames - -function AttachmentChips({ - ids, - attachments, -}: { - ids: readonly string[]; - attachments: ReadonlyMap; -}) { +function ThinkingMessageView({ message }: { message: ThinkingMessage }) { return ( -
- {ids.map((id) => { - const attachment = attachments.get(id); - const label = attachment?.name ?? attachment?.mediaType ?? id; - return ( - - 📎{' '} - - - ); - })} +
+ {message.text} + {message.status === 'streaming' ? : null}
); } -function AttachmentLink({ - attachment, - label, -}: { - attachment: TranscriptAttachment | undefined; - label: string; -}) { - const sessionId = useContext(SessionContext); - const { baseUrl, config } = useConnection(); - const [downloading, setDownloading] = useState(false); - const [error, setError] = useState(null); - const source = attachment?.source; - if (source === undefined) return label; - if (source.kind === 'url') { - return ( - - {label} - - ); - } - const download = async (): Promise => { - setDownloading(true); - setError(null); - try { - const blob = await fetchTranscriptAttachment({ - baseUrl, - token: config.token.trim() || undefined, - sessionId, - source, - }); - const href = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = href; - link.download = attachment?.name ?? source.fileId; - link.click(); - setTimeout(() => { - URL.revokeObjectURL(href); - }, 0); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } finally { - setDownloading(false); - } - }; +function AttachmentChips({ ids }: { ids: readonly string[] }) { return ( - +
+ {ids.map((id) => ( + + 📎 {id} + + ))} +
); } -function FrameView({ - frame, - tasks, - interactions, - attachments, -}: { - frame: TranscriptFrame; - tasks: ReadonlyMap; - interactions: ReadonlyMap; - attachments: ReadonlyMap; -}) { - switch (frame.kind) { - case 'text': { - const chips = - frame.attachmentIds !== undefined && frame.attachmentIds.length > 0 ? ( - - ) : null; - const taskBadge = - frame.taskId !== undefined ? ( -
- - task: {frame.taskId} - {tasks.get(frame.taskId) !== undefined ? ` (${tasks.get(frame.taskId)!.state})` : ''} - -
- ) : null; - const bubble = - frame.role === 'user' ? ( -
-
- {frame.text} -
-
- ) : ( -
- {frame.text} -
- ); - return ( - <> - {taskBadge} - {chips} - {bubble} - - ); - } - case 'thinking': - return ( -
- {frame.text} -
- ); - case 'tool': - return ; - case 'notice': - return ; - } -} +// ---------------------------------------------------------------- tool calls -function ToolFrameView({ - frame, - tasks, +function ToolCallView({ + call, interactions, + tasks, }: { - frame: ToolCallFrame; - tasks: ReadonlyMap; - interactions: ReadonlyMap; + call: ToolCallMessage; + interactions: ReadonlyMap; + tasks: ReadonlyMap; }) { - const task = frame.taskId !== undefined ? tasks.get(frame.taskId) : undefined; - // The interaction anchored at this call (via approvalId, or by scanning the - // entity's toolCallId for requests that predate the back-link). + const task = call.task_id !== undefined ? tasks.get(call.task_id) : undefined; const linked = [...interactions.values()].filter( (interaction) => - interaction.interactionId === frame.approvalId || interaction.toolCallId === frame.toolCallId, + interaction.interaction_id === call.approval_id || + interaction.tool_call_id === call.tool_call_id, ); return (
tool - {frame.name} - {frame.toolCallId} - {frame.view !== undefined && frame.view !== frame.name ? ( - view: {frame.view} + {call.name} + {call.tool_call_id} + {call.view !== undefined && call.view !== call.name ? ( + view: {call.view} ) : null} - {frame.agentRefs?.map((ref) => ( - - agent: {ref.agentId} + {call.agent_refs?.map((ref) => ( + + agent: {ref.agent_id} ))} {task !== undefined ? task: {task.state} : null} - {frame.todoId !== undefined ? ( - todo: {frame.todoId} + {call.todo_id !== undefined ? ( + todo: {call.todo_id} ) : null}
- {frame.input !== undefined ? ( - typeof frame.input === 'string' ? ( + {call.input !== undefined ? ( + typeof call.input === 'string' ? (
-            {frame.input}
+            {call.input}
           
) : ( - + ) + ) : call.input_text !== undefined && call.input_text !== '' ? ( +
+          {call.input_text}
+        
) : null} - {frame.output !== undefined ? ( - typeof frame.output === 'string' ? ( + {call.output !== undefined ? ( + typeof call.output === 'string' ? (
-            {frame.output}
+            {call.output}
           
) : ( - + ) - ) : task !== undefined && task.outputTail !== '' ? ( + ) : task !== undefined && task.output_tail !== '' ? (
-          {task.outputTail}
+          {task.output_tail}
         
) : null} - {frame.error !== undefined && frame.error !== frame.output ? ( -
{frame.error}
+ {call.error !== undefined && call.error !== call.output ? ( +
{call.error}
+ ) : null} + {call.progress !== undefined ? ( +
+ progress ({call.progress.kind}):{' '} + {call.progress.text ?? (call.progress.percent !== undefined ? `${call.progress.percent}%` : call.progress.custom_kind ?? '')} +
) : null} {linked.map((interaction) => ( - + ))}
); } +// ---------------------------------------------------------------- interactions + function InteractionEntityView({ interaction, nested, }: { - interaction: TranscriptInteraction; + interaction: InteractionMessage; nested?: boolean; }) { const { klient } = useConnection(); const sessionId = useContext(SessionContext); const [busy, setBusy] = useState(false); const [respondError, setRespondError] = useState(null); - /** Question answers in progress: question text → selected option labels. */ + /** Question answers in progress: question id → selected option labels. */ const [selections, setSelections] = useState>>({}); - /** Question free-text ("Other") input: question text → draft. */ + /** Question free-text ("Other") input: question id → draft. */ const [others, setOthers] = useState>>({}); const pending = interaction.state === 'pending'; - const questionRequest = - interaction.interactionKind === 'question' - ? (interaction.request as QuestionRequest | undefined) - : undefined; + const questionRequest = interaction.kind === 'question' ? interaction.request : undefined; const run = (fn: () => Promise): void => { setBusy(true); @@ -1195,30 +959,30 @@ function InteractionEntityView({ klient .session(sessionId) .service(ISessionApprovalService) - .decide(interaction.interactionId, { decision }), + .decide(interaction.interaction_id, { decision }), ); }; - const toggleOption = (question: QuestionItem, label: string): void => { + const toggleOption = (question: InteractionQuestionItem, label: string): void => { setSelections((prev) => { - const current = prev[question.question] ?? []; + const current = prev[question.id] ?? []; const next = - question.multiSelect === true + question.multi_select === true ? current.includes(label) ? current.filter((item) => item !== label) : [...current, label] : current.includes(label) ? [] : [label]; - return { ...prev, [question.question]: next }; + return { ...prev, [question.id]: next }; }); }; const submitAnswers = (): void => { const answers: Record = {}; for (const question of questionRequest?.questions ?? []) { - const parts = [...(selections[question.question] ?? [])]; - const other = (others[question.question] ?? '').trim(); + const parts = [...(selections[question.id] ?? [])]; + const other = (others[question.id] ?? '').trim(); if (other !== '') parts.push(other); if (parts.length > 0) answers[question.question] = parts.join(', '); } @@ -1228,13 +992,13 @@ function InteractionEntityView({ klient .session(sessionId) .service(ISessionQuestionService) - .answer(interaction.interactionId, result), + .answer(interaction.interaction_id, result), ); }; const dismiss = (): void => { run(() => - klient.session(sessionId).service(ISessionQuestionService).dismiss(interaction.interactionId), + klient.session(sessionId).service(ISessionQuestionService).dismiss(interaction.interaction_id), ); }; @@ -1245,13 +1009,16 @@ function InteractionEntityView({ }`} >
- {interaction.interactionKind} + {interaction.kind} {interaction.state} - tool: {interaction.toolCallId} + tool: {interaction.tool_call_id}
- {interaction.request !== undefined ? : null} + {interaction.request !== undefined && questionRequest === undefined ? ( + + ) : null} + {questionRequest !== undefined && !pending ? : null} {interaction.response !== undefined ? : null} - {pending && interaction.interactionKind === 'approval' ? ( + {pending && interaction.kind === 'approval' ? (
decide('approved')} disabled={busy}> Approve @@ -1264,14 +1031,14 @@ function InteractionEntityView({ {pending && questionRequest !== undefined ? (
{questionRequest.questions.map((question) => ( -
+
{question.header ?? question.question}
{question.options.map((option) => { - const selected = (selections[question.question] ?? []).includes(option.label); + const selected = (selections[question.id] ?? []).includes(option.label); return (
@@ -1316,20 +1083,112 @@ function InteractionEntityView({ ); } -function NoticeFrameView({ frame }: { frame: NoticeFrame }) { - const tone = - frame.level === 'error' - ? 'bg-red-950/50 text-red-400' - : frame.level === 'warning' - ? 'bg-amber-950/40 text-amber-300' - : 'bg-neutral-900/60 text-neutral-400'; +// ---------------------------------------------------------------- state entities + +function SystemMarkerView({ message }: { message: SystemMessage }) { + return ( +
+
+
+ system({message.subtype}) + {message.system_id} + {message.at !== undefined ? {relTime(Date.parse(message.at))} : null} +
+
+ {message.payload !== undefined ? : null} +
+ ); +} + +function TaskCard({ task }: { task: TaskMessage }) { + const failed = + task.state === 'failed' || task.state === 'timed_out' || task.state === 'lost'; return ( -
- {frame.source !== undefined ? ( - [{frame.source}] +
+
+ + task: {task.kind} + + {task.description ?? task.task_id} + + {task.state} + {task.detached ? ' (detached)' : ''} + + {task.child_agent_id !== undefined ? ( + agent: {task.child_agent_id} + ) : null} +
+ {task.output_tail !== '' ? ( +
+          {task.output_tail}
+        
+ ) : null} + {task.error !== undefined ? ( +
+          {task.error}
+        
+ ) : null} + {task.result_summary !== undefined ? ( +
{task.result_summary}
) : null} - {frame.message} - {frame.detail !== undefined ? : null}
); } + +function TodoCard({ todo }: { todo: TodoMessage }) { + return ( +
+
todo (latest)
+ {todo.items.map((entry, i) => ( +
+ + {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} + + + {entry.title} + +
+ ))} +
+ ); +} + +function SessionStateBadges({ sessionState }: { sessionState: SessionStateMessage }) { + return ( + <> + {sessionState.pending_interaction !== undefined && + sessionState.pending_interaction !== 'none' ? ( + {sessionState.pending_interaction} + ) : null} + {sessionState.model !== undefined ? {sessionState.model} : null} + {sessionState.permission !== undefined ? ( + perm: {sessionState.permission} + ) : null} + {sessionState.modes?.plan !== undefined ? plan mode : null} + {sessionState.modes?.swarm !== undefined ? swarm : null} + {sessionState.goal !== undefined ? ( + + goal: {sessionState.goal.status} + + ) : null} + {sessionState.context_tokens !== undefined ? ( + + ctx {sessionState.context_tokens} + {sessionState.max_context_tokens !== undefined + ? `/${sessionState.max_context_tokens}` + : ''} + + ) : null} + + ); +} diff --git a/apps/kimi-inspect/src/components/Inspector.tsx b/apps/kimi-inspect/src/components/Inspector.tsx index 1f9ccd1ee6c..e8cbf72683b 100644 --- a/apps/kimi-inspect/src/components/Inspector.tsx +++ b/apps/kimi-inspect/src/components/Inspector.tsx @@ -21,7 +21,8 @@ import { serviceByName } from '../channel'; import { useConnection } from '../connection'; import { type AnyService } from '../panels'; import { fetchAgentRuntimeBinding } from '../snapshots/api'; -import { fetchTranscriptPlan, type TranscriptPlanInfo } from '../transcript/api'; +import { fetchFullHistory } from '../transcript/api'; +import { projectPlans, type PlanInfo } from '../transcript/plan'; import { ActionButton, Badge, ErrorLine } from '../ui'; import { ScopePanels } from './ServicePanels'; @@ -175,20 +176,21 @@ export function Inspector({ } // --------------------------------------------------------------------------- -// Plan lookup — `GET /api/v1/sessions/{id}/transcript/plan`: the reviewed plan -// of one ExitPlanMode tool call, queried by tool_call_id (copy it from a tool -// frame in the chat view). Read-only, fetched on demand like everything else -// here. +// Plan lookup — derived from the message stream (`GET /sessions/{id}/history` +// full read + client-side `projectPlans`): the reviewed plan of one +// ExitPlanMode tool call, found by tool_call_id (copy it from a tool frame in +// the chat view), or every plan of the agent. Read-only, fetched on demand +// like everything else here. // --------------------------------------------------------------------------- function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string }) { const { baseUrl, config } = useConnection(); const [toolCallId, setToolCallId] = useState(''); - const [result, setResult] = useState(null); + const [result, setResult] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - // A plan belongs to one agent's transcript — stale results from another + // A plan belongs to one agent's timeline — stale results from another // session/agent are misleading, so reset on switch. useEffect(() => { setResult(null); @@ -200,16 +202,14 @@ function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string } try { setError(null); const token = config.token.trim(); + const messages = await fetchFullHistory({ + baseUrl, + token: token === '' ? undefined : token, + sessionId, + agentId, + }); const id = toolCallId.trim(); - setResult( - await fetchTranscriptPlan({ - baseUrl, - token: token === '' ? undefined : token, - sessionId, - agentId, - toolCallId: id === '' ? undefined : id, - }), - ); + setResult(projectPlans(messages, id === '' ? undefined : id)); } catch (error) { setResult(null); setError(error); @@ -256,7 +256,7 @@ function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string } ); } -function PlanEntryView({ entry }: { entry: TranscriptPlanInfo }) { +function PlanEntryView({ entry }: { entry: PlanInfo }) { const review = entry.review; return (
diff --git a/apps/kimi-inspect/src/components/audit/AuditPanel.tsx b/apps/kimi-inspect/src/components/audit/AuditPanel.tsx index 7dbdea72745..318e443bebf 100644 --- a/apps/kimi-inspect/src/components/audit/AuditPanel.tsx +++ b/apps/kimi-inspect/src/components/audit/AuditPanel.tsx @@ -1,32 +1,32 @@ /** * Audit panel — the `Audit` tab of the chat view's right dock - * (`RightPanel`): replays how the visible `TranscriptChatStore` was built, - * entry by entry. It used to be a standalone column docked inside the chat - * view. + * (`RightPanel`): replays how the visible `ChatStore` was built, entry by + * entry. It used to be a standalone column docked inside the chat view. * - * - Timeline (draggable slider + entry list): every REST page load, WS - * frame (`transcript.ops` / `transcript.reset`), loss signal, and user - * action the channel processed, with its timestamp. + * - Timeline (draggable slider + entry list): every REST history page, + * every WS message (entity / delta / state), and every channel event + * (subscribe ack, reconnect, catch-up fallback, prompt/cancel), with its + * timestamp. * - Detail tabs for the selected entry: `Diff` (structural diff vs the * previous entry — added/modified/removed colored), `State` (the full - * store state at that point, goal/plan/todos included), `Event` (the - * raw REST request/response or WS payload). + * store state at that point: entity timeline plus the interaction / + * task / todo / session.state entities), `Event` (the raw REST + * request/response or WS payload). */ -import { EMPTY_AGENT_STATE } from '@moonshot-ai/transcript'; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { diffValue, type DiffNode } from '../../audit/diff'; import { serializeState } from '../../audit/serialize'; import type { AuditEntry, AuditTrail } from '../../audit/trail'; import { tailTrunc } from '../../audit/truncate'; +import { EMPTY_CHAT_STATE } from '../../transcript/store'; import { Badge } from '../../ui'; import { plainNode, StateTree } from './StateTree'; -const KIND_TONE: Record = { +const KIND_TONE: Record = { rest: 'sky', - ops: 'green', - reset: 'violet', + ws: 'green', event: 'neutral', }; @@ -41,15 +41,14 @@ function EventJson({ entry }: { entry: AuditEntry }) { const payload = useMemo(() => { switch (entry.kind) { case 'rest': - return { request: entry.request, appliedAs: entry.appliedAs, response: entry.page }; - case 'ops': - return { envelopeAt: entry.envelopeAt, delivery: entry.delivery, ops: entry.ops }; - case 'reset': return { - envelopeAt: entry.envelopeAt, - hasMoreOlder: entry.hasMoreOlder, - snapshot: entry.snapshot, + request: entry.request, + mode: entry.mode, + messageCount: entry.messageCount, + inFlight: entry.inFlight, }; + case 'ws': + return entry.message; case 'event': return { event: entry.event, detail: entry.detail }; } @@ -89,7 +88,7 @@ export function AuditPanel({ trail }: { trail: AuditTrail }) { if (current === undefined || tab === 'event') return null; if (tab === 'state') return plainNode(serializeState(current.state)); const prevState = - currentPos > 0 ? (entries[currentPos - 1]?.state ?? EMPTY_AGENT_STATE) : EMPTY_AGENT_STATE; + currentPos > 0 ? (entries[currentPos - 1]?.state ?? EMPTY_CHAT_STATE) : EMPTY_CHAT_STATE; return diffValue(serializeState(prevState), serializeState(current.state)); }, [current, currentPos, entries, tab]); @@ -130,7 +129,7 @@ export function AuditPanel({ trail }: { trail: AuditTrail }) {
{entries.length === 0 ? (
- Nothing recorded yet — the initial transcript load is still running. + Nothing recorded yet — the initial history load is still running.
) : null} {entries.map((entry, pos) => ( diff --git a/apps/kimi-inspect/src/components/audit/StateTree.test.tsx b/apps/kimi-inspect/src/components/audit/StateTree.test.tsx index 8ce34226a26..ac893b4ecd9 100644 --- a/apps/kimi-inspect/src/components/audit/StateTree.test.tsx +++ b/apps/kimi-inspect/src/components/audit/StateTree.test.tsx @@ -7,41 +7,92 @@ * 2. Whole-subtree adds expand into fully fielded, indented tree rows. */ -import { EMPTY_AGENT_STATE, type AgentState, type TranscriptTurn } from '@moonshot-ai/transcript'; +import type { AssistantMessage, StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { diffValue } from '../../audit/diff'; import { serializeState } from '../../audit/serialize'; +import { EMPTY_CHAT_STATE, type ChatState } from '../../transcript/store'; import { plainNode, StateTree } from './StateTree'; -function turn(n: number, prompt: string): TranscriptTurn { +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +let tick = 0; + +function ts(): string { + tick += 1; + return new Date(T0 + tick * 1000).toISOString(); +} + +function turnMsg(n: number, text?: string): TurnMessage { return { - kind: 'turn', - turnId: `t${n}`, + type: 'turn', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + turn_id: `t${n}`, ordinal: n, state: 'completed', origin: { kind: 'user' }, - prompt, - steps: [], + user_message_id: text, + }; +} + +function stepMsg(stepId: string): StepMessage { + return { + type: 'step', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + step_id: stepId, + turn_id: stepId.split('.')[0] ?? 't0', + ordinal: Number(stepId.split('.')[1] ?? '1'), + state: 'running', + }; +} + +function assistantMsg(stepId: string, text: string): AssistantMessage { + return { + type: 'assistant', + session_id: 's1', + agent_id: 'main', + timestamp: ts(), + message_id: `${stepId}.a0`, + turn_id: stepId.split('.')[0] ?? 't0', + step_id: stepId, + status: 'streaming', + text, }; } -function stateWith(items: readonly TranscriptTurn[]): AgentState { - return { ...EMPTY_AGENT_STATE, items }; +type FlatMessage = TurnMessage | StepMessage | AssistantMessage; + +function stateWithTimeline(items: readonly FlatMessage[]): ChatState { + return { + ...EMPTY_CHAT_STATE, + entries: items.map((message) => ({ + key: + message.type === 'turn' + ? `turn:${message.turn_id}` + : message.type === 'step' + ? `step:${message.step_id}` + : `assistant:${message.message_id}`, + message, + })), + }; } describe('StateTree', () => { it('collapses unchanged subtrees instead of dumping compact JSON', () => { - const t0 = turn(0, 'PROMPT_ZERO'); - const prev = stateWith([t0, turn(1, 'PROMPT_ONE')]); - const next: AgentState = { ...prev, items: [t0, turn(1, 'PROMPT_ONE_V2')] }; + const t0 = turnMsg(0, 'PROMPT_ZERO'); + const prev = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE')]); + const next: ChatState = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE_V2')]); const html = renderToStaticMarkup( , ); // No one-line JSON blob anywhere. - expect(html).not.toContain('{"kind"'); - // The unchanged turn t0 stays folded: its prompt is not rendered… + expect(html).not.toContain('{"type"'); + // The unchanged turn t0 stays folded: its marker is not rendered… expect(html).not.toContain('PROMPT_ZERO'); // …while the modified turn opens and shows old → new. expect(html).toContain('PROMPT_ONE_V2'); @@ -51,39 +102,31 @@ describe('StateTree', () => { it('expands whole-subtree adds into full field rows (all keys, no JSON dump)', () => { const root = diffValue( - serializeState(EMPTY_AGENT_STATE), - serializeState(stateWith([turn(0, 'HELLO')])), + serializeState(EMPTY_CHAT_STATE), + serializeState(stateWithTimeline([turnMsg(0, 'HELLO')])), ); const html = renderToStaticMarkup(); - expect(html).not.toContain('{"kind"'); - for (const field of ['turnId', 'ordinal', 'state', 'origin', 'prompt', 'steps']) { + expect(html).not.toContain('{"type"'); + for (const field of ['turn_id', 'ordinal', 'state', 'origin', 'timestamp', 'agent_id']) { expect(html).toContain(field); } expect(html).toContain('HELLO'); }); it('expands added subtrees with id-based keys and renders closing braces', () => { - const withSteps: TranscriptTurn = { - ...turn(0, 'Q'), - steps: [ - { - kind: 'step', - stepId: 't0.1', - turnId: 't0', - ordinal: 1, - state: 'running', - frames: [{ kind: 'thinking', frameId: 't0.1.f1', text: 'hmm' }], - }, - ], - }; const html = renderToStaticMarkup( , ); // Array children are keyed by their ids, not #indices. expect(html).toContain('t0.1'); - expect(html).toContain('t0.1.f1'); + expect(html).toContain('t0.1.a0'); expect(html).not.toContain('#0'); // Open containers end with an explicit closing brace row. expect(html).toContain(']'); @@ -92,12 +135,15 @@ describe('StateTree', () => { it('plain state mode opens to defaultDepth and shows all top-level fields', () => { const html = renderToStaticMarkup( - , + , ); - for (const field of ['items', 'tasks', 'interactions', 'todos', 'meta', 'hasMoreOlder']) { + for (const field of ['timeline', 'interactions', 'tasks', 'todos', 'hasMoreOlder']) { expect(html).toContain(field); } - expect(html).not.toContain('{"kind"'); + expect(html).not.toContain('{"type"'); }); it('collapses multiline strings into a hover-preview button', () => { diff --git a/apps/kimi-inspect/src/components/audit/StateTree.tsx b/apps/kimi-inspect/src/components/audit/StateTree.tsx index f85ca171895..66fd4f69ed1 100644 --- a/apps/kimi-inspect/src/components/audit/StateTree.tsx +++ b/apps/kimi-inspect/src/components/audit/StateTree.tsx @@ -1,7 +1,7 @@ /** * Diff-aware state tree for the audit panel. * - * Renders a serialized `AgentState` (see `audit/serialize.ts`) as a + * Renders a serialized `ChatState` (see `audit/serialize.ts`) as a * collapsible tree, colored by the structural diff against the previous * trail entry: added = green, removed = red + strikethrough, modified = * amber (`old → new` on leaves). Every field is rendered — long strings diff --git a/apps/kimi-inspect/src/transcript/api.ts b/apps/kimi-inspect/src/transcript/api.ts index 7aa54cee855..69e6d9582d1 100644 --- a/apps/kimi-inspect/src/transcript/api.ts +++ b/apps/kimi-inspect/src/transcript/api.ts @@ -1,282 +1,125 @@ /** - * REST client for the transcript page endpoint: - * `GET {baseUrl}/api/v1/sessions/{sessionId}/transcript`. + * REST client for the history endpoint of the message protocol: + * `GET {baseUrl}/api/v1/sessions/{sessionId}/history`. * - * This is the ONLY source of full transcript state: the initial load fetches - * the newest page, a full refresh re-reads page by page from the tail - * backwards, and "load earlier" pages further with a `before_turn` cursor. - * (The WS channel, by contrast, carries incremental `transcript.ops` only.) + * This is the ONLY source of persisted (completed) timeline state: the + * initial load fetches the newest page, "load earlier" pages further with a + * `before_turn` cursor, and a reconnect catch-up pages forward from an + * `after_step` cursor. The in-flight step's entities arrive over the WS + * recovery payload instead (idempotent replace-by-id at the seam). * - * Pages are turn-segment slices keyed by a turn-id cursor (`before_turn` - * pages towards older turns). The response is validated with the - * package-owned `transcriptResponseSchema` — the schema is the single source - * of truth for the wire shape, local code consumes the domain model types. + * Pages are flat entity-message slices (`{ messages, in_flight? }`, + * time-ordered, same schemas as the WS stream). There is deliberately no + * has-more flag: a page shorter than `page_size` is the end in that + * direction, an empty page is definitive. */ -import { - transcriptOpsCatchupResponseSchema, - transcriptPlanResponseSchema, - transcriptResponseSchema, - type AttachmentSource, - type TranscriptAttachment, - type TranscriptInteraction, - type TranscriptItem, - type TranscriptMeta, - type TranscriptOperation, - type TranscriptTask, - type TranscriptTodo, -} from '@moonshot-ai/transcript'; +import { historyResponseSchema, type HistoryMessage } from '@moonshot-ai/kap-server/protocol'; -type StoredAttachmentSource = Extract; +export const HISTORY_PAGE_SIZE = 500; -export interface FetchTranscriptAttachmentOptions { - readonly baseUrl: string; - readonly token?: string; - readonly sessionId: string; - readonly source: StoredAttachmentSource; - readonly fetchImpl?: typeof fetch; -} - -export function transcriptAttachmentUrl( - baseUrl: string, - sessionId: string, - source: AttachmentSource, -): string { - if (source.kind === 'url') return source.url; - if (source.kind === 'file') { - return `${baseUrl}/api/v1/files/${encodeURIComponent(source.fileId)}`; - } - return `${baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/media/${encodeURIComponent(source.fileId)}`; -} - -export async function fetchTranscriptAttachment( - opts: FetchTranscriptAttachmentOptions, -): Promise { - const headers: Record = {}; - if (opts.token !== undefined && opts.token !== '') { - headers['authorization'] = `Bearer ${opts.token}`; - } - const doFetch = opts.fetchImpl ?? fetch; - const res = await doFetch( - transcriptAttachmentUrl(opts.baseUrl, opts.sessionId, opts.source), - { headers }, - ); - if (!res.ok) throw new Error(`attachment download failed (${res.status})`); - return res.blob(); -} - -/** One transcript page as merged by the chat store. */ -export interface TranscriptPage { - readonly items: readonly TranscriptItem[]; - /** `has_more` in the query direction — more older turns exist. */ - readonly hasMoreOlder: boolean; - /** Global, unpaginated state (every response carries the current whole). */ - readonly tasks: readonly TranscriptTask[]; - readonly interactions: readonly TranscriptInteraction[]; - readonly attachments: readonly TranscriptAttachment[]; - readonly todos: readonly TranscriptTodo[]; - readonly meta: TranscriptMeta; - readonly pendingInteractions: readonly string[]; - /** Op-batch watermark (state includes every batch with seq <= N); absent on legacy servers. */ - readonly seq?: number | undefined; +export interface HistoryPage { + readonly messages: readonly HistoryMessage[]; + /** Current streaming position of a live session; absent for idle/cold ones. */ + readonly inFlight?: { turn_id: string; step_id: string }; } -/** One turn per page: fine-grained paging — the viewport grows a turn at a time. */ -export const TRANSCRIPT_PAGE_SIZE = 1; - -export interface FetchTranscriptPageOptions { +export interface FetchHistoryPageOptions { readonly baseUrl: string; - readonly token?: string | undefined; + readonly token?: string; readonly sessionId: string; readonly agentId: string; - /** Turn-id cursor; when set, fetches up to `pageSize` segments strictly older. */ - readonly beforeTurn?: string | undefined; - readonly pageSize?: number | undefined; + /** Turn-id cursor; fetches up to `pageSize` messages strictly older than that turn. */ + readonly beforeTurn?: string; + /** Step-id cursor; fetches up to `pageSize` messages strictly newer than that step. */ + readonly afterStep?: string; + readonly pageSize?: number; /** Injectable for tests. */ readonly fetchImpl?: typeof fetch; } -export async function fetchTranscriptPage( - opts: FetchTranscriptPageOptions, -): Promise { +export async function fetchHistoryPage(opts: FetchHistoryPageOptions): Promise { const params = new URLSearchParams({ agent_id: opts.agentId, - page_size: String(opts.pageSize ?? TRANSCRIPT_PAGE_SIZE), + page_size: String(opts.pageSize ?? HISTORY_PAGE_SIZE), }); if (opts.beforeTurn !== undefined) params.set('before_turn', opts.beforeTurn); + if (opts.afterStep !== undefined) params.set('after_step', opts.afterStep); const headers: Record = {}; if (opts.token !== undefined && opts.token !== '') { headers['authorization'] = `Bearer ${opts.token}`; } const doFetch = opts.fetchImpl ?? fetch; const res = await doFetch( - `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/transcript?${params.toString()}`, + `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/history?${params.toString()}`, { headers }, ); const envelope = (await res.json()) as { code: number; msg: string; data: unknown }; if (envelope.code !== 0) { - throw new Error(`transcript page failed (${envelope.code}): ${envelope.msg}`); + throw new Error(`history page failed (${envelope.code}): ${envelope.msg}`); } - const parsed = transcriptResponseSchema.safeParse(envelope.data); + const parsed = historyResponseSchema.safeParse(envelope.data); if (!parsed.success) { - throw new Error('transcript page: unexpected response shape'); + throw new Error('history page: unexpected response shape'); } - const items: readonly TranscriptItem[] = parsed.data.items; - const tasks: readonly TranscriptTask[] = parsed.data.tasks; - const interactions: readonly TranscriptInteraction[] = parsed.data.interactions; - const attachments: readonly TranscriptAttachment[] = parsed.data.attachments; - const todos: readonly TranscriptTodo[] = parsed.data.todos; - return { - items, - hasMoreOlder: parsed.data.has_more, - tasks, - interactions, - attachments, - todos, - meta: parsed.data.meta, - pendingInteractions: parsed.data.pending_interactions, - seq: parsed.data.seq, - }; -} - -// ---------------------------------------------------------------- ops catch-up - -/** One sequenced op batch from the catch-up endpoint. */ -export interface TranscriptOpBatch { - readonly seq: number; - readonly ops: readonly TranscriptOperation[]; -} - -export interface TranscriptOpsCatchup { - readonly batches: readonly TranscriptOpBatch[]; - readonly latestSeq: number; - /** False = the journal cannot cover `sinceSeq`; the caller must full-refresh. */ - readonly complete: boolean; -} - -export interface FetchTranscriptOpsOptions { - readonly baseUrl: string; - readonly token?: string | undefined; - readonly sessionId: string; - readonly agentId: string; - /** Return journaled batches with seq strictly greater than this watermark. */ - readonly sinceSeq: number; - /** Injectable for tests. */ - readonly fetchImpl?: typeof fetch; + return { messages: parsed.data.messages, inFlight: parsed.data.in_flight }; } /** - * Point-to-point catch-up: `GET .../transcript/ops?agent_id=&since_seq=N`. - * Available on sequenced servers; a 404/envelope error means the server - * predates the endpoint and the caller should fall back to a full refresh. + * Read the agent's WHOLE history (newest page + `before_turn` paging to the + * beginning) in timeline order. On-demand debug reads only (plan lookup) — + * the chat channel pages lazily instead. */ -export async function fetchTranscriptOps( - opts: FetchTranscriptOpsOptions, -): Promise { - const params = new URLSearchParams({ - agent_id: opts.agentId, - since_seq: String(opts.sinceSeq), - }); - const headers: Record = {}; - if (opts.token !== undefined && opts.token !== '') { - headers['authorization'] = `Bearer ${opts.token}`; - } - const doFetch = opts.fetchImpl ?? fetch; - const res = await doFetch( - `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/transcript/ops?${params.toString()}`, - { headers }, - ); - const envelope = (await res.json()) as { code: number; msg: string; data: unknown }; - if (envelope.code !== 0) { - throw new Error(`transcript ops failed (${envelope.code}): ${envelope.msg}`); - } - const parsed = transcriptOpsCatchupResponseSchema.safeParse(envelope.data); - if (!parsed.success) { - throw new Error('transcript ops: unexpected response shape'); - } - return { - batches: parsed.data.batches, - latestSeq: parsed.data.latest_seq, - complete: parsed.data.complete, - }; -} - -// ------------------------------------------------------------------ plan lookup - -/** The review round-trip of one ExitPlanMode call, from the plan endpoint. */ -export interface TranscriptPlanReview { - readonly state: 'pending' | 'approved' | 'rejected' | 'cancelled'; - readonly selectedOption?: string | undefined; - readonly feedback?: string | undefined; -} - -/** Plan information of one ExitPlanMode tool call (`GET .../transcript/plan`). */ -export interface TranscriptPlanInfo { - readonly toolCallId: string; - readonly turnId: string; - /** Which fact the content was projected from server-side. */ - readonly source: 'interaction' | 'display' | 'output'; - readonly plan: string; - readonly path?: string | undefined; - readonly options?: readonly { label: string; description?: string | undefined }[] | undefined; - readonly review?: TranscriptPlanReview | undefined; -} - -export interface FetchTranscriptPlanOptions { +export async function fetchFullHistory(opts: { readonly baseUrl: string; - readonly token?: string | undefined; + readonly token?: string; readonly sessionId: string; readonly agentId: string; - /** Narrow the read to one ExitPlanMode call; omitted lists every plan of the agent. */ - readonly toolCallId?: string | undefined; - /** Injectable for tests. */ + readonly pageSize?: number; readonly fetchImpl?: typeof fetch; +}): Promise { + const pageSize = opts.pageSize ?? HISTORY_PAGE_SIZE; + const messages: HistoryMessage[] = []; + const seen = new Set(); + let beforeTurn: string | undefined; + for (;;) { + const page = await fetchHistoryPage({ ...opts, beforeTurn, pageSize }); + if (page.messages.length === 0) break; + const fresh: HistoryMessage[] = []; + for (const message of page.messages) { + const key = historyEntityKey(message); + if (seen.has(key)) continue; + seen.add(key); + fresh.push(message); + } + messages.unshift(...fresh); + if (page.messages.length < pageSize) break; + const oldest = page.messages.find((message) => 'turn_id' in message)?.turn_id; + if (oldest === undefined || oldest === beforeTurn) break; + beforeTurn = oldest; + } + return messages; } -/** - * Plan lookup: `GET .../transcript/plan?agent_id=[&tool_call_id=]`, in - * timeline order. With `toolCallId` set, a 40416 envelope means the tool - * call does not exist or is not an ExitPlanMode call (the message says - * which). - */ -export async function fetchTranscriptPlan( - opts: FetchTranscriptPlanOptions, -): Promise { - const params = new URLSearchParams({ agent_id: opts.agentId }); - if (opts.toolCallId !== undefined && opts.toolCallId !== '') { - params.set('tool_call_id', opts.toolCallId); - } - const headers: Record = {}; - if (opts.token !== undefined && opts.token !== '') { - headers['authorization'] = `Bearer ${opts.token}`; - } - const doFetch = opts.fetchImpl ?? fetch; - const res = await doFetch( - `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}/transcript/plan?${params.toString()}`, - { headers }, - ); - const envelope = (await res.json()) as { code: number; msg: string; data: unknown }; - if (envelope.code !== 0) { - throw new Error(`transcript plan failed (${envelope.code}): ${envelope.msg}`); - } - const parsed = transcriptPlanResponseSchema.safeParse(envelope.data); - if (!parsed.success) { - throw new Error('transcript plan: unexpected response shape'); +function historyEntityKey(message: HistoryMessage): string { + switch (message.type) { + case 'turn': + return `turn:${message.turn_id}`; + case 'step': + return `step:${message.step_id}`; + case 'user': + case 'assistant': + case 'thinking': + return `${message.type}:${message.message_id}`; + case 'tool_call': + return `tool_call:${message.tool_call_id}`; + case 'system': + return `system:${message.system_id}`; + case 'interaction': + return `interaction:${message.interaction_id}`; + case 'task': + return `task:${message.task_id}`; + case 'todo': + return `todo:${message.todo_id}`; } - return parsed.data.plans.map((entry) => ({ - toolCallId: entry.tool_call_id, - turnId: entry.turn_id, - source: entry.source, - plan: entry.plan, - path: entry.path, - options: entry.options, - review: - entry.review === undefined - ? undefined - : { - state: entry.review.state, - selectedOption: entry.review.selected_option, - feedback: entry.review.feedback, - }, - })); } diff --git a/apps/kimi-inspect/src/transcript/channel.ts b/apps/kimi-inspect/src/transcript/channel.ts new file mode 100644 index 00000000000..8e4b2f86142 --- /dev/null +++ b/apps/kimi-inspect/src/transcript/channel.ts @@ -0,0 +1,271 @@ +/** + * Chat channel — owns the `ChatStore`, the `AuditTrail`, the REST history + * pipeline and the `/api/v3/ws` subscription for one (session, agent) pair. + * + * Recovery per the protocol, all of it converging through idempotent + * replace-by-id upserts (no buffering, no cursors beyond the two REST + * page cursors, no reset frames): + * + * - Initial load / full refresh: newest REST history page (`replace`), + * then re-cover the previously loaded window with `before_turn` pages. + * - Live + recovery payload: every WS message is applied to the store + * as it lands; recovery and live are the same path. + * - Subscribe ack (initial and every reconnect): `after_step` catch-up + * anchored at the newest TERMINAL step (the server answers with the + * slice after that step's last entity, so the step that was streaming + * at disconnect is re-read in full; overlap is idempotent). An empty + * catch-up is verified against the newest page — if the anchor itself + * is gone (undo/clear while disconnected), fall back to a full refresh. + * - `in_flight` on a history response means the WS replay re-sends that + * step's entities from the start; nothing to do but let them land. + */ + +import type { WsLikeCtor } from '../channel/wsLike'; +import { AuditTrail } from '../audit/trail'; +import { fetchHistoryPage, HISTORY_PAGE_SIZE, type HistoryPage } from './api'; +import { + ChatStore, + newestTerminalStepId, + oldestTurnId, + recoverLoadedWindow, +} from './store'; +import { ChatWs } from './ws'; + +export interface ChatChannelOptions { + readonly baseUrl: string; + readonly token?: string; + readonly sessionId: string; + readonly agentId: string; + readonly pageSize?: number; + readonly WebSocketImpl?: WsLikeCtor; + readonly fetchImpl?: typeof fetch; + readonly reconnectDelayMs?: number; + readonly notifyIntervalMs?: number; + /** Fired before a replace-mode refresh drops the current window (scroll anchor hook). */ + readonly onWillReplace?: () => void; + readonly onLoaded?: () => void; + readonly onLoadError?: (error: unknown) => void; +} + +export class ChatChannel { + readonly store: ChatStore; + readonly trail: AuditTrail; + + private readonly opts: ChatChannelOptions; + private readonly pageSize: number; + private readonly ws: ChatWs; + private queue: Promise = Promise.resolve(); + private refreshQueued = false; + private catchUpQueued = false; + private disposed = false; + + constructor(opts: ChatChannelOptions) { + this.opts = opts; + this.pageSize = opts.pageSize ?? HISTORY_PAGE_SIZE; + this.store = new ChatStore({ notifyIntervalMs: opts.notifyIntervalMs }); + this.trail = new AuditTrail(); + this.ws = new ChatWs({ + url: opts.baseUrl, + token: opts.token, + sessionId: opts.sessionId, + agentIds: [opts.agentId], + WebSocketImpl: opts.WebSocketImpl, + reconnectDelayMs: opts.reconnectDelayMs, + handlers: { + onMessage: (message) => { + this.store.applyLive(message); + this.trail.recordWs(message, this.store.getState()); + }, + onAck: (code, msg) => { + if (code === 0) { + this.trail.recordEvent('ack', undefined, this.store.getState()); + this.scheduleCatchUp(); + return; + } + this.trail.recordEvent('ack-error', msg, this.store.getState()); + this.opts.onLoadError?.(new Error(`subscribe rejected (${code}): ${msg ?? ''}`)); + }, + onProtocolError: (code, msg) => { + this.trail.recordEvent('protocol-error', `${code}: ${msg}`, this.store.getState()); + }, + onInvalidFrame: () => { + this.trail.recordEvent('invalid-frame', undefined, this.store.getState()); + }, + onReconnectScheduled: () => { + this.trail.recordEvent('reconnect', undefined, this.store.getState()); + }, + }, + }); + } + + /** Kick the initial load (the socket is already connecting). */ + start(): void { + this.scheduleRefresh(); + } + + /** Page one older slice into the window (`before_turn`); rejects on fetch failure. */ + async loadOlder(): Promise { + const oldest = oldestTurnId(this.store.getState().entries); + if (oldest === undefined) return; + const page = await this.fetchPage({ beforeTurn: oldest }); + if (this.disposed) return; + this.store.applyHistoryPage(page.messages, 'prepend'); + this.store.setHasMoreOlder(page.messages.length === this.pageSize); + this.trail.recordRest( + { beforeTurn: oldest, pageSize: this.pageSize }, + 'prepend', + page.messages.length, + page.inFlight, + this.store.getState(), + ); + } + + /** Force a WS reconnect (debug/testing): the ack re-triggers the after_step catch-up. */ + reconnect(delayMs = 0): void { + this.ws.reconnect(delayMs); + } + + close(): void { + this.disposed = true; + this.ws.close(); + this.store.flushNotify(); + } + + private scheduleRefresh(): void { + if (this.refreshQueued) return; + this.refreshQueued = true; + this.enqueue(async () => { + this.refreshQueued = false; + await this.doRefresh(); + }); + } + + private scheduleCatchUp(): void { + if (this.catchUpQueued) return; + this.catchUpQueued = true; + this.enqueue(async () => { + this.catchUpQueued = false; + await this.doCatchUp(); + }); + } + + private enqueue(task: () => Promise): void { + this.queue = this.queue.then(task).catch(() => {}); + } + + private async doRefresh(): Promise { + const prevOldest = oldestTurnId(this.store.getState().entries); + if (prevOldest !== undefined) this.opts.onWillReplace?.(); + try { + const page = await this.fetchPage({}); + if (this.disposed) return; + this.store.applyHistoryPage(page.messages, 'replace'); + this.store.setHasMoreOlder(page.messages.length === this.pageSize); + this.trail.recordRest( + { pageSize: this.pageSize }, + 'replace', + page.messages.length, + page.inFlight, + this.store.getState(), + ); + await recoverLoadedWindow( + this.store, + prevOldest, + async (beforeTurn) => { + const older = await this.fetchPage({ beforeTurn }); + if (this.disposed) return []; + this.store.setHasMoreOlder(older.messages.length === this.pageSize); + return older.messages; + }, + () => this.disposed, + (beforeTurn, messages) => { + this.trail.recordRest( + { beforeTurn, pageSize: this.pageSize }, + 'prepend', + messages.length, + undefined, + this.store.getState(), + ); + }, + ); + if (!this.disposed) this.opts.onLoaded?.(); + } catch (error) { + if (!this.disposed) this.opts.onLoadError?.(error); + } + } + + private async doCatchUp(): Promise { + const anchor = newestTerminalStepId(this.store.getState().entries); + if (anchor === undefined) { + this.scheduleRefresh(); + return; + } + let cursor = anchor; + for (;;) { + let page: HistoryPage; + try { + page = await this.fetchPage({ afterStep: cursor }); + } catch (error) { + if (!this.disposed) this.opts.onLoadError?.(error); + return; + } + if (this.disposed) return; + if (page.messages.length === 0) { + let probe: HistoryPage; + try { + probe = await this.fetchPage({}); + } catch { + return; + } + if (this.disposed) return; + if (!anchorAliveInPage(probe.messages, cursor)) { + this.trail.recordEvent( + 'catchup-refresh', + `anchor ${cursor} no longer exists`, + this.store.getState(), + ); + this.scheduleRefresh(); + } + return; + } + this.store.applyHistoryPage(page.messages, 'tail'); + this.trail.recordRest( + { afterStep: cursor, pageSize: this.pageSize }, + 'tail', + page.messages.length, + page.inFlight, + this.store.getState(), + ); + if (page.messages.length < this.pageSize) return; + const next = newestTerminalStepId(this.store.getState().entries); + if (next === undefined || next === cursor) return; + cursor = next; + } + } + + private fetchPage(cursor: { + beforeTurn?: string; + afterStep?: string; + pageSize?: number; + }): Promise { + return fetchHistoryPage({ + baseUrl: this.opts.baseUrl, + token: this.opts.token, + sessionId: this.opts.sessionId, + agentId: this.opts.agentId, + beforeTurn: cursor.beforeTurn, + afterStep: cursor.afterStep, + pageSize: cursor.pageSize ?? this.pageSize, + fetchImpl: this.opts.fetchImpl, + }); + } +} + +function anchorAliveInPage(messages: HistoryPage['messages'], cursor: string): boolean { + const cursorTurn = cursor.split('.')[0]!; + return messages.some( + (message) => + ('step_id' in message && message.step_id === cursor) || + ('turn_id' in message && message.turn_id === cursorTurn), + ); +} diff --git a/apps/kimi-inspect/src/transcript/plan.ts b/apps/kimi-inspect/src/transcript/plan.ts new file mode 100644 index 00000000000..f73b70de335 --- /dev/null +++ b/apps/kimi-inspect/src/transcript/plan.ts @@ -0,0 +1,189 @@ +/** + * Plan derivation from the message stream — the new-protocol replacement + * for the removed `GET /transcript/plan` endpoint. + * + * Under the message protocol there is no plan lookup endpoint; the data + * lives in the timeline itself: the EnterPlanMode/ExitPlanMode tool calls, + * the approval interaction that carries the review (its + * `request.tool_input_display` holds the `plan_review` display payload with + * the plan content, path and offered options; its `response` holds the + * decision, selected label and feedback), and the `system(plan.revision)` + * version marker (its payload path points at the plan document). + * `session.state.modes.plan` mirrors the current mode/revision over the WS + * but is not part of REST history, so derivation here runs purely over a + * history message list (in timeline order). + */ + +import type { + HistoryMessage, + InteractionMessage, + ToolCallMessage, +} from '@moonshot-ai/kap-server/protocol'; + +export interface PlanReview { + readonly state: 'pending' | 'approved' | 'rejected' | 'cancelled'; + readonly selectedOption?: string; + readonly feedback?: string; +} + +export interface PlanInfo { + readonly toolCallId: string; + readonly turnId: string; + /** Which message the content was derived from. */ + readonly source: 'interaction' | 'display' | 'output'; + readonly plan: string; + readonly path?: string; + readonly options?: readonly { label: string; description?: string }[]; + readonly review?: PlanReview; +} + +export function projectPlans( + messages: readonly HistoryMessage[], + toolCallId?: string, +): PlanInfo[] { + const interactions: InteractionMessage[] = []; + const revisionPaths: string[] = []; + for (const message of messages) { + if (message.type === 'interaction') interactions.push(message); + if (message.type === 'system' && message.subtype === 'plan.revision') { + const path = readRevisionPath(message.payload); + if (path !== undefined) revisionPaths.push(path); + } + } + const plans: PlanInfo[] = []; + for (const message of messages) { + if (message.type !== 'tool_call' || message.name !== 'ExitPlanMode') continue; + if (toolCallId !== undefined && message.tool_call_id !== toolCallId) continue; + const info = projectPlanCall(message, interactions); + if (info === undefined) continue; + plans.push( + info.path === undefined && revisionPaths.length > 0 + ? { ...info, path: revisionPaths.at(-1) } + : info, + ); + } + return plans; +} + +function projectPlanCall( + call: ToolCallMessage, + interactions: readonly InteractionMessage[], +): PlanInfo | undefined { + const interaction = interactions.find( + (candidate) => + candidate.kind === 'approval' && + (candidate.interaction_id === call.approval_id || + (call.approval_id === undefined && candidate.tool_call_id === call.tool_call_id)), + ); + const review = readPlanReview(interaction); + if (interaction !== undefined && interaction.kind === 'approval') { + const fromInteraction = readPlanReviewDisplay(interaction.request?.tool_input_display); + if (fromInteraction !== undefined) { + return { + toolCallId: call.tool_call_id, + turnId: call.turn_id, + source: 'interaction', + ...fromInteraction, + review, + }; + } + } + const fromDisplay = readPlanReviewDisplay(call.display); + if (fromDisplay !== undefined) { + return { + toolCallId: call.tool_call_id, + turnId: call.turn_id, + source: 'display', + ...fromDisplay, + review, + }; + } + const fromOutput = parsePlanFromOutput(call.output); + if (fromOutput !== undefined) { + return { + toolCallId: call.tool_call_id, + turnId: call.turn_id, + source: 'output', + ...fromOutput, + review, + }; + } + return undefined; +} + +function readPlanReview(interaction: InteractionMessage | undefined): PlanReview | undefined { + if (interaction === undefined || interaction.kind !== 'approval') return undefined; + const state = interaction.state; + if (state !== 'pending' && state !== 'approved' && state !== 'rejected' && state !== 'cancelled') { + return undefined; + } + const response = interaction.response; + const selected = + typeof response?.selected_label === 'string' && response.selected_label.length > 0 + ? response.selected_label + : undefined; + const feedback = + typeof response?.feedback === 'string' && response.feedback.length > 0 + ? response.feedback + : undefined; + return { state, selectedOption: selected, feedback }; +} + +interface PlanReviewDisplayInfo { + readonly plan: string; + readonly path?: string; + readonly options?: readonly { label: string; description?: string }[]; +} + +function readPlanReviewDisplay(display: unknown): PlanReviewDisplayInfo | undefined { + if (display === null || typeof display !== 'object') return undefined; + const d = display as { kind?: unknown; plan?: unknown; path?: unknown; options?: unknown }; + if (d.kind !== 'plan_review' || typeof d.plan !== 'string' || d.plan.trim().length === 0) { + return undefined; + } + const options = Array.isArray(d.options) + ? d.options + .map((option: unknown): { label: string; description?: string } | null => { + if (option === null || typeof option !== 'object') return null; + const o = option as { label?: unknown; description?: unknown }; + if (typeof o.label !== 'string' || o.label.length === 0) return null; + return { + label: o.label, + description: typeof o.description === 'string' ? o.description : undefined, + }; + }) + .filter((o): o is { label: string; description?: string } => o !== null) + : undefined; + return { + plan: d.plan, + path: typeof d.path === 'string' ? d.path : undefined, + options: options !== undefined && options.length > 0 ? options : undefined, + }; +} + +function readRevisionPath(payload: unknown): string | undefined { + if (payload === null || typeof payload !== 'object') return undefined; + const path = (payload as { path?: unknown }).path; + return typeof path === 'string' && path.length > 0 ? path : undefined; +} + +const PLAN_SAVED_TO_MARKER = 'Plan saved to: '; +const PLAN_BODY_MARKERS = ['## Approved Plan:\n', '## Plan (auto-approved, not user-reviewed):\n']; + +function parsePlanFromOutput(output: unknown): { plan: string; path?: string } | undefined { + if (typeof output !== 'string') return undefined; + let path: string | undefined; + for (const line of output.split('\n')) { + if (line.startsWith(PLAN_SAVED_TO_MARKER)) { + path = line.slice(PLAN_SAVED_TO_MARKER.length).trim() || undefined; + break; + } + } + for (const marker of PLAN_BODY_MARKERS) { + const index = output.indexOf(marker); + if (index === -1) continue; + const plan = output.slice(index + marker.length); + if (plan.trim().length > 0) return { plan, path }; + } + return undefined; +} diff --git a/apps/kimi-inspect/src/transcript/store.ts b/apps/kimi-inspect/src/transcript/store.ts index 6b9ed94fc49..da560579cfd 100644 --- a/apps/kimi-inspect/src/transcript/store.ts +++ b/apps/kimi-inspect/src/transcript/store.ts @@ -1,115 +1,181 @@ /** - * Per-(session, agent) transcript state for the chat view. + * Per-(session, agent) chat state for the message protocol v3. * - * A thin observable wrapper over the package's L1 convergence path - * (`applyOperation` on an `AgentState`) — the reducer is NOT re-implemented - * here. State arrives through exactly two channels: + * The store is a deliberately thin reflection of the wire: every entity + * message upserts by (type, own id) with its content fields as the + * authoritative whole (replace-by-id), the delta family + * (`assistant.delta` / `thinking.delta` / `tool_call.delta`) appends to the + * already-existing entity (an entity always precedes its deltas on the + * stream; an orphan delta is dropped — the entity's next upsert carries the + * cumulative content anyway), and `tool.progress` patches the entity's + * latest-progress field. Recovery payloads and live traffic are applied + * through the exact same path — idempotent overwrite makes them + * indistinguishable, so there is no reset/buffer/cursor machinery at all. * - * - REST pages (`applyPage`): the only source of FULL state. A `replace` - * page (initial load / full refresh) is the newest slice and replaces - * local state wholesale, globals included; a non-replace page is an older - * slice fetched with `before_turn` and prepended ahead of the loaded - * window (items only — globals stay with the fresher live state). - * - WS delta ops (`applyOps`): incremental `transcript.ops` only. Ops are - * idempotent upserts plus offset-placed appends, so ops buffered while a - * REST refresh is in flight converge when flushed onto the fresh pages. + * `system(undo)` / `system(clear)` land on the timeline in place AND + * truncate it: every entry whose own id is in `payload.removed_ids` is + * dropped together with its subtree (all entries carrying that turn_id), + * and interactions anchored at a removed tool call are cascaded out. * - * `onGap` surfaces `append` placement gaps so the caller can trigger a full - * REST refresh (the WS channel carries no snapshots to fall back on). + * State entities have one channel each: `interaction` / `task` / `todo` + * upsert into keyed maps, `session.state` replaces the single latest + * snapshot. Global messages (workspace/session/config/…) are not consumed + * by this store. + * + * An upsert whose `timestamp` is strictly older than the held entity's is + * skipped: a REST page folded before a live update must not rewind it. + * + * Notifications are trailing-edge throttled (`notifyIntervalMs`) so a + * per-token delta stream does not become a per-token React render; state + * reads (`getState`) always see the latest applied message regardless. */ -import { - applyOperation, - EMPTY_AGENT_STATE, - itemId, - type AgentState, - type TranscriptItem, - type TranscriptOperation, -} from '@moonshot-ai/transcript'; - -import type { TranscriptPage } from './api'; - -export function countTurns(items: readonly TranscriptItem[]): number { - let count = 0; - for (const item of items) if (item.kind === 'turn') count += 1; - return count; +import type { + AssistantMessage, + HistoryMessage, + InteractionMessage, + ServerMessage, + SessionStateMessage, + SystemMessage, + TaskMessage, + ThinkingMessage, + TodoMessage, + ToolCallMessage, +} from '@moonshot-ai/kap-server/protocol'; + +export type TimelineMessage = + | Extract + | Extract + | Extract + | AssistantMessage + | ThinkingMessage + | ToolCallMessage + | SystemMessage; + +export interface TimelineEntry { + readonly key: string; + readonly message: TimelineMessage; +} + +export interface ChatState { + readonly entries: readonly TimelineEntry[]; + readonly interactions: ReadonlyMap; + readonly tasks: ReadonlyMap; + readonly todos: ReadonlyMap; + readonly sessionState: SessionStateMessage | undefined; + readonly hasMoreOlder: boolean; +} + +export const EMPTY_CHAT_STATE: ChatState = { + entries: [], + interactions: new Map(), + tasks: new Map(), + todos: new Map(), + sessionState: undefined, + hasMoreOlder: false, +}; + +export type HistoryPageMode = 'replace' | 'prepend' | 'tail'; + +export function timelineKeyOf(message: TimelineMessage): string { + switch (message.type) { + case 'turn': + return `turn:${message.turn_id}`; + case 'step': + return `step:${message.step_id}`; + case 'user': + case 'assistant': + case 'thinking': + return `${message.type}:${message.message_id}`; + case 'tool_call': + return `tool_call:${message.tool_call_id}`; + case 'system': + return `system:${message.system_id}`; + } +} + +function ownIdOf(message: TimelineMessage): string { + switch (message.type) { + case 'turn': + return message.turn_id; + case 'step': + return message.step_id; + case 'user': + case 'assistant': + case 'thinking': + return message.message_id; + case 'tool_call': + return message.tool_call_id; + case 'system': + return message.system_id; + } +} + +export function turnIdOf(message: TimelineMessage): string | undefined { + return message.type === 'system' ? undefined : message.turn_id; } -export function oldestTurnId(items: readonly TranscriptItem[]): string | undefined { - for (const item of items) if (item.kind === 'turn') return item.turnId; +export function oldestTurnId(entries: readonly TimelineEntry[]): string | undefined { + for (const entry of entries) { + const turnId = turnIdOf(entry.message); + if (turnId !== undefined) return turnId; + } return undefined; } -export function hasTurnId(items: readonly TranscriptItem[], turnId: string): boolean { - return items.some((item) => item.kind === 'turn' && item.turnId === turnId); +export function hasTurnId(entries: readonly TimelineEntry[], turnId: string): boolean { + return entries.some((entry) => turnIdOf(entry.message) === turnId); +} + +export function newestTerminalStepId(entries: readonly TimelineEntry[]): string | undefined { + for (let i = entries.length - 1; i >= 0; i -= 1) { + const message = entries[i]!.message; + if (message.type === 'step' && message.state !== 'running') return message.step_id; + } + return undefined; } /** - * Re-cover a previously loaded window after a full refresh: page backwards - * until `prevOldestTurnId` (the window's oldest turn before the refresh) is - * loaded again. A count-based stop silently drops the window's head when new - * turns arrived meanwhile (the server window shifted, so the same count no - * longer reaches as far back). Stops at the oldest available page - * (`hasMoreOlder` false), on a no-progress page, or when `isDisposed`. + * Re-cover a previously loaded window after a replace-mode refresh: page + * backwards until `prevOldestTurnId` is loaded again (a count-based stop + * silently drops the window's head when new turns arrived meanwhile). Stops + * at the oldest available page, on a no-progress page, or when `isDisposed`. */ export async function recoverLoadedWindow( - store: TranscriptChatStore, + store: ChatStore, prevOldestTurnId: string | undefined, - fetchPage: (beforeTurn: string) => Promise, + fetchPage: (beforeTurn: string) => Promise, isDisposed: () => boolean, - onPageApplied?: (page: TranscriptPage) => void, + onPageApplied?: (beforeTurn: string, messages: readonly HistoryMessage[]) => void, ): Promise { if (prevOldestTurnId === undefined) return; - while (!hasTurnId(store.getState().items, prevOldestTurnId) && store.getState().hasMoreOlder) { - const oldest = oldestTurnId(store.getState().items); + while (!hasTurnId(store.getState().entries, prevOldestTurnId) && store.getState().hasMoreOlder) { + const oldest = oldestTurnId(store.getState().entries); if (oldest === undefined) break; - const before = countTurns(store.getState().items); + const before = store.getState().entries.length; const page = await fetchPage(oldest); if (isDisposed()) return; - store.applyPage(page); - onPageApplied?.(page); - if (countTurns(store.getState().items) === before) break; + store.applyHistoryPage(page, 'prepend'); + onPageApplied?.(oldest, page); + if (store.getState().entries.length === before) break; } } -/** - * Serialize refresh-style triggers: at most one run in flight; a trigger that - * arrives while a run is in flight is coalesced into exactly one follow-up run - * (so a subscribe ack landing mid-load still produces a post-load reconcile - * instead of being dropped). - */ -export function createCoalescedRunner(run: () => Promise): () => void { - let running = false; - let queued = false; - const kick = (): void => { - if (running) { - queued = true; - return; - } - running = true; - void run().finally(() => { - running = false; - if (queued) { - queued = false; - kick(); - } - }); - }; - return kick; -} - -export class TranscriptChatStore { - private state: AgentState = EMPTY_AGENT_STATE; +export class ChatStore { + private state: ChatState = EMPTY_CHAT_STATE; private readonly listeners = new Set<() => void>(); + private readonly notifyIntervalMs: number; + private notifyTimer: ReturnType | undefined; + private dirty = false; - /** Called when an `append` op could not be placed — the caller should refresh. */ - onGap: (() => void) | undefined; + constructor(opts?: { notifyIntervalMs?: number }) { + this.notifyIntervalMs = opts?.notifyIntervalMs ?? 80; + } - getState(): AgentState { + getState(): ChatState { return this.state; } - /** `useSyncExternalStore`-compatible subscribe. */ subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { @@ -117,59 +183,299 @@ export class TranscriptChatStore { }; }; + setHasMoreOlder(flag: boolean): void { + if (this.state.hasMoreOlder === flag) return; + this.state = { ...this.state, hasMoreOlder: flag }; + this.scheduleNotify(); + } + /** - * Merge one REST page. With `replace`, the page is the newest slice and - * becomes the whole state (initial load / full refresh); otherwise it is an - * older slice prepended ahead of the window (deduped by item id), updating - * only `items` and `hasMoreOlder`. + * Merge one REST history page. `replace` installs the page as the whole + * window (entries absent from it are dropped, except ones newer than the + * page's newest timestamp — live traffic that outran the fetch); + * `prepend` inserts the older slice ahead of the window (deduped by key); + * `tail` upserts the catch-up slice in page order. system(undo/clear) + * messages inside a page truncate exactly like live ones. */ - applyPage(page: TranscriptPage, opts?: { replace?: boolean }): void { - if (opts?.replace === true) { - this.state = { - items: page.items, - tasks: new Map(page.tasks.map((task) => [task.taskId, task])), - interactions: new Map( - page.interactions.map((interaction) => [interaction.interactionId, interaction]), - ), - attachments: new Map( - page.attachments.map((attachment) => [attachment.attachmentId, attachment]), - ), - todos: new Map(page.todos.map((todo) => [todo.todoId, todo])), - // The page contract carries no prompt slice yet; prompt.upsert ops - // still accumulate through the shared reducer between refreshes. - prompts: new Map(), - meta: page.meta, - pendingInteractions: new Set(page.pendingInteractions), - hasMoreOlder: page.hasMoreOlder, - }; - this.notify(); + applyHistoryPage(messages: readonly HistoryMessage[], mode: HistoryPageMode): void { + if (mode === 'replace') { + const pageMax = maxTimestamp(messages); + const carried = pageMax === undefined ? [] : this.newerThan(this.state.entries, pageMax); + const next: TimelineEntry[] = []; + const seen = new Set(); + for (const message of messages) { + if (!isTimelineMessage(message)) { + this.applyStateMessage(message); + continue; + } + const key = timelineKeyOf(message); + if (seen.has(key)) continue; + seen.add(key); + next.push(this.preferHeld(key, message)); + } + for (const entry of carried) { + if (!seen.has(entry.key)) next.push(entry); + } + this.state = { ...this.state, entries: next }; + this.applyTruncations(messages); + this.scheduleNotify(); return; } - const existing = new Set(this.state.items.map(itemId)); - const fresh = page.items.filter((item) => !existing.has(itemId(item))); - if (fresh.length === 0 && page.hasMoreOlder === this.state.hasMoreOlder) return; - this.state = { - ...this.state, - items: [...fresh, ...this.state.items], - hasMoreOlder: page.hasMoreOlder, - }; - this.notify(); - } - - /** Apply incremental WS ops; notifies once per changed batch. */ - applyOps(ops: readonly TranscriptOperation[]): void { - let changed = false; - for (const op of ops) { - const result = applyOperation(this.state, op); - if (result.gap !== undefined) this.onGap?.(); - if (!result.changed) continue; - this.state = result.state; - changed = true; + if (mode === 'prepend') { + const existing = new Set(this.state.entries.map((entry) => entry.key)); + const fresh: TimelineEntry[] = []; + for (const message of messages) { + if (!isTimelineMessage(message)) { + this.applyStateMessage(message); + continue; + } + const key = timelineKeyOf(message); + if (existing.has(key)) continue; + existing.add(key); + fresh.push({ key, message }); + } + if (fresh.length > 0) { + this.state = { ...this.state, entries: [...fresh, ...this.state.entries] }; + } + this.applyTruncations(messages); + this.scheduleNotify(); + return; + } + for (const message of messages) this.applyEntity(message); + } + + /** Apply one live (or recovery) WS message; recovery and live share this path. */ + applyLive(message: ServerMessage): void { + switch (message.type) { + case 'assistant.delta': { + this.patchText(`assistant:${message.message_id}`, message.text); + return; + } + case 'thinking.delta': { + this.patchText(`thinking:${message.message_id}`, message.text); + return; + } + case 'tool_call.delta': { + this.patchToolCall(message.tool_call_id, (call) => ({ + ...call, + input_text: (call.input_text ?? '') + message.input_text, + })); + return; + } + case 'tool.progress': { + this.patchToolCall(message.tool_call_id, (call) => ({ ...call, progress: message.progress })); + return; + } + case 'interaction': + case 'task': + case 'todo': + case 'session.state': { + this.applyStateMessage(message); + return; + } + case 'turn': + case 'step': + case 'user': + case 'assistant': + case 'thinking': + case 'tool_call': + case 'system': { + this.applyEntity(message); + return; + } + default: + return; } - if (changed) this.notify(); } - private notify(): void { + /** Flush a pending throttled notification (teardown / explicit sync point). */ + flushNotify(): void { + if (this.notifyTimer !== undefined) { + clearTimeout(this.notifyTimer); + this.notifyTimer = undefined; + } + if (!this.dirty) return; + this.dirty = false; for (const listener of this.listeners) listener(); } + + private applyEntity(message: HistoryMessage): void { + if (!isTimelineMessage(message)) { + this.applyStateMessage(message); + return; + } + const key = timelineKeyOf(message); + const index = this.state.entries.findIndex((entry) => entry.key === key); + if (index < 0) { + this.state = { ...this.state, entries: [...this.state.entries, { key, message }] }; + } else { + const held = this.state.entries[index]!.message; + if (held === message || held.timestamp > message.timestamp) return; + const entries = [...this.state.entries]; + entries[index] = { key, message }; + this.state = { ...this.state, entries }; + } + if (message.type === 'system' && (message.subtype === 'undo' || message.subtype === 'clear')) { + this.truncate(message); + } + this.scheduleNotify(); + } + + private applyStateMessage( + message: InteractionMessage | TaskMessage | TodoMessage | SessionStateMessage, + ): void { + switch (message.type) { + case 'interaction': { + const held = this.state.interactions.get(message.interaction_id); + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + const interactions = new Map([ + ...this.state.interactions, + [message.interaction_id, message] as const, + ]); + this.state = { ...this.state, interactions }; + break; + } + case 'task': { + const held = this.state.tasks.get(message.task_id); + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + const tasks = new Map([...this.state.tasks, [message.task_id, message] as const]); + this.state = { ...this.state, tasks }; + break; + } + case 'todo': { + const held = this.state.todos.get(message.todo_id); + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + const todos = new Map([...this.state.todos, [message.todo_id, message] as const]); + this.state = { ...this.state, todos }; + break; + } + case 'session.state': { + const held = this.state.sessionState; + if (held === message) return; + if (held !== undefined && held.timestamp > message.timestamp) return; + this.state = { ...this.state, sessionState: message }; + break; + } + } + this.scheduleNotify(); + } + + private patchText(key: string, text: string): void { + this.patchEntry(key, (message) => { + if (message.type !== 'assistant' && message.type !== 'thinking') return message; + return { ...message, text: message.text + text }; + }); + } + + private patchToolCall( + toolCallId: string, + patch: (call: ToolCallMessage) => ToolCallMessage, + ): void { + this.patchEntry(`tool_call:${toolCallId}`, (message) => { + if (message.type !== 'tool_call') return message; + return patch(message); + }); + } + + private patchEntry(key: string, patch: (message: TimelineMessage) => TimelineMessage): void { + const index = this.state.entries.findIndex((entry) => entry.key === key); + if (index < 0) return; + const current = this.state.entries[index]!; + const next = patch(current.message); + if (next === current.message) return; + const entries = [...this.state.entries]; + entries[index] = { key, message: next }; + this.state = { ...this.state, entries }; + this.scheduleNotify(); + } + + private applyTruncations(messages: readonly HistoryMessage[]): void { + for (const message of messages) { + if (message.type === 'system' && (message.subtype === 'undo' || message.subtype === 'clear')) { + this.truncate(message); + } + } + } + + private truncate(message: SystemMessage): void { + if (message.subtype !== 'undo' && message.subtype !== 'clear') return; + const removed = new Set(message.payload.removed_ids); + if (removed.size === 0) return; + const removedToolCalls = new Set(); + const entries = this.state.entries.filter((entry) => { + const current = entry.message; + if (removed.has(ownIdOf(current))) { + if (current.type === 'tool_call') removedToolCalls.add(current.tool_call_id); + return false; + } + if (current.type !== 'system' && removed.has(current.turn_id)) { + if (current.type === 'tool_call') removedToolCalls.add(current.tool_call_id); + return false; + } + return true; + }); + let interactions = this.state.interactions; + if (removedToolCalls.size > 0) { + const next = new Map(interactions); + for (const [id, interaction] of next) { + if (interaction.tool_call_id !== undefined && removedToolCalls.has(interaction.tool_call_id)) { + next.delete(id); + } + } + interactions = next; + } + this.state = { ...this.state, entries, interactions }; + } + + private preferHeld(key: string, message: TimelineMessage): TimelineEntry { + const held = this.state.entries.find((entry) => entry.key === key); + if (held !== undefined && held.message.timestamp > message.timestamp) return held; + return { key, message }; + } + + private newerThan(entries: readonly TimelineEntry[], timestamp: string): TimelineEntry[] { + return entries.filter((entry) => entry.message.timestamp > timestamp); + } + + private scheduleNotify(): void { + this.dirty = true; + if (this.notifyIntervalMs <= 0) { + this.flushNotify(); + return; + } + if (this.notifyTimer !== undefined) return; + this.notifyTimer = setTimeout(() => { + this.notifyTimer = undefined; + this.flushNotify(); + }, this.notifyIntervalMs); + this.notifyTimer.unref?.(); + } +} + +function isTimelineMessage( + message: HistoryMessage | ServerMessage, +): message is TimelineMessage { + switch (message.type) { + case 'turn': + case 'step': + case 'user': + case 'assistant': + case 'thinking': + case 'tool_call': + case 'system': + return true; + default: + return false; + } +} + +function maxTimestamp(messages: readonly HistoryMessage[]): string | undefined { + let max: string | undefined; + for (const message of messages) { + if (max === undefined || message.timestamp > max) max = message.timestamp; + } + return max; } diff --git a/apps/kimi-inspect/src/transcript/transcript.test.ts b/apps/kimi-inspect/src/transcript/transcript.test.ts index 507d5c15e94..9aa00702dc6 100644 --- a/apps/kimi-inspect/src/transcript/transcript.test.ts +++ b/apps/kimi-inspect/src/transcript/transcript.test.ts @@ -1,117 +1,182 @@ /** - * Transcript glue-layer tests — the app's own REST/WS/store plumbing. The L2 - * reducer semantics themselves are covered by `@moonshot-ai/transcript`'s own - * test suite and are intentionally not re-tested here. + * Message-protocol glue-layer tests — the app's own REST/WS/store/channel + * plumbing for the v3 protocol. The wire schemas themselves are covered by + * kap-server's contract tests and are intentionally not re-tested here. */ -import { - itemId, - type StepHeader, - type TranscriptOperation, - type TranscriptTurn, - type TurnHeader, - type TurnState, -} from '@moonshot-ai/transcript'; +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 { - fetchTranscriptAttachment, - fetchTranscriptOps, - fetchTranscriptPage, - fetchTranscriptPlan, - transcriptAttachmentUrl, - type TranscriptPage, -} from './api'; -import { - countTurns, - createCoalescedRunner, + ChatStore, + newestTerminalStepId, oldestTurnId, recoverLoadedWindow, - TranscriptChatStore, + type TimelineEntry, } from './store'; -import { TranscriptWs } from './ws'; +import { ChatWs } from './ws'; // ---------------------------------------------------------------- fixtures -function turnHeader(n: number, state: TurnState = 'completed'): TurnHeader { - return { kind: 'turn', turnId: `t${n}`, ordinal: n, state, origin: { kind: 'user' } }; +const T0 = Date.parse('2026-01-01T00:00:00.000Z'); +let tick = 0; + +function ts(offsetMs?: number): string { + tick += 1; + return new Date(T0 + tick * 1000 + (offsetMs ?? 0)).toISOString(); } -function turnItem(n: number): TranscriptTurn { - return { ...turnHeader(n), steps: [] }; +const base = { session_id: 's1', agent_id: 'main' } as const; + +function turnMsg(n: number, state: 'running' | 'completed' = 'completed', at?: string): TurnMessage { + return { + type: 'turn', + ...base, + timestamp: at ?? ts(), + turn_id: `t${n}`, + ordinal: n, + state, + origin: { kind: 'user' }, + }; } -function stepHeader(stepId: string, ordinal: number): StepHeader { - return { kind: 'step', stepId, turnId: stepId.split('.')[0] ?? 't1', ordinal, state: 'running' }; +function stepMsg( + stepId: string, + state: StepMessage['state'] = 'completed', + at?: string, +): 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, + state, + }; } -describe('transcript attachments', () => { - it('maps each attachment locator to its transport route', () => { - expect( - transcriptAttachmentUrl('http://h:1', 's 1', { kind: 'file', fileId: 'f 1' }), - ).toBe('http://h:1/api/v1/files/f%201'); - expect( - transcriptAttachmentUrl('http://h:1', 's 1', { - kind: 'session_media', - fileId: 'f 1', - }), - ).toBe('http://h:1/api/v1/sessions/s%201/media/f%201'); - expect( - transcriptAttachmentUrl('http://h:1', 's1', { - kind: 'url', - url: 'https://example.com/a.png', - }), - ).toBe('https://example.com/a.png'); - }); +function userMsg(stepId: string, text: string, at?: string): UserMessage { + const turnId = stepId.split('.')[0] ?? 't1'; + return { + type: 'user', + ...base, + timestamp: at ?? ts(), + message_id: `${stepId}.u0`, + turn_id: turnId, + step_id: stepId, + text, + status: 'completed', + created_at: at ?? ts(), + }; +} - it('fetches stored attachment bytes with bearer auth', async () => { - const fetchImpl = vi.fn(async () => new Response('media-bytes', { status: 200 })); +function assistantMsg( + stepId: string, + text: string, + status: 'streaming' | 'completed' = 'completed', + at?: string, +): 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, + }; +} - const blob = await fetchTranscriptAttachment({ - baseUrl: 'http://h:1', - token: 'tok', - sessionId: 's1', - source: { kind: 'session_media', fileId: 'f_1' }, - fetchImpl: fetchImpl as typeof fetch, - }); +function toolCallMsg( + stepId: string, + id: string, + overrides: Partial = {}, +): 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', + state: 'running', + ...overrides, + }; +} - expect(fetchImpl).toHaveBeenCalledWith( - 'http://h:1/api/v1/sessions/s1/media/f_1', - { headers: { authorization: 'Bearer tok' } }, - ); - await expect(blob.text()).resolves.toBe('media-bytes'); - }); -}); +function systemMsg( + subtype: SystemMessage['subtype'], + systemId: string, + payload?: unknown, +): SystemMessage { + return { + type: 'system', + ...base, + timestamp: ts(), + system_id: systemId, + subtype, + payload, + } as SystemMessage; +} -const textFrameUpsert = (turnId: string, stepId: string, frameId: string, text: string) => ({ - op: 'frame.upsert' as const, - turnId, - stepId, - frame: { kind: 'text' as const, frameId, role: 'assistant' as const, text }, -}); +function interactionMsg(id: string, toolCallId?: string): InteractionMessage { + return { + type: 'interaction', + ...base, + timestamp: ts(), + interaction_id: id, + kind: 'approval', + state: 'pending', + tool_call_id: toolCallId, + }; +} -const frameAppend = ( - turnId: string, - stepId: string, - frameId: string, - offset: number, - text: string, -) => ({ - op: 'append' as const, - target: { type: 'frame' as const, turnId, stepId, frameId }, - offset, - text, -}); +function taskMsg(id: string, state: TaskMessage['state'] = 'running'): TaskMessage { + return { + type: 'task', + ...base, + timestamp: ts(), + task_id: id, + kind: 'shell', + state, + detached: false, + output_tail: '', + }; +} -const emptyPage = { - tasks: [], - interactions: [], - attachments: [], - todos: [], - meta: {}, - pendingInteractions: [], -} as const; +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' }; @@ -173,42 +238,52 @@ class FakeWs implements WsLike { sentFrames(): Record[] { return this.sent.map((data) => JSON.parse(data) as Record); } + + hello(): void { + this.serverFrame({ + type: 'hello', + protocol_version: '3', + server_id: 'srv', + capabilities: ['step_replay_v1'], + }); + } } -function makeWs(handlers: Partial[0]['handlers']> = {}) { +function makeWs(handlers: Partial[0]['handlers']> = {}) { const seen = { - ops: [] as { - agentId: string; - ops: readonly TranscriptOperation[]; - at?: string; - seq?: number; - }[], - resets: [] as { agentId: string; hasMoreOlder: boolean; at?: string; seq?: number }[], - resyncs: 0, + messages: [] as ServerMessage[], + acks: [] as { code: number; msg?: string }[], + protocolErrors: [] as { code: number; msg: string }[], + invalid: 0, reconnects: 0, }; - const ws = new TranscriptWs({ + const ws = new ChatWs({ url: 'http://h:1', token: 'tok', sessionId: 's1', - agentId: 'main', + agentIds: ['main'], WebSocketImpl: FakeWs, + reconnectDelayMs: 1, handlers: { - onOps: (agentId, ops, meta) => { - seen.ops.push({ agentId, ops, at: meta?.at, seq: meta?.seq }); - handlers.onOps?.(agentId, ops, meta); + onMessage: (message) => { + seen.messages.push(message); + handlers.onMessage?.(message); + }, + onAck: (code, msg) => { + seen.acks.push({ code, msg }); + handlers.onAck?.(code, msg); }, - onReset: (agentId, _snapshot, hasMoreOlder, meta) => { - seen.resets.push({ agentId, hasMoreOlder, at: meta?.at, seq: meta?.seq }); - handlers.onReset?.(agentId, _snapshot, hasMoreOlder, meta); + onProtocolError: (code, msg) => { + seen.protocolErrors.push({ code, msg }); + handlers.onProtocolError?.(code, msg); }, - onResyncRequired: () => { - seen.resyncs += 1; - handlers.onResyncRequired?.(); + onInvalidFrame: () => { + seen.invalid += 1; + handlers.onInvalidFrame?.(null); }, - onReconnected: () => { + onReconnectScheduled: () => { seen.reconnects += 1; - handlers.onReconnected?.(); + handlers.onReconnectScheduled?.(0); }, }, }); @@ -217,416 +292,171 @@ function makeWs(handlers: Partial[0][ // ---------------------------------------------------------------- api -describe('fetchTranscriptPage', () => { +describe('fetchHistoryPage', () => { const pageData = { - agent_id: 'main', - items: [turnItem(1)], - has_more: true, - tasks: [ - { taskId: 'bash-1', kind: 'shell', state: 'running', detached: false, outputTail: 'x' }, - ], - interactions: [], - attachments: [], - todos: [], - meta: { activity: 'turn' }, - agents: [], - pending_interactions: ['apr-1'], - seq: 42, + 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 fetchTranscriptPage({ + 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/transcript?'); + 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=1'); + expect(calls[0]!.url).toContain('page_size=50'); expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' }); - expect(page.hasMoreOlder).toBe(true); - expect(page.items.map((item) => itemId(item))).toEqual(['t1']); - expect(page.tasks.map((task) => task.taskId)).toEqual(['bash-1']); - expect(page.meta.activity).toBe('turn'); - expect(page.pendingInteractions).toEqual(['apr-1']); - expect(page.seq).toBe(42); + expect(page.messages).toHaveLength(1); + expect(page.inFlight).toEqual({ turn_id: 't1', step_id: 't1.2' }); }); - it('throws on a non-zero envelope code', async () => { - const { fetchImpl } = fakeFetch({ code: 40401, msg: 'session not found', data: null }); - await expect( - fetchTranscriptPage({ 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({ agent_id: 'main', items: 'nope' })); - await expect( - fetchTranscriptPage({ baseUrl: 'http://h:1', sessionId: 's1', agentId: 'main', fetchImpl }), - ).rejects.toThrow('unexpected response shape'); - }); -}); - -// ---------------------------------------------------------------- ops catch-up - -describe('fetchTranscriptOps', () => { - const catchupData = { - agent_id: 'main', - batches: [ - { seq: 6, ops: [{ op: 'meta.merge', meta: { activity: 'turn' } }] }, - { seq: 7, ops: [{ op: 'turn.upsert', turn: turnHeader(7, 'running') }] }, - ], - latest_seq: 7, - complete: true, - }; - - it('requests the ops endpoint with since_seq and unwraps batches in order', async () => { - const { calls, fetchImpl } = fakeFetch(okEnvelope(catchupData)); - const res = await fetchTranscriptOps({ + it('sends after_step and omits unset cursors', async () => { + const { calls, fetchImpl } = fakeFetch(okEnvelope({ messages: [], has_more: false })); + await fetchHistoryPage({ baseUrl: 'http://h:1', - token: 'tok', sessionId: 's1', agentId: 'main', - sinceSeq: 5, + afterStep: 't1.3', fetchImpl, }); - expect(calls[0]!.url).toContain('/api/v1/sessions/s1/transcript/ops?'); - expect(calls[0]!.url).toContain('agent_id=main'); - expect(calls[0]!.url).toContain('since_seq=5'); - expect(res.complete).toBe(true); - expect(res.latestSeq).toBe(7); - expect(res.batches.map((batch) => batch.seq)).toEqual([6, 7]); + expect(calls[0]!.url).toContain('after_step=t1.3'); + expect(calls[0]!.url).not.toContain('before_turn'); + expect(calls[0]!.init?.headers).toEqual({}); }); - it('surfaces an incomplete catch-up (journal cannot cover)', async () => { - const { fetchImpl } = fakeFetch( - okEnvelope({ ...catchupData, batches: [], latest_seq: 500, complete: false }), - ); - const res = await fetchTranscriptOps({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - sinceSeq: 5, - fetchImpl, - }); - expect(res.complete).toBe(false); - expect(res.batches).toEqual([]); - }); - - it('throws on a legacy server (envelope error) so callers fall back', async () => { - const { fetchImpl } = fakeFetch({ code: 40404, msg: 'unknown route', data: null }); + it('throws on a non-zero envelope code', async () => { + const { fetchImpl } = fakeFetch({ code: 40401, msg: 'session not found', data: null }); await expect( - fetchTranscriptOps({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - sinceSeq: 5, - fetchImpl, - }), - ).rejects.toThrow('unknown route'); + fetchHistoryPage({ baseUrl: 'http://h:1', sessionId: 's9', agentId: 'main', fetchImpl }), + ).rejects.toThrow('session not found'); }); -}); -// ------------------------------------------------------------------ plan lookup - -describe('fetchTranscriptPlan', () => { - const planEntry = { - tool_call_id: 'call_plan', - turn_id: 't3', - source: 'interaction', - plan: '# The Plan\n\nDo the thing.', - path: '/tmp/plans/foo.md', - options: [{ label: 'Approach A', description: 'fast' }], - review: { state: 'approved', selected_option: 'Approach A', feedback: 'looks good' }, - }; - - it('requests the plan endpoint with agent_id/tool_call_id and maps the snake_case payload', async () => { - const { calls, fetchImpl } = fakeFetch(okEnvelope({ agent_id: 'main', plans: [planEntry] })); - const plans = await fetchTranscriptPlan({ - baseUrl: 'http://h:1', - token: 'tok', - sessionId: 's 1', - agentId: 'main', - toolCallId: 'call_plan', - fetchImpl, - }); - expect(calls).toHaveLength(1); - expect(calls[0]!.url).toContain('/api/v1/sessions/s%201/transcript/plan?'); - expect(calls[0]!.url).toContain('agent_id=main'); - expect(calls[0]!.url).toContain('tool_call_id=call_plan'); - expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' }); - expect(plans).toEqual([ - { - toolCallId: 'call_plan', - turnId: 't3', - 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('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('omits tool_call_id from the query when unset (lists every plan of the agent)', async () => { - const { calls, fetchImpl } = fakeFetch( - okEnvelope({ - agent_id: 'main', - plans: [ - { tool_call_id: 'call_draft', turn_id: 't1', source: 'display', plan: '# Draft' }, - { tool_call_id: 'call_final', turn_id: 't2', source: 'output', plan: '# Final' }, - ], - }), - ); - const plans = await fetchTranscriptPlan({ + it('fetchFullHistory pages before_turn to the beginning and returns timeline order', async () => { + const pages: Record = { + 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[0]!.url).not.toContain('tool_call_id'); - expect(plans.map((p) => [p.toolCallId, p.plan])).toEqual([ - ['call_draft', '# Draft'], - ['call_final', '# Final'], - ]); - expect(plans[0]!.review).toBeUndefined(); - expect(plans[0]!.path).toBeUndefined(); - expect(plans[0]!.options).toBeUndefined(); - }); - - it('throws on a 40416 envelope (unknown tool call / not ExitPlanMode)', async () => { - const { fetchImpl } = fakeFetch({ - code: 40416, - msg: 'no ExitPlanMode tool call found for tool_call_id: call_nope', - data: null, - }); - await expect( - fetchTranscriptPlan({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - toolCallId: 'call_nope', - fetchImpl, - }), - ).rejects.toThrow('40416'); - }); - - it('throws when the payload fails schema validation', async () => { - const { fetchImpl } = fakeFetch(okEnvelope({ agent_id: 'main', plans: 'nope' })); - await expect( - fetchTranscriptPlan({ - baseUrl: 'http://h:1', - sessionId: 's1', - agentId: 'main', - fetchImpl, - }), - ).rejects.toThrow('unexpected response shape'); + 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']); }); }); // ---------------------------------------------------------------- ws -describe('TranscriptWs', () => { - it('connects with the bearer subprotocol and sends the grade spec via subscribe_v2', () => { +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/v1/ws'); + expect(sock.url).toBe('ws://h:1/api/v3/ws'); expect(sock.protocols).toEqual(['kimi-code.bearer.tok']); sock.open(); - expect(sock.sentFrames()[0]).toMatchObject({ - type: 'client_hello', - payload: { - subscriptions: ['s1'], - }, - }); - expect(sock.sentFrames()[1]).toMatchObject({ - type: 'subscribe_v2', - payload: { - session_id: 's1', - transcript: { main: 'block' }, - }, + expect(sock.sent).toHaveLength(0); + sock.hello(); + expect(sock.sentFrames()[0]).toEqual({ + type: 'subscribe', + id: 1, + session_id: 's1', + agent_ids: ['main'], }); }); - it('forwards transcript.ops and surfaces transcript.reset via onReset, both with envelope meta', () => { + 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: 'transcript.reset', - seq: 1, - volatile: true, - session_id: 's1', - timestamp: '2026-01-01T00:00:00Z', - payload: { - type: 'transcript.reset', - agent_id: 'main', - snapshot: { items: [], tasks: [], interactions: [], meta: {} }, - has_more_older: true, - seq: 41, - }, - }); - expect(seen.ops).toHaveLength(0); - expect(seen.resets).toEqual([ - { agentId: 'main', hasMoreOlder: true, at: '2026-01-01T00:00:00Z', seq: 41 }, - ]); - sock.serverFrame({ - type: 'transcript.ops', - seq: 1, - volatile: true, + type: 'session.state', session_id: 's1', - timestamp: '2026-01-01T00:00:01Z', - payload: { - type: 'transcript.ops', - agent_id: 'main', - ops: [{ op: 'meta.merge', meta: { activity: 'turn' } }], - seq: 42, - }, + timestamp: ts(), + busy: false, + main_turn_active: false, + activity: 'idle', }); - expect(seen.ops).toHaveLength(1); - expect(seen.ops[0]!.agentId).toBe('main'); - expect(seen.ops[0]!.at).toBe('2026-01-01T00:00:01Z'); - expect(seen.ops[0]!.seq).toBe(42); - expect(seen.ops[0]!.ops[0]).toMatchObject({ op: 'meta.merge' }); + expect(seen.messages.map((m) => m.type)).toEqual(['turn', 'session.state']); }); - it('sends a clean client_hello and carries grades/transcript_since on subscribe_v2', async () => { + it('surfaces protocol error frames and ignores acks for other ids', () => { FakeWs.reset(); - let watermark: number | undefined; - new TranscriptWs({ - url: 'http://h:1', - sessionId: 's1', - agentId: 'main', - WebSocketImpl: FakeWs, - getSince: () => watermark, - reconnectDelayMs: 1, - handlers: { onOps: () => {}, onResyncRequired: () => {}, onReconnected: () => {} }, - }); - const sock = FakeWs.instances[0]!; - sock.open(); - expect(sock.sentFrames()[0]).toMatchObject({ - type: 'client_hello', - payload: { client_id: 'kimi-inspect', subscriptions: ['s1'] }, - }); - expect(sock.sentFrames()[0]).not.toHaveProperty('payload.transcript'); - expect(sock.sentFrames()[1]).toMatchObject({ - type: 'subscribe_v2', - payload: { session_id: 's1', transcript: { main: 'block' } }, - }); - expect( - (sock.sentFrames()[1] as { payload: Record }).payload['transcript_since'], - ).toBeUndefined(); - watermark = 42; - sock.emit('close'); - await vi.waitFor(() => { - expect(FakeWs.instances.length).toBeGreaterThan(1); - }); - const second = FakeWs.instances[1]!; - second.open(); - expect(second.sentFrames()[1]).toMatchObject({ - type: 'subscribe_v2', - payload: { session_id: 's1', transcript_since: { main: 42 } }, - }); - }); - - it('still ignores transcript.reset when no onReset handler is set', () => { - FakeWs.reset(); - const seen = { ops: 0 }; - new TranscriptWs({ - url: 'http://h:1', - sessionId: 's1', - agentId: 'main', - WebSocketImpl: FakeWs, - handlers: { - onOps: () => { - seen.ops += 1; - }, - onResyncRequired: () => {}, - onReconnected: () => {}, - }, - }); + const { seen } = makeWs(); const sock = FakeWs.instances[0]!; sock.open(); - sock.serverFrame({ - type: 'transcript.reset', - timestamp: '2026-01-01T00:00:00Z', - payload: { - type: 'transcript.reset', - agent_id: 'main', - snapshot: { items: [], tasks: [], interactions: [], meta: {} }, - has_more_older: false, - }, - }); - expect(seen.ops).toBe(0); + 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('answers ping with pong carrying the nonce', () => { + it('ignores unknown future message types but reports malformed known ones', () => { FakeWs.reset(); - makeWs(); + const { seen } = makeWs(); const sock = FakeWs.instances[0]!; sock.open(); - sock.serverFrame({ type: 'ping', timestamp: '2026-01-01T00:00:00Z', payload: { nonce: 'n1' } }); - expect(sock.sentFrames().at(-1)).toEqual({ type: 'pong', payload: { nonce: 'n1' } }); + 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('surfaces resync_required for its session (and ignores other sessions)', () => { + it('re-subscribes after a drop and fires onAck per subscribe', async () => { FakeWs.reset(); const { seen } = makeWs(); - const sock = FakeWs.instances[0]!; - sock.open(); - sock.serverFrame({ - type: 'resync_required', - timestamp: '2026-01-01T00:00:00Z', - payload: { session_id: 'other', reason: 'buffer_overflow', current_seq: 5 }, - }); - expect(seen.resyncs).toBe(0); - sock.serverFrame({ - type: 'resync_required', - timestamp: '2026-01-01T00:00:00Z', - payload: { session_id: 's1', reason: 'buffer_overflow', current_seq: 5 }, + 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); }); - expect(seen.resyncs).toBe(1); - }); - - it('re-subscribes after a drop and reports the reconnect only on the subscribe_v2 ack', () => { - vi.useFakeTimers(); - try { - FakeWs.reset(); - const { seen } = makeWs(); - const first = FakeWs.instances[0]!; - first.open(); - // Open alone does not reconcile: the server attaches the transcript - // stream only after processing subscribe_v2. - expect(seen.reconnects).toBe(0); - // Neither does the client_hello ack. - const helloId = (first.sentFrames()[0] as { id: string }).id; - first.serverFrame({ type: 'ack', id: helloId, code: 0, msg: 'success', payload: {} }); - expect(seen.reconnects).toBe(0); - const subscribeV2Id = (first.sentFrames()[1] as { id: string }).id; - first.serverFrame({ type: 'ack', id: subscribeV2Id, code: 0, msg: 'success', payload: {} }); - expect(seen.reconnects).toBe(1); - first.emit('close'); - vi.advanceTimersByTime(600); - expect(FakeWs.instances).toHaveLength(2); - const second = FakeWs.instances[1]!; - second.open(); - expect(second.sentFrames()[0]).toMatchObject({ type: 'client_hello' }); - expect(second.sentFrames()[1]).toMatchObject({ type: 'subscribe_v2' }); - expect(seen.reconnects).toBe(1); - const subscribeV2Id2 = (second.sentFrames()[1] as { id: string }).id; - second.serverFrame({ type: 'ack', id: subscribeV2Id2, code: 0, msg: 'success', payload: {} }); - expect(seen.reconnects).toBe(2); - } finally { - vi.useRealTimers(); - } + 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()', () => { @@ -640,276 +470,439 @@ describe('TranscriptWs', () => { // ---------------------------------------------------------------- store -describe('TranscriptChatStore', () => { - it('applyPage(replace) installs the newest slice wholesale (items + globals)', () => { - const store = new TranscriptChatStore(); - store.applyOps([{ op: 'turn.upsert', turn: turnHeader(9, 'running') }]); - store.applyPage( - { - ...emptyPage, - items: [turnItem(1), turnItem(2)], - hasMoreOlder: true, - tasks: [ - { taskId: 'bash-1', kind: 'shell', state: 'running', detached: false, outputTail: '' }, - ], - meta: { activity: 'idle' }, - pendingInteractions: ['apr-1'], - }, - { replace: true }, - ); +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(state.items.map((item) => itemId(item))).toEqual(['t1', 't2']); - expect(state.hasMoreOlder).toBe(true); - expect(state.tasks.get('bash-1')?.kind).toBe('shell'); - expect(state.meta.activity).toBe('idle'); - expect([...state.pendingInteractions]).toEqual(['apr-1']); - }); - - it('prepends older pages ahead of the window, dedupes, keeps live globals', () => { - const store = new TranscriptChatStore(); - store.applyPage( - { ...emptyPage, items: [turnItem(3)], hasMoreOlder: true, meta: { activity: 'idle' } }, - { replace: true }, - ); - store.applyPage({ - ...emptyPage, - items: [turnItem(1), turnItem(2)], - hasMoreOlder: true, - meta: {}, + expect(entryKeys(state.entries)).toEqual(['turn:t1', 'step:t1.1']); + const turn = state.entries[0]!.message as TurnMessage; + expect(turn.state).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', '2026-01-01T00:00:10.000Z')); + store.applyLive(assistantMsg('t1.1', 'hel', 'streaming', '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"}', }); - expect(store.getState().items.map((item) => itemId(item))).toEqual(['t1', 't2', 't3']); - expect(store.getState().hasMoreOlder).toBe(true); - // Globals from the older page do not clobber the fresher live state. - expect(store.getState().meta.activity).toBe('idle'); - store.applyPage({ ...emptyPage, items: [turnItem(2)], hasMoreOlder: false }); - expect(store.getState().items.map((item) => itemId(item))).toEqual(['t1', 't2', 't3']); - expect(store.getState().hasMoreOlder).toBe(false); - }); - - it('applies ops through the package reducer and notifies once per batch', () => { - const store = new TranscriptChatStore(); - let notified = 0; - store.subscribe(() => { - notified += 1; + store.applyLive({ + type: 'tool.progress', + ...base, + timestamp: ts(), + tool_call_id: 'call_1', + progress: { kind: 'stdout', text: 'file.txt' }, }); - store.applyOps([ - { op: 'turn.upsert', turn: turnHeader(1, 'running') }, - { op: 'step.upsert', turnId: 't1', step: stepHeader('t1.1', 1) }, - textFrameUpsert('t1', 't1.1', 't1.1.f1', ''), - frameAppend('t1', 't1.1', 't1.1.f1', 0, 'hel'), - frameAppend('t1', 't1.1', 't1.1.f1', 3, 'lo'), + 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', ]); - expect(notified).toBe(1); - const turn = store.getState().items[0]; - expect(turn?.kind).toBe('turn'); - if (turn?.kind === 'turn') { - expect(turn.steps[0]?.frames[0]).toMatchObject({ kind: 'text', text: 'hello' }); - } - }); - - it('absorbs duplicate ops without notifying', () => { - const store = new TranscriptChatStore(); - store.applyOps([{ op: 'turn.upsert', turn: turnHeader(1, 'running') }]); - let notified = 0; - store.subscribe(() => { - notified += 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.applyOps([{ op: 'turn.upsert', turn: turnHeader(1, 'running') }]); - expect(notified).toBe(0); - }); - - it('buffered ops converge when flushed onto freshly fetched pages', () => { - const store = new TranscriptChatStore(); - // Simulate: REST page lands AFTER the live ops were produced (buffered). - const buffered: TranscriptOperation[] = [ - { op: 'turn.upsert', turn: turnHeader(1, 'running') }, - { op: 'step.upsert', turnId: 't1', step: stepHeader('t1.1', 1) }, - textFrameUpsert('t1', 't1.1', 't1.1.f1', ''), - frameAppend('t1', 't1.1', 't1.1.f1', 0, 'hello'), - ]; - // The REST snapshot already includes part of the stream ('hel'). - const pageTurn: TranscriptTurn = { - ...turnHeader(1, 'running'), - steps: [ - { - kind: 'step', - stepId: 't1.1', - turnId: 't1', - ordinal: 1, - state: 'running', - frames: [{ kind: 'text', frameId: 't1.1.f1', role: 'assistant', text: 'hel' }], - }, - ], - }; - store.applyPage({ ...emptyPage, items: [pageTurn], hasMoreOlder: false }, { replace: true }); - store.applyOps(buffered); - const turn = store.getState().items[0]; - if (turn?.kind !== 'turn') throw new Error('expected turn'); - expect(turn.steps[0]?.frames[0]).toMatchObject({ kind: 'text', text: 'hello' }); - }); - - it('surfaces append placement gaps through onGap', () => { - const store = new TranscriptChatStore(); - let gaps = 0; - store.onGap = () => { - gaps += 1; - }; - store.applyOps([frameAppend('t1', 't1.1', 't1.1.f1', 0, 'x')]); - expect(gaps).toBe(1); + store.applyLive({ + type: 'session.state', + session_id: 's1', + timestamp: ts(), + busy: true, + main_turn_active: true, + activity: 'turn', + }); + store.applyLive({ + type: 'workspace', + timestamp: ts(), + subtype: 'updated', + workspace: { + id: 'wd_test_0123456789ab', + root: '/tmp', + name: 'tmp', + created_at: ts(), + last_opened_at: ts(), + session_count: 1, + }, + }); + const state = store.getState(); + expect(state.interactions.get('ix-1')?.state).toBe('pending'); + expect(state.tasks.get('task-1')?.kind).toBe('shell'); + expect(state.todos.get('todo')?.items).toHaveLength(1); + expect(state.sessionState?.busy).toBe(true); + 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', '2026-01-01T00:00:09.000Z')); + store.applyLive(turnMsg(1, 'completed', '2026-01-01T00:00:01.000Z')); + store.applyHistoryPage( + [turnMsg(1, 'completed', '2026-01-01T00:00:01.500Z'), stepMsg('t1.1', 'completed', '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']); }); }); +// ---------------------------------------------------------------- helpers + describe('recoverLoadedWindow', () => { - const range = (from: number, to: number): TranscriptTurn[] => - Array.from({ length: to - from + 1 }, (_, i) => turnItem(from + i)); - const pageOf = (items: TranscriptTurn[], hasMoreOlder: boolean): TranscriptPage => ({ - ...emptyPage, - items, - hasMoreOlder, - }); + const pageOf = (items: HistoryMessage[], hasMore: boolean): HistoryMessage[] => items; it('pages backwards until the previous oldest turn is re-covered', async () => { - const store = new TranscriptChatStore(); - // The refresh landed the newest page (t36..t65) while the previously - // loaded window reached t1 — a count-based stop would drop t1..t5. - store.applyPage(pageOf(range(36, 65), true), { replace: true }); - + const store = makeStore(); + store.applyHistoryPage([turnMsg(4), turnMsg(5), turnMsg(6)], 'replace'); + store.setHasMoreOlder(true); const fetched: string[] = []; await recoverLoadedWindow( store, - 't1', + 't2', async (beforeTurn) => { fetched.push(beforeTurn); - return beforeTurn === 't36' ? pageOf(range(6, 35), true) : pageOf(range(1, 5), false); + store.setHasMoreOlder(beforeTurn !== 't2'); + return beforeTurn === 't4' ? [turnMsg(2), turnMsg(3)] : []; }, () => false, ); - - expect(fetched).toEqual(['t36', 't6']); - expect(countTurns(store.getState().items)).toBe(65); - expect(oldestTurnId(store.getState().items)).toBe('t1'); - }); - - it('stops immediately when the window is already covered', async () => { - const store = new TranscriptChatStore(); - store.applyPage(pageOf(range(1, 30), true), { replace: true }); - let calls = 0; - await recoverLoadedWindow( - store, - 't1', - async () => { - calls += 1; - return pageOf([], false); - }, - () => false, - ); - expect(calls).toBe(0); + 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 = new TranscriptChatStore(); - store.applyPage(pageOf(range(10, 20), true), { replace: true }); + 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, ); - // The anchor no longer exists server-side: one no-progress probe, then stop. - expect(fetched).toEqual(['t10']); - expect(countTurns(store.getState().items)).toBe(11); - }); - - it('reports each applied page through onPageApplied', async () => { - const store = new TranscriptChatStore(); - store.applyPage(pageOf(range(36, 65), true), { replace: true }); - const applied: TranscriptPage[] = []; - await recoverLoadedWindow( - store, - 't1', - async (beforeTurn) => - beforeTurn === 't36' ? pageOf(range(6, 35), true) : pageOf(range(1, 5), false), - () => false, - (page) => { - applied.push(page); - }, - ); - expect(applied.map((page) => page.items.map((item) => itemId(item)))).toEqual([ - range(6, 35).map((turn) => turn.turnId), - range(1, 5).map((turn) => turn.turnId), - ]); + expect(fetched).toEqual(['t5']); }); }); -describe('createCoalescedRunner', () => { - const deferred = (): { promise: Promise; resolve: () => void } => { - let resolve!: () => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; - }; +describe('ChatChannel', () => { + function scriptedFetch(script: { noCursor: unknown[]; afterStep?: Record }) { + 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 }; + } - it('runs once per trigger when idle', async () => { - let runs = 0; - const kick = createCoalescedRunner(async () => { - runs += 1; + 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, }); - kick(); - await Promise.resolve(); - kick(); - await Promise.resolve(); - expect(runs).toBe(2); - }); - - it('coalesces triggers during a run into exactly one follow-up', async () => { - let runs = 0; - const gates: Array<() => void> = []; - const kick = createCoalescedRunner(async () => { - runs += 1; - const gate = deferred(); - gates.push(gate.resolve); - await gate.promise; + 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((resolve) => { + releaseFirst = resolve; }); - kick(); - kick(); - kick(); - expect(runs).toBe(1); - gates[0]?.(); + 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(runs).toBe(2); + expect(calls).toHaveLength(3); }); - gates[1]?.(); + 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(gates.length).toBe(2); + expect(aliveChannel.channel.store.getState().entries.length).toBeGreaterThan(0); }); - // No third run: the two mid-run triggers were coalesced into one. - }); - - it('queues again when a trigger lands during the follow-up run', async () => { - let runs = 0; - const gates: Array<() => void> = []; - const kick = createCoalescedRunner(async () => { - runs += 1; - const gate = deferred(); - gates.push(gate.resolve); - await gate.promise; + await vi.waitFor(() => { + expect(alive.calls).toHaveLength(3); }); - kick(); - kick(); - gates[0]?.(); + 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(runs).toBe(2); + expect( + goneChannel.channel.trail.getEntries().some((e) => e.kind === 'event' && e.event === 'catchup-refresh'), + ).toBe(true); }); - kick(); - gates[1]?.(); await vi.waitFor(() => { - expect(runs).toBe(3); + expect(newestTerminalStepId(goneChannel.channel.store.getState().entries)).toBe('t2.1'); }); - gates[2]?.(); + goneChannel.channel.close(); + }); +}); + +// ---------------------------------------------------------------- plan + +describe('projectPlans', () => { + const planCall = (id: string, overrides: Partial = {}): ToolCallMessage => + toolCallMsg('t1.1', id, { name: 'ExitPlanMode', state: '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', + state: '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', state: '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']); }); }); diff --git a/apps/kimi-inspect/src/transcript/ws.ts b/apps/kimi-inspect/src/transcript/ws.ts index 8e5d160e73e..d421d40bd91 100644 --- a/apps/kimi-inspect/src/transcript/ws.ts +++ b/apps/kimi-inspect/src/transcript/ws.ts @@ -1,106 +1,97 @@ /** - * Minimal `/api/v1/ws` client for the transcript stream — **block grade**. + * Minimal `/api/v3/ws` client for the message protocol. * - * The socket is used exclusively as an incremental channel, at the cheapest - * grade that keeps the live view correct: 'block' drops the per-token - * `append` frames (the bulk of transcript traffic) and still receives the - * whole-state frame upserts at every flush point, so content converges - * without a REST round-trip. After the - * upgrade, the client sends `client_hello` with the session in - * `subscriptions`, then a `subscribe_v2` frame carrying the opt-in - * `transcript` grade map (plus the `transcript_since` cursor when a - * watermark is known), and forwards every `transcript.ops` frame to the - * consumer. Full state never comes from here: - * `transcript.reset` snapshots are ignored by the store (they are surfaced - * through the optional `onReset` handler for observers like the audit panel), - * because complete data (initial load and any refresh) is read back from the - * REST transcript API, paged from the tail backwards. + * Handshake per the protocol contract: the server sends `hello` right after + * the upgrade, the client answers with `subscribe` (`{id, session_id, + * agent_ids?, omit?}`), the server replies with `ack` (matched by `id`) and + * then streams the recovery payload followed by live traffic — one ordered + * session sequence, no cursors anywhere. Heartbeat is the WS protocol-level + * ping/pong, handled by the WebSocket implementation itself. * - * Loss signals are surfaced, not repaired locally — transcript frames are - * volatile by design (never journaled), so the consumer answers them with a - * REST refresh: `resync_required` → `onResyncRequired`, and the - * `subscribe_v2` ack after every established socket → `onReconnected` (the - * server attaches the stream only after processing `subscribe_v2`; ops - * emitted between the REST page load and that point are missed). + * Every data frame is validated against the shared + * `serverMessageSchema`; control frames (`hello` / `ack` / `error`) are + * handled here, everything else is forwarded through `onMessage`. The union + * is open: a frame whose `type` is not in the current schema is a future + * message type and is ignored silently; a frame that names a known type but + * fails validation is a server bug and surfaces via `onInvalidFrame`. * - * The bearer token is presented at the upgrade through the - * `kimi-code.bearer.` subprotocol (the only credential channel a - * browser WebSocket has). + * A drop is answered with a backoff reconnect and a fresh subscribe — the + * recovery payload is idempotent, so the consumer's only job on `onAck` is + * to run its REST tail catch-up. The bearer token rides the + * `kimi-code.bearer.` subprotocol at the upgrade (the only + * credential channel a browser WebSocket has). */ -import { - transcriptOpsEventSchema, - transcriptResetEventSchema, - type AgentTranscriptSnapshot, - type TranscriptOperation, -} from '@moonshot-ai/transcript'; +import { serverMessageSchema, type ServerMessage } from '@moonshot-ai/kap-server/protocol'; import type { WsLike, WsLikeCtor } from '../channel/wsLike'; -/** Envelope/payload metadata carried alongside a transcript frame (for auditing + seq tracking). */ -export interface TranscriptFrameMeta { - /** Envelope `timestamp` (server send time, ISO); absent on legacy servers. */ - readonly at?: string | undefined; - /** Op-batch sequence number (payload `seq`); absent on legacy servers. */ - readonly seq?: number | undefined; -} +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; + +const KNOWN_MESSAGE_TYPES: ReadonlySet = new Set([ + 'turn', + 'step', + 'user', + 'assistant', + 'assistant.delta', + 'thinking', + 'thinking.delta', + 'tool_call', + 'tool_call.delta', + 'tool.progress', + 'system', + 'interaction', + 'task', + 'todo', + 'session.state', + 'session', + 'workspace', + 'config', + 'config.warning', + 'model_catalog', + 'plugin', + 'capability', + 'hello', + 'ack', + 'error', +]); -export interface TranscriptWsHandlers { - /** Incremental L2 op batch for the agent (the only data frame consumed). */ - onOps: (agentId: string, ops: readonly TranscriptOperation[], meta?: TranscriptFrameMeta) => void; - /** - * Baseline snapshot frame. The chat consumer deliberately ignores these - * (full state is REST-sourced) — the handler exists for observers such as - * the audit panel that want to record every frame on the wire. - */ - onReset?: ( - agentId: string, - snapshot: AgentTranscriptSnapshot, - hasMoreOlder: boolean, - meta?: TranscriptFrameMeta, - ) => void; - /** Server signalled desync for our session — consumer should REST-refresh. */ - onResyncRequired: () => void; - /** Socket re-established after a drop — volatile ops were missed meanwhile. */ - onReconnected: () => void; +export interface ChatWsHandlers { + /** Any validated non-control server message (entity, delta, state, global). */ + onMessage: (message: ServerMessage) => void; + /** The subscribe ack (code 0 = subscribed) — fires on every (re)subscribe. */ + onAck: (code: number, msg?: string) => void; + /** Protocol-level `error` frame (auth failure, unknown frame, slow consumer). */ + onProtocolError: (code: number, msg: string) => void; + /** A frame naming a KNOWN type failed schema validation (server bug). */ + onInvalidFrame?: (raw: unknown) => void; + /** The socket dropped and a reconnect attempt is scheduled. */ + onReconnectScheduled?: (attempt: number) => void; } -export interface TranscriptWsOptions { - /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v1/ws` URL. */ +export interface ChatWsOptions { + /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v3/ws` URL. */ readonly url: string; - readonly token?: string | undefined; + readonly token?: string; readonly sessionId: string; - readonly agentId: string; - readonly handlers: TranscriptWsHandlers; - /** - * Returns the caller's current op-batch watermark at (re)subscribe time; - * when defined it is sent as the `transcript_since` cursor so a sequenced - * server replays missed batches instead of sending a baseline reset. - */ - readonly getSince?: (() => number | undefined) | undefined; + /** Agents to subscribe; defaults to all agents of the session when empty. */ + readonly agentIds?: readonly string[]; + /** Message types to exclude from the subscription (exact `type` names). */ + readonly omit?: readonly string[]; + readonly handlers: ChatWsHandlers; /** WebSocket implementation; defaults to the global `WebSocket`. */ readonly WebSocketImpl?: WsLikeCtor; /** Base delay (ms) for the reconnect backoff. Default `500`. */ readonly reconnectDelayMs?: number; } -interface ServerFrame { - readonly type: string; - readonly id?: string; - readonly code?: number; - readonly timestamp?: string; - readonly payload?: unknown; -} - -const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; - -export class TranscriptWs { +export class ChatWs { private readonly wsUrl: string; private readonly token?: string; private readonly sessionId: string; - private readonly agentId: string; - private readonly handlers: TranscriptWsHandlers; - private readonly getSince?: (() => number | undefined) | undefined; + private readonly agentIds?: readonly string[]; + private readonly omit?: readonly string[]; + private readonly handlers: ChatWsHandlers; private readonly WsCtor: WsLikeCtor; private readonly reconnectDelayMs: number; @@ -108,17 +99,15 @@ export class TranscriptWs { private manualClose = false; private reconnectAttempt = 0; private reconnectTimer: ReturnType | undefined; - private helloId: string | undefined; - private subscribeV2Id: string | undefined; - private subscribeV2Acked = false; + private subscribeId = 0; - constructor(opts: TranscriptWsOptions) { - this.wsUrl = toWsUrl(opts.url); + constructor(opts: ChatWsOptions) { + this.wsUrl = toWsV3Url(opts.url); this.token = opts.token; this.sessionId = opts.sessionId; - this.agentId = opts.agentId; + this.agentIds = opts.agentIds; + this.omit = opts.omit; this.handlers = opts.handlers; - this.getSince = opts.getSince; const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined); if (ctor === undefined) { throw new Error('no WebSocket implementation available; pass WebSocketImpl'); @@ -140,6 +129,25 @@ export class TranscriptWs { ws?.close(); } + /** Force a reconnect (debug/testing): drop the socket and re-subscribe after `delayMs`. */ + reconnect(delayMs = 0): void { + if (this.manualClose) return; + if (this.reconnectTimer !== undefined) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + const ws = this.ws; + this.ws = undefined; + ws?.close(); + this.reconnectAttempt += 1; + this.handlers.onReconnectScheduled?.(this.reconnectAttempt); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delayMs); + this.reconnectTimer.unref?.(); + } + private connect(): void { const protocols = this.token !== undefined && this.token.length > 0 @@ -155,108 +163,68 @@ export class TranscriptWs { this.ws = ws; ws.addEventListener('open', () => { this.reconnectAttempt = 0; - this.helloId = `kimi-inspect-${Date.now().toString(36)}`; - this.subscribeV2Id = `${this.helloId}-sub`; - this.subscribeV2Acked = false; - const since = this.getSince?.(); - this.send({ - type: 'client_hello', - id: this.helloId, - payload: { - client_id: 'kimi-inspect', - subscriptions: [this.sessionId], - }, - }); - // Transcript grades ride only `subscribe_v2` — sent right after the - // hello on the same socket, so the server processes them in order. - this.send({ - type: 'subscribe_v2', - id: this.subscribeV2Id, - payload: { - session_id: this.sessionId, - transcript: { [this.agentId]: 'block' }, - transcript_since: since !== undefined ? { [this.agentId]: since } : undefined, - }, - }); - // The reconcile fires on the subscribe_v2 ACK (see onMessage) — the - // server attaches the transcript stream only after processing - // subscribe_v2, so refreshing at open could finish before the - // subscription is active and still miss the ops in between. }); ws.addEventListener('message', (event: { data: unknown }) => { this.onMessage(event.data); }); ws.addEventListener('close', () => { - // Stale socket (a manual close already cleared `this.ws`). if (this.ws !== ws) return; this.ws = undefined; if (!this.manualClose) this.scheduleReconnect(); }); - ws.addEventListener('error', () => { - // The 'close' event always follows 'error'; reconnect logic lives there. - }); + ws.addEventListener('error', () => {}); } private onMessage(raw: unknown): void { - let frame: ServerFrame; + let frame: unknown; try { - frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame; + frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)); } catch { + this.handlers.onInvalidFrame?.(raw); return; } - switch (frame.type) { - case 'ack': { - // The subscribe_v2 ack: the server has attached the transcript stream - // by now — reconcile once per socket (ops emitted between the REST - // page load and this point are missed; the consumer refreshes). - if (!this.subscribeV2Acked && frame.id !== undefined && frame.id === this.subscribeV2Id) { - this.subscribeV2Acked = true; - this.handlers.onReconnected(); - } - return; + const parsed = serverMessageSchema.safeParse(frame); + if (!parsed.success) { + const type = (frame as { readonly type?: unknown } | null)?.type; + if (typeof type !== 'string' || KNOWN_MESSAGE_TYPES.has(type)) { + this.handlers.onInvalidFrame?.(frame); } - case 'transcript.ops': { - const parsed = transcriptOpsEventSchema.safeParse(frame.payload); - if (!parsed.success) return; - this.handlers.onOps(parsed.data.agent_id, parsed.data.ops, { - at: frame.timestamp, - seq: parsed.data.seq, + return; + } + const message = parsed.data; + switch (message.type) { + case 'hello': { + this.subscribeId += 1; + this.send({ + type: 'subscribe', + id: this.subscribeId, + session_id: this.sessionId, + agent_ids: this.agentIds !== undefined && this.agentIds.length > 0 ? [...this.agentIds] : undefined, + omit: this.omit !== undefined && this.omit.length > 0 ? [...this.omit] : undefined, }); return; } - case 'transcript.reset': { - // Snapshots are deliberately ignored by the chat store: full state is - // REST-sourced. Surface them to optional observers (audit panel). - if (this.handlers.onReset === undefined) return; - const parsed = transcriptResetEventSchema.safeParse(frame.payload); - if (!parsed.success) return; - this.handlers.onReset( - parsed.data.agent_id, - parsed.data.snapshot, - parsed.data.has_more_older, - { at: frame.timestamp, seq: parsed.data.seq }, - ); + case 'ack': { + if (message.id === this.subscribeId) { + this.handlers.onAck(message.code, message.msg); + } return; } - case 'ping': { - const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce; - this.send({ type: 'pong', payload: { nonce } }); + case 'error': { + this.handlers.onProtocolError(message.code, message.msg); return; } - case 'resync_required': { - const sessionId = (frame.payload as { session_id?: unknown } | undefined)?.session_id; - if (sessionId === this.sessionId) this.handlers.onResyncRequired(); + default: { + this.handlers.onMessage(message); return; } - default: - // server_hello / ack / legacy session events — not consumed here. - return; } } private scheduleReconnect(): void { if (this.manualClose) return; this.reconnectAttempt += 1; + this.handlers.onReconnectScheduled?.(this.reconnectAttempt); const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; @@ -271,21 +239,20 @@ export class TranscriptWs { try { ws.send(JSON.stringify(frame)); } catch { - // best-effort; the close handler handles teardown } } } -/** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ -function toWsUrl(base: string): string { +/** Derive the `/api/v3/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ +function toWsV3Url(base: string): string { const url = new URL(base); if (url.protocol === 'http:') url.protocol = 'ws:'; else if (url.protocol === 'https:') url.protocol = 'wss:'; if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { throw new Error(`unsupported URL scheme for WS transport: ${base}`); } - if (!url.pathname.endsWith('/api/v1/ws')) { - url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`; + if (!url.pathname.endsWith('/api/v3/ws')) { + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v3/ws`; } url.search = ''; url.hash = ''; diff --git a/packages/kap-server/package.json b/packages/kap-server/package.json index 6709ec295bb..28f17bd6d99 100644 --- a/packages/kap-server/package.json +++ b/packages/kap-server/package.json @@ -13,6 +13,10 @@ "types": "./src/contract.ts", "default": "./src/contract.ts" }, + "./protocol": { + "types": "./src/protocol/messages/index.ts", + "default": "./src/protocol/messages/index.ts" + }, "./search-worker-runtime": { "types": "./src/search/worker/runtime.ts", "default": "./src/search/worker/runtime.ts" diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index 6ea45dcbdfe..e32cb1eacdb 100644 --- a/packages/kap-server/src/protocol/error-codes.ts +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -74,6 +74,7 @@ export const ErrorCode = { FS_GREP_TIMEOUT: 41305, FS_WATCH_LIMIT_EXCEEDED: 42902, + WS_SLOW_CONSUMER: 42903, INTERNAL_ERROR: 50001, PERSISTENCE_FAILURE: 50003, diff --git a/packages/kap-server/src/protocol/messages/ack.ts b/packages/kap-server/src/protocol/messages/ack.ts new file mode 100644 index 00000000000..4313dc38b58 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/ack.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ackMessageSchema = z.object({ + type: z.literal('ack'), + id: z.number().int().nonnegative(), + code: z.number().int(), + msg: z.string().optional(), +}); + +export type AckMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/agent-phase.ts b/packages/kap-server/src/protocol/messages/agent-phase.ts new file mode 100644 index 00000000000..e6349cbcd63 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/agent-phase.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +const phaseTurnId = z.number().int().nonnegative(); +const phaseStep = z.number().int().nonnegative(); +const phaseSince = z.number(); +const phaseAt = z.number(); + +export const agentPhaseSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('idle'), + }), + z.object({ + kind: z.literal('running'), + turn_id: phaseTurnId, + step: phaseStep, + step_id: z.string().min(1), + since: phaseSince, + }), + z.object({ + kind: z.literal('streaming'), + turn_id: phaseTurnId, + step: phaseStep, + step_id: z.string().min(1), + stream: z.enum(['assistant', 'thinking', 'tool_call']), + tool_call_id: z.string().min(1).optional(), + tool_name: z.string().optional(), + since: phaseSince, + }), + z.object({ + kind: z.literal('tool_call'), + turn_id: phaseTurnId, + step: phaseStep, + tool_call_id: z.string().min(1), + name: z.string().min(1), + since: phaseSince, + }), + z.object({ + kind: z.literal('retrying'), + turn_id: phaseTurnId, + step: phaseStep, + step_id: z.string().min(1), + failed_attempt: z.number().int().positive(), + next_attempt: z.number().int().positive(), + max_attempts: z.number().int().positive(), + delay_ms: z.number().nonnegative(), + error_name: z.string().optional(), + status_code: z.number().int().optional(), + since: phaseSince, + }), + z.object({ + kind: z.literal('awaiting_approval'), + turn_id: phaseTurnId, + step: phaseStep.optional(), + approval: z.unknown().optional(), + since: phaseSince, + }), + z.object({ + kind: z.literal('interrupted'), + turn_id: phaseTurnId, + step: phaseStep.optional(), + reason: z.enum(['aborted', 'max_steps', 'error']), + message: z.string().optional(), + at: phaseAt, + }), + z.object({ + kind: z.literal('ended'), + turn_id: phaseTurnId, + reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), + duration_ms: z.number().nonnegative().optional(), + at: phaseAt, + }), +]); + +export type AgentPhase = z.infer; diff --git a/packages/kap-server/src/protocol/messages/assistant-delta.ts b/packages/kap-server/src/protocol/messages/assistant-delta.ts new file mode 100644 index 00000000000..c22e4bc4e01 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/assistant-delta.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const assistantDeltaMessageSchema = z.object({ + type: z.literal('assistant.delta'), + ...timelineMessageBase, + message_id: z.string().min(1), + text: z.string(), +}); + +export type AssistantDelta = z.infer; diff --git a/packages/kap-server/src/protocol/messages/assistant.ts b/packages/kap-server/src/protocol/messages/assistant.ts new file mode 100644 index 00000000000..86e491d7d73 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/assistant.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const assistantMessageSchema = z.object({ + type: z.literal('assistant'), + ...timelineMessageBase, + message_id: z.string().min(1), + turn_id: z.string().min(1), + step_id: z.string().min(1), + status: z.enum(['streaming', 'completed']), + text: z.string(), +}); + +export type AssistantMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/base.ts b/packages/kap-server/src/protocol/messages/base.ts new file mode 100644 index 00000000000..2be5e4bc3f5 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/base.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +const ISO_8601_REGEX = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/; + +export const isoDateTimeSchema = z + .string() + .refine((value) => ISO_8601_REGEX.test(value), { + message: 'must be an ISO 8601 datetime string', + }) + .transform((value, ctx) => { + const ms = Date.parse(value); + if (Number.isNaN(ms)) { + ctx.addIssue({ + code: 'custom', + message: 'invalid ISO 8601 datetime', + }); + return z.NEVER; + } + return new Date(ms).toISOString(); + }); + +export const timelineMessageBase = { + session_id: z.string().min(1), + agent_id: z.string().min(1), + timestamp: isoDateTimeSchema, +}; + +export const sessionMessageBase = { + session_id: z.string().min(1), + timestamp: isoDateTimeSchema, +}; + +export const globalMessageBase = { + timestamp: isoDateTimeSchema, +}; diff --git a/packages/kap-server/src/protocol/messages/capability.ts b/packages/kap-server/src/protocol/messages/capability.ts new file mode 100644 index 00000000000..0e8c05b21b9 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/capability.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const capabilityMessageSchema = z.object({ + type: z.literal('capability'), + ...globalMessageBase, + capability_id: z.string().min(1).optional(), +}); + +export type CapabilityChanged = z.infer; + +export type CapabilityChangedMessage = CapabilityChanged; diff --git a/packages/kap-server/src/protocol/messages/config-warning.ts b/packages/kap-server/src/protocol/messages/config-warning.ts new file mode 100644 index 00000000000..04637c572f5 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/config-warning.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const configWarningMessageSchema = z.object({ + type: z.literal('config.warning'), + ...globalMessageBase, + warnings: z.array(z.string()), +}); + +export type ConfigWarning = z.infer; + +export type ConfigWarningMessage = ConfigWarning; diff --git a/packages/kap-server/src/protocol/messages/config.ts b/packages/kap-server/src/protocol/messages/config.ts new file mode 100644 index 00000000000..d8b46be5799 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/config.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const configMessageSchema = z.object({ + type: z.literal('config'), + ...globalMessageBase, + config: z.unknown().nonoptional(), + changed_fields: z.array(z.string().min(1)).optional(), +}); + +export type ConfigMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/entity-id.ts b/packages/kap-server/src/protocol/messages/entity-id.ts new file mode 100644 index 00000000000..f6da4124d1d --- /dev/null +++ b/packages/kap-server/src/protocol/messages/entity-id.ts @@ -0,0 +1,30 @@ +import type { ServerMessage } from './union'; + +export function entityId(msg: ServerMessage): string { + const m = msg as { + message_id?: string; + tool_call_id?: string; + interaction_id?: string; + task_id?: string; + todo_id?: string; + system_id?: string; + step_id?: string; + turn_id?: string; + }; + return ( + m.message_id ?? + m.tool_call_id ?? + m.interaction_id ?? + m.task_id ?? + m.todo_id ?? + m.system_id ?? + m.step_id ?? + m.turn_id ?? + '' + ); +} + +export function entityKey(msg: ServerMessage): string { + const agent = (msg as { agent_id?: string }).agent_id ?? ''; + return `${agent}:${msg.type}:${entityId(msg)}`; +} diff --git a/packages/kap-server/src/protocol/messages/error.ts b/packages/kap-server/src/protocol/messages/error.ts new file mode 100644 index 00000000000..3e900b47ca8 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/error.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const errorMessageSchema = z.object({ + type: z.literal('error'), + code: z.number().int(), + msg: z.string(), +}); + +export type ErrorMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/hello.ts b/packages/kap-server/src/protocol/messages/hello.ts new file mode 100644 index 00000000000..7083d7512cf --- /dev/null +++ b/packages/kap-server/src/protocol/messages/hello.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const helloMessageSchema = z.object({ + type: z.literal('hello'), + protocol_version: z.string().min(1), + server_id: z.string().min(1), + capabilities: z.array(z.string().min(1)), +}); + +export type HelloMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/history.ts b/packages/kap-server/src/protocol/messages/history.ts new file mode 100644 index 00000000000..66caf59801c --- /dev/null +++ b/packages/kap-server/src/protocol/messages/history.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { assistantMessageSchema } from './assistant'; +import { interactionMessageSchema } from './interaction'; +import { stepMessageSchema } from './step'; +import { systemMessageSchema } from './system'; +import { taskMessageSchema } from './task'; +import { thinkingMessageSchema } from './thinking'; +import { todoMessageSchema } from './todo'; +import { toolCallMessageSchema } from './tool-call'; +import { turnMessageSchema } from './turn'; +import { userMessageSchema } from './user'; + +export const historyMessageSchema = z.discriminatedUnion('type', [ + turnMessageSchema, + stepMessageSchema, + userMessageSchema, + assistantMessageSchema, + thinkingMessageSchema, + toolCallMessageSchema, + systemMessageSchema, + interactionMessageSchema, + taskMessageSchema, + todoMessageSchema, +]); + +export type HistoryMessage = z.infer; + +export const historyQuerySchema = z.object({ + before_turn: z.string().min(1).optional(), + after_step: z.string().min(1).optional(), + page_size: z.number().int().positive().optional(), + agent_id: z.string().min(1).optional(), +}); + +export type HistoryQuery = z.infer; + +export const historyInFlightSchema = z.object({ + turn_id: z.string().min(1), + step_id: z.string().min(1), +}); + +export type HistoryInFlight = z.infer; + +export const historyResponseSchema = z.object({ + messages: z.array(historyMessageSchema), + has_more: z.boolean(), + in_flight: historyInFlightSchema.optional(), +}); + +export type HistoryResponse = z.infer; diff --git a/packages/kap-server/src/protocol/messages/index.ts b/packages/kap-server/src/protocol/messages/index.ts new file mode 100644 index 00000000000..c2d483e7afc --- /dev/null +++ b/packages/kap-server/src/protocol/messages/index.ts @@ -0,0 +1,35 @@ +export * from './ack'; +export * from './agent-phase'; +export * from './assistant'; +export * from './assistant-delta'; +export * from './base'; +export * from './capability'; +export * from './config'; +export * from './config-warning'; +export * from './entity-id'; +export * from './error'; +export * from './hello'; +export * from './history'; +export * from './interaction'; +export * from './model-catalog'; +export * from './plugin'; +export * from './session'; +export * from './session-state'; +export * from './step'; +export * from './step-usage'; +export * from './subscribe'; +export * from './system'; +export * from './task'; +export * from './thinking'; +export * from './thinking-delta'; +export * from './todo'; +export * from './tool-call'; +export * from './tool-call-delta'; +export * from './tool-progress'; +export * from './turn'; +export * from './turn-origin'; +export * from './union'; +export * from './unsubscribe'; +export * from './user'; +export * from './user-message-origin'; +export * from './workspace'; diff --git a/packages/kap-server/src/protocol/messages/interaction.ts b/packages/kap-server/src/protocol/messages/interaction.ts new file mode 100644 index 00000000000..8a0629c27c4 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/interaction.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; + +export const interactionApprovalRequestSchema = z.object({ + tool_name: z.string().min(1), + action: z.string(), + tool_input_display: z.unknown().optional(), + expires_at: isoDateTimeSchema.optional(), +}); + +export type InteractionApprovalRequest = z.infer; + +export const interactionApprovalResponseSchema = z.object({ + decision: z.enum(['approved', 'rejected', 'cancelled']), + scope: z.literal('session').optional(), + feedback: z.string().optional(), + selected_label: z.string().optional(), +}); + +export type InteractionApprovalResponse = z.infer; + +export const interactionQuestionOptionSchema = z.object({ + id: z.string().min(1), + label: z.string(), + description: z.string().optional(), +}); + +export type InteractionQuestionOption = z.infer; + +export const interactionQuestionItemSchema = z.object({ + id: z.string().min(1), + question: z.string(), + header: z.string().optional(), + body: z.string().optional(), + options: z.array(interactionQuestionOptionSchema), + multi_select: z.boolean().optional(), + allow_other: z.boolean().optional(), + other_label: z.string().optional(), + other_description: z.string().optional(), +}); + +export type InteractionQuestionItem = z.infer; + +export const interactionQuestionRequestSchema = z.object({ + questions: z.array(interactionQuestionItemSchema), +}); + +export type InteractionQuestionRequest = z.infer; + +export const interactionQuestionAnswerSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('single'), option_id: z.string().min(1) }), + z.object({ kind: z.literal('multi'), option_ids: z.array(z.string().min(1)).min(1) }), + z.object({ kind: z.literal('other'), text: z.string() }), + z.object({ + kind: z.literal('multi_with_other'), + option_ids: z.array(z.string().min(1)), + other_text: z.string(), + }), + z.object({ kind: z.literal('skipped') }), +]); + +export type InteractionQuestionAnswer = z.infer; + +export const interactionQuestionResponseSchema = z.object({ + answers: z.record(z.string().min(1), interactionQuestionAnswerSchema), + method: z.enum(['enter', 'space', 'number_key', 'click']).optional(), + note: z.string().optional(), +}); + +export type InteractionQuestionResponse = z.infer; + +export const interactionStateSchema = z.enum([ + 'pending', + 'approved', + 'rejected', + 'cancelled', + 'answered', + 'dismissed', +]); + +export type InteractionState = z.infer; + +const interactionMessageBase = { + type: z.literal('interaction'), + ...timelineMessageBase, + interaction_id: z.string().min(1), + state: interactionStateSchema, + tool_call_id: z.string().min(1).optional(), +}; + +export const interactionMessageSchema = z.discriminatedUnion('kind', [ + z.object({ + ...interactionMessageBase, + kind: z.literal('approval'), + request: interactionApprovalRequestSchema.optional(), + response: interactionApprovalResponseSchema.optional(), + }), + z.object({ + ...interactionMessageBase, + kind: z.literal('question'), + request: interactionQuestionRequestSchema.optional(), + response: interactionQuestionResponseSchema.optional(), + }), +]); + +export type InteractionMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/model-catalog.ts b/packages/kap-server/src/protocol/messages/model-catalog.ts new file mode 100644 index 00000000000..c4787aee132 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/model-catalog.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const modelCatalogMessageSchema = z.object({ + type: z.literal('model_catalog'), + ...globalMessageBase, +}); + +export type CatalogChanged = z.infer; + +export type ModelCatalogChangedMessage = CatalogChanged; diff --git a/packages/kap-server/src/protocol/messages/plugin.ts b/packages/kap-server/src/protocol/messages/plugin.ts new file mode 100644 index 00000000000..45dd1ff3740 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/plugin.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { globalMessageBase } from './base'; + +export const pluginMessageSchema = z.object({ + type: z.literal('plugin'), + ...globalMessageBase, +}); + +export type PluginChanged = z.infer; + +export type PluginChangedMessage = PluginChanged; diff --git a/packages/kap-server/src/protocol/messages/session-state.ts b/packages/kap-server/src/protocol/messages/session-state.ts new file mode 100644 index 00000000000..2ce33704738 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/session-state.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +import { agentPhaseSchema } from './agent-phase'; +import { sessionMessageBase } from './base'; +import { stepUsageSchema } from './step-usage'; + +export const sessionStateUsageSchema = z.object({ + by_model: z.record(z.string(), stepUsageSchema).optional(), + current_turn: stepUsageSchema.optional(), + total: stepUsageSchema.optional(), +}); + +export type SessionStateUsage = z.infer; + +export const sessionStateGoalSchema = z.object({ + objective: z.string(), + status: z.enum(['active', 'paused', 'blocked', 'complete']), + completion_criterion: z.string().optional(), + budget_used: z.number().optional(), + budget_limit: z.number().optional(), +}); + +export type SessionStateGoal = z.infer; + +export const sessionStateModesSchema = z.object({ + plan: z + .object({ + review_path: z.string().optional(), + version: z.number().int().optional(), + }) + .optional(), + swarm: z + .object({ + trigger: z.string().optional(), + }) + .optional(), +}); + +export type SessionStateModes = z.infer; + +export const sessionStateMessageSchema = z.object({ + type: z.literal('session.state'), + ...sessionMessageBase, + busy: z.boolean(), + main_turn_active: z.boolean(), + pending_interaction: z.enum(['none', 'approval', 'question']).optional(), + last_turn_reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']).optional(), + activity: z.enum(['idle', 'turn', 'disposing', 'unknown']), + phase: agentPhaseSchema.optional(), + model: z.string().optional(), + thinking_effort: z.string().optional(), + permission: z.enum(['manual', 'yolo', 'auto']).optional(), + usage: sessionStateUsageSchema.optional(), + context_tokens: z.number().int().nonnegative().optional(), + max_context_tokens: z.number().int().nonnegative().optional(), + context_usage: z.number().optional(), + goal: sessionStateGoalSchema.optional(), + modes: sessionStateModesSchema.optional(), +}); + +export type SessionStateMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/session.ts b/packages/kap-server/src/protocol/messages/session.ts new file mode 100644 index 00000000000..4666280b1f2 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/session.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +import { globalMessageBase, isoDateTimeSchema } from './base'; +import { workspaceIdSchema } from './workspace'; + +const sessionInfoMetadataSchema = z + .object({ + cwd: z.string().min(1), + }) + .catchall(z.unknown()); + +const sessionInfoAgentConfigSchema = z.object({ + model: z.string(), + system_prompt: z.string().optional(), + tools: z.array(z.string()).optional(), + mcp_servers: z.array(z.string()).optional(), + thinking: z.string().min(1).optional(), + permission_mode: z.enum(['manual', 'yolo', 'auto']).optional(), + plan_mode: z.boolean().optional(), + swarm_mode: z.boolean().optional(), + tower_mode: z.boolean().optional(), + tower_base: z.string().min(1).optional(), + goal_objective: z.string().optional(), + goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), +}); + +const sessionInfoUsageSchema = z.object({ + input_tokens: z.number().int().nonnegative(), + output_tokens: z.number().int().nonnegative(), + cache_read_tokens: z.number().int().nonnegative(), + cache_creation_tokens: z.number().int().nonnegative(), + total_cost_usd: z.number().nonnegative().optional(), + context_tokens: z.number().int().nonnegative(), + context_limit: z.number().int().nonnegative().optional(), + turn_count: z.number().int().nonnegative().optional(), +}); + +const sessionInfoPermissionRuleSchema = z.object({ + id: z.string().min(1), + tool_name: z.string().min(1), + matcher: z + .object({ + kind: z.enum(['command_prefix', 'path_glob', 'exact_input', 'always']), + value: z.string().optional(), + }) + .optional(), + decision: z.literal('approved'), + created_at: isoDateTimeSchema, + created_by: z.enum(['user', 'agent']), +}); + +export const sessionInfoSchema = z.object({ + id: z.string().min(1), + workspace_id: workspaceIdSchema, + title: z.string(), + created_at: isoDateTimeSchema, + updated_at: isoDateTimeSchema, + busy: z.boolean(), + main_turn_active: z.boolean().optional(), + pending_interaction: z.enum(['none', 'approval', 'question']).optional(), + last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), + archived: z.boolean().optional(), + archived_at: isoDateTimeSchema.optional(), + current_prompt_id: z.string().min(1).optional(), + last_prompt: z.string().optional(), + metadata: sessionInfoMetadataSchema, + agent_config: sessionInfoAgentConfigSchema, + usage: sessionInfoUsageSchema, + permission_rules: z.array(sessionInfoPermissionRuleSchema), + message_count: z.number().int().nonnegative(), + last_seq: z.number().int().nonnegative(), +}); + +export type SessionInfo = z.infer; + +export const sessionMessageSchema = z.object({ + type: z.literal('session'), + ...globalMessageBase, + subtype: z.enum(['created', 'updated', 'archived', 'deleted']), + session: sessionInfoSchema, + changed_fields: z.array(z.string().min(1)).optional(), +}); + +export type SessionMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/step-usage.ts b/packages/kap-server/src/protocol/messages/step-usage.ts new file mode 100644 index 00000000000..3355e5254c8 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/step-usage.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const stepUsageSchema = z.object({ + input_other: z.number().int().nonnegative(), + output: z.number().int().nonnegative(), + input_cache_read: z.number().int().nonnegative(), + input_cache_creation: z.number().int().nonnegative(), +}); + +export type StepUsage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/step.ts b/packages/kap-server/src/protocol/messages/step.ts new file mode 100644 index 00000000000..92aed228995 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/step.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; +import { stepUsageSchema } from './step-usage'; + +export const stepTimingSchema = z.object({ + llm_first_token_ms: z.number().nonnegative().optional(), + llm_stream_duration_ms: z.number().nonnegative().optional(), +}); + +export type StepTiming = z.infer; + +export const stepRetrySchema = z.object({ + failed_attempt: z.number().int().positive(), + next_attempt: z.number().int().positive(), + max_attempts: z.number().int().positive(), + delay_ms: z.number().nonnegative(), + error_name: z.string(), + error_message: z.string(), + status_code: z.number().int().optional(), +}); + +export type StepRetry = z.infer; + +export const stepMessageSchema = z.object({ + type: z.literal('step'), + ...timelineMessageBase, + step_id: z.string().min(1), + turn_id: z.string().min(1), + ordinal: z.number().int().nonnegative(), + state: z.enum(['running', 'completed', 'interrupted', 'failed']), + started_at: isoDateTimeSchema.optional(), + ended_at: isoDateTimeSchema.optional(), + usage: stepUsageSchema.optional(), + finish_reason: z.string().optional(), + timing: stepTimingSchema.optional(), + retry: stepRetrySchema.optional(), + end_reason: z.string().optional(), + end_message: z.string().optional(), +}); + +export type StepMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/subscribe.ts b/packages/kap-server/src/protocol/messages/subscribe.ts new file mode 100644 index 00000000000..e0e2018c3fb --- /dev/null +++ b/packages/kap-server/src/protocol/messages/subscribe.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const subscribeMessageSchema = z.object({ + type: z.literal('subscribe'), + id: z.number().int().nonnegative(), + session_id: z.string().min(1), + agent_ids: z.array(z.string().min(1)).optional(), + omit: z.array(z.string().min(1)).optional(), +}); + +export type SubscribeMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/system.ts b/packages/kap-server/src/protocol/messages/system.ts new file mode 100644 index 00000000000..6eb57dbbb16 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/system.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; + +export const systemRemovedIdsPayloadSchema = z.object({ + removed_ids: z.array(z.string().min(1)), +}); + +export type SystemRemovedIdsPayload = z.infer; + +const systemMessageBase = { + type: z.literal('system'), + ...timelineMessageBase, + system_id: z.string().min(1), + at: isoDateTimeSchema.optional(), +}; + +export const systemMessageSchema = z.discriminatedUnion('subtype', [ + z.object({ + ...systemMessageBase, + subtype: z.literal('compaction'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('undo'), + payload: systemRemovedIdsPayloadSchema, + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('clear'), + payload: systemRemovedIdsPayloadSchema, + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('goal'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('plan.enter'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('plan.exit'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('plan.revision'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('swarm.enter'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('swarm.exit'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('skill'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('notice'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('hook'), + payload: z.unknown().optional(), + }), + z.object({ + ...systemMessageBase, + subtype: z.literal('interruption'), + payload: z.unknown().optional(), + }), +]); + +export type SystemMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/task.ts b/packages/kap-server/src/protocol/messages/task.ts new file mode 100644 index 00000000000..170f5db5760 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/task.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; +import { stepUsageSchema } from './step-usage'; + +export const taskMessageSchema = z.object({ + type: z.literal('task'), + ...timelineMessageBase, + task_id: z.string().min(1), + kind: z.enum(['shell', 'subagent', 'tool', 'other']), + state: z.enum(['running', 'completed', 'failed', 'timed_out', 'killed', 'lost']), + detached: z.boolean(), + description: z.string().optional(), + child_agent_id: z.string().min(1).optional(), + output_tail: z.string(), + started_at: isoDateTimeSchema.optional(), + ended_at: isoDateTimeSchema.optional(), + result_summary: z.string().optional(), + error: z.string().optional(), + state_reason: z.string().optional(), + usage: stepUsageSchema.optional(), + model: z.string().optional(), + thinking_effort: z.string().optional(), +}); + +export type TaskMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/thinking-delta.ts b/packages/kap-server/src/protocol/messages/thinking-delta.ts new file mode 100644 index 00000000000..62675a812d6 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/thinking-delta.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const thinkingDeltaMessageSchema = z.object({ + type: z.literal('thinking.delta'), + ...timelineMessageBase, + message_id: z.string().min(1), + text: z.string(), +}); + +export type ThinkingDelta = z.infer; diff --git a/packages/kap-server/src/protocol/messages/thinking.ts b/packages/kap-server/src/protocol/messages/thinking.ts new file mode 100644 index 00000000000..278f8c0ce05 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/thinking.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const thinkingMessageSchema = z.object({ + type: z.literal('thinking'), + ...timelineMessageBase, + message_id: z.string().min(1), + turn_id: z.string().min(1), + step_id: z.string().min(1), + status: z.enum(['streaming', 'completed']), + text: z.string(), +}); + +export type ThinkingMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/todo.ts b/packages/kap-server/src/protocol/messages/todo.ts new file mode 100644 index 00000000000..ee338b16b05 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/todo.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; + +export const todoItemSchema = z.object({ + title: z.string(), + status: z.enum(['pending', 'in_progress', 'done']), +}); + +export type TodoItem = z.infer; + +export const todoMessageSchema = z.object({ + type: z.literal('todo'), + ...timelineMessageBase, + todo_id: z.string().min(1), + items: z.array(todoItemSchema), + updated_at: isoDateTimeSchema.optional(), +}); + +export type TodoMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/tool-call-delta.ts b/packages/kap-server/src/protocol/messages/tool-call-delta.ts new file mode 100644 index 00000000000..37c1548b013 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/tool-call-delta.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const toolCallDeltaMessageSchema = z.object({ + type: z.literal('tool_call.delta'), + ...timelineMessageBase, + tool_call_id: z.string().min(1), + input_text: z.string(), +}); + +export type ToolCallDelta = z.infer; diff --git a/packages/kap-server/src/protocol/messages/tool-call.ts b/packages/kap-server/src/protocol/messages/tool-call.ts new file mode 100644 index 00000000000..645b53a9a24 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/tool-call.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; +import { toolProgressPayloadSchema } from './tool-progress'; + +export const toolCallAgentRefSchema = z.object({ + agent_id: z.string().min(1), + role: z.enum(['child', 'member']).optional(), +}); + +export type ToolCallAgentRef = z.infer; + +export const toolCallMessageSchema = z.object({ + type: z.literal('tool_call'), + ...timelineMessageBase, + tool_call_id: z.string().min(1), + turn_id: z.string().min(1), + step_id: z.string().min(1), + name: z.string().min(1), + view: z.string().optional(), + state: z.enum(['running', 'done', 'error']), + input: z.unknown().optional(), + input_text: z.string().optional(), + output: z.unknown().optional(), + display: z.unknown().optional(), + error: z.string().optional(), + progress: toolProgressPayloadSchema.optional(), + task_id: z.string().min(1).optional(), + approval_id: z.string().min(1).optional(), + todo_id: z.string().min(1).optional(), + agent_refs: z.array(toolCallAgentRefSchema).optional(), +}); + +export type ToolCallMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/tool-progress.ts b/packages/kap-server/src/protocol/messages/tool-progress.ts new file mode 100644 index 00000000000..dfefac93914 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/tool-progress.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { timelineMessageBase } from './base'; + +export const toolProgressPayloadSchema = z.object({ + kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), + text: z.string().optional(), + percent: z.number().optional(), + custom_kind: z.string().optional(), + custom_data: z.unknown().optional(), +}); + +export type ToolProgressPayload = z.infer; + +export const toolProgressMessageSchema = z.object({ + type: z.literal('tool.progress'), + ...timelineMessageBase, + tool_call_id: z.string().min(1), + progress: toolProgressPayloadSchema, +}); + +export type ToolProgress = z.infer; diff --git a/packages/kap-server/src/protocol/messages/turn-origin.ts b/packages/kap-server/src/protocol/messages/turn-origin.ts new file mode 100644 index 00000000000..64bac92d490 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/turn-origin.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const turnOriginSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('user') }), + z.object({ kind: z.literal('cron') }), + z.object({ kind: z.literal('task'), task_id: z.string().min(1) }), + z.object({ kind: z.literal('hook') }), + z.object({ kind: z.literal('compaction') }), + z.object({ kind: z.literal('side') }), + z.object({ kind: z.literal('goal') }), + z.object({ kind: z.literal('other') }), +]); + +export type TurnOrigin = z.infer; diff --git a/packages/kap-server/src/protocol/messages/turn.ts b/packages/kap-server/src/protocol/messages/turn.ts new file mode 100644 index 00000000000..2a8890a978f --- /dev/null +++ b/packages/kap-server/src/protocol/messages/turn.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; +import { turnOriginSchema } from './turn-origin'; + +export const turnUsageSchema = z.object({ + input_tokens: z.number().int().nonnegative().optional(), + output_tokens: z.number().int().nonnegative().optional(), + cached_tokens: z.number().int().nonnegative().optional(), + cost: z.number().nonnegative().optional(), +}); + +export type TurnUsage = z.infer; + +export const turnMessageSchema = z.object({ + type: z.literal('turn'), + ...timelineMessageBase, + turn_id: z.string().min(1), + ordinal: z.number().int().nonnegative(), + state: z.enum(['running', 'completed']), + origin: turnOriginSchema, + user_message_id: z.string().min(1).optional(), + attachment_ids: z.array(z.string().min(1)).optional(), + started_at: isoDateTimeSchema.optional(), + ended_at: isoDateTimeSchema.optional(), + usage: turnUsageSchema.optional(), + duration_ms: z.number().nonnegative().optional(), +}); + +export type TurnMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/union.ts b/packages/kap-server/src/protocol/messages/union.ts new file mode 100644 index 00000000000..5bc5ceed54d --- /dev/null +++ b/packages/kap-server/src/protocol/messages/union.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +import { ackMessageSchema } from './ack'; +import { assistantMessageSchema } from './assistant'; +import { assistantDeltaMessageSchema, type AssistantDelta } from './assistant-delta'; +import { capabilityMessageSchema } from './capability'; +import { configMessageSchema } from './config'; +import { configWarningMessageSchema } from './config-warning'; +import { errorMessageSchema } from './error'; +import { helloMessageSchema } from './hello'; +import { interactionMessageSchema } from './interaction'; +import { modelCatalogMessageSchema } from './model-catalog'; +import { pluginMessageSchema } from './plugin'; +import { sessionMessageSchema } from './session'; +import { sessionStateMessageSchema } from './session-state'; +import { stepMessageSchema } from './step'; +import { subscribeMessageSchema } from './subscribe'; +import { systemMessageSchema } from './system'; +import { taskMessageSchema } from './task'; +import { thinkingMessageSchema } from './thinking'; +import { thinkingDeltaMessageSchema, type ThinkingDelta } from './thinking-delta'; +import { todoMessageSchema } from './todo'; +import { toolCallMessageSchema } from './tool-call'; +import { toolCallDeltaMessageSchema, type ToolCallDelta } from './tool-call-delta'; +import { toolProgressMessageSchema, type ToolProgress } from './tool-progress'; +import { turnMessageSchema } from './turn'; +import { unsubscribeMessageSchema } from './unsubscribe'; +import { userMessageSchema } from './user'; +import { workspaceMessageSchema } from './workspace'; + +export const serverMessageSchema = z.discriminatedUnion('type', [ + turnMessageSchema, + stepMessageSchema, + userMessageSchema, + assistantMessageSchema, + assistantDeltaMessageSchema, + thinkingMessageSchema, + thinkingDeltaMessageSchema, + toolCallMessageSchema, + toolCallDeltaMessageSchema, + toolProgressMessageSchema, + systemMessageSchema, + interactionMessageSchema, + taskMessageSchema, + todoMessageSchema, + sessionStateMessageSchema, + sessionMessageSchema, + workspaceMessageSchema, + configMessageSchema, + configWarningMessageSchema, + modelCatalogMessageSchema, + pluginMessageSchema, + capabilityMessageSchema, + helloMessageSchema, + ackMessageSchema, + errorMessageSchema, +]); + +export type ServerMessage = z.infer; + +export type DeltaMessage = AssistantDelta | ThinkingDelta | ToolCallDelta | ToolProgress; + +export const clientMessageSchema = z.discriminatedUnion('type', [ + subscribeMessageSchema, + unsubscribeMessageSchema, +]); + +export type ClientMessage = z.infer; + +export class ContractViolation extends Error { + readonly issues: z.core.$ZodIssue[]; + readonly raw: unknown; + + constructor(issues: z.core.$ZodIssue[], raw: unknown) { + super( + `server message contract violation: ${issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`, + ); + this.name = 'ContractViolation'; + this.issues = issues; + this.raw = raw; + } +} + +export function parseServerMessage(raw: unknown): ServerMessage { + const result = serverMessageSchema.safeParse(raw); + if (!result.success) throw new ContractViolation(result.error.issues, raw); + return result.data; +} diff --git a/packages/kap-server/src/protocol/messages/unsubscribe.ts b/packages/kap-server/src/protocol/messages/unsubscribe.ts new file mode 100644 index 00000000000..9ea626e0220 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/unsubscribe.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const unsubscribeMessageSchema = z.object({ + type: z.literal('unsubscribe'), + id: z.number().int().nonnegative(), + session_id: z.string().min(1), +}); + +export type UnsubscribeMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/user-message-origin.ts b/packages/kap-server/src/protocol/messages/user-message-origin.ts new file mode 100644 index 00000000000..52fa763931a --- /dev/null +++ b/packages/kap-server/src/protocol/messages/user-message-origin.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const userMessageOriginSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('cron'), cron_id: z.string().min(1).optional(), schedule: z.string().min(1).optional() }), + z.object({ kind: z.literal('channel'), channel_id: z.string().min(1) }), + z.object({ kind: z.literal('task'), task_id: z.string().min(1) }), + z.object({ + kind: z.literal('skill'), + skill_name: z.string().min(1), + args: z.string().optional(), + trigger: z.string().optional(), + }), +]); + +export type UserMessageOrigin = z.infer; diff --git a/packages/kap-server/src/protocol/messages/user.ts b/packages/kap-server/src/protocol/messages/user.ts new file mode 100644 index 00000000000..a8a3308509b --- /dev/null +++ b/packages/kap-server/src/protocol/messages/user.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +import { isoDateTimeSchema, timelineMessageBase } from './base'; +import { userMessageOriginSchema } from './user-message-origin'; + +export const skillActivationSchema = z.object({ + skill_name: z.string().min(1), + skill_args: z.string().optional(), +}); + +export type SkillActivation = z.infer; + +export const taskNotificationPayloadSchema = z.object({ + title: z.string(), + body: z.string(), + severity: z.string().optional(), + type: z.string().optional(), + source_kind: z.string().optional(), + source_id: z.string().optional(), + agent_id: z.string().optional(), + raw: z.string().optional(), +}); + +export type TaskNotificationPayload = z.infer; + +export const userMessageSchema = z.object({ + type: z.literal('user'), + ...timelineMessageBase, + message_id: z.string().min(1), + turn_id: z.string().min(1), + step_id: z.string().min(1).optional(), + text: z.string(), + attachment_ids: z.array(z.string().min(1)).optional(), + skill_activations: z.array(skillActivationSchema).optional(), + status: z.enum(['running', 'completed']), + created_at: isoDateTimeSchema, + finished_at: isoDateTimeSchema.optional(), + steered_at: isoDateTimeSchema.optional(), + origin: userMessageOriginSchema.optional(), + notification: taskNotificationPayloadSchema.optional(), +}); + +export type UserMessage = z.infer; diff --git a/packages/kap-server/src/protocol/messages/workspace.ts b/packages/kap-server/src/protocol/messages/workspace.ts new file mode 100644 index 00000000000..1a23b568019 --- /dev/null +++ b/packages/kap-server/src/protocol/messages/workspace.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { globalMessageBase, isoDateTimeSchema } from './base'; + +export const workspaceIdSchema = z + .string() + .regex(/^wd_[a-z0-9._-]+_[0-9a-f]{12}$/, { + message: 'workspace_id must be a wd__ string', + }); + +export type WorkspaceId = z.infer; + +export const workspaceInfoSchema = z.object({ + id: workspaceIdSchema, + root: z.string().min(1), + name: z.string().min(1).max(100), + created_at: isoDateTimeSchema, + last_opened_at: isoDateTimeSchema, + session_count: z.number().int().nonnegative(), +}); + +export type WorkspaceInfo = z.infer; + +export const workspaceMessageSchema = z.object({ + type: z.literal('workspace'), + ...globalMessageBase, + subtype: z.enum(['created', 'updated', 'deleted']), + workspace: workspaceInfoSchema, +}); + +export type WorkspaceMessage = z.infer; diff --git a/packages/kap-server/src/routes/history.ts b/packages/kap-server/src/routes/history.ts new file mode 100644 index 00000000000..ffdc423da51 --- /dev/null +++ b/packages/kap-server/src/routes/history.ts @@ -0,0 +1,111 @@ +import { type Scope } from '@moonshot-ai/agent-core-v2'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { ErrorCode } from '../protocol/error-codes'; +import { historyResponseSchema } from '../protocol/messages'; +import { requestLog } from '../lib/requestLog'; +import { defineRoute } from '../middleware/defineRoute'; +import { + HistorySessionNotFoundError, + readSessionHistory, + type HistoryServiceDeps, +} from '../services/history/historyService'; + +interface HistoryRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const historyQueryCoercion = z + .object({ + before_turn: z.string().min(1).optional(), + after_step: z.string().min(1).optional(), + page_size: z.coerce.number().int().min(1).max(500).optional(), + agent_id: z.string().min(1).optional(), + }) + .superRefine((value, ctx) => { + if (value.before_turn !== undefined && value.after_step !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'before_turn and after_step are mutually exclusive', + path: ['before_turn'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (value.agent_id !== undefined && !isPlainAgentId(value.agent_id)) { + ctx.addIssue({ + code: 'custom', + message: 'agent_id must be a plain agent id (no path separators)', + path: ['agent_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const AGENT_ID_PATTERN = /^[^/\\]+$/; + +function isPlainAgentId(agentId: string): boolean { + return AGENT_ID_PATTERN.test(agentId) && agentId !== '.' && agentId !== '..'; +} + +export interface HistoryRouteDeps { + readonly core: Scope; + readonly homeDir: string; + readonly projection: HistoryServiceDeps['projection']; +} + +export function registerHistoryRoutes(app: HistoryRouteHost, deps: HistoryRouteDeps): void { + const route = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/history', + params: sessionIdParamSchema, + querystring: historyQueryCoercion, + success: { data: historyResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: + 'Flat entity-message history of one agent timeline, cold-rebuilt from the persisted wire records (live sessions flush first). Messages are time-ordered and share the WS entity schemas. before_turn pages to older turns, after_step catches up newer than a step, page_size bounds the page (default 200, max 500). agent_id defaults to the main agent. Live sessions carry in_flight with the current streaming position', + tags: ['history'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const page = await readSessionHistory(deps, session_id, req.query); + reply.send(okEnvelope(page, req.id)); + } catch (error) { + if (error instanceof HistorySessionNotFoundError) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, error.message, req.id, error.stack), + ); + return; + } + requestLog(req)?.error({ err: error }, 'history request failed'); + reply.send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + error instanceof Error ? error.message : String(error), + req.id, + error instanceof Error ? error.stack : undefined, + ), + ); + } + }, + ); + app.get(route.path, route.options, route.handler as Parameters[2]); +} diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 992a0b2a863..9f83e012bb1 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -9,6 +9,7 @@ import { okEnvelope } from '../envelope'; import type { MetaFeature } from '../protocol/rest-meta'; import { type IConnectionRegistry } from '../transport/ws/connectionRegistry'; import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; +import type { ProjectionService } from '../services/projection'; import type { TranscriptService } from '../services/transcript/transcriptService'; import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; @@ -19,6 +20,7 @@ import { registerFileHistoryRoutes } from './fileHistory'; import { registerFilesRoutes } from './files'; import { registerFsRoutes } from './fs'; import { registerGuiStoreRoutes } from './guiStore'; +import { registerHistoryRoutes } from './history'; import { registerMessagesRoutes } from './messages'; import type { IGuiStoreService } from '../services/guiStore/guiStore'; import { registerDebugRoutes } from '../transport/registerDebugRoutes'; @@ -69,6 +71,8 @@ export interface RegisterApiV1RoutesOptions { readonly connectionRegistry: IConnectionRegistry; readonly broadcaster: SessionEventBroadcaster; readonly transcriptService: TranscriptService; + readonly homeDir: string; + readonly projectionService: ProjectionService; readonly pluginMarketplaceUrl: () => string; readonly pluginMarketplaceIsDefault: boolean; readonly dangerousBypassAuth?: boolean; @@ -140,6 +144,11 @@ export async function registerApiV1Routes( apiV1 as unknown as Parameters[0], core, ); + registerHistoryRoutes(apiV1 as unknown as Parameters[0], { + core, + homeDir: opts.homeDir, + projection: opts.projectionService, + }); registerSearchRoutes(apiV1 as unknown as Parameters[0], core); registerTasksRoutes(apiV1 as unknown as Parameters[0], core); registerApprovalsRoutes( diff --git a/packages/kap-server/src/services/history/coldFold.ts b/packages/kap-server/src/services/history/coldFold.ts new file mode 100644 index 00000000000..d29f76a9eed --- /dev/null +++ b/packages/kap-server/src/services/history/coldFold.ts @@ -0,0 +1,1743 @@ +import { + daemonFileRefFromPart, + parseDaemonFileUrl, + type TokenUsage, +} from '@moonshot-ai/agent-core-v2'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message'; + +import type { + HistoryMessage, + InteractionMessage, + StepTiming, + StepUsage, + SystemMessage, + TaskMessage, + TaskNotificationPayload, + TurnOrigin, + UserMessageOrigin, +} from '../../protocol/messages'; +import { + mapInteractionEndState, + notificationTextOf, + parseToolArgs, + promptTextOf, + skillActivationsOf, + taskNotificationOriginOf, + todoWriteItems, + toTurnOrigin, + userOriginOf, + wantsUserMessage, + wireInteractionRequest, + wireInteractionResponse, +} from '../projection/agentProjector'; +import type { ContextRecord } from '../projection/heal'; +import { + SystemIdAllocator, + TODO_ENTITY_ID, + attachmentIdOf, + isUndoAnchorOrigin, + isVisibleTurnOrigin, + stepIdOf, + stepUserMessageIdOf, + textMessageIdOf, + turnIdOf, + turnOrdinalOf, + turnUserMessageIdOf, + type DurableSystemSubtype, +} from '../projection/ids'; + +export interface ColdFoldOptions { + readonly sessionId: string; + readonly agentId: string; + readonly live: boolean; + readonly fallbackTimestamp: string; + readonly subagentTaskIds?: ReadonlyMap; + readonly resolvePlanRevisionKey?: (key: string) => string; +} + +interface TurnDraft { + readonly turnId: string; + readonly rawId: number; + readonly origin: TurnOrigin; + state: 'running' | 'completed'; + userMessageId?: string; + attachmentIds?: string[]; + startedAt?: string; + endedAt?: string; + durationMs?: number; + at: string; +} + +interface StepDraft { + readonly stepId: string; + readonly turnId: string; + readonly ordinal: number; + state: 'running' | 'completed' | 'interrupted' | 'failed'; + startedAt?: string; + endedAt?: string; + usage?: StepUsage; + finishReason?: string; + timing?: StepTiming; + retry?: { + failed_attempt: number; + next_attempt: number; + max_attempts: number; + delay_ms: number; + error_name: string; + error_message: string; + status_code?: number; + }; + endReason?: string; + endMessage?: string; + at: string; +} + +interface TextDraft { + readonly messageId: string; + readonly kind: 'assistant' | 'thinking'; + readonly turnId: string; + readonly stepId: string; + text: string; + at: string; +} + +interface ToolDraft { + readonly toolCallId: string; + readonly turnId: string; + readonly stepId: string; + name: string; + state: 'running' | 'done' | 'error'; + input?: unknown; + inputText?: string; + output?: unknown; + error?: string; + taskId?: string; + approvalId?: string; + todoId?: string; + agentRefs: { agent_id: string; role?: 'child' | 'member' }[]; + at: string; +} + +interface UserDraft { + readonly messageId: string; + readonly turnId: string; + readonly stepId?: string; + readonly text: string; + status: 'running' | 'completed'; + createdAt: string; + finishedAt?: string; + steeredAt?: string; + origin?: UserMessageOrigin; + notification?: TaskNotificationPayload; + attachmentIds?: string[]; + skillActivations?: { skill_name: string; skill_args?: string }[]; + at: string; +} + +interface SystemDraft { + readonly systemId: string; + readonly subtype: SystemMessage['subtype']; + readonly payload: unknown; + readonly at?: string; +} + +interface InteractionDraft { + readonly interactionId: string; + readonly kind: 'approval' | 'question'; + state: InteractionMessage['state']; + toolCallId?: string; + request?: unknown; + response?: unknown; + at: string; +} + +interface TaskDraft { + readonly taskId: string; + readonly kind: TaskMessage['kind']; + state: TaskMessage['state']; + detached: boolean; + description?: string; + childAgentId?: string; + outputTail: string; + startedAt?: string; + endedAt?: string; + resultSummary?: string; + error?: string; + stateReason?: string; + usage?: StepUsage; + model?: string; + thinkingEffort?: string; + at: string; +} + +interface PendingSteer { + readonly input: readonly ContentPart[]; + readonly origin: UserMessageOrigin | undefined; + readonly skillActivations: { skill_name: string; skill_args?: string }[] | undefined; + readonly skipBlocks: number; + readonly at: string; + readonly notification?: { readonly payload: TaskNotificationPayload; readonly text: string }; +} + +interface TurnScratch { + currentStep?: number; + userSeq: number; + attachmentSeq: number; + pendingSteers: PendingSteer[]; + openingInputKey?: string; + openingSteerDeduped: boolean; +} + +interface GoalState { + objective: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + completionCriterion?: string; + budgetUsed?: number; + budgetLimit?: number; +} + +const TASK_STATES = new Set([ + 'running', + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); + +const GOAL_STATUSES = new Set(['active', 'paused', 'blocked', 'complete']); + +export function foldWireHistory( + records: readonly ContextRecord[], + options: ColdFoldOptions, +): HistoryMessage[] { + const turns = new Map(); + const steps = new Map(); + const texts = new Map(); + const stepTextIds = new Map(); + const stepTextSeqs = new Map(); + const tools = new Map(); + const users = new Map(); + const systems = new Map(); + const interactions = new Map(); + const tasks = new Map(); + const order: string[] = []; + const timelineIds: string[] = []; + const sysIds = new SystemIdAllocator(); + + const stepRefs = new Map(); + const scratchByTurn = new Map(); + let currentTurn: number | undefined; + + let nextTurnId = 0; + let phantomUserSeq = 0; + const cancelledTurnIds = new Set(); + const hiddenTurnIds = new Set(); + const turnPromptIds = new Map(); + const pendingAnchorTurnIds: number[] = []; + const undoAnchors: { rawId: number }[] = []; + let undoAnchorFloor = 0; + const activeCancelTurnIds = new Set(); + + const queuedPrompts = new Map(); + + const subagentTaskIds = new Map(options.subagentTaskIds ?? []); + const agentTaskLinks: { taskId: string; agentId: string; parentToolCallId?: string }[] = []; + for (const record of records) { + if (record.type !== 'task.started' && record.type !== 'task.terminated') continue; + const info = record['info'] as { kind?: unknown; agentId?: unknown; taskId?: unknown; parentToolCallId?: unknown } | undefined; + if (info?.kind !== 'agent') continue; + if (typeof info.agentId !== 'string' || typeof info.taskId !== 'string') continue; + subagentTaskIds.set(info.agentId, info.taskId); + agentTaskLinks.push({ + taskId: info.taskId, + agentId: info.agentId, + parentToolCallId: typeof info.parentToolCallId === 'string' ? info.parentToolCallId : undefined, + }); + } + + let goal: GoalState | undefined; + let lastAt = options.fallbackTimestamp; + + const at = (record: ContextRecord): string => { + const time = record.time; + if (typeof time === 'number' && Number.isFinite(time)) { + lastAt = new Date(time).toISOString(); + } + return lastAt; + }; + + const scratch = (rawId: number): TurnScratch => { + let entry = scratchByTurn.get(rawId); + if (entry === undefined) { + entry = { userSeq: 0, attachmentSeq: 0, pendingSteers: [], openingSteerDeduped: false }; + scratchByTurn.set(rawId, entry); + } + return entry; + }; + + const pushSystem = ( + subtype: DurableSystemSubtype, + payload: unknown, + recordAt: string, + ): void => { + const systemId = sysIds.next(subtype); + systems.set(systemId, { systemId, subtype, payload, at: recordAt }); + order.push(`sys:${systemId}`); + timelineIds.push(systemId); + }; + + const skipCancelledTurnIds = (): void => { + while (cancelledTurnIds.delete(nextTurnId)) { + hiddenTurnIds.add(nextTurnId); + nextTurnId += 1; + } + }; + + const createTextDraft = ( + stepId: string, + turnId: string, + kind: 'assistant' | 'thinking', + recordAt: string, + ): TextDraft => { + const seq = (stepTextSeqs.get(stepId) ?? 0) + 1; + stepTextSeqs.set(stepId, seq); + const draft: TextDraft = { + messageId: textMessageIdOf(stepId, seq), + kind, + turnId, + stepId, + text: '', + at: recordAt, + }; + texts.set(draft.messageId, draft); + const entry = stepTextIds.get(stepId) ?? {}; + entry[kind] = draft.messageId; + stepTextIds.set(stepId, entry); + order.push(`text:${draft.messageId}`); + return draft; + }; + + const ensureStepDraft = ( + rawId: number, + stepOrdinal: number, + recordAt: string, + ): StepDraft | undefined => { + if (hiddenTurnIds.has(rawId)) return undefined; + const turnId = turnIdOf(rawId); + if (!turns.has(turnId)) return undefined; + const stepId = stepIdOf(turnId, stepOrdinal); + const existing = steps.get(stepId); + if (existing !== undefined) return existing; + const draft: StepDraft = { + stepId, + turnId, + ordinal: stepOrdinal, + state: 'running', + startedAt: recordAt, + at: recordAt, + }; + steps.set(stepId, draft); + order.push(`step:${stepId}`); + return draft; + }; + + const emitSteer = (rawId: number, step: StepDraft, steer: PendingSteer): void => { + const turnId = turnIdOf(rawId); + const entry = scratch(rawId); + entry.userSeq += 1; + const messageId = stepUserMessageIdOf(step.stepId, entry.userSeq); + const textsOut: string[] = []; + const attachmentIds: string[] = []; + for (const part of steer.input.slice(steer.skipBlocks)) { + if (part.type === 'text') { + textsOut.push(part.text); + continue; + } + if (daemonFileRefFromPart(part) === undefined) continue; + entry.attachmentSeq += 1; + attachmentIds.push(attachmentIdOf(step.stepId, entry.attachmentSeq)); + } + const draft: UserDraft = { + messageId, + turnId, + stepId: step.stepId, + text: steer.notification?.text ?? textsOut.join(''), + status: 'running', + createdAt: steer.at, + steeredAt: steer.at, + origin: steer.origin, + notification: steer.notification?.payload, + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + skillActivations: steer.skillActivations, + at: steer.at, + }; + users.set(messageId, draft); + order.push(`user:${messageId}`); + }; + + const flushSteers = (rawId: number, step: StepDraft): void => { + const entry = scratch(rawId); + for (const steer of entry.pendingSteers) emitSteer(rawId, step, steer); + entry.pendingSteers = []; + }; + + const dropTurnDetails = (turnId: string): void => { + turns.delete(turnId); + for (const [stepId, step] of steps) { + if (step.turnId !== turnId) continue; + steps.delete(stepId); + const entry = stepTextIds.get(stepId); + if (entry?.assistant !== undefined) texts.delete(entry.assistant); + if (entry?.thinking !== undefined) texts.delete(entry.thinking); + stepTextIds.delete(stepId); + stepTextSeqs.delete(stepId); + } + for (const [toolCallId, tool] of tools) { + if (tool.turnId === turnId) tools.delete(toolCallId); + } + for (const [messageId, user] of users) { + if (user.turnId === turnId) users.delete(messageId); + } + }; + + const removedKeys = new Set(); + const truncateTimeline = (cutIndex: number): string[] => { + const removed = timelineIds.slice(cutIndex); + for (const id of removed) { + if (turnOrdinalOf(id) !== undefined) { + dropTurnDetails(id); + } else { + systems.delete(id); + } + } + timelineIds.length = cutIndex; + return removed; + }; + + const pruneOrder = (): void => { + const kept = order.filter((key) => !removedKeys.has(key)); + order.length = 0; + order.push(...kept); + removedKeys.clear(); + }; + + const markRemoved = (ids: readonly string[]): void => { + for (const id of ids) { + if (turnOrdinalOf(id) !== undefined) { + removedKeys.add(`turn:${id}`); + for (const [stepId, step] of steps) { + if (step.turnId === id) removedKeys.add(`step:${stepId}`); + } + for (const messageId of texts.keys()) { + if (texts.get(messageId)?.turnId === id) removedKeys.add(`text:${messageId}`); + } + for (const [toolCallId, tool] of tools) { + if (tool.turnId === id) removedKeys.add(`tool:${toolCallId}`); + } + for (const [messageId, user] of users) { + if (user.turnId === id) removedKeys.add(`user:${messageId}`); + } + } else { + removedKeys.add(`sys:${id}`); + } + } + }; + + const onTurnPrompt = (record: ContextRecord): void => { + skipCancelledTurnIds(); + const rawId = nextTurnId; + nextTurnId += 1; + phantomUserSeq = 0; + const origin = record['origin']; + const promptId = record['promptId']; + if (typeof promptId === 'string') { + turnPromptIds.set(rawId, promptId); + queuedPrompts.delete(promptId); + } + if (isUndoAnchorOrigin(origin)) pendingAnchorTurnIds.push(rawId); + currentTurn = rawId; + if (!isVisibleTurnOrigin(origin)) { + hiddenTurnIds.add(rawId); + return; + } + const recordAt = at(record); + const turnId = turnIdOf(rawId); + const input = Array.isArray(record['input']) ? (record['input'] as ContentPart[]) : []; + const skipBlocks = bundledSkillCount(origin); + const promptText = turnPromptText(input, skipBlocks); + const attachments = promptAttachmentCount(input, origin); + const attachmentIds = + attachments > 0 + ? Array.from({ length: attachments }, (_, i) => attachmentIdOf(turnId, i + 1)) + : undefined; + const wantsUser = wantsUserMessage(origin, promptText); + const taskOrigin = taskNotificationOriginOf(origin); + const draft: TurnDraft = { + turnId, + rawId, + origin: toTurnOrigin(origin, options.agentId, subagentTaskIds), + state: 'running', + userMessageId: wantsUser || taskOrigin !== undefined ? turnUserMessageIdOf(turnId) : undefined, + attachmentIds, + startedAt: recordAt, + at: recordAt, + }; + turns.set(turnId, draft); + order.push(`turn:${turnId}`); + timelineIds.push(turnId); + scratchByTurn.set(rawId, { + userSeq: 0, + attachmentSeq: 0, + pendingSteers: [], + openingInputKey: JSON.stringify(input), + openingSteerDeduped: false, + }); + if (draft.userMessageId !== undefined) { + const notification = + taskOrigin === undefined ? undefined : parseNotificationXmlText(promptText ?? ''); + const user: UserDraft = { + messageId: draft.userMessageId, + turnId, + text: notification === undefined ? (promptText ?? '') : notificationTextOf(notification), + status: 'running', + createdAt: recordAt, + origin: taskOrigin ?? userOriginOf(origin), + notification, + attachmentIds, + skillActivations: skillActivationsOf(origin), + at: recordAt, + }; + users.set(user.messageId, user); + order.push(`user:${user.messageId}`); + } + emitSkillSystems(origin, input, recordAt, pushSystem); + }; + + const onTurnSteer = (record: ContextRecord): void => { + const origin = record['origin'] as + | { kind?: string; skillActivations?: readonly { skillName: string; skillArgs?: string }[]; trigger?: string } + | undefined; + const kind = origin?.kind; + if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'cron_job') return; + if (kind === 'skill_activation' && origin?.trigger !== 'user-slash') return; + const rawId = currentTurn; + if (rawId === undefined || hiddenTurnIds.has(rawId)) return; + const input = Array.isArray(record['input']) ? (record['input'] as ContentPart[]) : []; + const steer: PendingSteer = { + input, + origin: userOriginOf(origin), + skillActivations: skillActivationsOf(origin), + skipBlocks: kind === 'user' ? (origin?.skillActivations?.length ?? 0) : 0, + at: at(record), + }; + const entry = scratch(rawId); + if ( + entry.currentStep === undefined && + !entry.openingSteerDeduped && + entry.openingInputKey !== undefined && + entry.openingInputKey === JSON.stringify(input) + ) { + entry.openingSteerDeduped = true; + return; + } + const stepOrdinal = entry.currentStep; + if (stepOrdinal !== undefined) { + const step = steps.get(stepIdOf(turnIdOf(rawId), stepOrdinal)); + if (step !== undefined && step.state === 'running') { + emitSteer(rawId, step, steer); + return; + } + } + entry.pendingSteers.push(steer); + }; + + const onLoopEvent = (record: ContextRecord): void => { + const event = record['event'] as { type?: string } | undefined; + if (event?.type === undefined) return; + switch (event.type) { + case 'step.begin': { + const e = event as { uuid: string; turnId?: string; step?: number }; + if (e.turnId === undefined || e.step === undefined) return; + const turn = Number(e.turnId); + if (!Number.isInteger(turn)) return; + stepRefs.set(e.uuid, { turn, step: e.step }); + const draft = ensureStepDraft(turn, e.step, at(record)); + if (draft === undefined) return; + draft.startedAt = draft.startedAt ?? at(record); + const entry = scratch(turn); + entry.currentStep = e.step; + entry.userSeq = 0; + entry.attachmentSeq = 0; + currentTurn = turn; + flushSteers(turn, draft); + return; + } + case 'step.end': { + const e = event as { + uuid: string; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + usage?: TokenUsage; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + }; + const ref = stepRefs.get(e.uuid); + if (ref === undefined) return; + const draft = steps.get(stepIdOf(turnIdOf(ref.turn), ref.step)); + if (draft === undefined) return; + draft.state = 'completed'; + draft.endedAt = at(record); + draft.usage = e.usage === undefined ? undefined : toSnakeUsage(e.usage); + draft.finishReason = e.finishReason ?? e.rawFinishReason ?? e.providerFinishReason; + draft.timing = + e.llmFirstTokenLatencyMs === undefined && e.llmStreamDurationMs === undefined + ? undefined + : { + llm_first_token_ms: e.llmFirstTokenLatencyMs, + llm_stream_duration_ms: e.llmStreamDurationMs, + }; + draft.retry = undefined; + draft.at = at(record); + return; + } + case 'content.part': { + const e = event as { + stepUuid: string; + part: { type: string; text?: string; think?: string }; + turnId?: string; + step?: number; + }; + const ref = resolveStepRef(stepRefs, e.stepUuid, e.turnId, e.step); + if (ref === undefined) return; + const draft = ensureStepDraft(ref.turn, ref.step, at(record)); + if (draft === undefined) return; + const kind = e.part.type === 'text' ? 'assistant' : e.part.type === 'think' ? 'thinking' : undefined; + const partText = e.part.type === 'think' ? e.part.think : e.part.text; + if (kind === undefined || typeof partText !== 'string') return; + const stepId = draft.stepId; + const existingId = stepTextIds.get(stepId)?.[kind]; + const text = existingId === undefined ? undefined : texts.get(existingId); + const target = text ?? createTextDraft(stepId, draft.turnId, kind, at(record)); + target.text += partText; + target.at = at(record); + return; + } + case 'tool.call': { + const e = event as { + stepUuid: string; + toolCallId: string; + name: string; + args?: unknown; + turnId?: string; + step?: number; + }; + const ref = resolveStepRef(stepRefs, e.stepUuid, e.turnId, e.step); + if (ref === undefined) return; + const draft = ensureStepDraft(ref.turn, ref.step, at(record)); + if (draft === undefined) return; + const existing = tools.get(e.toolCallId); + const input = parseToolArgs(e.args); + const tool: ToolDraft = { + toolCallId: e.toolCallId, + turnId: draft.turnId, + stepId: draft.stepId, + name: e.name, + state: existing?.state ?? 'running', + input, + inputText: typeof e.args === 'string' ? e.args : undefined, + output: existing?.output, + error: existing?.error, + taskId: existing?.taskId ?? taskIdByToolCall(e.toolCallId), + approvalId: existing?.approvalId, + todoId: + existing?.todoId ?? + (e.name === 'TodoList' && todoWriteItems(input) !== undefined + ? TODO_ENTITY_ID + : undefined), + agentRefs: existing?.agentRefs ?? agentRefsOf(e.toolCallId), + at: at(record), + }; + tools.set(e.toolCallId, tool); + if (existing === undefined) order.push(`tool:${e.toolCallId}`); + return; + } + case 'tool.result': { + const e = event as { + toolCallId: string; + result: { output: unknown; isError?: boolean }; + }; + const existing = tools.get(e.toolCallId); + if (existing === undefined) return; + const isError = e.result.isError === true; + existing.state = isError ? 'error' : 'done'; + existing.output = e.result.output; + existing.error = isError && typeof e.result.output === 'string' ? e.result.output : undefined; + existing.at = at(record); + return; + } + default: + return; + } + }; + + const taskIdByToolCall = (toolCallId: string): string | undefined => { + for (const link of agentTaskLinks) { + if (link.parentToolCallId === toolCallId) return link.taskId; + } + return undefined; + }; + + const agentRefsOf = (toolCallId: string): { agent_id: string; role?: 'child' | 'member' }[] => { + const refs: { agent_id: string; role?: 'child' | 'member' }[] = []; + for (const link of agentTaskLinks) { + if (link.parentToolCallId === toolCallId) refs.push({ agent_id: link.agentId, role: 'child' }); + } + return refs; + }; + + const onTaskNotificationAppend = ( + message: { content?: ContentPart[] }, + taskOrigin: Extract, + record: ContextRecord, + ): void => { + const recordAt = at(record); + const input = Array.isArray(message.content) ? message.content : []; + const rawText = promptTextOf(input); + const notification = parseNotificationXmlText(rawText); + const rawId = currentTurn; + if (rawId !== undefined && !hiddenTurnIds.has(rawId)) { + const turnId = turnIdOf(rawId); + const turn = turns.get(turnId); + const entry = scratchByTurn.get(rawId); + if ( + turn !== undefined && + turn.origin.kind === 'task' && + turn.origin.task_id === taskOrigin.task_id && + entry?.currentStep === undefined + ) { + return; + } + if (turn !== undefined && turn.state === 'running') { + const steer: PendingSteer = { + input, + origin: taskOrigin, + skillActivations: undefined, + skipBlocks: 0, + at: recordAt, + notification: + notification === undefined + ? undefined + : { payload: notification, text: notificationTextOf(notification) }, + }; + const stepOrdinal = entry?.currentStep; + if (stepOrdinal !== undefined) { + const step = steps.get(stepIdOf(turnId, stepOrdinal)); + if (step !== undefined && step.state === 'running') { + emitSteer(rawId, step, steer); + return; + } + } + scratch(rawId).pendingSteers.push(steer); + return; + } + } + phantomUserSeq += 1; + const turnId = turnIdOf(nextTurnId); + const draft: UserDraft = { + messageId: `${turnId}.u${phantomUserSeq}`, + turnId, + text: notification === undefined ? rawText : notificationTextOf(notification), + status: 'completed', + createdAt: recordAt, + origin: taskOrigin, + notification, + at: recordAt, + }; + users.set(draft.messageId, draft); + order.push(`user:${draft.messageId}`); + }; + + const onAppendMessage = (record: ContextRecord): void => { + const message = record['message'] as + | { + id?: string; + role?: string; + content?: ContentPart[]; + toolCalls?: readonly { id: string; name: string; arguments: string | null }[]; + toolCallId?: string; + isError?: boolean; + origin?: unknown; + } + | undefined; + if (message?.role === undefined) return; + if (message.role === 'user') { + const taskOrigin = taskNotificationOriginOf(message.origin); + if (taskOrigin !== undefined) { + onTaskNotificationAppend(message, taskOrigin, record); + return; + } + if (!isUndoAnchorOrigin(message.origin)) return; + const messageId = typeof message.id === 'string' ? message.id : undefined; + const matchingIndex = + messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => turnPromptIds.get(turnId) === messageId) + : -1; + const legacyIndex = + matchingIndex < 0 && messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => !turnPromptIds.has(turnId)) + : -1; + const matchedTurnId = + matchingIndex >= 0 + ? pendingAnchorTurnIds.splice(matchingIndex, 1)[0] + : legacyIndex >= 0 + ? pendingAnchorTurnIds.splice(legacyIndex, 1)[0] + : messageId === undefined + ? pendingAnchorTurnIds.shift() + : undefined; + if (matchedTurnId !== undefined && !turnPromptIds.has(matchedTurnId) && messageId !== undefined) { + turnPromptIds.set(matchedTurnId, messageId); + } + undoAnchors.push({ rawId: matchedTurnId ?? nextTurnId }); + return; + } + if (message.role === 'assistant') { + const recordAt = at(record); + let rawId = currentTurn; + if (rawId === undefined || hiddenTurnIds.has(rawId) || !turns.has(turnIdOf(rawId))) { + rawId = nextTurnId; + nextTurnId += 1; + const turnId = turnIdOf(rawId); + const draft: TurnDraft = { + turnId, + rawId, + origin: { kind: 'other' }, + state: 'running', + startedAt: recordAt, + at: recordAt, + }; + turns.set(turnId, draft); + order.push(`turn:${turnId}`); + timelineIds.push(turnId); + currentTurn = rawId; + scratchByTurn.set(rawId, { + userSeq: 0, + attachmentSeq: 0, + pendingSteers: [], + openingSteerDeduped: false, + }); + } + const entry = scratch(rawId); + const ordinal = (entry.currentStep ?? 0) + 1; + const step = ensureStepDraft(rawId, ordinal, recordAt); + if (step === undefined) return; + entry.currentStep = ordinal; + step.state = 'completed'; + step.endedAt = recordAt; + step.at = recordAt; + for (const part of message.content ?? []) { + if (part.type === 'text' && typeof part.text === 'string' && part.text.length > 0) { + const existingId = stepTextIds.get(step.stepId)?.assistant; + const target = + (existingId === undefined ? undefined : texts.get(existingId)) ?? + createTextDraft(step.stepId, step.turnId, 'assistant', recordAt); + target.text += part.text; + target.at = recordAt; + } else if (part.type === 'think') { + const think = (part as { think?: unknown }).think; + if (typeof think !== 'string' || think.length === 0) continue; + const existingId = stepTextIds.get(step.stepId)?.thinking; + const target = + (existingId === undefined ? undefined : texts.get(existingId)) ?? + createTextDraft(step.stepId, step.turnId, 'thinking', recordAt); + target.text += think; + target.at = recordAt; + } + } + for (const call of message.toolCalls ?? []) { + if (tools.has(call.id)) continue; + const input = parseToolArgs(call.arguments ?? undefined); + const tool: ToolDraft = { + toolCallId: call.id, + turnId: step.turnId, + stepId: step.stepId, + name: call.name, + state: 'running', + input, + inputText: typeof call.arguments === 'string' ? call.arguments : undefined, + taskId: taskIdByToolCall(call.id), + todoId: + call.name === 'TodoList' && todoWriteItems(input) !== undefined + ? TODO_ENTITY_ID + : undefined, + agentRefs: agentRefsOf(call.id), + at: recordAt, + }; + tools.set(call.id, tool); + order.push(`tool:${call.id}`); + } + return; + } + if (message.role === 'tool') { + const toolCallId = message.toolCallId; + if (typeof toolCallId !== 'string') return; + const existing = tools.get(toolCallId); + if (existing === undefined) return; + const output = promptTextOf(message.content ?? []); + const isError = message.isError === true; + existing.state = isError ? 'error' : 'done'; + existing.output = output; + existing.error = isError ? output : undefined; + existing.at = at(record); + return; + } + }; + + const onTurnEnded = (record: ContextRecord): void => { + const rawId = record['turnId']; + if (typeof rawId !== 'number' || !Number.isInteger(rawId)) return; + const pendingIndex = pendingAnchorTurnIds.indexOf(rawId); + if (pendingIndex >= 0) pendingAnchorTurnIds.splice(pendingIndex, 1); + const draft = turns.get(turnIdOf(rawId)); + if (draft === undefined) return; + const recordAt = at(record); + const reason = record['reason']; + const entry = scratch(rawId); + let step = + entry.currentStep === undefined + ? undefined + : steps.get(stepIdOf(turnIdOf(rawId), entry.currentStep)); + if (step === undefined && entry.pendingSteers.length > 0) { + const ordinal = (entry.currentStep ?? 0) + 1; + step = ensureStepDraft(rawId, ordinal, recordAt); + entry.currentStep = ordinal; + } + if (step !== undefined && step.state === 'running') { + step.state = reason === 'failed' || reason === 'blocked' ? 'failed' : 'interrupted'; + step.endedAt = recordAt; + step.at = recordAt; + } + if (step !== undefined) flushSteers(rawId, step); + entry.pendingSteers = []; + draft.state = 'completed'; + draft.endedAt = recordAt; + draft.durationMs = typeof record['durationMs'] === 'number' ? record['durationMs'] : undefined; + draft.at = recordAt; + for (const user of users.values()) { + if (user.turnId !== draft.turnId || user.status !== 'running') continue; + user.status = 'completed'; + user.finishedAt = recordAt; + user.at = recordAt; + } + }; + + const onUndo = (record: ContextRecord): void => { + const count = record['count']; + if (typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) return; + let firstUndone: number | undefined; + for (let i = 0; i < count && undoAnchors.length > undoAnchorFloor; i++) { + const anchor = undoAnchors.pop(); + if (anchor !== undefined) firstUndone = anchor.rawId; + } + if (firstUndone === undefined) return; + const cut = timelineIds.findIndex((id) => { + const ordinal = turnOrdinalOf(id); + return ordinal !== undefined && ordinal >= firstUndone; + }); + if (cut < 0) return; + const removed = timelineIds.slice(cut); + markRemoved(removed); + truncateTimeline(cut); + pruneOrder(); + for (let turnId = firstUndone; turnId < nextTurnId; turnId++) hiddenTurnIds.add(turnId); + if (currentTurn !== undefined && currentTurn >= firstUndone) currentTurn = undefined; + pushSystem('undo', { removed_ids: removed }, at(record)); + }; + + const onClear = (record: ContextRecord): void => { + const removed = [...timelineIds]; + markRemoved(removed); + for (const id of removed) { + if (turnOrdinalOf(id) !== undefined) dropTurnDetails(id); + } + systems.clear(); + timelineIds.length = 0; + pruneOrder(); + undoAnchorFloor = undoAnchors.length; + currentTurn = undefined; + scratchByTurn.clear(); + pushSystem('clear', { removed_ids: removed }, at(record)); + }; + + const onInteractionRequest = (record: ContextRecord): void => { + const kind = record['kind']; + if (kind !== 'approval' && kind !== 'question') return; + const id = record['id']; + if (typeof id !== 'string') return; + const payload = record['request']; + const innerToolCallId = (payload as { toolCallId?: unknown } | undefined)?.toolCallId; + const toolCallId = + typeof record['toolCallId'] === 'string' + ? record['toolCallId'] + : typeof innerToolCallId === 'string' + ? innerToolCallId + : undefined; + const recordAt = at(record); + const draft: InteractionDraft = { + interactionId: id, + kind, + state: 'pending', + toolCallId, + request: wireInteractionRequest(kind, payload), + at: recordAt, + }; + interactions.set(id, draft); + order.push(`ix:${id}`); + if (toolCallId !== undefined) { + const tool = tools.get(toolCallId); + if (tool !== undefined && tool.approvalId !== id) { + tool.approvalId = id; + tool.at = recordAt; + } + } + }; + + const onInteractionResolved = (record: ContextRecord): void => { + const id = record['id']; + if (typeof id !== 'string') return; + const draft = interactions.get(id); + if (draft === undefined) return; + const response = record['response']; + draft.state = mapInteractionEndState(draft.kind, response); + draft.response = wireInteractionResponse(draft.kind, draft.request, response); + draft.at = at(record); + }; + + const onTaskRecord = (record: ContextRecord): void => { + const info = record['info'] as + | { + taskId?: unknown; + kind?: unknown; + status?: unknown; + detached?: unknown; + description?: unknown; + agentId?: unknown; + startedAt?: unknown; + endedAt?: unknown; + stopReason?: unknown; + model?: unknown; + thinkingEffort?: unknown; + } + | undefined; + if (info === undefined || typeof info.taskId !== 'string') return; + const recordAt = at(record); + const taskId = info.taskId; + const prev = tasks.get(taskId); + const status = info.status; + const draft: TaskDraft = { + taskId, + kind: mapTaskKind(info.kind), + state: + typeof status === 'string' && TASK_STATES.has(status as TaskMessage['state']) + ? (status as TaskMessage['state']) + : (prev?.state ?? 'running'), + detached: typeof info.detached === 'boolean' ? info.detached : (prev?.detached ?? true), + description: typeof info.description === 'string' ? info.description : prev?.description, + childAgentId: typeof info.agentId === 'string' ? info.agentId : prev?.childAgentId, + outputTail: + typeof record['outputTail'] === 'string' ? record['outputTail'] : (prev?.outputTail ?? ''), + startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), + endedAt: epochMsToIso(info.endedAt) ?? prev?.endedAt, + resultSummary: prev?.resultSummary, + error: prev?.error, + stateReason: typeof info.stopReason === 'string' ? info.stopReason : prev?.stateReason, + usage: prev?.usage, + model: typeof info.model === 'string' ? info.model : prev?.model, + thinkingEffort: + typeof info.thinkingEffort === 'string' ? info.thinkingEffort : prev?.thinkingEffort, + at: recordAt, + }; + tasks.set(taskId, draft); + if (prev === undefined) order.push(`task:${taskId}`); + }; + + const onGoalRecord = (record: ContextRecord): void => { + if (record.type === 'goal.create') { + goal = { + objective: typeof record['objective'] === 'string' ? record['objective'] : '', + status: 'active', + completionCriterion: + typeof record['completionCriterion'] === 'string' + ? record['completionCriterion'] + : undefined, + budgetUsed: 0, + }; + pushSystem('goal', goalPayloadOf(goal), at(record)); + return; + } + if (record.type === 'goal.update') { + if (goal !== undefined) { + const status = record['status']; + const tokenBudget = (record['budgetLimits'] as { tokenBudget?: unknown } | undefined) + ?.tokenBudget; + goal = { + ...goal, + status: + typeof status === 'string' && GOAL_STATUSES.has(status) + ? (status as GoalState['status']) + : goal.status, + budgetUsed: + typeof record['tokensUsed'] === 'number' ? record['tokensUsed'] : goal.budgetUsed, + budgetLimit: typeof tokenBudget === 'number' ? tokenBudget : goal.budgetLimit, + }; + } + if ( + record['status'] === undefined && + record['budgetLimits'] === undefined && + record['turnsUsed'] === undefined + ) { + return; + } + pushSystem('goal', goal === undefined ? undefined : goalPayloadOf(goal), at(record)); + return; + } + goal = undefined; + pushSystem('goal', undefined, at(record)); + }; + + for (const record of records) { + switch (record.type) { + case 'turn.prompt': + onTurnPrompt(record); + break; + case 'turn.steer': + onTurnSteer(record); + break; + case 'context.append_loop_event': + onLoopEvent(record); + break; + case 'context.append_message': + onAppendMessage(record); + break; + case 'turn.ended': + onTurnEnded(record); + break; + case 'turn.step.interrupted': { + const rawId = record['turnId']; + const step = record['step']; + if (typeof rawId !== 'number' || typeof step !== 'number') break; + if (typeof record['reason'] !== 'string') break; + const draft = ensureStepDraft(rawId, step, at(record)); + if (draft === undefined) break; + draft.state = 'interrupted'; + draft.endedAt = at(record); + draft.endReason = record['reason']; + draft.endMessage = typeof record['message'] === 'string' ? record['message'] : undefined; + draft.at = at(record); + break; + } + case 'turn.step.retrying': { + const rawId = record['turnId']; + const step = record['step']; + if (typeof rawId !== 'number' || typeof step !== 'number') break; + if ( + typeof record['failedAttempt'] !== 'number' || + typeof record['nextAttempt'] !== 'number' || + typeof record['maxAttempts'] !== 'number' || + typeof record['delayMs'] !== 'number' || + typeof record['errorName'] !== 'string' || + typeof record['errorMessage'] !== 'string' + ) { + break; + } + const draft = ensureStepDraft(rawId, step, at(record)); + if (draft === undefined) break; + draft.retry = { + failed_attempt: record['failedAttempt'] as number, + next_attempt: record['nextAttempt'] as number, + max_attempts: record['maxAttempts'] as number, + delay_ms: record['delayMs'] as number, + error_name: record['errorName'] as string, + error_message: record['errorMessage'] as string, + status_code: typeof record['statusCode'] === 'number' ? record['statusCode'] : undefined, + }; + draft.at = at(record); + break; + } + case 'turn.cancel': { + const target = record['target']; + const turnId = record['turnId']; + if (target === 'queued' && typeof turnId === 'number' && turnId >= nextTurnId) { + cancelledTurnIds.add(turnId); + skipCancelledTurnIds(); + break; + } + if ( + target !== 'active' || + typeof turnId !== 'number' || + !Number.isInteger(turnId) || + turnId < 0 || + activeCancelTurnIds.has(turnId) + ) { + break; + } + activeCancelTurnIds.add(turnId); + if (record['reason'] !== 'user_cancelled') break; + pushSystem( + 'interruption', + { turn_id: turnIdOf(turnId), reason: 'user_cancelled' }, + at(record), + ); + break; + } + case 'context.undo': + onUndo(record); + break; + case 'context.clear': + onClear(record); + break; + case 'context.apply_compaction': { + undoAnchorFloor = undoAnchors.length; + const text = compactionSummaryText(record); + pushSystem( + 'compaction', + { phase: 'completed', text: text.length > 0 ? text : undefined }, + at(record), + ); + break; + } + case 'interaction.request': + onInteractionRequest(record); + break; + case 'interaction.resolved': + onInteractionResolved(record); + break; + case 'task.started': + case 'task.terminated': + onTaskRecord(record); + break; + case 'goal.create': + case 'goal.update': + case 'goal.clear': + onGoalRecord(record); + break; + case 'plan_mode.enter': + pushSystem('plan.enter', undefined, at(record)); + break; + case 'plan_mode.exit': + pushSystem('plan.exit', undefined, at(record)); + break; + case 'plan_mode.cancel': + break; + case 'plan.revision': { + const key = record['key']; + const path = + typeof key === 'string' + ? (options.resolvePlanRevisionKey?.(key) ?? key) + : typeof record['path'] === 'string' + ? record['path'] + : undefined; + pushSystem( + 'plan.revision', + { + id: record['id'], + version: record['version'], + path, + sha256: record['sha256'], + bytes: record['bytes'], + }, + at(record), + ); + break; + } + case 'swarm_mode.enter': + pushSystem('swarm.enter', undefined, at(record)); + break; + case 'swarm_mode.exit': + pushSystem('swarm.exit', undefined, at(record)); + break; + case 'prompt.accepted': { + const promptId = record['promptId']; + const content = record['content']; + if (typeof promptId !== 'string' || !Array.isArray(content)) break; + queuedPrompts.set(promptId, { content: content as ContentPart[], at: at(record) }); + break; + } + case 'prompt.aborted': + case 'prompt.completed': { + const promptId = record['promptId']; + if (typeof promptId === 'string') queuedPrompts.delete(promptId); + break; + } + case 'prompt.steered': { + const ids = record['promptIds']; + if (!Array.isArray(ids)) break; + for (const id of ids) { + if (typeof id === 'string') queuedPrompts.delete(id); + } + break; + } + default: + break; + } + } + + let queuedRawId = nextTurnId; + for (const { content, at: acceptedAt } of queuedPrompts.values()) { + while (cancelledTurnIds.delete(queuedRawId)) queuedRawId += 1; + const turnId = turnIdOf(queuedRawId); + queuedRawId += 1; + const messageId = turnUserMessageIdOf(turnId); + const draft: UserDraft = { + messageId, + turnId, + text: promptTextOf(content), + status: 'running', + createdAt: acceptedAt, + at: acceptedAt, + }; + users.set(messageId, draft); + order.push(`user:${messageId}`); + } + + const finalTurnState = (draft: TurnDraft): 'running' | 'completed' => + draft.state === 'running' && options.live ? 'running' : 'completed'; + + const finalStepState = (draft: StepDraft): StepDraft['state'] => + draft.state === 'running' && !options.live ? 'interrupted' : draft.state; + + const turnUsageOf = (turnId: string): StepUsage | undefined => { + let total: StepUsage | undefined; + for (const step of steps.values()) { + if (step.turnId !== turnId || step.usage === undefined) continue; + total = { + input_other: (total?.input_other ?? 0) + step.usage.input_other, + output: (total?.output ?? 0) + step.usage.output, + input_cache_read: (total?.input_cache_read ?? 0) + step.usage.input_cache_read, + input_cache_creation: + (total?.input_cache_creation ?? 0) + step.usage.input_cache_creation, + }; + } + return total; + }; + + const messages: HistoryMessage[] = []; + for (const key of order) { + const [kind, id] = splitKey(key); + switch (kind) { + case 'turn': { + const draft = turns.get(id); + if (draft === undefined) break; + const usage = turnUsageOf(id); + messages.push({ + type: 'turn', + ...baseFields(options, draft.at), + turn_id: draft.turnId, + ordinal: draft.rawId, + state: finalTurnState(draft), + origin: draft.origin, + user_message_id: draft.userMessageId, + attachment_ids: draft.attachmentIds, + started_at: draft.startedAt, + ended_at: draft.endedAt, + usage: usage === undefined ? undefined : turnUsageToWire(usage), + duration_ms: draft.durationMs, + }); + break; + } + case 'step': { + const draft = steps.get(id); + if (draft === undefined) break; + messages.push({ + type: 'step', + ...baseFields(options, draft.at), + step_id: draft.stepId, + turn_id: draft.turnId, + ordinal: draft.ordinal, + state: finalStepState(draft), + started_at: draft.startedAt, + ended_at: draft.endedAt, + usage: draft.usage, + finish_reason: draft.finishReason, + timing: draft.timing, + retry: draft.retry, + end_reason: draft.endReason, + end_message: draft.endMessage, + }); + break; + } + case 'user': { + const draft = users.get(id); + if (draft === undefined) break; + messages.push({ + type: 'user', + ...baseFields(options, draft.at), + message_id: draft.messageId, + turn_id: draft.turnId, + step_id: draft.stepId, + text: draft.text, + attachment_ids: draft.attachmentIds, + skill_activations: draft.skillActivations, + status: draft.status === 'running' && !options.live ? 'completed' : draft.status, + created_at: draft.createdAt, + finished_at: draft.finishedAt, + steered_at: draft.steeredAt, + origin: draft.origin, + notification: draft.notification, + }); + break; + } + case 'text': { + const draft = texts.get(id); + if (draft === undefined) break; + const step = steps.get(draft.stepId); + const streaming = + options.live && step !== undefined && finalStepState(step) === 'running'; + const body = { + ...baseFields(options, draft.at), + message_id: draft.messageId, + turn_id: draft.turnId, + step_id: draft.stepId, + status: (streaming ? 'streaming' : 'completed') as 'streaming' | 'completed', + text: draft.text, + }; + if (draft.kind === 'assistant') messages.push({ type: 'assistant', ...body }); + else messages.push({ type: 'thinking', ...body }); + break; + } + case 'tool': { + const draft = tools.get(id); + if (draft === undefined) break; + messages.push({ + type: 'tool_call', + ...baseFields(options, draft.at), + tool_call_id: draft.toolCallId, + turn_id: draft.turnId, + step_id: draft.stepId, + name: draft.name, + state: draft.state === 'running' && !options.live ? 'done' : draft.state, + input: draft.input, + input_text: draft.inputText, + output: draft.output, + error: draft.error, + task_id: draft.taskId, + approval_id: draft.approvalId, + todo_id: draft.todoId, + agent_refs: draft.agentRefs.length > 0 ? draft.agentRefs : undefined, + }); + break; + } + case 'sys': { + const draft = systems.get(id); + if (draft === undefined) break; + messages.push({ + type: 'system', + ...baseFields(options, draft.at ?? lastAt), + system_id: draft.systemId, + subtype: draft.subtype, + payload: draft.payload, + at: draft.at, + } as HistoryMessage); + break; + } + case 'ix': { + const draft = interactions.get(id); + if (draft === undefined) break; + const state = + draft.state === 'pending' && !options.live ? ('cancelled' as const) : draft.state; + messages.push({ + type: 'interaction', + ...baseFields(options, draft.at), + interaction_id: draft.interactionId, + kind: draft.kind, + state, + tool_call_id: draft.toolCallId, + request: draft.request, + response: draft.response, + } as HistoryMessage); + break; + } + case 'task': { + const draft = tasks.get(id); + if (draft === undefined) break; + messages.push({ + type: 'task', + ...baseFields(options, draft.at), + task_id: draft.taskId, + kind: draft.kind, + state: draft.state, + detached: draft.detached, + description: draft.description, + child_agent_id: draft.childAgentId, + output_tail: draft.outputTail, + started_at: draft.startedAt, + ended_at: draft.endedAt, + result_summary: draft.resultSummary, + error: draft.error, + state_reason: draft.stateReason, + usage: draft.usage, + model: draft.model, + thinking_effort: draft.thinkingEffort, + }); + break; + } + default: + break; + } + } + let lastTodoTool: ToolDraft | undefined; + for (const tool of tools.values()) { + if (tool.todoId !== undefined && tool.state === 'done') lastTodoTool = tool; + } + if (lastTodoTool !== undefined) { + const items = todoWriteItems(lastTodoTool.input); + if (items !== undefined) { + messages.push({ + type: 'todo', + ...baseFields(options, lastAt), + todo_id: TODO_ENTITY_ID, + items: items.map((item) => ({ title: item.title, status: item.status })), + updated_at: lastTodoTool.at, + }); + } + } + return messages; +} + +function splitKey(key: string): [string, string] { + const index = key.indexOf(':'); + return [key.slice(0, index), key.slice(index + 1)]; +} + +function baseFields( + options: ColdFoldOptions, + timestamp: string, +): { session_id: string; agent_id: string; timestamp: string } { + return { session_id: options.sessionId, agent_id: options.agentId, timestamp }; +} + +function parseNotificationXmlText(text: string): TaskNotificationPayload | undefined { + const match = text.match(/^]*)>\n?/); + if (!match) return undefined; + const attrs = match[1]!; + const attr = (name: string): string | undefined => + attrs.match(new RegExp(`${name}="([^"]*)"`))?.[1]; + const rest = text.slice(match[0].length).replace(/\n?<\/notification>\s*$/, ''); + let title = ''; + let severity: string | undefined; + const bodyLines: string[] = []; + for (const line of rest.split('\n')) { + if (line.startsWith('Title: ')) title = line.slice('Title: '.length); + else if (line.startsWith('Severity: ')) severity = line.slice('Severity: '.length); + else bodyLines.push(line); + } + return { + title, + body: bodyLines.join('\n').replaceAll(/^\n+|\n+$/g, ''), + severity, + type: attr('type'), + source_kind: attr('source_kind'), + source_id: attr('source_id'), + agent_id: attr('agent_id'), + raw: text, + }; +} + +function bundledSkillCount(origin: unknown): number { + const candidate = origin as + | { kind?: unknown; skillActivations?: readonly unknown[] } + | null + | undefined; + if (candidate?.kind !== 'user') return 0; + return candidate.skillActivations?.length ?? 0; +} + +function turnPromptText(input: readonly ContentPart[], skipBlocks: number): string | undefined { + const text = input + .filter((part): part is ContentPart & { type: 'text' } => part.type === 'text') + .slice(skipBlocks) + .map((part) => part.text) + .join(''); + return text.length > 0 ? text : undefined; +} + +function promptAttachmentCount(input: readonly ContentPart[], origin: unknown): number { + let count = 0; + for (const part of input) { + if (part.type === 'image_url') { + if (mediaFileId(part.imageUrl.url, part.imageUrl.id) !== undefined) count += 1; + } else if (part.type === 'video_url') { + if (mediaFileId(part.videoUrl.url, part.videoUrl.id) !== undefined) count += 1; + } else if (part.type === 'audio_url') { + if (mediaFileId(part.audioUrl.url, part.audioUrl.id) !== undefined) count += 1; + } + } + const candidate = origin as + | { kind?: unknown; attachments?: readonly unknown[] } + | null + | undefined; + if (candidate?.kind === 'user' || candidate?.kind === 'skill_activation') { + count += candidate.attachments?.length ?? 0; + } + return count; +} + +function mediaFileId(url: string, id: string | undefined): string | undefined { + const fileId = parseDaemonFileUrl(url)?.fileId; + if (id === undefined) return fileId; + return fileId === id ? id : undefined; +} + +function emitSkillSystems( + origin: unknown, + input: readonly ContentPart[], + recordAt: string, + pushSystem: (subtype: DurableSystemSubtype, payload: unknown, at: string) => void, +): void { + const candidate = origin as + | { + kind?: unknown; + skillActivations?: readonly { + activationId?: unknown; + skillName?: unknown; + skillArgs?: unknown; + skillPath?: unknown; + skillSource?: unknown; + }[]; + activationId?: unknown; + skillName?: unknown; + skillArgs?: unknown; + skillPath?: unknown; + skillSource?: unknown; + pluginId?: unknown; + commandName?: unknown; + commandArgs?: unknown; + trigger?: unknown; + } + | null + | undefined; + if (candidate?.kind === 'user') { + const activations = candidate.skillActivations ?? []; + activations.forEach((activation, index) => { + const block = input[index]; + pushSystem( + 'skill', + { + trigger: 'user-slash', + activation_id: activation.activationId, + skill_name: activation.skillName, + skill_args: activation.skillArgs, + skill_path: activation.skillPath, + skill_source: activation.skillSource, + text: block !== undefined && block.type === 'text' ? block.text : '', + }, + recordAt, + ); + }); + return; + } + if (candidate?.kind === 'skill_activation') { + pushSystem( + 'skill', + { + trigger: candidate.trigger, + activation_id: candidate.activationId, + skill_name: candidate.skillName, + skill_args: candidate.skillArgs, + skill_path: candidate.skillPath, + skill_source: candidate.skillSource, + }, + recordAt, + ); + return; + } + if (candidate?.kind === 'plugin_command') { + pushSystem( + 'skill', + { + variant: 'plugin_command', + trigger: candidate.trigger, + activation_id: candidate.activationId, + plugin_id: candidate.pluginId, + command_name: candidate.commandName, + command_args: candidate.commandArgs, + }, + recordAt, + ); + } +} + +function resolveStepRef( + stepRefs: ReadonlyMap, + stepUuid: string, + turnId: string | undefined, + step: number | undefined, +): { turn: number; step: number } | undefined { + const direct = stepRefs.get(stepUuid); + if (direct !== undefined) return direct; + if (turnId === undefined || step === undefined) return undefined; + const turn = Number(turnId); + if (!Number.isInteger(turn)) return undefined; + return { turn, step }; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} + +function turnUsageToWire(usage: StepUsage): { + input_tokens: number; + output_tokens: number; + cached_tokens: number; +} { + return { + input_tokens: usage.input_other + usage.input_cache_creation, + output_tokens: usage.output, + cached_tokens: usage.input_cache_read, + }; +} + +function mapTaskKind(kind: unknown): TaskMessage['kind'] { + switch (kind) { + case 'process': + return 'shell'; + case 'agent': + return 'subagent'; + default: + return 'other'; + } +} + +function epochMsToIso(value: unknown): string | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? new Date(value).toISOString() + : undefined; +} + +function goalPayloadOf(goal: GoalState): Record { + return { + objective: goal.objective, + status: goal.status, + completion_criterion: goal.completionCriterion, + budget_used: goal.budgetUsed, + budget_limit: goal.budgetLimit, + }; +} + +function compactionSummaryText(record: ContextRecord): string { + const summary = record['summary']; + if (typeof summary === 'string') return summary; + const contextSummary = record['contextSummary']; + if (typeof contextSummary === 'string') return contextSummary; + if (summary !== null && typeof summary === 'object' && !Array.isArray(summary)) { + const content = (summary as { content?: unknown }).content; + if (Array.isArray(content)) return promptTextOf(content as ContentPart[]); + } + return ''; +} diff --git a/packages/kap-server/src/services/history/historyService.ts b/packages/kap-server/src/services/history/historyService.ts new file mode 100644 index 00000000000..d686ad44243 --- /dev/null +++ b/packages/kap-server/src/services/history/historyService.ts @@ -0,0 +1,168 @@ +import { join } from 'node:path'; + +import { + getLiveSessionById, + IAgentLifecycleService, + ISessionIndex, + IWireService, + MAIN_AGENT_ID, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +import { + historyResponseSchema, + type HistoryMessage, + type HistoryResponse, +} from '../../protocol/messages'; +import { readWireRecords, type ContextRecord } from '../projection/heal'; +import type { ProjectionService } from '../projection/projectionService'; +import { foldWireHistory } from './coldFold'; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; + +export class HistorySessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'HistorySessionNotFoundError'; + this.sessionId = sessionId; + } +} + +export interface HistoryQueryOptions { + readonly before_turn?: string; + readonly after_step?: string; + readonly page_size?: number; + readonly agent_id?: string; +} + +export interface HistoryServiceDeps { + readonly homeDir: string; + readonly core: Scope; + readonly projection: ProjectionService; +} + +export async function readSessionHistory( + deps: HistoryServiceDeps, + sessionId: string, + query: HistoryQueryOptions, +): Promise { + const summary = await deps.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) throw new HistorySessionNotFoundError(sessionId); + const agentId = query.agent_id ?? MAIN_AGENT_ID; + const live = getLiveSessionById(deps.core.accessor, sessionId) !== undefined; + if (live) await flushAgentWire(deps.core, sessionId, agentId); + const records = await readAgentWire(deps.homeDir, summary.workspaceId, sessionId, agentId); + let subagentTaskIds: ReadonlyMap | undefined; + if (agentId !== MAIN_AGENT_ID) { + if (live) await flushAgentWire(deps.core, sessionId, MAIN_AGENT_ID); + const mainRecords = await readAgentWire(deps.homeDir, summary.workspaceId, sessionId, MAIN_AGENT_ID); + subagentTaskIds = scanSubagentTaskIds(mainRecords); + } + const all = foldWireHistory(records, { + sessionId, + agentId, + live, + fallbackTimestamp: new Date(summary.createdAt).toISOString(), + subagentTaskIds, + resolvePlanRevisionKey: (key) => + join('sessions', summary.workspaceId, sessionId, 'agents', agentId, key), + }); + const page = paginateHistory(all, query); + const inFlight = live ? deps.projection.inFlight(sessionId, agentId) : undefined; + const response: HistoryResponse = { + messages: page.messages, + has_more: page.hasMore, + in_flight: inFlight, + }; + const parsed = historyResponseSchema.safeParse(response); + if (!parsed.success) { + throw new Error( + `history response failed schema validation: ${parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; ')}`, + ); + } + return parsed.data; +} + +export interface HistoryPage { + readonly messages: HistoryMessage[]; + readonly hasMore: boolean; +} + +export function paginateHistory( + messages: readonly HistoryMessage[], + query: HistoryQueryOptions, +): HistoryPage { + const pageSize = Math.min(Math.max(query.page_size ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE); + if (query.before_turn !== undefined) { + const cursorIndex = messages.findIndex( + (message) => message.type === 'turn' && message.turn_id === query.before_turn, + ); + if (cursorIndex < 0) return { messages: [], hasMore: false }; + const anchors = turnAnchorIndices(messages, cursorIndex); + const start = anchors.length > pageSize ? anchors[anchors.length - pageSize]! : 0; + return { messages: messages.slice(start, cursorIndex), hasMore: anchors.length > pageSize }; + } + if (query.after_step !== undefined) { + let index = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if ('step_id' in message && message.step_id === query.after_step) { + index = i; + break; + } + } + if (index < 0) return { messages: [], hasMore: false }; + const end = Math.min(messages.length, index + 1 + pageSize); + return { messages: messages.slice(index + 1, end), hasMore: end < messages.length }; + } + const anchors = turnAnchorIndices(messages, messages.length); + const start = anchors.length > pageSize ? anchors[anchors.length - pageSize]! : 0; + return { messages: messages.slice(start), hasMore: anchors.length > pageSize }; +} + +function turnAnchorIndices(messages: readonly HistoryMessage[], endExclusive: number): number[] { + const anchors: number[] = []; + for (let i = 0; i < endExclusive; i++) { + if (messages[i]!.type === 'turn') anchors.push(i); + } + return anchors; +} + +function scanSubagentTaskIds(records: readonly ContextRecord[]): Map { + const map = new Map(); + for (const record of records) { + if (record.type !== 'task.started' && record.type !== 'task.terminated') continue; + const info = record['info'] as { kind?: unknown; agentId?: unknown; taskId?: unknown } | undefined; + if (info?.kind !== 'agent') continue; + if (typeof info.agentId !== 'string' || typeof info.taskId !== 'string') continue; + map.set(info.agentId, info.taskId); + } + return map; +} + +async function flushAgentWire(core: Scope, sessionId: string, agentId: string): Promise { + const session = getLiveSessionById(core.accessor, sessionId); + const handle = session?.accessor.get(IAgentLifecycleService).handleOf(agentId); + if (handle === undefined) return; + await handle.accessor.get(IWireService).flush(); +} + +async function readAgentWire( + homeDir: string, + workspaceId: string, + sessionId: string, + agentId: string, +): Promise { + try { + return await readWireRecords( + join(homeDir, 'sessions', workspaceId, sessionId, 'agents', agentId, 'wire.jsonl'), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} diff --git a/packages/kap-server/src/services/history/index.ts b/packages/kap-server/src/services/history/index.ts new file mode 100644 index 00000000000..07e4eee8a03 --- /dev/null +++ b/packages/kap-server/src/services/history/index.ts @@ -0,0 +1,2 @@ +export * from './coldFold'; +export * from './historyService'; diff --git a/packages/kap-server/src/services/projection/agentProjector.ts b/packages/kap-server/src/services/projection/agentProjector.ts new file mode 100644 index 00000000000..c52b6147ffb --- /dev/null +++ b/packages/kap-server/src/services/projection/agentProjector.ts @@ -0,0 +1,2490 @@ +import { + daemonFileRefFromPart, + readTodoItems, + type AgentTaskInfo, + type TokenUsage, +} from '@moonshot-ai/agent-core-v2'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/kosong/contract/message'; + +import type { + AssistantMessage, + InteractionMessage, + ServerMessage, + StepMessage, + StepRetry, + StepTiming, + StepUsage, + SystemMessage, + TaskMessage, + TaskNotificationPayload, + ThinkingMessage, + TodoMessage, + ToolCallAgentRef, + ToolCallMessage, + ToolProgressPayload, + TurnMessage, + TurnOrigin, + UserMessage, + UserMessageOrigin, +} from '../../protocol/messages'; +import { PROJECTION_IGNORED_EVENT_TYPES, type ProjectionBusEvent } from './events'; +import type { WireTurnFold } from './heal'; +import { + SystemIdAllocator, + TODO_ENTITY_ID, + attachmentIdOf, + isCompactionSystemId, + isUndoAnchorOrigin, + stepIdOf, + stepUserMessageIdOf, + textMessageIdOf, + turnIdOf, + turnOrdinalOf, + turnUserMessageIdOf, +} from './ids'; + +const TASK_OUTPUT_TAIL_MAX = 8192; +const PENDING_CLEAR_SETTLE_MS = 100; + +export interface ProjectorInteraction { + readonly id: string; + readonly kind: 'approval' | 'question'; + readonly payload: unknown; + readonly origin: { readonly agentId?: string; readonly turnId?: number }; + readonly createdAt: number; +} + +export interface ProjectorLookups { + readonly stepOrdinal?: (turnId: string) => number | undefined; + readonly resolvePlanRevisionKey?: (key: string) => string; +} + +export interface ProjectorHooks { + readonly onUnknownEvent?: (type: string) => void; + readonly onDeferred?: (messages: ServerMessage[]) => void; +} + +interface TurnRecord { + turnId: string; + ordinal: number; + state: 'running' | 'completed'; + origin: TurnOrigin; + anchor: boolean; + promptId?: string; + userMessageId?: string; + attachmentIds?: string[]; + openingKey?: { text: string; attachments: number }; + openingSteerDeduped: boolean; + startedAt?: string; + endedAt?: string; + durationMs?: number; + usage?: StepUsage; +} + +interface StepRecord { + stepId: string; + turnId: string; + ordinal: number; + state: 'running' | 'completed' | 'interrupted' | 'failed'; + startedAt?: string; + endedAt?: string; + usage?: StepUsage; + finishReason?: string; + timing?: StepTiming; + retry?: StepRetry; + endReason?: string; + endMessage?: string; +} + +interface TextRecord { + messageId: string; + kind: 'assistant' | 'thinking'; + turnId: string; + stepId: string; + status: 'streaming' | 'completed'; + text: string; +} + +interface ToolRecord { + toolCallId: string; + turnId: string; + stepId: string; + name: string; + state: 'running' | 'done' | 'error'; + input?: unknown; + inputText?: string; + output?: unknown; + display?: unknown; + error?: string; + progress?: ToolProgressPayload; + taskId?: string; + approvalId?: string; + todoId?: string; + agentRefs: ToolCallAgentRef[]; + startedAt?: string; +} + +interface TaskRecord { + taskId: string; + kind: TaskMessage['kind']; + state: TaskMessage['state']; + detached: boolean; + description?: string; + childAgentId?: string; + outputTail: string; + startedAt?: string; + endedAt?: string; + resultSummary?: string; + error?: string; + stateReason?: string; + usage?: StepUsage; + model?: string; + thinkingEffort?: string; +} + +interface InteractionRecord { + interactionId: string; + kind: 'approval' | 'question'; + state: 'pending' | 'approved' | 'rejected' | 'cancelled' | 'answered' | 'dismissed'; + toolCallId?: string; + request?: unknown; + response?: unknown; +} + +interface UserRecord { + messageId: string; + turnId: string; + stepId?: string; + promptId?: string; + text: string; + status: 'running' | 'completed'; + createdAt: string; + finishedAt?: string; + steeredAt?: string; + origin?: UserMessageOrigin; + notification?: TaskNotificationPayload; + attachmentIds?: string[]; + skillActivations?: { skill_name: string; skill_args?: string }[]; +} + +interface PromptRecord { + promptId: string; + text: string; + status: 'running' | 'queued' | 'completed' | 'aborted'; + createdAt: string; + turnId?: string; + messageId?: string; + predicted?: boolean; +} + +interface PendingSteer { + readonly input: readonly ContentPart[]; + readonly origin: UserMessageOrigin | undefined; + readonly skillActivations: { skill_name: string; skill_args?: string }[] | undefined; + readonly skipBlocks: number; + readonly at: string; + readonly notification?: { readonly payload: TaskNotificationPayload; readonly text: string }; +} + +export class AgentMessageProjector { + private currentTurn: TurnRecord | undefined; + private currentStep: StepRecord | undefined; + private openText: TextRecord | undefined; + private openThinking: TextRecord | undefined; + private userSeq = 0; + private attachmentSeq = 0; + private phantomUserSeq = 0; + private readonly turns = new Map(); + private readonly steps = new Map(); + private readonly texts = new Map(); + private readonly stepTextIds = new Map(); + private readonly stepTextSeqs = new Map(); + private readonly tools = new Map(); + private readonly tasks = new Map(); + private readonly shellTasks = new Map(); + private readonly interactions = new Map(); + private readonly users = new Map(); + private readonly prompts = new Map(); + private readonly stepOrdinals = new Map(); + private readonly stepUsageByTurn = new Map(); + private pendingSteers: PendingSteer[] = []; + private pendingFullCut = false; + private pendingClearTimer: NodeJS.Timeout | undefined; + private todoItems: { title: string; status: 'pending' | 'in_progress' | 'done' }[] | undefined; + private todoUpdatedAt: string | undefined; + private planMode = false; + private swarmMode = false; + private readonly timelineIds: string[] = []; + private readonly sysIds = new SystemIdAllocator(); + private readonly endedTurnOrdinals: number[] = []; + private readonly anchorTurnOrdinals = new Set(); + private timelineRewriteCount = 0; + private nextTurnIdHint = 0; + private queuedTurnIdCursor: number | undefined; + + constructor( + readonly agentId: string, + private readonly sessionId: string, + private readonly subagentTaskIds: Map, + private readonly lookups?: ProjectorLookups, + private readonly hooks?: ProjectorHooks, + ) {} + + map(event: ProjectionBusEvent): ServerMessage[] { + switch (event.type) { + case 'plan.revision': + return this.onPlanRevision(event); + case 'turn.started': + return this.onTurnStarted(event); + case 'turn.ended': + return this.onTurnEnded(event); + case 'turn.step.started': + return this.onStepStarted(event); + case 'turn.step.completed': + return this.onStepCompleted(event); + case 'turn.step.interrupted': + return this.onStepInterrupted(event); + case 'turn.step.retrying': + return this.onStepRetrying(event); + case 'assistant.delta': + return this.onTextDelta(event, 'assistant'); + case 'thinking.delta': + return this.onTextDelta(event, 'thinking'); + case 'tool.call.delta': + return this.onToolCallDelta(event); + case 'tool.progress': + return this.onToolProgress(event); + case 'tool.call.started': + return this.onToolCallStarted(event); + case 'tool.result': + return this.onToolResult(event); + case 'task.started': + case 'task.terminated': + return this.onTaskLifecycle(event); + case 'shell.started': + return this.onShellStarted(event); + case 'shell.output': + return this.onShellOutput(event); + case 'shell.completed': + return this.onShellCompleted(event); + case 'subagent.spawned': + return this.onSubagentSpawned(event); + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.suspended': + return this.onSubagentRun(event); + case 'goal.updated': + return this.onGoalUpdated(event); + case 'agent.status.updated': + return this.onAgentStatusUpdated(event); + case 'agent.activity.updated': + return []; + case 'prompt.submitted': + return this.onPromptSubmitted(event); + case 'prompt.queued': + return this.onPromptQueued(event); + case 'prompt.started': + return this.onPromptStarted(event); + case 'prompt.completed': + return this.onPromptCompleted(event); + case 'prompt.aborted': + return this.onPromptAborted(event); + case 'prompt.steered': + return this.onPromptSteered(event); + case 'turn.steer': + return this.onTurnSteered(event); + case 'hook.result': + return [this.systemOp('hook', hookPayload(event), event.time)]; + case 'skill.activated': + return [this.systemOp('skill', skillPayload(event), event.time)]; + case 'plugin_command.activated': + return [ + this.systemOp( + 'skill', + { ...skillPayload(event), variant: 'plugin_command' }, + event.time, + ), + ]; + case 'compaction.started': + case 'compaction.blocked': + case 'compaction.cancelled': + return []; + case 'compaction.completed': { + const result = event.result; + const text = + result.summary.length > 0 ? result.summary : result.contextSummary; + return [ + this.systemOp( + 'compaction', + { phase: 'completed', text: text !== undefined && text.length > 0 ? text : undefined }, + event.time, + ), + ]; + } + case 'context.spliced': + return this.onContextSpliced(event); + case 'context.undone': + return this.onContextUndone(event); + case 'error': + return [ + this.systemOp( + 'notice', + { level: 'error', message: event.message, ...restOf(event) }, + event.time, + ), + ]; + case 'warning': + return [ + this.systemOp( + 'notice', + { level: 'warning', message: event.message, code: event.code }, + event.time, + ), + ]; + case 'prompt.accepted': + case 'cron.fired': + case 'permission.approval.requested': + case 'permission.approval.resolved': + case 'subagent.started': + return []; + case 'task.notified': + return this.onTaskNotified(event); + default: { + const type = (event as { type: string }).type; + if (PROJECTION_IGNORED_EVENT_TYPES.has(type)) return []; + this.hooks?.onUnknownEvent?.(type); + return []; + } + } + } + + seedActiveTurn(info: { + turnId: number; + promptId?: string; + origin?: TurnOrigin; + anchor?: boolean; + }): void { + const turnId = turnIdOf(info.turnId); + this.noteTurnId(info.turnId); + if (info.anchor === true) this.anchorTurnOrdinals.add(info.turnId); + this.currentTurn = { + turnId, + ordinal: info.turnId, + state: 'running', + origin: info.origin ?? { kind: 'other' }, + anchor: info.anchor === true, + promptId: info.promptId, + userMessageId: info.promptId === undefined ? undefined : turnUserMessageIdOf(turnId), + openingSteerDeduped: false, + }; + this.turns.set(turnId, this.currentTurn); + this.timelineIds.push(turnId); + } + + seedTask(info: AgentTaskInfo): ServerMessage[] { + if (info.status !== 'running') return []; + const agentInfo = agentInfoOf(info); + const kind = mapTaskKind(info.kind); + const task = this.upsertTask(info.taskId, (prev) => ({ + taskId: info.taskId, + kind, + state: 'running', + detached: info.detached ?? prev?.detached ?? kind !== 'shell', + description: info.description, + childAgentId: agentInfo?.agentId ?? prev?.childAgentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), + model: agentInfo?.model ?? prev?.model, + thinkingEffort: agentInfo?.thinkingEffort ?? prev?.thinkingEffort, + })); + const childAgentId = agentInfo?.agentId; + if (info.kind === 'agent' && typeof childAgentId === 'string' && childAgentId.length > 0) { + this.subagentTaskIds.set(childAgentId, info.taskId); + } + return [this.taskOp(task)]; + } + + seedTodo( + items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[], + ): ServerMessage[] { + if (items.length === 0) return []; + this.todoItems = items.map((item) => ({ title: item.title, status: item.status })); + this.todoUpdatedAt = undefined; + return [this.todoOp()]; + } + + taskOutputUpdated(taskId: string, outputTail: string): ServerMessage[] { + const task = this.tasks.get(taskId); + if (task === undefined || task.outputTail === outputTail) return []; + task.outputTail = outputTail; + return [this.taskOp(task)]; + } + + seedModes(modes: { planMode?: boolean; swarmMode?: boolean }): void { + if (modes.planMode !== undefined) this.planMode = modes.planMode; + if (modes.swarmMode !== undefined) this.swarmMode = modes.swarmMode; + } + + todoChanged( + items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[], + ): ServerMessage[] { + this.todoItems = items.map((item) => ({ title: item.title, status: item.status })); + this.todoUpdatedAt = nowIso(); + return [this.todoOp()]; + } + + interactionRequested(interaction: ProjectorInteraction): ServerMessage[] { + const payload = interaction.payload as Record | null; + const toolCallId = + typeof payload?.['toolCallId'] === 'string' ? payload['toolCallId'] : undefined; + const record: InteractionRecord = { + interactionId: interaction.id, + kind: interaction.kind, + state: 'pending', + toolCallId, + request: this.wireInteractionRequest(interaction), + }; + this.interactions.set(interaction.id, record); + const ops: ServerMessage[] = [this.interactionOp(record)]; + if (toolCallId !== undefined) { + const tool = this.tools.get(toolCallId); + if (tool !== undefined && tool.approvalId !== interaction.id) { + tool.approvalId = interaction.id; + ops.push(this.toolOp(tool)); + } + } + return ops; + } + + interactionResolved(id: string, response: unknown): ServerMessage[] { + const record = this.interactions.get(id); + if (record === undefined) return []; + record.state = mapInteractionEndState(record.kind, response); + record.response = this.wireInteractionResponse(record, response); + return [this.interactionOp(record)]; + } + + recoveryMessages(): ServerMessage[] { + const ops: ServerMessage[] = []; + const turn = this.currentTurn; + if (turn !== undefined && turn.state === 'running') { + ops.push(this.turnOp(turn)); + const step = this.currentStep; + const replayStepId = + step !== undefined && step.turnId === turn.turnId ? step.stepId : undefined; + if (step !== undefined && replayStepId !== undefined) { + ops.push(this.stepOp(step)); + for (const record of this.texts.values()) { + if (record.stepId === replayStepId) ops.push(this.textOp(record)); + } + } + for (const tool of this.tools.values()) { + if (tool.turnId !== turn.turnId) continue; + if (tool.state === 'running' || tool.stepId === replayStepId) ops.push(this.toolOp(tool)); + } + } + for (const record of this.interactions.values()) { + if (record.state === 'pending') ops.push(this.interactionOp(record)); + } + for (const task of this.tasks.values()) { + if (task.state === 'running') ops.push(this.taskOp(task)); + } + if (this.todoItems !== undefined) ops.push(this.todoOp()); + return ops; + } + + notifyContextCleared(): ServerMessage[] { + this.cancelPendingClearTimer(); + this.pendingFullCut = false; + return this.applyClear(); + } + + applyTimelineSeed(seed: { + timelineIds: readonly string[]; + systemCounts: ReadonlyMap; + anchorTurnOrdinals: readonly number[]; + nextTurnId: number; + }): void { + if (this.timelineRewriteCount > 0) return; + const existing = new Set(this.timelineIds); + this.timelineIds.unshift(...seed.timelineIds.filter((id) => !existing.has(id))); + for (const [subtype, count] of seed.systemCounts) this.sysIds.seed(subtype, count); + for (const ordinal of seed.anchorTurnOrdinals) this.anchorTurnOrdinals.add(ordinal); + this.nextTurnIdHint = Math.max(this.nextTurnIdHint, seed.nextTurnId); + } + + dispose(): void { + this.cancelPendingClearTimer(); + } + + private noteTurnId(turnId: number): void { + this.nextTurnIdHint = Math.max(this.nextTurnIdHint, turnId + 1); + } + + takeEndedTurnOrdinals(): number[] { + return this.endedTurnOrdinals.splice(0); + } + + healTurn(ordinal: number, fold: WireTurnFold): ServerMessage[] { + const turnId = turnIdOf(ordinal); + const held = this.turns.get(turnId); + if (held?.state !== 'completed') return []; + const ops: ServerMessage[] = []; + const stepOrdinals = new Set([...fold.steps.keys(), ...fold.texts.keys()]); + for (const wireTool of fold.tools.values()) stepOrdinals.add(wireTool.step); + for (const stepOrdinal of [...stepOrdinals].toSorted((a, b) => a - b)) { + const wireStep = fold.steps.get(stepOrdinal); + const stepId = stepIdOf(turnId, stepOrdinal); + const live = this.steps.get(stepId); + if (live === undefined) { + const step: StepRecord = { + stepId, + turnId, + ordinal: stepOrdinal, + state: wireStep?.state ?? 'interrupted', + endedAt: wireStep?.endedAt, + usage: wireStep?.usage, + finishReason: wireStep?.finishReason, + timing: wireStep?.timing, + endReason: wireStep?.endReason, + endMessage: wireStep?.endMessage, + }; + this.steps.set(stepId, step); + this.stepOrdinals.set(turnId, Math.max(this.stepOrdinals.get(turnId) ?? 0, stepOrdinal)); + ops.push(this.stepOp(step)); + } else if (live.state === 'running' && wireStep !== undefined) { + live.state = wireStep.state; + live.endedAt = wireStep.endedAt; + live.usage = live.usage ?? wireStep.usage; + live.finishReason = live.finishReason ?? wireStep.finishReason; + live.timing = live.timing ?? wireStep.timing; + live.endReason = live.endReason ?? wireStep.endReason; + live.endMessage = live.endMessage ?? wireStep.endMessage; + ops.push(this.stepOp(live)); + } + const wireTexts = fold.texts.get(stepOrdinal); + if (wireTexts !== undefined) { + ops.push(...this.healStepTexts(stepId, turnId, wireTexts)); + } + } + for (const [toolCallId, wireTool] of fold.tools) { + const live = this.tools.get(toolCallId); + const stepId = stepIdOf(turnId, wireTool.step); + if (live === undefined) { + const tool: ToolRecord = { + toolCallId, + turnId, + stepId, + name: wireTool.name, + state: wireTool.isError === true ? 'error' : 'done', + input: parseToolArgs(wireTool.args), + inputText: typeof wireTool.args === 'string' ? wireTool.args : undefined, + output: wireTool.output, + error: + wireTool.isError === true && typeof wireTool.output === 'string' + ? wireTool.output + : undefined, + agentRefs: [], + }; + this.tools.set(toolCallId, tool); + ops.push(this.toolOp(tool)); + continue; + } + const liveHasOutcome = + live.output !== undefined || live.error !== undefined || live.state !== 'running'; + const wireHasOutcome = wireTool.output !== undefined || wireTool.isError === true; + if (liveHasOutcome || !wireHasOutcome) continue; + live.state = wireTool.isError === true ? 'error' : 'done'; + live.output = wireTool.output; + live.error = + wireTool.isError === true && typeof wireTool.output === 'string' + ? wireTool.output + : undefined; + ops.push(this.toolOp(live)); + } + this.dropTurnDetails(turnId); + return ops; + } + + inFlight(): { turn_id: string; step_id: string } | undefined { + const turn = this.currentTurn; + const step = this.currentStep; + if (turn === undefined || step === undefined) return undefined; + if (turn.state !== 'running' || step.turnId !== turn.turnId) return undefined; + return { turn_id: turn.turnId, step_id: step.stepId }; + } + + private healStepTexts( + stepId: string, + turnId: string, + wireTexts: { assistant: string; thinking: string; first: 'assistant' | 'thinking' }, + ): ServerMessage[] { + const ops: ServerMessage[] = []; + const kinds: readonly ('assistant' | 'thinking')[] = + wireTexts.first === 'thinking' ? ['thinking', 'assistant'] : ['assistant', 'thinking']; + for (const kind of kinds) { + const wireText = kind === 'assistant' ? wireTexts.assistant : wireTexts.thinking; + const liveId = this.stepTextIds.get(stepId)?.[kind]; + const live = liveId === undefined ? undefined : this.texts.get(liveId); + if (live === undefined) { + if (wireText.length === 0) continue; + const record = this.createTextRecord(stepId, turnId, kind); + record.text = wireText; + record.status = 'completed'; + ops.push(this.textOp(record)); + continue; + } + if (wireText.length > live.text.length) { + live.text = wireText; + live.status = 'completed'; + ops.push(this.textOp(live)); + } + } + return ops; + } + + private onTurnStarted(event: { + time: number; + turnId: number; + promptId?: string; + origin: unknown; + prompt?: string; + promptAttachments?: readonly unknown[]; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + if (this.currentTurn !== undefined && this.currentTurn.state === 'running') { + ops.push(...this.finalizeTurn(this.currentTurn, event.time)); + } + const turnId = turnIdOf(event.turnId); + this.noteTurnId(event.turnId); + this.phantomUserSeq = 0; + const origin = this.mapTurnOrigin(event.origin); + const attachments = event.promptAttachments ?? []; + const attachmentIds = attachments.map((_, index) => attachmentIdOf(turnId, index + 1)); + const promptRecord = event.promptId === undefined ? undefined : this.prompts.get(event.promptId); + const promptText = event.prompt ?? promptRecord?.text; + if ( + promptRecord?.messageId !== undefined && + promptRecord.turnId !== undefined && + promptRecord.turnId !== turnId + ) { + const stale = this.users.get(promptRecord.messageId); + if (stale !== undefined && stale.status === 'running') { + stale.status = 'completed'; + stale.finishedAt = epochMsToIso(event.time); + ops.push(this.userOp(stale)); + } + promptRecord.turnId = undefined; + promptRecord.messageId = undefined; + } + const wantsUser = wantsUserMessage(event.origin, promptText); + if (promptRecord !== undefined) promptRecord.predicted = false; + const anchor = isUndoAnchorOrigin(event.origin); + if (anchor) this.anchorTurnOrdinals.add(event.turnId); + const turn: TurnRecord = { + turnId, + ordinal: event.turnId, + state: 'running', + origin, + anchor, + promptId: event.promptId, + userMessageId: wantsUser ? turnUserMessageIdOf(turnId) : undefined, + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + openingKey: { text: promptText ?? '', attachments: attachmentIds.length }, + openingSteerDeduped: false, + startedAt: epochMsToIso(event.time), + }; + this.currentTurn = turn; + this.turns.set(turnId, turn); + this.timelineIds.push(turnId); + this.currentStep = undefined; + this.openText = undefined; + this.openThinking = undefined; + this.pendingSteers = []; + ops.push(this.turnOp(turn)); + if (wantsUser && turn.userMessageId !== undefined) { + const user: UserRecord = { + messageId: turn.userMessageId, + turnId, + promptId: event.promptId, + text: promptText ?? '', + status: 'running', + createdAt: promptRecord?.createdAt ?? epochMsToIso(event.time), + origin: userOriginOf(event.origin), + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + skillActivations: skillActivationsOf(event.origin), + }; + this.users.set(user.messageId, user); + if (promptRecord !== undefined) { + promptRecord.turnId = turnId; + promptRecord.messageId = user.messageId; + } + ops.push(this.userOp(user)); + } + return ops; + } + + private onTurnEnded(event: { + time: number; + turnId: number; + reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; + durationMs?: number; + interruptReason?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + const turn = this.currentTurn?.turnId === turnId ? this.currentTurn : this.turns.get(turnId); + if (turn === undefined) return ops; + ops.push(...this.finalizeTurn(turn, event.time, event.reason, event.durationMs)); + this.currentStep = undefined; + if (this.currentTurn?.turnId === turnId) this.currentTurn = undefined; + this.endedTurnOrdinals.push(event.turnId); + if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') { + ops.push( + this.systemOp( + 'interruption', + { turn_id: turnId, reason: event.interruptReason }, + event.time, + ), + ); + } + return ops; + } + + private finalizeTurn( + turn: TurnRecord, + time: number, + reason?: 'completed' | 'cancelled' | 'failed' | 'blocked', + durationMs?: number, + ): ServerMessage[] { + const ops = this.flushOpenTexts(); + const turnId = turn.turnId; + if (this.currentStep !== undefined && this.currentStep.turnId === turnId) { + const step = this.currentStep; + if (step.state === 'running') { + step.state = reason === 'failed' || reason === 'blocked' ? 'failed' : 'interrupted'; + step.endedAt = epochMsToIso(time); + ops.push(this.stepOp(step)); + } + } else if (this.pendingSteers.length > 0) { + const ordinal = (this.stepOrdinals.get(turnId) ?? this.lookups?.stepOrdinal?.(turnId) ?? 0) + 1; + const step: StepRecord = { + stepId: stepIdOf(turnId, ordinal), + turnId, + ordinal, + state: 'interrupted', + endedAt: epochMsToIso(time), + }; + this.stepOrdinals.set(turnId, ordinal); + this.steps.set(step.stepId, step); + this.currentStep = step; + ops.push(this.stepOp(step)); + } + const step = this.currentStep; + if (step !== undefined && step.turnId === turnId && this.pendingSteers.length > 0) { + for (const pending of this.pendingSteers) { + ops.push(this.steerUserMessage(step, pending)); + } + } + this.pendingSteers = []; + turn.state = 'completed'; + turn.endedAt = epochMsToIso(time); + turn.durationMs = durationMs; + turn.usage = this.takeTurnUsage(turnId); + for (const user of this.users.values()) { + if (user.turnId !== turnId || user.status !== 'running') continue; + user.status = 'completed'; + user.finishedAt = epochMsToIso(time); + ops.push(this.userOp(user)); + } + ops.push(this.turnOp(turn)); + return ops; + } + + private takeTurnUsage(turnId: string): StepUsage | undefined { + const usages = this.stepUsageByTurn.get(turnId); + this.stepUsageByTurn.delete(turnId); + if (usages === undefined || usages.length === 0) return undefined; + let inputOther = 0; + let output = 0; + let inputCacheRead = 0; + let inputCacheCreation = 0; + for (const usage of usages) { + inputOther += usage.input_other; + output += usage.output; + inputCacheRead += usage.input_cache_read; + inputCacheCreation += usage.input_cache_creation; + } + return { + input_other: inputOther, + output, + input_cache_read: inputCacheRead, + input_cache_creation: inputCacheCreation, + }; + } + + private onStepStarted(event: { time: number; turnId: number; step: number }): ServerMessage[] { + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + if (this.currentStep !== undefined && this.currentStep.state === 'running') { + ops.push(...this.flushOpenTexts()); + this.currentStep.state = 'completed'; + this.currentStep.endedAt = epochMsToIso(event.time); + ops.push(this.stepOp(this.currentStep)); + } + const stepId = stepIdOf(turnId, event.step); + this.stepOrdinals.set(turnId, event.step); + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + state: 'running', + startedAt: epochMsToIso(event.time), + }; + this.currentStep = step; + this.steps.set(stepId, step); + this.userSeq = 0; + this.attachmentSeq = 0; + this.openText = undefined; + this.openThinking = undefined; + ops.push(this.stepOp(step)); + for (const pending of this.pendingSteers) { + ops.push(this.steerUserMessage(step, pending)); + } + this.pendingSteers = []; + return ops; + } + + private onStepCompleted(event: { + time: number; + turnId: number; + step: number; + usage?: TokenUsage; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + ops.push(...this.flushOpenTexts()); + const turnId = turnIdOf(event.turnId); + const stepId = stepIdOf(turnId, event.step); + const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId); + const usage = event.usage === undefined ? undefined : toSnakeUsage(event.usage); + if (usage !== undefined) { + const usages = this.stepUsageByTurn.get(turnId) ?? []; + usages.push(usage); + this.stepUsageByTurn.set(turnId, usages); + } + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + state: 'completed', + startedAt: prev?.startedAt, + endedAt: epochMsToIso(event.time), + usage, + finishReason: event.finishReason ?? event.rawFinishReason ?? event.providerFinishReason, + timing: timingOf(event), + }; + this.currentStep = step; + this.steps.set(stepId, step); + ops.push(this.stepOp(step)); + return ops; + } + + private onStepInterrupted(event: { + time: number; + turnId: number; + step: number; + reason: string; + message?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + ops.push(...this.flushOpenTexts()); + const turnId = turnIdOf(event.turnId); + const stepId = stepIdOf(turnId, event.step); + const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId); + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + state: 'interrupted', + startedAt: prev?.startedAt, + endedAt: epochMsToIso(event.time), + endReason: event.reason, + endMessage: event.message, + }; + this.currentStep = step; + this.steps.set(stepId, step); + ops.push(this.stepOp(step)); + return ops; + } + + private onStepRetrying(event: { + turnId: number; + step: number; + failedAttempt: number; + nextAttempt: number; + maxAttempts: number; + delayMs: number; + errorName: string; + errorMessage: string; + statusCode?: number; + }): ServerMessage[] { + const turnId = turnIdOf(event.turnId); + const stepId = stepIdOf(turnId, event.step); + const prev = this.currentStep?.stepId === stepId ? this.currentStep : this.steps.get(stepId); + const step: StepRecord = { + stepId, + turnId, + ordinal: event.step, + state: 'running', + startedAt: prev?.startedAt, + retry: { + failed_attempt: event.failedAttempt, + next_attempt: event.nextAttempt, + max_attempts: event.maxAttempts, + delay_ms: event.delayMs, + error_name: event.errorName, + error_message: event.errorMessage, + status_code: event.statusCode, + }, + }; + this.currentStep = step; + this.steps.set(stepId, step); + return [this.stepOp(step)]; + } + + private onTextDelta( + event: { time: number; turnId: number; delta: string }, + kind: 'assistant' | 'thinking', + ): ServerMessage[] { + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + let open = kind === 'assistant' ? this.openText : this.openThinking; + if (open === undefined || open.stepId !== step.stepId) { + open = this.createTextRecord(step.stepId, turnId, kind); + if (kind === 'assistant') this.openText = open; + else this.openThinking = open; + ops.push(this.textOp(open)); + } + open.text += event.delta; + ops.push(this.textDeltaOp(open, event.delta)); + return ops; + } + + private flushOpenTexts(): ServerMessage[] { + const ops: ServerMessage[] = []; + for (const open of [this.openText, this.openThinking]) { + if (open === undefined) continue; + open.status = 'completed'; + ops.push(this.textOp(open)); + } + this.openText = undefined; + this.openThinking = undefined; + return ops; + } + + private ensureTurn(turnId: string, time: number, ops: ServerMessage[]): TurnRecord { + if (this.currentTurn !== undefined && this.currentTurn.turnId === turnId) { + return this.currentTurn; + } + const ordinal = turnOrdinalOf(turnId) ?? 0; + this.noteTurnId(ordinal); + const turn: TurnRecord = { + turnId, + ordinal, + state: 'running', + origin: { kind: 'other' }, + anchor: false, + openingSteerDeduped: false, + startedAt: epochMsToIso(time), + }; + this.currentTurn = turn; + this.turns.set(turnId, turn); + this.timelineIds.push(turnId); + ops.push(this.turnOp(turn)); + return turn; + } + + private ensureStep(turnId: string, time: number, ops: ServerMessage[]): StepRecord { + if (this.currentStep !== undefined && this.currentStep.turnId === turnId) { + return this.currentStep; + } + const ordinal = this.lookups?.stepOrdinal?.(turnId) ?? this.stepOrdinals.get(turnId) ?? 1; + const step: StepRecord = { + stepId: stepIdOf(turnId, ordinal), + turnId, + ordinal, + state: 'running', + startedAt: epochMsToIso(time), + }; + this.stepOrdinals.set(turnId, ordinal); + this.currentStep = step; + this.steps.set(step.stepId, step); + ops.push(this.stepOp(step)); + return step; + } + + private onToolCallDelta(event: { + time: number; + turnId: number; + toolCallId: string; + name?: string; + argumentsPart?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const prev = this.tools.get(event.toolCallId); + if (prev !== undefined) { + prev.inputText = (prev.inputText ?? '') + (event.argumentsPart ?? ''); + ops.push(this.toolDeltaOp(event.toolCallId, event.argumentsPart ?? '')); + return ops; + } + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + const tool: ToolRecord = { + toolCallId: event.toolCallId, + turnId, + stepId: step.stepId, + name: event.name ?? '', + state: 'running', + inputText: event.argumentsPart ?? '', + agentRefs: [], + startedAt: epochMsToIso(event.time), + }; + this.tools.set(event.toolCallId, tool); + ops.push(this.toolOp(tool)); + if ((event.argumentsPart ?? '').length > 0) { + ops.push(this.toolDeltaOp(event.toolCallId, event.argumentsPart ?? '')); + } + return ops; + } + + private onToolProgress(event: { + toolCallId: string; + update: { + kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; + text?: string; + percent?: number; + customKind?: string; + customData?: unknown; + }; + }): ServerMessage[] { + const tool = this.tools.get(event.toolCallId); + if (tool === undefined) return []; + tool.progress = { + kind: event.update.kind, + text: event.update.text, + percent: event.update.percent, + custom_kind: event.update.customKind, + custom_data: event.update.customData, + }; + return [ + { + type: 'tool.progress', + ...this.base(), + tool_call_id: event.toolCallId, + progress: tool.progress, + }, + ]; + } + + private onToolCallStarted(event: { + time: number; + turnId: number; + toolCallId: string; + name: string; + args: unknown; + display?: unknown; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + const prev = this.tools.get(event.toolCallId); + const input = parseToolArgs(event.args); + const todoItems = event.name === 'TodoList' ? todoWriteItems(input) : undefined; + const tool: ToolRecord = { + toolCallId: event.toolCallId, + turnId, + stepId: step.stepId, + name: event.name, + state: 'running', + input, + inputText: prev?.inputText ?? (typeof event.args === 'string' ? event.args : undefined), + display: event.display, + todoId: todoItems !== undefined ? TODO_ENTITY_ID : undefined, + progress: prev?.progress, + agentRefs: prev?.agentRefs ?? [], + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + }; + this.tools.set(event.toolCallId, tool); + ops.push(this.toolOp(tool)); + return ops; + } + + private onToolResult(event: { + time: number; + turnId: number; + toolCallId: string; + output: unknown; + isError?: boolean; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + let tool = this.tools.get(event.toolCallId); + if (tool === undefined) { + const turnId = turnIdOf(event.turnId); + this.ensureTurn(turnId, event.time, ops); + const step = this.ensureStep(turnId, event.time, ops); + tool = { + toolCallId: event.toolCallId, + turnId, + stepId: step.stepId, + name: '', + state: 'running', + agentRefs: [], + }; + this.tools.set(event.toolCallId, tool); + } + const isError = event.isError === true; + tool.state = isError ? 'error' : 'done'; + tool.output = event.output; + tool.error = isError && typeof event.output === 'string' ? event.output : undefined; + ops.push(this.toolOp(tool)); + return ops; + } + + private onTaskLifecycle(event: { + type: 'task.started' | 'task.terminated'; + time: number; + info: AgentTaskInfo; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const { info } = event; + const agentInfo = agentInfoOf(info); + const parentTool = + agentInfo?.parentToolCallId === undefined + ? undefined + : this.tools.get(agentInfo.parentToolCallId); + const task = this.upsertTask(info.taskId, (prev) => ({ + taskId: info.taskId, + kind: mapTaskKind(info.kind), + state: info.status, + detached: info.detached ?? prev?.detached ?? true, + description: info.description, + childAgentId: agentInfo?.agentId ?? prev?.childAgentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? parentTool?.startedAt ?? epochMsToIso(info.startedAt), + endedAt: info.endedAt === null ? prev?.endedAt : epochMsToIso(info.endedAt), + resultSummary: prev?.resultSummary, + usage: prev?.usage, + error: prev?.error, + stateReason: info.stopReason ?? prev?.stateReason, + model: agentInfo?.model ?? prev?.model, + thinkingEffort: agentInfo?.thinkingEffort ?? prev?.thinkingEffort, + })); + if (event.type === 'task.started') { + const childAgentId = agentInfo?.agentId; + if (info.kind === 'agent' && typeof childAgentId === 'string' && childAgentId.length > 0) { + this.subagentTaskIds.set(childAgentId, info.taskId); + if (parentTool !== undefined && parentTool.taskId !== info.taskId) { + parentTool.taskId = info.taskId; + ops.push(this.toolOp(parentTool)); + } + } + } + ops.push(this.taskOp(task)); + return ops; + } + + private onShellStarted(event: { + time: number; + commandId: string; + taskId: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + this.shellTasks.set(event.commandId, event.taskId); + const task = this.upsertTask(event.taskId, (prev) => ({ + taskId: event.taskId, + kind: 'shell', + state: 'running', + detached: prev?.detached ?? false, + description: prev?.description, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + })); + ops.push(this.taskOp(task)); + return ops; + } + + private shellTaskId(event: { commandId: string; taskId?: string }): string { + const taskId = + this.shellTasks.get(event.commandId) ?? event.taskId ?? `shell-${event.commandId}`; + this.shellTasks.set(event.commandId, taskId); + return taskId; + } + + private onShellOutput(event: { + time: number; + commandId: string; + taskId?: string; + update: { kind: string; text?: string }; + }): ServerMessage[] { + const text = event.update.text; + if (typeof text !== 'string' || text.length === 0) return []; + const ops = this.settlePendingClear(); + const taskId = this.shellTaskId(event); + const task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: prev?.kind ?? 'shell', + state: 'running', + detached: prev?.detached ?? false, + description: prev?.description, + outputTail: tailWindow((prev?.outputTail ?? '') + text), + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + })); + ops.push(this.taskOp(task)); + return ops; + } + + private onShellCompleted(event: { + time: number; + commandId: string; + taskId?: string; + isError: boolean; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const taskId = this.shellTaskId(event); + const task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: prev?.kind ?? 'shell', + state: event.isError ? 'failed' : 'completed', + detached: prev?.detached ?? false, + description: prev?.description, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? epochMsToIso(event.time), + endedAt: epochMsToIso(event.time), + })); + ops.push(this.taskOp(task)); + return ops; + } + + private upsertTask( + taskId: string, + build: (prev: TaskRecord | undefined) => TaskRecord, + ): TaskRecord { + const task = build(this.tasks.get(taskId)); + this.tasks.set(taskId, task); + return task; + } + + private onSubagentSpawned(event: { + time: number; + subagentId: string; + parentToolCallId: string; + description?: string; + swarmIndex?: number; + runInBackground: boolean; + taskId?: string; + model?: string; + thinkingEffort?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const tool = this.tools.get(event.parentToolCallId); + if (tool !== undefined) { + const ref: ToolCallAgentRef = { + agent_id: event.subagentId, + role: event.swarmIndex !== undefined ? 'member' : 'child', + }; + tool.agentRefs = [...tool.agentRefs, ref]; + ops.push(this.toolOp(tool)); + } + const taskId = event.taskId; + if (taskId === undefined) return ops; + this.subagentTaskIds.set(event.subagentId, taskId); + if (tool !== undefined && tool.taskId !== taskId) { + tool.taskId = taskId; + ops.push(this.toolOp(tool)); + } + const task = this.upsertTask(taskId, (prev) => ({ + taskId, + kind: 'subagent', + state: 'running', + detached: event.runInBackground, + description: event.description ?? prev?.description, + childAgentId: event.subagentId, + outputTail: prev?.outputTail ?? '', + startedAt: prev?.startedAt ?? tool?.startedAt ?? epochMsToIso(event.time), + model: event.model ?? prev?.model, + thinkingEffort: event.thinkingEffort ?? prev?.thinkingEffort, + })); + ops.push(this.taskOp(task)); + return ops; + } + + private onSubagentRun(event: { + type: 'subagent.completed' | 'subagent.failed' | 'subagent.suspended'; + time: number; + subagentId: string; + resultSummary?: string; + usage?: TokenUsage; + error?: string; + reason?: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const taskKey = this.subagentTaskIds.get(event.subagentId) ?? event.subagentId; + const existing = this.tasks.get(taskKey); + if (existing === undefined) return ops; + const terminal = event.type !== 'subagent.suspended'; + existing.state = + event.type === 'subagent.completed' + ? 'completed' + : event.type === 'subagent.failed' + ? 'failed' + : 'running'; + if (terminal) existing.endedAt = epochMsToIso(event.time); + existing.resultSummary = event.resultSummary ?? existing.resultSummary; + existing.usage = event.usage === undefined ? existing.usage : toSnakeUsage(event.usage); + existing.error = event.error ?? existing.error; + existing.stateReason = event.reason ?? existing.stateReason; + ops.push(this.taskOp(existing)); + return ops; + } + + private onGoalUpdated(event: { + time: number; + snapshot: { + objective: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + completionCriterion?: string; + tokensUsed: number; + budget: { tokenBudget: number | null }; + } | null; + }): ServerMessage[] { + const snapshot = event.snapshot; + const payload = + snapshot === null + ? undefined + : { + objective: snapshot.objective, + status: snapshot.status, + completion_criterion: snapshot.completionCriterion, + budget_used: snapshot.tokensUsed, + budget_limit: snapshot.budget.tokenBudget ?? undefined, + }; + return [this.systemOp('goal', payload, event.time)]; + } + + private onAgentStatusUpdated(event: { + time: number; + planMode?: boolean; + swarmMode?: boolean; + }): ServerMessage[] { + const ops: ServerMessage[] = []; + if (event.planMode !== undefined && event.planMode !== this.planMode) { + this.planMode = event.planMode; + if (event.planMode) { + ops.push(this.systemOp('plan.enter', undefined, event.time)); + } else if (this.planExitApproved()) { + ops.push(this.systemOp('plan.exit', undefined, event.time)); + } + } + if (event.swarmMode !== undefined && event.swarmMode !== this.swarmMode) { + this.swarmMode = event.swarmMode; + ops.push( + this.systemOp(event.swarmMode ? 'swarm.enter' : 'swarm.exit', undefined, event.time), + ); + } + return ops; + } + + private planExitApproved(): boolean { + let latest: ToolRecord | undefined; + for (const tool of this.tools.values()) { + if (tool.name === 'ExitPlanMode') latest = tool; + } + if (latest?.approvalId === undefined) return false; + return this.interactions.get(latest.approvalId)?.state === 'approved'; + } + + private onPlanRevision(event: { + time: number; + id: string; + version: number; + key: string; + sha256: string; + bytes: number; + }): ServerMessage[] { + const path = this.lookups?.resolvePlanRevisionKey?.(event.key) ?? event.key; + return [ + this.systemOp( + 'plan.revision', + { id: event.id, version: event.version, path, sha256: event.sha256, bytes: event.bytes }, + event.time, + ), + ]; + } + + private onPromptSubmitted(event: { + promptId: string; + userMessageId: string; + status: 'running' | 'queued'; + content: readonly ContentPart[]; + createdAt: string; + }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + this.prompts.set(event.promptId, { + promptId: event.promptId, + text: promptTextOf(event.content), + status: event.status, + createdAt: prev?.createdAt ?? event.createdAt, + turnId: prev?.turnId, + messageId: prev?.messageId, + }); + return []; + } + + private onPromptQueued(event: { + promptId: string; + content: readonly ContentPart[]; + }): ServerMessage[] { + let prev = this.prompts.get(event.promptId); + if (prev === undefined) { + prev = { + promptId: event.promptId, + text: promptTextOf(event.content), + status: 'queued', + createdAt: nowIso(), + }; + this.prompts.set(event.promptId, prev); + } + if (prev.messageId !== undefined) return []; + return [this.predictReservedUser(prev)]; + } + + private onPromptStarted(event: { promptId: string }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + if (prev === undefined) return []; + prev.status = 'running'; + if (prev.turnId !== undefined) return []; + return [this.predictReservedUser(prev)]; + } + + private predictReservedUser(prompt: PromptRecord): ServerMessage { + const ordinal = Math.max(this.nextTurnIdHint, this.queuedTurnIdCursor ?? 0); + this.queuedTurnIdCursor = ordinal + 1; + const turnId = turnIdOf(ordinal); + const messageId = turnUserMessageIdOf(turnId); + prompt.turnId = turnId; + prompt.messageId = messageId; + prompt.predicted = true; + const user: UserRecord = { + messageId, + turnId, + promptId: prompt.promptId, + text: prompt.text, + status: 'running', + createdAt: prompt.createdAt, + }; + this.users.set(messageId, user); + return this.userOp(user); + } + + private releasePredictedTurnId(prompt: PromptRecord): void { + if (prompt.predicted !== true || prompt.turnId === undefined) return; + prompt.predicted = false; + const ordinal = turnOrdinalOf(prompt.turnId); + if (ordinal !== undefined && this.queuedTurnIdCursor === ordinal + 1) { + this.queuedTurnIdCursor = ordinal; + } + } + + private onPromptCompleted(event: { promptId: string; finishedAt: string }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + if (prev === undefined) return []; + prev.status = 'completed'; + this.releasePredictedTurnId(prev); + return this.completeUserByPrompt(prev, event.finishedAt); + } + + private onPromptAborted(event: { promptId: string; abortedAt: string }): ServerMessage[] { + const prev = this.prompts.get(event.promptId); + if (prev === undefined) return []; + prev.status = 'aborted'; + this.releasePredictedTurnId(prev); + return this.completeUserByPrompt(prev, event.abortedAt); + } + + private completeUserByPrompt(prompt: PromptRecord, at: string): ServerMessage[] { + if (prompt.messageId === undefined) return []; + const user = this.users.get(prompt.messageId); + if (user === undefined || user.status !== 'running') return []; + user.status = 'completed'; + user.finishedAt = at; + return [this.userOp(user)]; + } + + private onPromptSteered(event: { + activePromptId: string; + promptIds: string[]; + content: readonly ContentPart[]; + steeredAt: string; + }): ServerMessage[] { + const active = this.prompts.get(event.activePromptId); + if (active !== undefined) active.text = promptTextOf(event.content); + const ops: ServerMessage[] = []; + for (const promptId of event.promptIds) { + const prev = this.prompts.get(promptId); + if (prev === undefined) continue; + prev.status = 'completed'; + this.releasePredictedTurnId(prev); + ops.push(...this.completeUserByPrompt(prev, event.steeredAt)); + } + return ops; + } + + private onTurnSteered(event: { + time: number; + input: readonly ContentPart[]; + origin: unknown; + }): ServerMessage[] { + const origin = event.origin as { + kind?: string; + skillActivations?: readonly { skillName: string; skillArgs?: string }[]; + jobId?: string; + cron?: string; + trigger?: string; + }; + const kind = origin.kind; + if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'cron_job') return []; + if (kind === 'skill_activation' && origin.trigger !== 'user-slash') return []; + const ops = this.settlePendingClear(); + const turn = this.currentTurn; + if (turn === undefined || turn.state !== 'running') return ops; + const steer: PendingSteer = { + input: event.input, + origin: userOriginOf(event.origin), + skillActivations: skillActivationsOf(event.origin), + skipBlocks: kind === 'user' ? (origin.skillActivations?.length ?? 0) : 0, + at: epochMsToIso(event.time), + }; + const step = this.currentStep; + const stepStarted = step !== undefined && step.turnId === turn.turnId; + if (!stepStarted && !turn.openingSteerDeduped && turn.openingKey !== undefined) { + const key = this.steerKey(steer); + if (key.text === turn.openingKey.text && key.attachments === turn.openingKey.attachments) { + turn.openingSteerDeduped = true; + return ops; + } + } + if (step !== undefined && step.state === 'running' && step.turnId === turn.turnId) { + ops.push(this.steerUserMessage(step, steer)); + return ops; + } + this.pendingSteers.push(steer); + return ops; + } + + private steerKey(steer: PendingSteer): { text: string; attachments: number } { + let text = ''; + let attachments = 0; + for (const part of steer.input.slice(steer.skipBlocks)) { + if (part.type === 'text') { + text += part.text; + continue; + } + if (daemonFileRefFromPart(part) !== undefined) attachments += 1; + } + return { text, attachments }; + } + + private steerUserMessage(step: StepRecord, steer: PendingSteer): ServerMessage { + this.userSeq += 1; + const messageId = stepUserMessageIdOf(step.stepId, this.userSeq); + const texts: string[] = []; + const attachmentIds: string[] = []; + for (const part of steer.input.slice(steer.skipBlocks)) { + if (part.type === 'text') { + texts.push(part.text); + continue; + } + if (daemonFileRefFromPart(part) === undefined) continue; + this.attachmentSeq += 1; + attachmentIds.push(attachmentIdOf(step.stepId, this.attachmentSeq)); + } + const user: UserRecord = { + messageId, + turnId: step.turnId, + stepId: step.stepId, + text: steer.notification?.text ?? texts.join(''), + status: 'running', + createdAt: steer.at, + steeredAt: steer.at, + origin: steer.origin, + notification: steer.notification?.payload, + attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, + skillActivations: steer.skillActivations, + }; + this.users.set(messageId, user); + return this.userOp(user); + } + + private onTaskNotified(event: { + time: number; + notificationType: string; + title: string; + body: string; + severity: string; + sourceKind: string; + sourceId: string; + }): ServerMessage[] { + const ops = this.settlePendingClear(); + const origin = taskUserOriginOf(event.sourceId); + if (origin === undefined) return ops; + const notification: TaskNotificationPayload = { + title: event.title, + body: event.body, + severity: event.severity, + type: event.notificationType, + source_kind: event.sourceKind, + source_id: event.sourceId, + }; + const text = notificationTextOf(notification); + const at = epochMsToIso(event.time); + const turn = this.currentTurn; + if ( + turn !== undefined && + turn.state === 'running' && + turn.origin.kind === 'task' && + turn.origin.task_id === origin.task_id + ) { + const messageId = turn.userMessageId ?? turnUserMessageIdOf(turn.turnId); + if (turn.userMessageId === undefined) { + turn.userMessageId = messageId; + ops.push(this.turnOp(turn)); + } + const existing = this.users.get(messageId); + if (existing !== undefined) { + if (existing.notification === undefined) { + existing.text = text; + existing.origin = origin; + existing.notification = notification; + ops.push(this.userOp(existing)); + } + return ops; + } + const user: UserRecord = { + messageId, + turnId: turn.turnId, + text, + status: 'running', + createdAt: at, + origin, + notification, + }; + this.users.set(messageId, user); + ops.push(this.userOp(user)); + return ops; + } + if (turn !== undefined && turn.state === 'running') { + const steer: PendingSteer = { + input: [], + origin, + skillActivations: undefined, + skipBlocks: 0, + at, + notification: { payload: notification, text }, + }; + const step = this.currentStep; + if (step !== undefined && step.state === 'running' && step.turnId === turn.turnId) { + ops.push(this.steerUserMessage(step, steer)); + return ops; + } + this.pendingSteers.push(steer); + return ops; + } + this.phantomUserSeq += 1; + const turnId = turnIdOf(this.nextTurnIdHint); + const user: UserRecord = { + messageId: `${turnId}.u${this.phantomUserSeq}`, + turnId, + text, + status: 'completed', + createdAt: at, + origin, + notification, + }; + this.users.set(user.messageId, user); + ops.push(this.userOp(user)); + return ops; + } + + private onContextSpliced(event: { + start: number; + deleteCount: number; + messages: readonly unknown[]; + }): ServerMessage[] { + if (event.start === 0 && event.deleteCount > 0 && event.messages.length === 0) { + this.pendingFullCut = true; + this.armPendingClearTimer(); + } + return []; + } + + private armPendingClearTimer(): void { + if (this.pendingClearTimer !== undefined) return; + this.pendingClearTimer = setTimeout(() => { + this.pendingClearTimer = undefined; + if (!this.pendingFullCut) return; + this.pendingFullCut = false; + this.hooks?.onDeferred?.(this.applyClear()); + }, PENDING_CLEAR_SETTLE_MS); + this.pendingClearTimer.unref(); + } + + private cancelPendingClearTimer(): void { + if (this.pendingClearTimer === undefined) return; + clearTimeout(this.pendingClearTimer); + this.pendingClearTimer = undefined; + } + + private settlePendingClear(): ServerMessage[] { + if (!this.pendingFullCut) return []; + this.cancelPendingClearTimer(); + this.pendingFullCut = false; + return this.applyClear(); + } + + private applyClear(): ServerMessage[] { + this.timelineRewriteCount += 1; + const removed = [...this.timelineIds]; + const op = this.systemOp('clear', { removed_ids: removed }, undefined); + this.turns.clear(); + this.steps.clear(); + this.texts.clear(); + this.stepTextIds.clear(); + this.stepTextSeqs.clear(); + this.tools.clear(); + this.users.clear(); + this.stepOrdinals.clear(); + this.stepUsageByTurn.clear(); + this.timelineIds.length = 0; + this.currentTurn = undefined; + this.currentStep = undefined; + this.openText = undefined; + this.openThinking = undefined; + this.pendingSteers = []; + return [op]; + } + + private onContextUndone(event: { + time: number; + turns: number; + fromTurnId?: number; + }): ServerMessage[] { + this.cancelPendingClearTimer(); + this.pendingFullCut = false; + const removed = this.removedIdsForUndo(event.turns, event.fromTurnId); + if (removed.length === 0) return []; + this.timelineRewriteCount += 1; + const op = this.systemOp('undo', { removed_ids: removed }, event.time); + for (const id of removed) { + if (turnOrdinalOf(id) === undefined) continue; + this.dropTurnDetails(id); + this.stepOrdinals.delete(id); + this.stepUsageByTurn.delete(id); + } + const firstRemoved = this.timelineIds.indexOf(removed[0]!); + if (firstRemoved >= 0) this.timelineIds.splice(firstRemoved); + return [op]; + } + + private removedIdsForUndo(turns: number, fromTurnId: number | undefined): string[] { + const cut = this.findUndoCutIndex(turns, fromTurnId); + if (cut === undefined) return []; + return this.timelineIds.slice(cut); + } + + private findUndoCutIndex(turns: number, fromTurnId: number | undefined): number | undefined { + if (fromTurnId !== undefined) { + for (let i = 0; i < this.timelineIds.length; i++) { + const ordinal = turnOrdinalOf(this.timelineIds[i]!); + if (ordinal !== undefined && ordinal >= fromTurnId) return i; + } + return undefined; + } + let remaining = turns; + for (let i = this.timelineIds.length - 1; i >= 0; i--) { + const id = this.timelineIds[i]!; + if (isCompactionSystemId(id)) return undefined; + const ordinal = turnOrdinalOf(id); + if (ordinal === undefined) continue; + if (!this.anchorTurnOrdinals.has(ordinal)) continue; + remaining -= 1; + if (remaining === 0) return i; + } + return undefined; + } + + private dropTurnDetails(turnId: string): void { + this.turns.delete(turnId); + for (const [stepId, step] of this.steps) { + if (step.turnId === turnId) this.steps.delete(stepId); + } + for (const [stepId, entry] of this.stepTextIds) { + if (!stepId.startsWith(`${turnId}.`)) continue; + if (entry.assistant !== undefined) this.texts.delete(entry.assistant); + if (entry.thinking !== undefined) this.texts.delete(entry.thinking); + this.stepTextIds.delete(stepId); + this.stepTextSeqs.delete(stepId); + } + for (const [toolCallId, tool] of this.tools) { + if (tool.turnId === turnId) this.tools.delete(toolCallId); + } + for (const [messageId, user] of this.users) { + if (user.turnId === turnId) this.users.delete(messageId); + } + } + + private mapTurnOrigin(origin: unknown): TurnOrigin { + return toTurnOrigin(origin, this.agentId, this.subagentTaskIds); + } + + private wireInteractionRequest(interaction: ProjectorInteraction): unknown { + return wireInteractionRequest(interaction.kind, interaction.payload); + } + + private wireInteractionResponse(record: InteractionRecord, response: unknown): unknown { + return wireInteractionResponse(record.kind, record.request, response); + } + + private createTextRecord( + stepId: string, + turnId: string, + kind: 'assistant' | 'thinking', + ): TextRecord { + const seq = (this.stepTextSeqs.get(stepId) ?? 0) + 1; + this.stepTextSeqs.set(stepId, seq); + const record: TextRecord = { + messageId: textMessageIdOf(stepId, seq), + kind, + turnId, + stepId, + status: 'streaming', + text: '', + }; + this.texts.set(record.messageId, record); + const entry = this.stepTextIds.get(stepId) ?? {}; + entry[kind] = record.messageId; + this.stepTextIds.set(stepId, entry); + return record; + } + + private base(): { session_id: string; agent_id: string; timestamp: string } { + return { session_id: this.sessionId, agent_id: this.agentId, timestamp: nowIso() }; + } + + private turnOp(turn: TurnRecord): TurnMessage { + return { + type: 'turn', + ...this.base(), + turn_id: turn.turnId, + ordinal: turn.ordinal, + state: turn.state, + origin: turn.origin, + user_message_id: turn.userMessageId, + attachment_ids: turn.attachmentIds, + started_at: turn.startedAt, + ended_at: turn.endedAt, + usage: turn.usage === undefined ? undefined : turnUsageToWire(turn.usage), + duration_ms: turn.durationMs, + }; + } + + private stepOp(step: StepRecord): StepMessage { + return { + type: 'step', + ...this.base(), + step_id: step.stepId, + turn_id: step.turnId, + ordinal: step.ordinal, + state: step.state, + started_at: step.startedAt, + ended_at: step.endedAt, + usage: step.usage, + finish_reason: step.finishReason, + timing: step.timing, + retry: step.retry, + end_reason: step.endReason, + end_message: step.endMessage, + }; + } + + private textOp(record: TextRecord): AssistantMessage | ThinkingMessage { + const base = { + ...this.base(), + message_id: record.messageId, + turn_id: record.turnId, + step_id: record.stepId, + status: record.status, + text: record.text, + }; + if (record.kind === 'assistant') return { type: 'assistant', ...base }; + return { type: 'thinking', ...base }; + } + + private textDeltaOp(record: TextRecord, delta: string): ServerMessage { + if (record.kind === 'assistant') { + return { + type: 'assistant.delta', + ...this.base(), + message_id: record.messageId, + text: delta, + }; + } + return { + type: 'thinking.delta', + ...this.base(), + message_id: record.messageId, + text: delta, + }; + } + + private toolOp(tool: ToolRecord): ToolCallMessage { + return { + type: 'tool_call', + ...this.base(), + tool_call_id: tool.toolCallId, + turn_id: tool.turnId, + step_id: tool.stepId, + name: tool.name, + state: tool.state, + input: tool.input, + input_text: tool.inputText, + output: tool.output, + display: tool.display, + error: tool.error, + progress: tool.progress, + task_id: tool.taskId, + approval_id: tool.approvalId, + todo_id: tool.todoId, + agent_refs: tool.agentRefs.length > 0 ? tool.agentRefs : undefined, + }; + } + + private toolDeltaOp(toolCallId: string, inputText: string): ServerMessage { + return { + type: 'tool_call.delta', + ...this.base(), + tool_call_id: toolCallId, + input_text: inputText, + }; + } + + private userOp(user: UserRecord): UserMessage { + return { + type: 'user', + ...this.base(), + message_id: user.messageId, + turn_id: user.turnId, + step_id: user.stepId, + text: user.text, + attachment_ids: user.attachmentIds, + skill_activations: user.skillActivations, + status: user.status, + created_at: user.createdAt, + finished_at: user.finishedAt, + steered_at: user.steeredAt, + origin: user.origin, + notification: user.notification, + }; + } + + private taskOp(task: TaskRecord): TaskMessage { + return { + type: 'task', + ...this.base(), + task_id: task.taskId, + kind: task.kind, + state: task.state, + detached: task.detached, + description: task.description, + child_agent_id: task.childAgentId, + output_tail: task.outputTail, + started_at: task.startedAt, + ended_at: task.endedAt, + result_summary: task.resultSummary, + error: task.error, + state_reason: task.stateReason, + usage: task.usage, + model: task.model, + thinking_effort: task.thinkingEffort, + }; + } + + private interactionOp(record: InteractionRecord): InteractionMessage { + return { + type: 'interaction', + ...this.base(), + interaction_id: record.interactionId, + kind: record.kind, + state: record.state, + tool_call_id: record.toolCallId, + request: record.request, + response: record.response, + } as InteractionMessage; + } + + private todoOp(): TodoMessage { + return { + type: 'todo', + ...this.base(), + todo_id: TODO_ENTITY_ID, + items: this.todoItems ?? [], + updated_at: this.todoUpdatedAt, + }; + } + + private systemOp( + subtype: SystemMessage['subtype'], + payload: unknown, + time?: number, + ): SystemMessage { + const systemId = this.sysIds.next(subtype); + this.timelineIds.push(systemId); + return { + type: 'system', + ...this.base(), + system_id: systemId, + subtype, + payload, + at: time === undefined ? undefined : epochMsToIso(time), + } as SystemMessage; + } +} + +export function toTurnOrigin( + origin: unknown, + agentId: string, + subagentTaskIds: ReadonlyMap, +): TurnOrigin { + const candidate = origin as + | { kind?: unknown; taskId?: unknown; name?: unknown } + | null + | undefined; + const kind = typeof candidate?.kind === 'string' ? candidate.kind : undefined; + if (kind === undefined) return { kind: 'other' }; + switch (kind) { + case 'user': + case 'skill_activation': + case 'plugin_command': + case 'shell_command': + return { kind: 'user' }; + case 'cron_job': + case 'cron_missed': + return { kind: 'cron' }; + case 'task': + case 'background_task': { + const taskId = candidate?.taskId; + return typeof taskId === 'string' ? { kind: 'task', task_id: taskId } : { kind: 'other' }; + } + case 'hook_result': + return { kind: 'hook' }; + case 'compaction_summary': + return { kind: 'compaction' }; + case 'system_trigger': { + if (candidate?.name === 'goal_continuation') return { kind: 'goal' }; + const taskId = subagentTaskIds.get(agentId); + return taskId === undefined ? { kind: 'other' } : { kind: 'task', task_id: taskId }; + } + default: + return { kind: 'other' }; + } +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function epochMsToIso(value: number): string { + return new Date(value).toISOString(); +} + +function restOf(event: { + readonly type: string; + readonly time?: number; + readonly agentId?: string; +}): Record { + const { type: _type, time: _time, agentId: _agentId, ...rest } = event; + return rest; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} + +function turnUsageToWire(usage: StepUsage): { + input_tokens: number; + output_tokens: number; + cached_tokens: number; +} { + return { + input_tokens: usage.input_other + usage.input_cache_creation, + output_tokens: usage.output, + cached_tokens: usage.input_cache_read, + }; +} + +function timingOf(event: { + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; +}): StepTiming | undefined { + if (event.llmFirstTokenLatencyMs === undefined && event.llmStreamDurationMs === undefined) { + return undefined; + } + return { + llm_first_token_ms: event.llmFirstTokenLatencyMs, + llm_stream_duration_ms: event.llmStreamDurationMs, + }; +} + +function mapTaskKind(kind: string): TaskMessage['kind'] { + switch (kind) { + case 'process': + return 'shell'; + case 'agent': + return 'subagent'; + default: + return 'other'; + } +} + +function agentInfoOf(info: AgentTaskInfo): + | { + agentId?: string; + parentToolCallId?: string; + model?: string; + thinkingEffort?: string; + } + | undefined { + if (info.kind !== 'agent') return undefined; + return info as { + agentId?: string; + parentToolCallId?: string; + model?: string; + thinkingEffort?: string; + }; +} + +function tailWindow(text: string): string { + return text.length <= TASK_OUTPUT_TAIL_MAX ? text : text.slice(text.length - TASK_OUTPUT_TAIL_MAX); +} + +export function parseToolArgs(args: unknown): unknown { + if (typeof args !== 'string' || args.length === 0) return args; + try { + return JSON.parse(args) as unknown; + } catch { + return args; + } +} + +export function todoWriteItems(input: unknown): readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[] | undefined { + const todos = (input as { todos?: unknown } | undefined)?.todos; + if (!Array.isArray(todos)) return undefined; + const items = readTodoItems(todos); + return items.length === 0 && todos.length > 0 ? undefined : items; +} + +export function mapInteractionEndState( + kind: 'approval' | 'question', + response: unknown, +): InteractionMessage['state'] { + if (isCancellation(response)) return 'cancelled'; + if (kind === 'question') return response === null ? 'dismissed' : 'answered'; + const decision = (response as { decision?: unknown } | null | undefined)?.decision; + if (decision === 'approved' || decision === 'rejected' || decision === 'cancelled') { + return decision; + } + return 'cancelled'; +} + +export function isCancellation(response: unknown): boolean { + return (response as { cancelled?: unknown } | null | undefined)?.cancelled === true; +} + +export function wantsUserMessage(origin: unknown, promptText: string | undefined): boolean { + const candidate = origin as { kind?: unknown; name?: unknown } | null | undefined; + switch (candidate?.kind) { + case 'user': + case 'skill_activation': + case 'plugin_command': + case 'shell_command': + case 'cron_job': + case 'cron_missed': + return true; + case 'system_trigger': + return ( + candidate.name === 'subagent' && + typeof promptText === 'string' && + promptText.length > 0 + ); + default: + return false; + } +} + +export function userOriginOf(origin: unknown): UserMessageOrigin | undefined { + const candidate = origin as + | { kind?: unknown; jobId?: unknown; cron?: unknown; skillName?: unknown; skillArgs?: unknown; trigger?: unknown } + | null + | undefined; + if (candidate?.kind === 'cron_job') return cronUserOrigin(candidate); + if (candidate?.kind === 'cron_missed') return { kind: 'cron' }; + if (candidate?.kind === 'skill_activation' && typeof candidate.skillName === 'string') { + return { + kind: 'skill', + skill_name: candidate.skillName, + args: typeof candidate.skillArgs === 'string' ? candidate.skillArgs : undefined, + trigger: typeof candidate.trigger === 'string' ? candidate.trigger : undefined, + }; + } + return undefined; +} + +export function taskUserOriginOf(taskId: unknown): Extract | undefined { + if (typeof taskId !== 'string' || taskId.length === 0) return undefined; + return { kind: 'task', task_id: taskId }; +} + +export function taskNotificationOriginOf( + origin: unknown, +): Extract | undefined { + const candidate = origin as { kind?: unknown; taskId?: unknown } | null | undefined; + if (candidate?.kind !== 'task' && candidate?.kind !== 'background_task') return undefined; + return taskUserOriginOf(candidate.taskId); +} + +export function notificationTextOf(notification: { + title: string; + body: string; +}): string { + return `${notification.title}\n${notification.body}`.trim(); +} + +function cronUserOrigin(candidate: { + jobId?: unknown; + cron?: unknown; +}): UserMessageOrigin | undefined { + if (typeof candidate.jobId !== 'string' || typeof candidate.cron !== 'string') return undefined; + return { kind: 'cron', cron_id: candidate.jobId, schedule: candidate.cron }; +} + +export function skillActivationsOf( + origin: unknown, +): { skill_name: string; skill_args?: string }[] | undefined { + const candidate = origin as { + kind?: unknown; + skillActivations?: readonly { skillName: string; skillArgs?: string }[]; + skillName?: unknown; + skillArgs?: unknown; + } | null | undefined; + if (candidate?.kind === 'user') { + const activations = candidate.skillActivations ?? []; + if (activations.length === 0) return undefined; + return activations.map((a) => ({ skill_name: a.skillName, skill_args: a.skillArgs })); + } + if (candidate?.kind === 'skill_activation' && typeof candidate.skillName === 'string') { + return [ + { + skill_name: candidate.skillName, + skill_args: typeof candidate.skillArgs === 'string' ? candidate.skillArgs : undefined, + }, + ]; + } + return undefined; +} + +export function promptTextOf(content: readonly ContentPart[]): string { + return content + .filter((part): part is ContentPart & { type: 'text' } => part.type === 'text') + .map((part) => part.text) + .join(''); +} + +function hookPayload(event: { + turnId?: number; + hookEvent: string; + content: string; + blocked?: boolean; +}): Record { + return { + turn_id: event.turnId, + hook_event: event.hookEvent, + content: event.content, + blocked: event.blocked, + }; +} + +function skillPayload(event: { + readonly activationId?: string; + readonly skillName?: string; + readonly skillArgs?: string; + readonly skillPath?: string; + readonly skillSource?: string; + readonly trigger?: string; + readonly pluginId?: string; + readonly commandName?: string; + readonly commandArgs?: string; +}): Record { + return { + trigger: event.trigger, + plugin_id: event.pluginId, + command_name: event.commandName, + command_args: event.commandArgs, + activation_id: event.activationId, + skill_name: event.skillName, + skill_args: event.skillArgs, + skill_path: event.skillPath, + skill_source: event.skillSource, + }; +} + +export function wireInteractionRequest(kind: 'approval' | 'question', payload: unknown): unknown { + if (kind === 'approval') { + const record = payload as Record | null; + const toolName = typeof record?.['toolName'] === 'string' ? record['toolName'] : undefined; + if (toolName === undefined || toolName.length === 0) return undefined; + return { + tool_name: toolName, + action: typeof record?.['action'] === 'string' ? record['action'] : '', + tool_input_display: record?.['display'], + }; + } + return toV3QuestionRequest(payload); +} + +export function wireInteractionResponse( + kind: 'approval' | 'question', + request: unknown, + response: unknown, +): unknown { + if (isCancellation(response)) { + return kind === 'approval' ? { decision: 'cancelled' } : undefined; + } + if (kind === 'approval') { + const r = response as { + decision?: unknown; + scope?: unknown; + feedback?: unknown; + selectedLabel?: unknown; + } | null; + if (r === null || typeof r !== 'object') return undefined; + const decision = r.decision; + if (decision !== 'approved' && decision !== 'rejected' && decision !== 'cancelled') { + return undefined; + } + return { + decision, + scope: r.scope === 'session' ? 'session' : undefined, + feedback: typeof r.feedback === 'string' ? r.feedback : undefined, + selected_label: typeof r.selectedLabel === 'string' ? r.selectedLabel : undefined, + }; + } + return mapQuestionResponse(request, response); +} + +export function toV3QuestionRequest(payload: unknown): unknown { + const request = payload as { + questions?: readonly { + question: string; + header?: string; + body?: string; + options: readonly { label: string; description?: string }[]; + multiSelect?: boolean; + otherLabel?: string; + otherDescription?: string; + }[]; + }; + if (request.questions === undefined) return undefined; + return { + questions: request.questions.map((item, i) => ({ + id: `q_${i}`, + question: item.question, + header: item.header, + body: item.body, + options: item.options.map((option, j) => ({ + id: `opt_${i}_${j}`, + label: option.label, + description: option.description, + })), + multi_select: item.multiSelect, + allow_other: true, + other_label: item.otherLabel, + other_description: item.otherDescription, + })), + }; +} + +function mapQuestionResponse(request: unknown, response: unknown): unknown { + const r = response as { answers?: unknown; method?: unknown } | null; + if (r === null || typeof r !== 'object' || r.answers === null || typeof r.answers !== 'object') { + return undefined; + } + const items = + ( + request as + | { + questions?: readonly { + id: string; + question: string; + options: readonly { id: string; label: string }[]; + }[]; + } + | undefined + )?.questions ?? []; + const answers: Record = {}; + for (const [key, value] of Object.entries(r.answers as Record)) { + const item = items.find((q) => q.id === key || q.question === key); + if (item === undefined) continue; + if (value === true) { + answers[item.id] = { kind: 'skipped' }; + continue; + } + if (typeof value !== 'string') continue; + const single = item.options.find((o) => o.label === value); + if (single !== undefined) { + answers[item.id] = { kind: 'single', option_id: single.id }; + continue; + } + const parts = value.split(', '); + const optionIds = parts.flatMap((part) => { + const found = item.options.find((o) => o.label === part); + return found === undefined ? [] : [found.id]; + }); + if (parts.length > 1 && optionIds.length === parts.length) { + answers[item.id] = { kind: 'multi', option_ids: optionIds }; + continue; + } + answers[item.id] = { kind: 'other', text: value }; + } + if (Object.keys(answers).length === 0) return undefined; + const method = r.method; + return { + answers, + method: + method === 'enter' || method === 'space' || method === 'number_key' || method === 'click' + ? method + : undefined, + }; +} diff --git a/packages/kap-server/src/services/projection/events.ts b/packages/kap-server/src/services/projection/events.ts new file mode 100644 index 00000000000..66273bebe67 --- /dev/null +++ b/packages/kap-server/src/services/projection/events.ts @@ -0,0 +1,118 @@ +import type { AgentActivityUpdated } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; +import type { ContextSpliced } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextEvents'; +import type { HookResult } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService'; +import type { + CompactionBlocked, + CompactionCancelled, + CompactionCompleted, + CompactionStarted, +} from '@moonshot-ai/agent-core-v2/agent/fullCompaction/compactionOps'; +import type { ContextUndone, CronFired, GoalUpdated } from '@moonshot-ai/agent-core-v2'; +import type { + AssistantDelta, + ThinkingDelta, + ToolCallDelta, + TurnStarted, + TurnStepCompleted, + TurnStepInterrupted, + TurnStepStarted, +} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { TurnEnded, TurnSteer } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; +import type { AgentErrorEvent } from '@moonshot-ai/agent-core-v2/agent/mcp/mcpEvents'; +import type { PluginCommandActivated } from '@moonshot-ai/agent-core-v2/agent/pluginCommand/pluginCommand'; +import type { WarningIssued } from '@moonshot-ai/agent-core-v2/agent/profile/profileOps'; +import type { + PromptAborted, + PromptCompleted, + PromptQueued, + PromptStarted, + PromptSteered, + PromptSubmitted, +} from '@moonshot-ai/agent-core-v2/agent/prompt/promptService'; +import type { PromptAccepted } from '@moonshot-ai/agent-core-v2/agent/prompt/promptOps'; +import type { + ShellCompleted, + ShellOutput, + ShellStarted, +} from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommandService'; +import type { SkillActivated } from '@moonshot-ai/agent-core-v2/features/skill/skillOps'; +import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/stepRetry/stepRetryService'; +import type { + TaskNotified, + TaskStarted, + TaskTerminatedNotice, +} from '@moonshot-ai/agent-core-v2/agent/task/taskOps'; +import type { + PermissionApprovalRequested, + PermissionApprovalResolved, +} from '@moonshot-ai/agent-core-v2/agent/toolApproval/toolApprovalService'; +import type { + ToolCallStarted, + ToolProgress, + ToolResultEvent, +} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; +import type { AgentStatusUpdated } from '@moonshot-ai/agent-core-v2/agent/usage/usageEvents'; +import type { PlanRevision } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; +import type { SubagentSuspended } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; +import type { + SubagentCompleted, + SubagentFailed, + SubagentSpawned, + SubagentStarted, +} from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; + +export type ProjectionBusEvent = + | ({ readonly type: 'plan.revision' } & PlanRevision) + | ({ readonly type: 'turn.started' } & TurnStarted) + | ({ readonly type: 'turn.ended' } & TurnEnded) + | ({ readonly type: 'turn.step.started' } & TurnStepStarted) + | ({ readonly type: 'turn.step.completed' } & TurnStepCompleted) + | ({ readonly type: 'turn.step.interrupted' } & TurnStepInterrupted) + | ({ readonly type: 'turn.step.retrying' } & TurnStepRetrying) + | ({ readonly type: 'assistant.delta' } & AssistantDelta) + | ({ readonly type: 'thinking.delta' } & ThinkingDelta) + | ({ readonly type: 'tool.call.delta' } & ToolCallDelta) + | ({ readonly type: 'tool.progress' } & ToolProgress) + | ({ readonly type: 'tool.call.started' } & ToolCallStarted) + | ({ readonly type: 'tool.result' } & ToolResultEvent) + | ({ readonly type: 'task.started' } & TaskStarted) + | ({ readonly type: 'task.terminated' } & TaskTerminatedNotice) + | ({ readonly type: 'task.notified' } & TaskNotified) + | ({ readonly type: 'shell.started' } & ShellStarted) + | ({ readonly type: 'shell.output' } & ShellOutput) + | ({ readonly type: 'shell.completed' } & ShellCompleted) + | ({ readonly type: 'subagent.spawned' } & SubagentSpawned) + | ({ readonly type: 'subagent.started' } & SubagentStarted) + | ({ readonly type: 'subagent.completed' } & SubagentCompleted) + | ({ readonly type: 'subagent.failed' } & SubagentFailed) + | ({ readonly type: 'subagent.suspended' } & SubagentSuspended) + | ({ readonly type: 'goal.updated' } & GoalUpdated) + | ({ readonly type: 'agent.status.updated' } & AgentStatusUpdated) + | ({ readonly type: 'agent.activity.updated' } & AgentActivityUpdated) + | ({ readonly type: 'prompt.accepted' } & PromptAccepted) + | ({ readonly type: 'prompt.queued' } & PromptQueued) + | ({ readonly type: 'prompt.submitted' } & PromptSubmitted) + | ({ readonly type: 'prompt.started' } & PromptStarted) + | ({ readonly type: 'prompt.completed' } & PromptCompleted) + | ({ readonly type: 'prompt.aborted' } & PromptAborted) + | ({ readonly type: 'prompt.steered' } & PromptSteered) + | ({ readonly type: 'turn.steer' } & TurnSteer) + | ({ readonly type: 'hook.result' } & HookResult) + | ({ readonly type: 'skill.activated' } & SkillActivated) + | ({ readonly type: 'plugin_command.activated' } & PluginCommandActivated) + | ({ readonly type: 'cron.fired' } & CronFired) + | ({ readonly type: 'compaction.started' } & CompactionStarted) + | ({ readonly type: 'compaction.blocked' } & CompactionBlocked) + | ({ readonly type: 'compaction.cancelled' } & CompactionCancelled) + | ({ readonly type: 'compaction.completed' } & CompactionCompleted) + | ({ readonly type: 'context.spliced' } & ContextSpliced) + | ({ readonly type: 'context.undone' } & ContextUndone) + | ({ readonly type: 'permission.approval.requested' } & PermissionApprovalRequested) + | ({ readonly type: 'permission.approval.resolved' } & PermissionApprovalResolved) + | ({ readonly type: 'error' } & AgentErrorEvent) + | ({ readonly type: 'warning' } & WarningIssued); + +export const PROJECTION_IGNORED_EVENT_TYPES: ReadonlySet = new Set([ + 'mcp.server.status', + 'tool.list.updated', +]); diff --git a/packages/kap-server/src/services/projection/heal.ts b/packages/kap-server/src/services/projection/heal.ts new file mode 100644 index 00000000000..55ec26e4743 --- /dev/null +++ b/packages/kap-server/src/services/projection/heal.ts @@ -0,0 +1,428 @@ +import { readFile } from 'node:fs/promises'; + +import type { TokenUsage } from '@moonshot-ai/agent-core-v2'; + +import type { StepTiming, StepUsage } from '../../protocol/messages'; +import { + SystemIdAllocator, + isUndoAnchorOrigin, + isVisibleTurnOrigin, + turnIdOf, + turnOrdinalOf, +} from './ids'; + +export interface ContextRecord { + readonly type: string; + readonly time?: number; + readonly [key: string]: unknown; +} + +export async function readWireRecords(wirePath: string): Promise { + const raw = await readFile(wirePath, 'utf8'); + const lines = raw.split('\n'); + const records: ContextRecord[] = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]!; + if (line.endsWith('\r')) line = line.slice(0, -1); + if (line.length === 0) continue; + try { + records.push(JSON.parse(line) as ContextRecord); + } catch (parseError) { + if (i === lines.length - 1) break; + throw new Error(`wire.jsonl: corrupted line ${i + 1} in ${wirePath}: ${String(parseError)}`, { + cause: parseError, + }); + } + } + return records; +} + +export interface TimelineSeed { + readonly timelineIds: string[]; + readonly systemCounts: ReadonlyMap; + readonly anchorTurnOrdinals: number[]; + readonly nextTurnId: number; +} + +export function foldTimelineSeed(records: readonly ContextRecord[]): TimelineSeed { + const timelineIds: string[] = []; + const anchorTurnOrdinals: number[] = []; + const sysIds = new SystemIdAllocator(); + let nextTurnId = 0; + let currentTurn: number | undefined; + const cancelledTurnIds = new Set(); + const hiddenTurnIds = new Set(); + const visibleTurnOrdinals = new Set(); + const turnPromptIds = new Map(); + const pendingAnchorTurnIds: number[] = []; + const undoAnchors: { rawId: number }[] = []; + let undoAnchorFloor = 0; + const activeCancelTurnIds = new Set(); + + const skipCancelledTurnIds = (): void => { + while (cancelledTurnIds.delete(nextTurnId)) { + hiddenTurnIds.add(nextTurnId); + nextTurnId += 1; + } + }; + + const pushSystem = (subtype: string): void => { + timelineIds.push(sysIds.next(subtype)); + }; + + const skillMarkerCount = (origin: unknown): number => { + const kind = (origin as { kind?: unknown } | null | undefined)?.kind; + if (kind === 'user') { + const activations = (origin as { skillActivations?: unknown } | null | undefined) + ?.skillActivations; + return Array.isArray(activations) ? activations.length : 0; + } + if (kind === 'skill_activation' || kind === 'plugin_command') return 1; + return 0; + }; + + for (const record of records) { + switch (record.type) { + case 'turn.prompt': { + skipCancelledTurnIds(); + const rawId = nextTurnId; + nextTurnId += 1; + const origin = record['origin']; + const promptId = record['promptId']; + if (typeof promptId === 'string') turnPromptIds.set(rawId, promptId); + if (isUndoAnchorOrigin(origin)) { + pendingAnchorTurnIds.push(rawId); + anchorTurnOrdinals.push(rawId); + } + currentTurn = rawId; + if (!isVisibleTurnOrigin(origin)) { + hiddenTurnIds.add(rawId); + break; + } + visibleTurnOrdinals.add(rawId); + timelineIds.push(turnIdOf(rawId)); + for (let i = 0; i < skillMarkerCount(origin); i++) pushSystem('skill'); + break; + } + case 'context.append_message': { + const message = record['message'] as + | { id?: string; role?: string; origin?: unknown } + | undefined; + if (message?.role === 'assistant') { + if ( + currentTurn === undefined || + hiddenTurnIds.has(currentTurn) || + !visibleTurnOrdinals.has(currentTurn) + ) { + const rawId = nextTurnId; + nextTurnId += 1; + visibleTurnOrdinals.add(rawId); + timelineIds.push(turnIdOf(rawId)); + currentTurn = rawId; + } + break; + } + if (message?.role !== 'user' || !isUndoAnchorOrigin(message.origin)) break; + const messageId = typeof message.id === 'string' ? message.id : undefined; + const matchingIndex = + messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => turnPromptIds.get(turnId) === messageId) + : -1; + const legacyIndex = + matchingIndex < 0 && messageId !== undefined + ? pendingAnchorTurnIds.findIndex((turnId) => !turnPromptIds.has(turnId)) + : -1; + const matchedTurnId = + matchingIndex >= 0 + ? pendingAnchorTurnIds.splice(matchingIndex, 1)[0] + : legacyIndex >= 0 + ? pendingAnchorTurnIds.splice(legacyIndex, 1)[0] + : messageId === undefined + ? pendingAnchorTurnIds.shift() + : undefined; + if (matchedTurnId !== undefined && !turnPromptIds.has(matchedTurnId) && messageId !== undefined) { + turnPromptIds.set(matchedTurnId, messageId); + } + undoAnchors.push({ rawId: matchedTurnId ?? nextTurnId }); + break; + } + case 'turn.ended': { + const rawId = record['turnId']; + if (typeof rawId !== 'number' || !Number.isInteger(rawId)) break; + const pendingIndex = pendingAnchorTurnIds.indexOf(rawId); + if (pendingIndex >= 0) pendingAnchorTurnIds.splice(pendingIndex, 1); + break; + } + case 'turn.cancel': { + const target = record['target']; + const turnId = record['turnId']; + if (target === 'queued' && typeof turnId === 'number' && turnId >= nextTurnId) { + cancelledTurnIds.add(turnId); + skipCancelledTurnIds(); + break; + } + if ( + target !== 'active' || + typeof turnId !== 'number' || + !Number.isInteger(turnId) || + turnId < 0 || + activeCancelTurnIds.has(turnId) + ) { + break; + } + activeCancelTurnIds.add(turnId); + if (record['reason'] !== 'user_cancelled') break; + pushSystem('interruption'); + break; + } + case 'context.undo': { + const count = record['count']; + if (typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) break; + let firstUndone: number | undefined; + for (let i = 0; i < count && undoAnchors.length > undoAnchorFloor; i++) { + const anchor = undoAnchors.pop(); + if (anchor !== undefined) firstUndone = anchor.rawId; + } + if (firstUndone === undefined) break; + const cut = timelineIds.findIndex((id) => { + const ordinal = turnOrdinalOf(id); + return ordinal !== undefined && ordinal >= firstUndone; + }); + if (cut < 0) break; + timelineIds.length = cut; + for (let turnId = firstUndone; turnId < nextTurnId; turnId++) hiddenTurnIds.add(turnId); + if (currentTurn !== undefined && currentTurn >= firstUndone) currentTurn = undefined; + pushSystem('undo'); + break; + } + case 'context.clear': { + timelineIds.length = 0; + undoAnchorFloor = undoAnchors.length; + currentTurn = undefined; + pushSystem('clear'); + break; + } + case 'context.apply_compaction': { + undoAnchorFloor = undoAnchors.length; + pushSystem('compaction'); + break; + } + case 'goal.create': + case 'goal.clear': { + pushSystem('goal'); + break; + } + case 'goal.update': { + if ( + record['status'] === undefined && + record['budgetLimits'] === undefined && + record['turnsUsed'] === undefined + ) { + break; + } + pushSystem('goal'); + break; + } + case 'plan_mode.enter': { + pushSystem('plan.enter'); + break; + } + case 'plan_mode.exit': { + pushSystem('plan.exit'); + break; + } + case 'plan.revision': { + pushSystem('plan.revision'); + break; + } + case 'swarm_mode.enter': { + pushSystem('swarm.enter'); + break; + } + case 'swarm_mode.exit': { + pushSystem('swarm.exit'); + break; + } + default: + break; + } + } + return { + timelineIds, + systemCounts: sysIds.counts(), + anchorTurnOrdinals, + nextTurnId, + }; +} + +export interface WireStepFold { + readonly state: 'completed' | 'interrupted'; + readonly endedAt?: string; + readonly usage?: StepUsage; + readonly finishReason?: string; + readonly timing?: StepTiming; + readonly endReason?: string; + readonly endMessage?: string; +} + +export interface WireToolFold { + readonly step: number; + readonly name: string; + readonly args: unknown; + readonly output?: unknown; + readonly isError?: boolean; +} + +export interface WireTurnFold { + readonly steps: Map; + readonly texts: Map; + readonly tools: Map; +} + +interface StepRef { + readonly turn: number; + readonly step: number; +} + +export function foldWireTurn(records: readonly ContextRecord[], turnOrdinal: number): WireTurnFold { + const steps = new Map(); + const texts = new Map< + number, + { assistant: string; thinking: string; first: 'assistant' | 'thinking' } + >(); + const tools = new Map(); + const stepRefs = new Map(); + const stepOf = (uuid: string | undefined): StepRef | undefined => + uuid === undefined ? undefined : stepRefs.get(uuid); + for (const record of records) { + if (record.type === 'context.append_loop_event') { + const event = record['event'] as { type?: string } | undefined; + if (event?.type === undefined) continue; + switch (event.type) { + case 'step.begin': { + const e = event as { uuid: string; turnId?: string; step?: number }; + if (e.turnId === undefined || e.step === undefined) continue; + const turn = Number(e.turnId); + if (!Number.isInteger(turn)) continue; + stepRefs.set(e.uuid, { turn, step: e.step }); + continue; + } + case 'step.end': { + const e = event as { + uuid: string; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + usage?: TokenUsage; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + }; + const ref = stepOf(e.uuid); + if (ref === undefined || ref.turn !== turnOrdinal) continue; + steps.set(ref.step, { + state: 'completed', + endedAt: record.time === undefined ? undefined : new Date(record.time).toISOString(), + usage: e.usage === undefined ? undefined : toSnakeUsage(e.usage), + finishReason: e.finishReason ?? e.rawFinishReason ?? e.providerFinishReason, + timing: + e.llmFirstTokenLatencyMs === undefined && e.llmStreamDurationMs === undefined + ? undefined + : { + llm_first_token_ms: e.llmFirstTokenLatencyMs, + llm_stream_duration_ms: e.llmStreamDurationMs, + }, + }); + continue; + } + case 'content.part': { + const e = event as { + stepUuid: string; + part: { type: string; text?: string; think?: string }; + turnId?: string; + step?: number; + }; + let ref = stepOf(e.stepUuid); + if (ref === undefined && e.turnId !== undefined && e.step !== undefined) { + const turn = Number(e.turnId); + if (Number.isInteger(turn)) ref = { turn, step: e.step }; + } + if (ref === undefined || ref.turn !== turnOrdinal) continue; + const entry = texts.get(ref.step) ?? { assistant: '', thinking: '', first: 'assistant' as const }; + if (e.part.type === 'text' && typeof e.part.text === 'string') { + if (entry.assistant.length === 0 && entry.thinking.length === 0) entry.first = 'assistant'; + entry.assistant += e.part.text; + } else if (e.part.type === 'think' && typeof e.part.think === 'string') { + if (entry.assistant.length === 0 && entry.thinking.length === 0) entry.first = 'thinking'; + entry.thinking += e.part.think; + } else { + continue; + } + texts.set(ref.step, entry); + continue; + } + case 'tool.call': { + const e = event as { + stepUuid: string; + toolCallId: string; + name: string; + args?: unknown; + turnId?: string; + step?: number; + }; + let ref = stepOf(e.stepUuid); + if (ref === undefined && e.turnId !== undefined && e.step !== undefined) { + const turn = Number(e.turnId); + if (Number.isInteger(turn)) ref = { turn, step: e.step }; + } + if (ref === undefined || ref.turn !== turnOrdinal) continue; + tools.set(e.toolCallId, { + step: ref.step, + name: e.name, + args: e.args, + output: tools.get(e.toolCallId)?.output, + isError: tools.get(e.toolCallId)?.isError, + }); + continue; + } + case 'tool.result': { + const e = event as { + toolCallId: string; + result: { output: unknown; isError?: boolean }; + }; + const existing = tools.get(e.toolCallId); + if (existing === undefined) continue; + tools.set(e.toolCallId, { + ...existing, + output: e.result.output, + isError: e.result.isError, + }); + continue; + } + default: + continue; + } + } + if (record.type === 'turn.step.interrupted') { + if (record['turnId'] !== turnOrdinal) continue; + const step = record['step']; + if (typeof step !== 'number') continue; + steps.set(step, { + state: 'interrupted', + endedAt: record.time === undefined ? undefined : new Date(record.time).toISOString(), + endReason: typeof record['reason'] === 'string' ? record['reason'] : undefined, + endMessage: typeof record['message'] === 'string' ? record['message'] : undefined, + }); + continue; + } + } + return { steps, texts, tools }; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} diff --git a/packages/kap-server/src/services/projection/ids.ts b/packages/kap-server/src/services/projection/ids.ts new file mode 100644 index 00000000000..f7bfcaa6612 --- /dev/null +++ b/packages/kap-server/src/services/projection/ids.ts @@ -0,0 +1,102 @@ +export const TODO_ENTITY_ID = 'todo'; + +export const DURABLE_SYSTEM_SUBTYPES = [ + 'compaction', + 'undo', + 'clear', + 'goal', + 'plan.enter', + 'plan.exit', + 'plan.revision', + 'swarm.enter', + 'swarm.exit', + 'skill', + 'interruption', +] as const; + +export type DurableSystemSubtype = (typeof DURABLE_SYSTEM_SUBTYPES)[number]; + +export const LIVE_ONLY_SYSTEM_SUBTYPES = ['hook', 'notice'] as const; + +export type LiveOnlySystemSubtype = (typeof LIVE_ONLY_SYSTEM_SUBTYPES)[number]; + +export function turnIdOf(ordinal: number): string { + return `t${ordinal}`; +} + +export function stepIdOf(turnId: string, ordinal: number): string { + return `${turnId}.${ordinal}`; +} + +export function textMessageIdOf(stepId: string, ordinal: number): string { + return `${stepId}.a${ordinal}`; +} + +export function stepUserMessageIdOf(stepId: string, ordinal: number): string { + return `${stepId}.u${ordinal}`; +} + +export function turnUserMessageIdOf(turnId: string): string { + return `${turnId}.u0`; +} + +export function attachmentIdOf(baseId: string, ordinal: number): string { + return `${baseId}.att${ordinal}`; +} + +export function turnOrdinalOf(turnId: string): number | undefined { + if (!/^t\d+$/.test(turnId)) return undefined; + return Number(turnId.slice(1)); +} + +export function stepRefOf(stepId: string): { turnId: string; ordinal: number } | undefined { + const match = /^(t\d+)\.(\d+)$/.exec(stepId); + if (match === null) return undefined; + return { turnId: match[1]!, ordinal: Number(match[2]) }; +} + +export function systemIdOf(subtype: string, ordinal: number): string { + return `sys_${subtype}_${ordinal}`; +} + +export function isCompactionSystemId(id: string): boolean { + return id.startsWith(`sys_compaction_`); +} + +export function isUndoAnchorOrigin(origin: unknown): boolean { + const kind = (origin as { kind?: unknown } | null | undefined)?.kind; + if (kind === undefined || kind === 'user') return true; + const trigger = (origin as { trigger?: unknown } | null | undefined)?.trigger; + return (kind === 'skill_activation' || kind === 'plugin_command') && trigger === 'user-slash'; +} + +export function isVisibleTurnOrigin(origin: unknown): boolean { + const kind = (origin as { kind?: unknown } | null | undefined)?.kind; + if (kind === 'system_trigger') { + const name = (origin as { name?: unknown } | null | undefined)?.name; + return name === 'goal_continuation' || name === 'subagent'; + } + if (kind === 'skill_activation' || kind === 'plugin_command') { + return (origin as { trigger?: unknown } | null | undefined)?.trigger === 'user-slash'; + } + if (kind === 'injection' || kind === 'retry' || kind === 'compaction_summary') return false; + return true; +} + +export class SystemIdAllocator { + private readonly seqs = new Map(); + + next(subtype: string): string { + const ordinal = (this.seqs.get(subtype) ?? 0) + 1; + this.seqs.set(subtype, ordinal); + return systemIdOf(subtype, ordinal); + } + + seed(subtype: string, ordinal: number): void { + this.seqs.set(subtype, Math.max(this.seqs.get(subtype) ?? 0, ordinal)); + } + + counts(): ReadonlyMap { + return this.seqs; + } +} diff --git a/packages/kap-server/src/services/projection/index.ts b/packages/kap-server/src/services/projection/index.ts new file mode 100644 index 00000000000..5a7ac88a955 --- /dev/null +++ b/packages/kap-server/src/services/projection/index.ts @@ -0,0 +1,7 @@ +export * from './agentProjector'; +export * from './events'; +export * from './heal'; +export * from './ids'; +export * from './projectionService'; +export * from './sessionProjection'; +export * from './sessionState'; diff --git a/packages/kap-server/src/services/projection/projectionService.ts b/packages/kap-server/src/services/projection/projectionService.ts new file mode 100644 index 00000000000..4c71220251a --- /dev/null +++ b/packages/kap-server/src/services/projection/projectionService.ts @@ -0,0 +1,87 @@ +import { + followSessionLifecycles, + getLiveSessionById, + type IDisposable, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +import type { ServerMessage } from '../../protocol/messages'; +import { + SessionProjection, + type ProjectionLogger, +} from './sessionProjection'; + +export interface ProjectionServiceDeps { + readonly homeDir: string; + readonly core: Scope; + readonly logger?: ProjectionLogger; +} + +export class ProjectionService { + private readonly live = new Map(); + + constructor(private readonly deps: ProjectionServiceDeps) { + followSessionLifecycles(deps.core.accessor, (service) => { + const d1 = service.onDidCloseSession(({ sessionId }) => { + this.dropSession(sessionId); + }); + const d2 = service.onDidArchiveSession(({ sessionId }) => { + this.dropSession(sessionId); + }); + return { + dispose: () => { + d1.dispose(); + d2.dispose(); + }, + }; + }); + } + + forSessionLive(sessionId: string): SessionProjection | undefined { + const existing = this.live.get(sessionId); + if (existing !== undefined) { + if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) return existing; + this.dropSession(sessionId); + return undefined; + } + const session = getLiveSessionById(this.deps.core.accessor, sessionId); + if (session === undefined) return undefined; + let projection: SessionProjection; + try { + projection = new SessionProjection(sessionId, session, this.deps); + } catch (error) { + if (error instanceof Error && error.message === 'InstantiationService has been disposed') { + return undefined; + } + throw error; + } + this.live.set(sessionId, projection); + return projection; + } + + onMessage( + sessionId: string, + listener: (message: ServerMessage) => void, + ): IDisposable | undefined { + return this.forSessionLive(sessionId)?.onMessage(listener); + } + + recoveryMessages(sessionId: string): ServerMessage[] { + return this.forSessionLive(sessionId)?.recoveryMessages() ?? []; + } + + notifyContextCleared(sessionId: string, agentId: string): void { + this.live.get(sessionId)?.notifyContextCleared(agentId); + } + + inFlight(sessionId: string, agentId: string): { turn_id: string; step_id: string } | undefined { + return this.forSessionLive(sessionId)?.inFlight(agentId); + } + + dropSession(sessionId: string): void { + const entry = this.live.get(sessionId); + if (entry === undefined) return; + this.live.delete(sessionId); + entry.dispose(); + } +} diff --git a/packages/kap-server/src/services/projection/sessionProjection.ts b/packages/kap-server/src/services/projection/sessionProjection.ts new file mode 100644 index 00000000000..ac53ae874e9 --- /dev/null +++ b/packages/kap-server/src/services/projection/sessionProjection.ts @@ -0,0 +1,545 @@ +import { join } from 'node:path'; + +import { + IAgentActivityView, + IAgentGoalService, + IAgentLifecycleService, + IAgentLoopService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentPromptService, + IAgentScopeContext, + IAgentStateService, + IAgentTaskService, + IAgentTodoService, + IEventBus, + ISessionActivityView, + ISessionIndex, + IWireService, + MAIN_AGENT_ID, + listSessionPendingInteractions, + onSessionInteractionDidChangePending, + onSessionInteractionDidResolve, + type AgentTaskInfo, + type IAgentScopeHandle, + type IDisposable, + type Interaction, + type ISessionScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { planKey } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; +import { swarmKey } from '@moonshot-ai/agent-core-v2/features/swarm/swarmOps'; + +import { serverMessageSchema, type ServerMessage } from '../../protocol/messages'; +import { readLegacyStatus } from '../legacyStatus/legacyStatus'; +import { AgentMessageProjector, toTurnOrigin, type ProjectorInteraction } from './agentProjector'; +import type { ProjectionBusEvent } from './events'; +import { foldTimelineSeed, foldWireTurn, readWireRecords, type ContextRecord } from './heal'; +import { isUndoAnchorOrigin } from './ids'; +import { SessionStateAggregator } from './sessionState'; + +const TURN_HEAL_DEBOUNCE_MS = 250; +const TASK_OUTPUT_TAIL_CHARS = 4096; + +export interface ProjectionLogger { + warn(obj: unknown, msg: string): void; +} + +export interface SessionProjectionDeps { + readonly homeDir: string; + readonly core: Scope; + readonly logger?: ProjectionLogger; +} + +export class SessionProjection { + private readonly projectors = new Map(); + private readonly agentDisposables = new Map(); + private readonly disposables: IDisposable[] = []; + private readonly listeners = new Set<(message: ServerMessage) => void>(); + private readonly aggregator = new SessionStateAggregator(); + private readonly subagentTaskIds = new Map(); + private readonly interactionAgents = new Map(); + private readonly knownInteractions = new Set(); + private readonly unknownEventTypes = new Set(); + private readonly validationFailures = new Map(); + private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); + private disposed = false; + + constructor( + readonly sessionId: string, + private readonly session: ISessionScopeHandle, + private readonly deps: SessionProjectionDeps, + ) { + const agents = session.accessor.get(IAgentLifecycleService); + for (const context of agents.list()) { + const handle = agents.handleOf(context.agentId); + if (handle !== undefined) this.subscribeAgent(handle); + } + this.disposables.push( + agents.onDidCreate( + this.guard((context) => { + const handle = agents.handleOf(context.agentId); + if (handle !== undefined) this.subscribeAgent(handle); + }), + ), + agents.onDidClose( + this.guard((context) => { + this.dropAgent(context.agentId); + }), + ), + onSessionInteractionDidChangePending( + agents, + this.guard(() => { + this.onInteractionsChanged(agents); + }), + ), + onSessionInteractionDidResolve( + agents, + this.guard(({ id, response }) => { + this.onInteractionResolve(id, response); + }), + ), + ); + for (const pending of listSessionPendingInteractions(agents)) { + this.announce(pending, false); + } + const activity = session.accessor.get(ISessionActivityView) as + | ISessionActivityView + | undefined; + if (activity !== undefined) { + this.aggregator.feedSessionActivity(activity.state()); + this.disposables.push( + activity.onDidChange( + this.guard((event) => { + this.aggregator.feedSessionActivity(event.state); + this.emitState(); + }), + ), + ); + } + } + + onMessage(listener: (message: ServerMessage) => void): IDisposable { + this.listeners.add(listener); + return { + dispose: () => { + this.listeners.delete(listener); + }, + }; + } + + recoveryMessages(): ServerMessage[] { + const messages: ServerMessage[] = [this.aggregator.snapshot(this.sessionId)]; + for (const projector of this.projectors.values()) { + messages.push(...projector.recoveryMessages()); + } + return messages.filter((message) => this.validate(message) !== undefined); + } + + notifyContextCleared(agentId: string): void { + const projector = this.projectors.get(agentId); + if (projector === undefined) return; + this.emitAll(projector.notifyContextCleared()); + } + + inFlight(agentId: string): { turn_id: string; step_id: string } | undefined { + return this.projectors.get(agentId)?.inFlight(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const pending of this.healTimers.values()) clearTimeout(pending.timer); + this.healTimers.clear(); + for (const list of this.agentDisposables.values()) { + for (const d of list) d.dispose(); + } + this.agentDisposables.clear(); + for (const d of this.disposables) d.dispose(); + for (const projector of this.projectors.values()) projector.dispose(); + this.projectors.clear(); + this.listeners.clear(); + this.interactionAgents.clear(); + this.knownInteractions.clear(); + } + + private subscribeAgent(handle: IAgentScopeHandle): void { + const agentId = handle.id; + if (this.projectors.has(agentId)) return; + const projector = new AgentMessageProjector( + agentId, + this.sessionId, + this.subagentTaskIds, + { + stepOrdinal: (turnId) => { + const view = handle.accessor.get(IAgentActivityView) as IAgentActivityView | undefined; + const turn = view?.state().turn; + return turn === undefined || `t${turn.turnId}` !== turnId ? undefined : turn.step; + }, + resolvePlanRevisionKey: (key) => + handle.accessor.get(IAgentScopeContext).scope(key), + }, + { + onUnknownEvent: (type) => { + if (this.unknownEventTypes.has(type)) return; + this.unknownEventTypes.add(type); + this.deps.logger?.warn( + { sessionId: this.sessionId, agentId, type }, + 'projection: unhandled engine event type, dropped', + ); + }, + onDeferred: (messages) => { + if (!this.disposed) this.emitAll(messages); + }, + }, + ); + this.projectors.set(agentId, projector); + const agentState = handle.accessor.get(IAgentStateService) as IAgentStateService | undefined; + const planMode = + agentState?.has(planKey) === true ? agentState.get(planKey).active : undefined; + const swarmMode = + agentState?.has(swarmKey) === true ? agentState.get(swarmKey) !== null : undefined; + projector.seedModes({ planMode, swarmMode }); + const disposables: IDisposable[] = []; + const bus = handle.accessor.get(IEventBus) as IEventBus | undefined; + if (bus !== undefined) { + disposables.push( + bus.subscribe( + this.guard((event) => { + this.onBusEvent(agentId, event as ProjectionBusEvent); + }), + ), + ); + } + const todo = handle.accessor.get(IAgentTodoService) as IAgentTodoService | undefined; + if (todo !== undefined) { + projector.seedTodo(todo.get()); + disposables.push( + todo.onDidChange( + this.guard((items) => { + this.emitAll(projector.todoChanged(items)); + }), + ), + ); + } + const tasks = handle.accessor.get(IAgentTaskService) as IAgentTaskService | undefined; + for (const info of tasks?.list() ?? []) projector.seedTask(info); + const loop = handle.accessor.get(IAgentLoopService) as IAgentLoopService | undefined; + const status = loop?.status(); + if (status?.state === 'running' && status.activeTurnId !== undefined) { + const prompts = handle.accessor.get(IAgentPromptService) as IAgentPromptService | undefined; + const activity = handle.accessor.get(IAgentActivityView) as IAgentActivityView | undefined; + const rawOrigin = activity?.state().turn?.origin; + projector.seedActiveTurn({ + turnId: status.activeTurnId, + promptId: prompts?.list().active?.id, + origin: + activity === undefined + ? undefined + : toTurnOrigin(rawOrigin, agentId, this.subagentTaskIds), + anchor: isUndoAnchorOrigin(rawOrigin), + }); + } + if (agentId === MAIN_AGENT_ID) { + this.seedMainAgent(handle, disposables, { planMode, swarmMode }); + } + this.agentDisposables.set(agentId, disposables); + void this.seedTimelineFromWire(agentId, projector); + } + + private seedMainAgent( + handle: IAgentScopeHandle, + disposables: IDisposable[], + modes: { planMode?: boolean; swarmMode?: boolean }, + ): void { + this.aggregator.feedMainStatus({ planMode: modes.planMode, swarmMode: modes.swarmMode }); + const legacy = readLegacyStatus(handle); + if (legacy !== undefined) { + this.aggregator.feedSeed({ + model: legacy.model.length > 0 ? legacy.model : undefined, + usage: legacy.usage, + contextTokens: legacy.contextTokens, + maxContextTokens: legacy.maxContextTokens, + }); + } + const profile = handle.accessor.get(IAgentProfileService) as IAgentProfileService | undefined; + if (profile !== undefined) { + this.aggregator.feedSeed({ thinkingEffort: profile.getEffectiveThinkingLevel() }); + } + const permission = handle.accessor.get(IAgentPermissionModeService) as + | IAgentPermissionModeService + | undefined; + if (permission !== undefined) { + this.aggregator.feedSeed({ permission: permission.mode }); + disposables.push( + permission.onDidChangeMode( + this.guard(({ mode }) => { + this.aggregator.feedSeed({ permission: mode }); + this.emitState(); + }), + ), + ); + } + const goal = handle.accessor.get(IAgentGoalService) as IAgentGoalService | undefined; + if (goal !== undefined) { + this.aggregator.feedGoal(goal.getGoal().goal); + } + const activity = handle.accessor.get(IAgentActivityView) as IAgentActivityView | undefined; + if (activity !== undefined) { + this.aggregator.feedMainActivity(activity.state()); + } + } + + private dropAgent(agentId: string): void { + for (const d of this.agentDisposables.get(agentId) ?? []) d.dispose(); + this.agentDisposables.delete(agentId); + this.projectors.get(agentId)?.dispose(); + this.projectors.delete(agentId); + const timer = this.healTimers.get(agentId); + if (timer !== undefined) { + clearTimeout(timer.timer); + this.healTimers.delete(agentId); + } + } + + private onBusEvent(agentId: string, event: ProjectionBusEvent): void { + if (this.disposed) return; + const projector = this.projectors.get(agentId); + if (projector === undefined) return; + this.emitAll(projector.map(event)); + if (event.type === 'task.terminated') { + const info = (event as { info?: AgentTaskInfo }).info; + if (info !== undefined) void this.patchTaskOutputTail(agentId, info.taskId); + } + for (const ordinal of projector.takeEndedTurnOrdinals()) { + this.scheduleHeal(agentId, ordinal); + } + if (agentId === MAIN_AGENT_ID) { + if (event.type === 'agent.status.updated') { + this.aggregator.feedMainStatus(event); + } else if (event.type === 'agent.activity.updated') { + this.aggregator.feedMainActivity(event); + } else if (event.type === 'goal.updated') { + this.aggregator.feedGoal(event.snapshot); + } else if (event.type === 'plan.revision') { + const handle = this.agentHandle(MAIN_AGENT_ID); + const path = handle?.accessor.get(IAgentScopeContext).scope(event.key) ?? event.key; + this.aggregator.feedPlanRevision(path, event.version); + } + } + this.emitState(); + } + + private agentHandle(agentId: string): IAgentScopeHandle | undefined { + return this.session.accessor.get(IAgentLifecycleService).handleOf(agentId); + } + + private guard(fn: (...args: A) => void): (...args: A) => void { + return (...args: A) => { + try { + fn(...args); + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'projection: event callback failed, continuing', + ); + } + }; + } + + private onInteractionsChanged(agents: IAgentLifecycleService): void { + for (const pending of listSessionPendingInteractions(agents)) { + if (this.knownInteractions.has(pending.id)) continue; + this.announce(pending, true); + } + } + + private announce(interaction: Interaction, emit: boolean): void { + if (interaction.kind !== 'approval' && interaction.kind !== 'question') return; + this.knownInteractions.add(interaction.id); + const agentId = interactionAgentId(interaction); + this.interactionAgents.set(interaction.id, agentId); + const projector = this.projectorFor(agentId); + if (projector === undefined) return; + const request: ProjectorInteraction = { + id: interaction.id, + kind: interaction.kind, + payload: interaction.payload, + origin: interaction.origin, + createdAt: interaction.createdAt, + }; + const ops = projector.interactionRequested(request); + if (emit) this.emitAll(ops); + } + + private onInteractionResolve(id: string, response: unknown): void { + this.knownInteractions.delete(id); + const agentId = this.interactionAgents.get(id); + if (agentId === undefined) return; + this.interactionAgents.delete(id); + const projector = this.projectors.get(agentId); + if (projector === undefined) return; + this.emitAll(projector.interactionResolved(id, response)); + this.emitState(); + } + + private projectorFor(agentId: string): AgentMessageProjector | undefined { + const existing = this.projectors.get(agentId); + if (existing !== undefined) return existing; + const handle = this.agentHandle(agentId); + if (handle === undefined) return undefined; + this.subscribeAgent(handle); + return this.projectors.get(agentId); + } + + private scheduleHeal(agentId: string, ordinal: number): void { + const existing = this.healTimers.get(agentId); + if (existing !== undefined) { + existing.ordinals.add(ordinal); + existing.timer.refresh(); + return; + } + const ordinals = new Set([ordinal]); + const timer = setTimeout(() => { + this.healTimers.delete(agentId); + void this.healTurns(agentId, ordinals); + }, TURN_HEAL_DEBOUNCE_MS); + timer.unref(); + this.healTimers.set(agentId, { ordinals, timer }); + } + + private async seedTimelineFromWire( + agentId: string, + projector: AgentMessageProjector, + ): Promise { + const records = await this.readAgentWire(agentId); + if (records === undefined) return; + if (this.disposed || this.projectors.get(agentId) !== projector) return; + projector.applyTimelineSeed(foldTimelineSeed(records)); + } + + private async healTurns(agentId: string, ordinals: ReadonlySet): Promise { + const projector = this.projectors.get(agentId); + if (projector === undefined || this.disposed) return; + const records = await this.readAgentWire(agentId); + if (records === undefined) return; + if (this.disposed || this.projectors.get(agentId) !== projector) return; + for (const ordinal of ordinals) { + this.emitAll(projector.healTurn(ordinal, foldWireTurn(records, ordinal))); + } + } + + private async patchTaskOutputTail(agentId: string, taskId: string): Promise { + const tasks = this.agentHandle(agentId)?.accessor.get(IAgentTaskService); + if (tasks === undefined) return; + let tail: string; + try { + tail = await tasks.readOutput(taskId, TASK_OUTPUT_TAIL_CHARS); + } catch { + return; + } + if (this.disposed || tail.length === 0) return; + this.emitAll(this.projectors.get(agentId)?.taskOutputUpdated(taskId, tail) ?? []); + } + + private async readAgentWire(agentId: string): Promise { + const index = this.deps.core.accessor.get(ISessionIndex) as ISessionIndex | undefined; + if (index === undefined) return undefined; + const summary = await index.get(this.sessionId); + if (summary === undefined) return undefined; + const wire = this.agentHandle(agentId)?.accessor.get(IWireService); + if (wire !== undefined) { + try { + await wire.flush(); + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + agentId, + err: error instanceof Error ? error.message : error, + }, + 'projection: wire flush failed, reading what is on disk', + ); + } + } + try { + return await readWireRecords( + join( + this.deps.homeDir, + 'sessions', + summary.workspaceId, + this.sessionId, + 'agents', + agentId, + 'wire.jsonl', + ), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + this.deps.logger?.warn( + { + sessionId: this.sessionId, + agentId, + err: error instanceof Error ? error.message : error, + }, + 'projection: wire read failed, continuing without it', + ); + return undefined; + } + } + + private emitAll(messages: ServerMessage[]): void { + for (const message of messages) this.emit(message); + } + + private emitState(): void { + const state = this.aggregator.changed(this.sessionId); + if (state !== undefined) this.emit(state); + } + + private emit(message: ServerMessage): void { + const parsed = this.validate(message); + if (parsed === undefined) return; + for (const listener of this.listeners) { + try { + listener(parsed); + } catch { + } + } + } + + private validate(message: ServerMessage): ServerMessage | undefined { + const parsed = serverMessageSchema.safeParse(message); + if (parsed.success) return parsed.data; + const type = String((message as { type?: unknown }).type); + const count = (this.validationFailures.get(type) ?? 0) + 1; + this.validationFailures.set(type, count); + if (count === 1 || count % 100 === 0) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + type, + count, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + 'projection: outbound message failed schema validation, dropped', + ); + } + return undefined; + } +} + +function interactionAgentId(interaction: Interaction): string { + const payloadAgent = (interaction.payload as { agentId?: unknown }).agentId; + return ( + interaction.origin.agentId ?? + (typeof payloadAgent === 'string' ? payloadAgent : undefined) ?? + MAIN_AGENT_ID + ); +} diff --git a/packages/kap-server/src/services/projection/sessionState.ts b/packages/kap-server/src/services/projection/sessionState.ts new file mode 100644 index 00000000000..5bf54c63874 --- /dev/null +++ b/packages/kap-server/src/services/projection/sessionState.ts @@ -0,0 +1,265 @@ +import type { + AgentActivityState, + PermissionMode, + SessionActivityState, + TokenUsage, + UsageStatus, +} from '@moonshot-ai/agent-core-v2'; + +import type { + AgentPhase, + SessionStateGoal, + SessionStateMessage, + SessionStateModes, + SessionStateUsage, + StepUsage, +} from '../../protocol/messages'; + +interface GoalSnapshotLike { + readonly objective: string; + readonly status: 'active' | 'paused' | 'blocked' | 'complete'; + readonly completionCriterion?: string; + readonly tokensUsed: number; + readonly budget: { readonly tokenBudget: number | null }; +} + +export class SessionStateAggregator { + private sessionActivity: SessionActivityState | undefined; + private mainActivity: AgentActivityState | undefined; + private model: string | undefined; + private thinkingEffort: string | undefined; + private permission: 'manual' | 'yolo' | 'auto' | undefined; + private usage: SessionStateUsage | undefined; + private contextTokens: number | undefined; + private maxContextTokens: number | undefined; + private goal: SessionStateGoal | null | undefined; + private planMode = false; + private swarmMode = false; + private planRevision: { path: string; version: number } | undefined; + private lastEmittedJson: string | undefined; + + feedSessionActivity(state: SessionActivityState): void { + this.sessionActivity = state; + } + + feedMainActivity(state: AgentActivityState): void { + this.mainActivity = state; + } + + feedMainStatus(event: { + model?: string; + thinkingEffort?: string; + usage?: UsageStatus; + contextTokens?: number; + maxContextTokens?: number; + planMode?: boolean; + swarmMode?: boolean; + permission?: 'manual' | 'yolo' | 'auto'; + }): void { + if (event.model !== undefined) this.model = event.model; + if (event.thinkingEffort !== undefined) this.thinkingEffort = event.thinkingEffort; + if (event.usage !== undefined) this.usage = usageToWire(event.usage); + if (event.contextTokens !== undefined) this.contextTokens = event.contextTokens; + if (event.maxContextTokens !== undefined) this.maxContextTokens = event.maxContextTokens; + if (event.planMode !== undefined) this.planMode = event.planMode; + if (event.swarmMode !== undefined) this.swarmMode = event.swarmMode; + if (event.permission !== undefined) this.permission = event.permission; + } + + feedSeed(seed: { + model?: string; + thinkingEffort?: string; + usage?: UsageStatus; + contextTokens?: number; + maxContextTokens?: number; + permission?: PermissionMode; + }): void { + if (seed.model !== undefined) this.model = seed.model; + if (seed.thinkingEffort !== undefined) this.thinkingEffort = seed.thinkingEffort; + if (seed.usage !== undefined) this.usage = usageToWire(seed.usage); + if (seed.contextTokens !== undefined) this.contextTokens = seed.contextTokens; + if (seed.maxContextTokens !== undefined) this.maxContextTokens = seed.maxContextTokens; + if (seed.permission !== undefined) this.permission = seed.permission; + } + + feedGoal(snapshot: GoalSnapshotLike | null): void { + this.goal = + snapshot === null + ? null + : { + objective: snapshot.objective, + status: snapshot.status, + completion_criterion: snapshot.completionCriterion, + budget_used: snapshot.tokensUsed, + budget_limit: snapshot.budget.tokenBudget ?? undefined, + }; + } + + feedPlanRevision(path: string, version: number): void { + this.planRevision = { path, version }; + } + + snapshot(sessionId: string): SessionStateMessage { + return this.build(sessionId); + } + + changed(sessionId: string): SessionStateMessage | undefined { + const next = this.build(sessionId); + const { timestamp: _timestamp, ...comparable } = next; + const json = JSON.stringify(comparable); + if (json === this.lastEmittedJson) return undefined; + this.lastEmittedJson = json; + return next; + } + + private build(sessionId: string): SessionStateMessage { + const busy = this.sessionActivity?.busy ?? false; + const activity = this.computeActivity(busy); + const contextUsage = + this.contextTokens !== undefined && + this.maxContextTokens !== undefined && + this.maxContextTokens > 0 + ? this.contextTokens / this.maxContextTokens + : undefined; + const modes = this.computeModes(); + return { + type: 'session.state', + session_id: sessionId, + timestamp: new Date().toISOString(), + busy, + main_turn_active: this.sessionActivity?.mainTurnActive ?? false, + pending_interaction: this.sessionActivity?.pendingInteraction, + last_turn_reason: this.mainActivity?.lastTurn?.reason ?? this.sessionActivity?.lastTurnReason, + activity, + phase: this.mainActivity === undefined ? undefined : toAgentPhase(this.mainActivity), + model: this.model, + thinking_effort: this.thinkingEffort, + permission: this.permission, + usage: this.usage, + context_tokens: this.contextTokens, + max_context_tokens: this.maxContextTokens, + context_usage: contextUsage, + goal: this.goal ?? undefined, + modes, + }; + } + + private computeActivity(busy: boolean): 'idle' | 'turn' | 'disposing' | 'unknown' { + if (this.mainActivity?.lifecycle === 'disposed') return 'disposing'; + if (this.sessionActivity === undefined) return 'unknown'; + return busy ? 'turn' : 'idle'; + } + + private computeModes(): SessionStateModes | undefined { + const modes: SessionStateModes = {}; + if (this.planMode) { + modes.plan = { + review_path: this.planRevision?.path, + version: this.planRevision?.version, + }; + } + if (this.swarmMode) modes.swarm = {}; + return modes.plan === undefined && modes.swarm === undefined ? undefined : modes; + } +} + +export function toAgentPhase(state: AgentActivityState): AgentPhase | undefined { + const { lifecycle, turn, lastTurn } = state; + if (turn === undefined) { + if (lifecycle === 'ready' && lastTurn !== undefined) { + return { + kind: 'ended', + turn_id: lastTurn.turnId, + reason: lastTurn.reason, + duration_ms: lastTurn.durationMs, + at: lastTurn.at, + }; + } + if (lifecycle === 'ready') return { kind: 'idle' }; + return undefined; + } + const stepId = `t${turn.turnId}.${turn.step}`; + if (turn.pendingApprovals.length > 0) { + const latest = turn.pendingApprovals.at(-1)!; + return { + kind: 'awaiting_approval', + turn_id: turn.turnId, + step: turn.step > 0 ? turn.step : undefined, + approval: { approval_id: latest.approvalId, tool_call_id: latest.toolCallId }, + since: latest.since, + }; + } + if (turn.ending && turn.endingReason !== undefined) { + return { + kind: 'interrupted', + turn_id: turn.turnId, + step: turn.step > 0 ? turn.step : undefined, + reason: turn.endingReason, + at: turn.since, + }; + } + switch (turn.phase) { + case 'running': + return { kind: 'running', turn_id: turn.turnId, step: turn.step, step_id: stepId, since: turn.since }; + case 'streaming': { + const latestTool = turn.activeToolCalls.at(-1); + return { + kind: 'streaming', + turn_id: turn.turnId, + step: turn.step, + step_id: stepId, + stream: turn.stream ?? 'assistant', + tool_call_id: turn.stream === 'tool_call' ? latestTool?.toolCallId : undefined, + tool_name: turn.stream === 'tool_call' ? latestTool?.name : undefined, + since: turn.since, + }; + } + case 'retrying': + return { + kind: 'retrying', + turn_id: turn.turnId, + step: turn.step, + step_id: stepId, + failed_attempt: turn.retry?.failedAttempt ?? 0, + next_attempt: turn.retry?.nextAttempt ?? 0, + max_attempts: turn.retry?.maxAttempts ?? 0, + delay_ms: turn.retry?.delayMs ?? 0, + error_name: turn.retry?.errorName, + status_code: turn.retry?.statusCode, + since: turn.since, + }; + case 'tool_call': { + const latest = turn.activeToolCalls.at(-1); + return { + kind: 'tool_call', + turn_id: turn.turnId, + step: turn.step, + tool_call_id: latest?.toolCallId ?? '', + name: latest?.name ?? '', + since: latest?.since ?? turn.since, + }; + } + } +} + +function usageToWire(usage: UsageStatus): SessionStateUsage { + return { + by_model: + usage.byModel === undefined + ? undefined + : Object.fromEntries( + Object.entries(usage.byModel).map(([model, u]) => [model, toSnakeUsage(u)]), + ), + current_turn: usage.currentTurn === undefined ? undefined : toSnakeUsage(usage.currentTurn), + total: usage.total === undefined ? undefined : toSnakeUsage(usage.total), + }; +} + +function toSnakeUsage(usage: TokenUsage): StepUsage { + return { + input_other: usage.inputOther, + output: usage.output, + input_cache_read: usage.inputCacheRead, + input_cache_creation: usage.inputCacheCreation, + }; +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index e35bf625c76..4cc5c68a27e 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -61,6 +61,7 @@ import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcast import type { ConfigWarningItem } from './transport/ws/v1/events'; import { FsWatchBridge } from './transport/ws/v1/fsWatchBridge'; import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; +import { registerWsV3, WS_PATH_V3 } from './transport/ws/v3/registerWsV3'; import { getServerVersion } from './version'; import { classify } from './security/bindClassify'; import { @@ -78,6 +79,7 @@ import { shutdownServerTelemetry, } from './services/telemetry'; import { TranscriptService } from './services/transcript/transcriptService'; +import { ProjectionService } from './services/projection'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; import { startConfigChangedPublisher } from './services/config/configChangedPublisher'; import { createAuthFailureLimiter } from './middleware/rateLimit'; @@ -339,6 +341,7 @@ export async function startServer(opts: ServerStartOptions): Promise { @@ -440,6 +443,8 @@ export async function startServer(opts: ServerStartOptions): Promise => { const url = req.url ?? ''; const isV1 = url === WS_PATH_V1 || url.startsWith(`${WS_PATH_V1}?`); - if (!isV1) { + const isV3 = url === WS_PATH_V3 || url.startsWith(`${WS_PATH_V3}?`); + if (!isV1 && !isV3) { socket.destroy(); return; } @@ -523,7 +536,8 @@ export async function startServer(opts: ServerStartOptions): Promise wssV1.emit('connection', ws, req)); + const wss = isV3 ? wssV3 : wssV1; + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); }; app.server.on('upgrade', (req, socket, head) => { void handleUpgrade(req, socket, head).catch((error: unknown) => @@ -534,6 +548,8 @@ export async function startServer(opts: ServerStartOptions): Promise { connectionRegistry.closeAll('server shutting down'); wssV1.close(); + wssV3.close(); + wsV3Hub.dispose(); await broadcaster.close(); }); diff --git a/packages/kap-server/src/transport/ws/v3/globalTranslator.ts b/packages/kap-server/src/transport/ws/v3/globalTranslator.ts new file mode 100644 index 00000000000..424b6ae862c --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/globalTranslator.ts @@ -0,0 +1,240 @@ +import { basename } from 'node:path'; + +import type { IDisposable, Workspace } from '@moonshot-ai/agent-core-v2'; + +import { serverMessageSchema, type ServerMessage, type WorkspaceInfo } from '../../../protocol/messages'; +import type { WsV3CoreEvent, WsV3GlobalSource, WsV3Logger } from './wsV3Deps'; + +export class GlobalMessageTranslator { + private queue: Promise = Promise.resolve(); + private readonly workspaces = new Map(); + private readonly validationFailures = new Map(); + private readonly disposable: IDisposable; + private disposed = false; + + constructor( + private readonly deps: WsV3GlobalSource, + private readonly emit: (message: ServerMessage) => void, + private readonly logger?: WsV3Logger, + ) { + this.disposable = deps.subscribe((event) => this.onEvent(event)); + this.enqueue(async () => { + for (const workspace of await deps.listWorkspaces()) { + this.workspaces.set(workspace.id, await deps.workspaceInfo(workspace)); + } + }); + } + + dispose(): void { + this.disposed = true; + this.disposable.dispose(); + this.workspaces.clear(); + } + + private onEvent(event: WsV3CoreEvent): void { + this.enqueue(async () => { + if (this.disposed) return; + for (const candidate of await this.translate(event)) this.emitValidated(candidate); + }); + } + + private enqueue(task: () => Promise): void { + this.queue = this.queue.then(task).catch(() => {}); + } + + private async translate(event: WsV3CoreEvent): Promise { + const timestamp = new Date().toISOString(); + switch (event.type) { + case 'event.workspace.created': + case 'event.workspace.updated': { + const workspace = workspaceRef(event.payload); + if (workspace === undefined) return []; + const info = await this.deps.workspaceInfo(workspace); + this.workspaces.set(info.id, info); + return [ + { + type: 'workspace', + timestamp, + subtype: event.type === 'event.workspace.created' ? 'created' : 'updated', + workspace: info, + }, + ]; + } + case 'event.workspace.deleted': { + const payload = workspaceDeletedRef(event.payload); + if (payload === undefined) return []; + const cached = this.workspaces.get(payload.workspaceId); + this.workspaces.delete(payload.workspaceId); + const fallback: WorkspaceInfo = { + id: payload.workspaceId, + root: payload.root, + name: basename(payload.root).slice(0, 100) || payload.root, + created_at: timestamp, + last_opened_at: timestamp, + session_count: 0, + }; + return [ + { type: 'workspace', timestamp, subtype: 'deleted', workspace: cached ?? fallback }, + ]; + } + case 'event.config.changed': { + const payload = asRecord(event.payload); + if (payload === undefined) return []; + return [ + { + type: 'config', + timestamp, + config: payload['config'], + changed_fields: stringArray(payload['changedFields']), + }, + ]; + } + case 'event.config.warning': { + const warnings = configWarningStrings(event.payload); + if (warnings === undefined) return []; + return [{ type: 'config.warning', timestamp, warnings }]; + } + case 'event.model_catalog.changed': + return [{ type: 'model_catalog', timestamp }]; + case 'event.plugin.changed': + return [{ type: 'plugin', timestamp }]; + case 'event.capability.changed': { + const payload = asRecord(event.payload); + const capabilityId = payload?.['capability_id']; + return [ + { + type: 'capability', + timestamp, + capability_id: + typeof capabilityId === 'string' && capabilityId.length > 0 + ? capabilityId + : undefined, + }, + ]; + } + case 'event.session.created': { + const payload = asRecord(event.payload); + const sessionId = stringField(payload, 'sessionId'); + if (payload === undefined || sessionId === undefined) return []; + const session = payload['session'] ?? (await this.deps.sessionInfo(sessionId)); + if (typeof session !== 'object' || session === null) return []; + return [{ type: 'session', timestamp, subtype: 'created', session }]; + } + case 'event.session.archived': { + const sessionId = stringField(asRecord(event.payload), 'sessionId'); + if (sessionId === undefined) return []; + const session = await this.deps.sessionInfo(sessionId); + if (session === undefined) return []; + return [{ type: 'session', timestamp, subtype: 'archived', session }]; + } + case 'session.meta.updated': { + const payload = asRecord(event.payload); + const sessionId = stringField(payload, 'sessionId'); + if (payload === undefined || sessionId === undefined) return []; + const session = await this.deps.sessionInfo(sessionId); + if (session === undefined) return []; + return [ + { + type: 'session', + timestamp, + subtype: 'updated', + session, + changed_fields: metaChangedFields(payload), + }, + ]; + } + default: + return []; + } + } + + private emitValidated(candidate: unknown): void { + const parsed = serverMessageSchema.safeParse(candidate); + if (!parsed.success) { + const type = String((candidate as { readonly type?: unknown } | null)?.type); + const count = (this.validationFailures.get(type) ?? 0) + 1; + this.validationFailures.set(type, count); + if (count === 1 || count % 100 === 0) { + this.logger?.warn( + { + type, + count, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + 'ws v3: global message failed schema validation, dropped', + ); + } + return; + } + this.emit(parsed.data); + } +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringField(payload: Record | undefined, key: string): string | undefined { + const value = payload?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out = value.filter((item): item is string => typeof item === 'string' && item.length > 0); + return out.length === 0 ? undefined : out; +} + +function workspaceRef(payload: unknown): Workspace | undefined { + const candidate = asRecord(payload)?.['workspace']; + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return undefined; + } + const ws = candidate as Partial; + if (typeof ws.id !== 'string' || ws.id.length === 0) return undefined; + if (typeof ws.root !== 'string' || ws.root.length === 0) return undefined; + if (typeof ws.name !== 'string') return undefined; + if (typeof ws.createdAt !== 'number' || typeof ws.lastOpenedAt !== 'number') return undefined; + return { + id: ws.id, + root: ws.root, + name: ws.name, + createdAt: ws.createdAt, + lastOpenedAt: ws.lastOpenedAt, + }; +} + +function workspaceDeletedRef(payload: unknown): { workspaceId: string; root: string } | undefined { + const record = asRecord(payload); + const workspaceId = stringField(record, 'workspaceId'); + const root = stringField(record, 'root'); + if (workspaceId === undefined || root === undefined) return undefined; + return { workspaceId, root }; +} + +function configWarningStrings(payload: unknown): string[] | undefined { + const warnings = asRecord(payload)?.['warnings']; + if (!Array.isArray(warnings)) return undefined; + const out: string[] = []; + for (const warning of warnings) { + const record = asRecord(warning); + const message = record?.['message']; + if (typeof message !== 'string' || message.length === 0) return undefined; + const domain = record?.['domain']; + out.push(typeof domain === 'string' && domain.length > 0 ? `${domain}: ${message}` : message); + } + return out; +} + +function metaChangedFields(payload: Record): string[] | undefined { + const patch = asRecord(payload['patch']); + if (patch === undefined) return undefined; + const out: string[] = []; + if (typeof patch['title'] === 'string') out.push('title'); + if (typeof patch['lastPrompt'] === 'string') out.push('last_prompt'); + return out.length === 0 ? undefined : out; +} diff --git a/packages/kap-server/src/transport/ws/v3/index.ts b/packages/kap-server/src/transport/ws/v3/index.ts new file mode 100644 index 00000000000..b9af9a9677a --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/index.ts @@ -0,0 +1,6 @@ +export * from './globalTranslator'; +export * from './registerWsV3'; +export * from './sessionLane'; +export * from './wsConnectionV3'; +export * from './wsV3Deps'; +export * from './wsV3Hub'; diff --git a/packages/kap-server/src/transport/ws/v3/registerWsV3.ts b/packages/kap-server/src/transport/ws/v3/registerWsV3.ts new file mode 100644 index 00000000000..b06321f916e --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/registerWsV3.ts @@ -0,0 +1,92 @@ +import { + IEventService, + ISessionIndex, + ISessionManager, + IWorkspaceService, + type Scope, + type Workspace, +} from '@moonshot-ai/agent-core-v2'; +import { WebSocketServer } from 'ws'; + +import type { WorkspaceInfo } from '../../../protocol/messages'; +import { resolveSessionFacts, toWireSession } from '../../../routes/sessions'; +import { toWireWorkspace } from '../../../routes/workspaces'; +import type { ProjectionService } from '../../../services/projection'; +import { selectWsBearerProtocol } from '../bearerProtocol'; +import type { IConnectionRegistry } from '../connectionRegistry'; +import { WsConnectionV3 } from './wsConnectionV3'; +import type { WsV3GlobalSource, WsV3Logger, WsV3SessionLifecycle } from './wsV3Deps'; +import { WsV3Hub } from './wsV3Hub'; + +export const WS_PATH_V3 = '/api/v3/ws'; + +export interface RegisterWsV3Options { + readonly registry: IConnectionRegistry; + readonly projection: ProjectionService; + readonly serverId: string; + readonly logger?: WsV3Logger; + readonly maxOutboundMessages?: number; + readonly heartbeatIntervalMs?: number; +} + +export interface WsV3Registration { + readonly wss: WebSocketServer; + readonly hub: WsV3Hub; +} + +export function registerWsV3(core: Scope, opts: RegisterWsV3Options): WsV3Registration { + const lifecycle: WsV3SessionLifecycle = { + onDidCreateSession(listener) { + const manager = core.accessor.get(ISessionManager); + const event = manager.onDidCreateSession; + if (event === undefined) return { dispose: () => {} }; + return event((created) => listener({ sessionId: created.sessionId })); + }, + async sessionExists(sessionId) { + return (await core.accessor.get(ISessionIndex).get(sessionId)) !== undefined; + }, + }; + const globalSource: WsV3GlobalSource = { + subscribe(listener) { + return core.accessor.get(IEventService).subscribe((event) => { + listener({ + type: event.type, + payload: (event as { readonly payload?: unknown }).payload, + }); + }); + }, + listWorkspaces: () => core.accessor.get(IWorkspaceService).list(), + workspaceInfo: (workspace: Workspace): Promise => toWireWorkspace(core, workspace), + async sessionInfo(sessionId) { + const summary = await core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + const cwd = + summary.cwd ?? + (await core.accessor.get(IWorkspaceService).get(summary.workspaceId))?.root; + if (cwd === undefined) return undefined; + return toWireSession(summary, cwd, resolveSessionFacts(core, sessionId)); + }, + }; + const hub = new WsV3Hub({ + projection: opts.projection, + lifecycle, + globalSource, + logger: opts.logger, + }); + const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); + wss.on('connection', (socket, req) => { + const conn = new WsConnectionV3({ + socket, + hub, + connectionRegistry: opts.registry, + remoteAddress: req.socket.remoteAddress ?? null, + userAgent: req.headers['user-agent'] ?? null, + serverId: opts.serverId, + logger: opts.logger, + maxOutboundMessages: opts.maxOutboundMessages, + heartbeatIntervalMs: opts.heartbeatIntervalMs, + }); + socket.on('close', () => opts.registry.remove(conn.id)); + }); + return { wss, hub }; +} diff --git a/packages/kap-server/src/transport/ws/v3/sessionLane.ts b/packages/kap-server/src/transport/ws/v3/sessionLane.ts new file mode 100644 index 00000000000..4bfd0049b77 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/sessionLane.ts @@ -0,0 +1,158 @@ +import type { IDisposable } from '@moonshot-ai/agent-core-v2'; + +import { ErrorCode } from '../../../protocol/error-codes'; +import type { ServerMessage } from '../../../protocol/messages'; +import { + passesSubscriptionFilter, + type SubscriptionFilter, + type WsConnectionV3, +} from './wsConnectionV3'; +import type { WsV3Logger, WsV3Projection, WsV3SessionLifecycle } from './wsV3Deps'; + +export interface LaneSubscriber { + readonly conn: WsConnectionV3; + readonly filter: SubscriptionFilter; + recoveryPending: boolean; +} + +export interface SessionLaneDeps { + readonly projection: WsV3Projection; + readonly lifecycle: WsV3SessionLifecycle; + readonly onEmpty: (lane: SessionLane) => void; + readonly logger?: WsV3Logger; +} + +export class SessionLane { + private readonly subscribers = new Set(); + private queue: Promise = Promise.resolve(); + private attachDisposable?: IDisposable; + private disposed = false; + + constructor( + readonly sessionId: string, + private readonly deps: SessionLaneDeps, + ) {} + + get subscriberCount(): number { + return this.subscribers.size; + } + + addSubscriber(sub: LaneSubscriber, requestId: number): void { + this.subscribers.add(sub); + this.enqueue(async () => { + if (this.disposed || !this.subscribers.has(sub)) return; + try { + const exists = await this.deps.lifecycle.sessionExists(this.sessionId); + if (!exists) { + this.subscribers.delete(sub); + sub.conn.untrackSubscription(this.sessionId); + sub.conn.enqueue({ + type: 'ack', + id: requestId, + code: ErrorCode.SESSION_NOT_FOUND, + msg: `session ${this.sessionId} does not exist`, + }); + this.disposeIfEmpty(); + return; + } + this.ensureAttached(); + sub.conn.enqueue({ type: 'ack', id: requestId, code: ErrorCode.SUCCESS }); + if (this.attachDisposable !== undefined) { + for (const message of this.deps.projection.recoveryMessages(this.sessionId)) { + if (passesSubscriptionFilter(sub.filter, message)) sub.conn.enqueue(message); + } + } + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'ws v3: subscription recovery failed, subscriber keeps live traffic without recovery', + ); + sub.conn.enqueue({ + type: 'ack', + id: requestId, + code: ErrorCode.INTERNAL_ERROR, + msg: 'subscription recovery failed', + }); + } finally { + sub.recoveryPending = false; + } + }); + } + + removeSubscriber(sub: LaneSubscriber): void { + if (!this.subscribers.delete(sub)) return; + this.disposeIfEmpty(); + } + + notifySessionLive(): void { + this.enqueue(() => { + if (this.disposed) return; + try { + this.attachDisposable?.dispose(); + this.attachDisposable = undefined; + this.ensureAttached(); + if (this.attachDisposable === undefined) return; + const recovery = this.deps.projection.recoveryMessages(this.sessionId); + for (const sub of this.subscribers) { + for (const message of recovery) { + if (passesSubscriptionFilter(sub.filter, message)) sub.conn.enqueue(message); + } + } + } catch (error) { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'ws v3: session-live recovery failed, subscribers keep live traffic without recovery', + ); + } finally { + for (const sub of this.subscribers) sub.recoveryPending = false; + } + }); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.attachDisposable?.dispose(); + this.attachDisposable = undefined; + this.subscribers.clear(); + } + + private ensureAttached(): void { + if (this.attachDisposable !== undefined) return; + this.attachDisposable = this.deps.projection.onMessage(this.sessionId, (message) => { + this.enqueue(() => this.fanout(message)); + }); + } + + private fanout(message: ServerMessage): void { + if (this.disposed) return; + for (const sub of this.subscribers) { + if (sub.recoveryPending) continue; + if (!passesSubscriptionFilter(sub.filter, message)) continue; + sub.conn.enqueue(message); + } + } + + private disposeIfEmpty(): void { + if (this.subscribers.size > 0) return; + this.deps.onEmpty(this); + } + + private enqueue(task: () => Promise | void): void { + this.queue = this.queue.then(task).catch((error) => { + this.deps.logger?.warn( + { + sessionId: this.sessionId, + err: error instanceof Error ? error.message : String(error), + }, + 'ws v3: session lane task failed, lane continues', + ); + }); + } +} diff --git a/packages/kap-server/src/transport/ws/v3/wsConnectionV3.ts b/packages/kap-server/src/transport/ws/v3/wsConnectionV3.ts new file mode 100644 index 00000000000..14bb3e8fadc --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/wsConnectionV3.ts @@ -0,0 +1,314 @@ +import { ulid } from 'ulid'; +import type { RawData, WebSocket } from 'ws'; + +import { ErrorCode } from '../../../protocol/error-codes'; +import { + clientMessageSchema, + serverMessageSchema, + type ServerMessage, + type SubscribeMessage, +} from '../../../protocol/messages'; +import type { IConnectionRegistry } from '../connectionRegistry'; +import type { LaneSubscriber } from './sessionLane'; +import type { WsV3Logger } from './wsV3Deps'; +import type { WsV3Hub } from './wsV3Hub'; + +export const V3_PROTOCOL_VERSION = '3'; +export const V3_CAPABILITIES: readonly string[] = ['step_replay_v1']; + +const DEFAULT_MAX_OUTBOUND_MESSAGES = 1000; +const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; +const DEFAULT_BACKPRESSURE_RETRY_MS = 5; +const DEFAULT_STALL_TIMEOUT_MS = 30_000; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; +const HEARTBEAT_MISS_LIMIT = 2; + +export interface SubscriptionFilter { + readonly agentIds?: ReadonlySet; + readonly omit: ReadonlySet; +} + +export function makeSubscriptionFilter(frame: SubscribeMessage): SubscriptionFilter { + return { + agentIds: frame.agent_ids === undefined ? undefined : new Set(frame.agent_ids), + omit: new Set(frame.omit ?? []), + }; +} + +export function passesSubscriptionFilter( + filter: SubscriptionFilter, + message: ServerMessage, +): boolean { + if (filter.omit.has(message.type)) return false; + if ( + filter.agentIds !== undefined && + 'agent_id' in message && + !filter.agentIds.has(message.agent_id) + ) { + return false; + } + return true; +} + +export interface WsConnectionV3Options { + readonly socket: WebSocket; + readonly hub: WsV3Hub; + readonly connectionRegistry?: IConnectionRegistry; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + readonly serverId: string; + readonly logger?: WsV3Logger; + readonly maxOutboundMessages?: number; + readonly highWaterMarkBytes?: number; + readonly backpressureRetryMs?: number; + readonly stallTimeoutMs?: number; + readonly heartbeatIntervalMs?: number; +} + +export class WsConnectionV3 { + readonly id: string; + readonly connectedAt: string; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + + private readonly socket: WebSocket; + private readonly hub: WsV3Hub; + private readonly logger?: WsV3Logger; + private readonly maxOutboundMessages: number; + private readonly highWaterMarkBytes: number; + private readonly backpressureRetryMs: number; + private readonly stallTimeoutMs: number; + private readonly heartbeatIntervalMs: number; + + private readonly subscriptions = new Map(); + private outbound: string[] = []; + private drainTimer?: ReturnType; + private heartbeatTimer?: ReturnType; + private missedPongs = 0; + private stallSince?: number; + private closed = false; + + constructor(opts: WsConnectionV3Options) { + this.id = `conn_${ulid()}`; + this.connectedAt = new Date().toISOString(); + this.remoteAddress = opts.remoteAddress; + this.userAgent = opts.userAgent; + this.socket = opts.socket; + this.hub = opts.hub; + this.logger = opts.logger; + this.maxOutboundMessages = opts.maxOutboundMessages ?? DEFAULT_MAX_OUTBOUND_MESSAGES; + this.highWaterMarkBytes = opts.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES; + this.backpressureRetryMs = opts.backpressureRetryMs ?? DEFAULT_BACKPRESSURE_RETRY_MS; + this.stallTimeoutMs = opts.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS; + this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + + this.socket.on('message', (data: RawData) => this.onRaw(data)); + this.socket.on('pong', () => { + this.missedPongs = 0; + }); + this.socket.on('close', () => this.onClose()); + this.socket.on('error', () => this.onClose()); + + opts.connectionRegistry?.add(this); + this.hub.addConnection(this); + this.sendImmediate({ + type: 'hello', + protocol_version: V3_PROTOCOL_VERSION, + server_id: opts.serverId, + capabilities: [...V3_CAPABILITIES], + }); + this.heartbeatTimer = setInterval(() => this.onHeartbeat(), this.heartbeatIntervalMs); + this.heartbeatTimer.unref?.(); + } + + get hasClientHello(): boolean { + return true; + } + + get subscriptionSessionIds(): readonly string[] { + return Array.from(this.subscriptions.keys()).toSorted(); + } + + trackSubscription(sessionId: string, sub: LaneSubscriber): void { + this.subscriptions.set(sessionId, sub); + } + + untrackSubscription(sessionId: string): void { + this.subscriptions.delete(sessionId); + } + + subscriptionFor(sessionId: string): LaneSubscriber | undefined { + return this.subscriptions.get(sessionId); + } + + enqueue(message: ServerMessage): void { + if (this.closed) return; + const validated = this.validateOutbound(message); + if (validated === undefined) return; + if (this.outbound.length >= this.maxOutboundMessages) { + this.overflow(); + return; + } + this.outbound.push(JSON.stringify(validated)); + this.drain(); + } + + close(code = 1000, reason?: string): void { + if (this.closed) return; + try { + this.socket.close(code, reason); + } catch { + this.onClose(); + } + } + + private onRaw(data: RawData): void { + if (this.closed) return; + let parsed: unknown; + try { + parsed = JSON.parse(rawDataToString(data)); + } catch { + this.enqueue({ + type: 'error', + code: ErrorCode.REQUEST_MALFORMED, + msg: 'frame is not valid JSON', + }); + return; + } + const result = clientMessageSchema.safeParse(parsed); + if (!result.success) { + const type = (parsed as { readonly type?: unknown } | null)?.type; + this.enqueue({ + type: 'error', + code: ErrorCode.VALIDATION_FAILED, + msg: + typeof type === 'string' + ? `unknown or invalid frame type: ${type}` + : 'frame failed client message validation', + }); + return; + } + const frame = result.data; + if (frame.type === 'subscribe') { + this.hub.subscribeSession(this, frame); + } else { + this.hub.unsubscribeSession(this, frame.session_id, frame.id); + } + } + + private onHeartbeat(): void { + this.missedPongs += 1; + if (this.missedPongs >= HEARTBEAT_MISS_LIMIT) { + this.logger?.warn( + { connId: this.id, remoteAddress: this.remoteAddress }, + 'ws v3: heartbeat timeout, terminating connection', + ); + try { + this.socket.terminate(); + } catch { + this.onClose(); + } + return; + } + try { + this.socket.ping(); + } catch { + } + } + + private overflow(): void { + if (this.closed) return; + this.logger?.warn( + { connId: this.id, remoteAddress: this.remoteAddress, queued: this.outbound.length }, + 'ws v3: outbound queue overflow, closing slow consumer', + ); + this.sendImmediate({ + type: 'error', + code: ErrorCode.WS_SLOW_CONSUMER, + msg: 'outbound queue overflow: slow consumer', + }); + this.outbound = []; + this.close(1008, 'slow consumer'); + } + + private drain(): void { + if (this.drainTimer !== undefined) { + clearTimeout(this.drainTimer); + this.drainTimer = undefined; + } + if (this.closed || this.socket.readyState !== this.socket.OPEN) { + this.outbound = []; + return; + } + while (this.outbound.length > 0) { + if (this.socket.bufferedAmount > this.highWaterMarkBytes) { + this.deferDrain(); + return; + } + const frame = this.outbound.shift(); + if (frame === undefined) break; + try { + this.socket.send(frame); + } catch { + } + } + this.stallSince = undefined; + } + + private deferDrain(): void { + const now = Date.now(); + this.stallSince ??= now; + if (now - this.stallSince >= this.stallTimeoutMs) { + this.overflow(); + return; + } + this.drainTimer = setTimeout(() => { + this.drainTimer = undefined; + this.drain(); + }, this.backpressureRetryMs); + this.drainTimer.unref?.(); + } + + private sendImmediate(message: ServerMessage): void { + if (this.closed || this.socket.readyState !== this.socket.OPEN) return; + const validated = this.validateOutbound(message); + if (validated === undefined) return; + try { + this.socket.send(JSON.stringify(validated)); + } catch { + } + } + + private validateOutbound(message: ServerMessage): ServerMessage | undefined { + const parsed = serverMessageSchema.safeParse(message); + if (parsed.success) return parsed.data; + this.logger?.warn( + { + connId: this.id, + type: (message as { readonly type?: unknown }).type, + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + 'ws v3: outbound message failed schema validation, dropped', + ); + return undefined; + } + + private onClose(): void { + if (this.closed) return; + this.closed = true; + if (this.drainTimer !== undefined) clearTimeout(this.drainTimer); + if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer); + this.outbound = []; + this.hub.dropConnection(this); + } +} + +function rawDataToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} diff --git a/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts b/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts new file mode 100644 index 00000000000..5e4097b1753 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts @@ -0,0 +1,32 @@ +import type { IDisposable, Workspace } from '@moonshot-ai/agent-core-v2'; + +import type { ServerMessage, WorkspaceInfo } from '../../../protocol/messages'; + +export interface WsV3Logger { + warn(obj: unknown, msg: string): void; +} + +export interface WsV3Projection { + onMessage( + sessionId: string, + listener: (message: ServerMessage) => void, + ): IDisposable | undefined; + recoveryMessages(sessionId: string): ServerMessage[]; +} + +export interface WsV3SessionLifecycle { + onDidCreateSession(listener: (event: { readonly sessionId: string }) => void): IDisposable; + sessionExists(sessionId: string): Promise; +} + +export interface WsV3CoreEvent { + readonly type: string; + readonly payload?: unknown; +} + +export interface WsV3GlobalSource { + subscribe(listener: (event: WsV3CoreEvent) => void): IDisposable; + listWorkspaces(): Promise; + workspaceInfo(workspace: Workspace): Promise; + sessionInfo(sessionId: string): Promise; +} diff --git a/packages/kap-server/src/transport/ws/v3/wsV3Hub.ts b/packages/kap-server/src/transport/ws/v3/wsV3Hub.ts new file mode 100644 index 00000000000..42f648a4558 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v3/wsV3Hub.ts @@ -0,0 +1,101 @@ +import type { IDisposable } from '@moonshot-ai/agent-core-v2'; + +import { ErrorCode } from '../../../protocol/error-codes'; +import type { ServerMessage, SubscribeMessage } from '../../../protocol/messages'; +import { GlobalMessageTranslator } from './globalTranslator'; +import { SessionLane, type LaneSubscriber } from './sessionLane'; +import { makeSubscriptionFilter, type WsConnectionV3 } from './wsConnectionV3'; +import type { + WsV3GlobalSource, + WsV3Logger, + WsV3Projection, + WsV3SessionLifecycle, +} from './wsV3Deps'; + +export interface WsV3HubDeps { + readonly projection: WsV3Projection; + readonly lifecycle: WsV3SessionLifecycle; + readonly globalSource: WsV3GlobalSource; + readonly logger?: WsV3Logger; +} + +export class WsV3Hub { + private readonly lanes = new Map(); + private readonly connections = new Set(); + private readonly translator: GlobalMessageTranslator; + private readonly lifecycleDisposable: IDisposable; + + constructor(private readonly deps: WsV3HubDeps) { + this.translator = new GlobalMessageTranslator( + deps.globalSource, + (message) => this.broadcastGlobal(message), + deps.logger, + ); + this.lifecycleDisposable = deps.lifecycle.onDidCreateSession((event) => { + this.lanes.get(event.sessionId)?.notifySessionLive(); + }); + } + + addConnection(conn: WsConnectionV3): void { + this.connections.add(conn); + } + + subscribeSession(conn: WsConnectionV3, frame: SubscribeMessage): void { + const lane = this.laneFor(frame.session_id); + const previous = conn.subscriptionFor(frame.session_id); + const sub: LaneSubscriber = { + conn, + filter: makeSubscriptionFilter(frame), + recoveryPending: true, + }; + conn.trackSubscription(frame.session_id, sub); + lane.addSubscriber(sub, frame.id); + if (previous !== undefined) lane.removeSubscriber(previous); + } + + unsubscribeSession(conn: WsConnectionV3, sessionId: string, requestId: number): void { + const sub = conn.subscriptionFor(sessionId); + if (sub !== undefined) { + conn.untrackSubscription(sessionId); + this.lanes.get(sessionId)?.removeSubscriber(sub); + } + conn.enqueue({ type: 'ack', id: requestId, code: ErrorCode.SUCCESS }); + } + + dropConnection(conn: WsConnectionV3): void { + this.connections.delete(conn); + for (const sessionId of conn.subscriptionSessionIds) { + const sub = conn.subscriptionFor(sessionId); + if (sub !== undefined) this.lanes.get(sessionId)?.removeSubscriber(sub); + conn.untrackSubscription(sessionId); + } + } + + dispose(): void { + this.lifecycleDisposable.dispose(); + this.translator.dispose(); + for (const lane of this.lanes.values()) lane.dispose(); + this.lanes.clear(); + this.connections.clear(); + } + + private broadcastGlobal(message: ServerMessage): void { + for (const conn of this.connections) conn.enqueue(message); + } + + private laneFor(sessionId: string): SessionLane { + const existing = this.lanes.get(sessionId); + if (existing !== undefined) return existing; + const lane = new SessionLane(sessionId, { + projection: this.deps.projection, + lifecycle: this.deps.lifecycle, + onEmpty: (empty) => { + if (this.lanes.get(empty.sessionId) === empty) this.lanes.delete(empty.sessionId); + empty.dispose(); + }, + logger: this.deps.logger, + }); + this.lanes.set(sessionId, lane); + return lane; + } +} diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 3a24e5db0c6..10fa1e96ef1 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -204,6 +204,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/sessions/{session_id}/goal", ], + [ + "GET", + "/api/v1/sessions/{session_id}/history", + ], [ "GET", "/api/v1/sessions/{session_id}/media/{file_id}", diff --git a/packages/kap-server/test/history.test.ts b/packages/kap-server/test/history.test.ts new file mode 100644 index 00000000000..cfe1a9954dd --- /dev/null +++ b/packages/kap-server/test/history.test.ts @@ -0,0 +1,355 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { + IAgentLifecycleService, + IEventBus, + ISessionIndex, + getLiveSessionById, + IModelCatalog, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; +import { TurnStarted, TurnStepStarted } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders } from './helpers/auth'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; + details?: { path: string; message: string }[]; +} + +interface HistoryWire { + messages: Record[]; + has_more: boolean; + in_flight?: { turn_id: string; step_id: string }; +} + +const T0 = 1_700_000_000_000; + +function rec(type: string, fields: Record, time: number): string { + return JSON.stringify({ type, time, ...fields }); +} + +function loopEvent(event: Record, time: number): string { + return rec('context.append_loop_event', { event }, time); +} + +const MAIN_WIRE = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'hello world' }], + origin: { kind: 'user' }, + promptId: 'p0', + }, T0), + rec('context.append_message', { + message: { + id: 'p0', + role: 'user', + content: [{ type: 'text', text: 'hello world' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, T0 + 1), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'Hi there' } }, T0 + 3), + loopEvent({ type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }, T0 + 4), + loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'file.txt' } }, T0 + 5), + loopEvent({ type: 'step.end', uuid: 'u1', finishReason: 'stop' }, T0 + 6), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 700 }, T0 + 7), + rec( + 'task.started', + { + info: { + taskId: 'task-2', + kind: 'agent', + agentId: 'sub-1', + parentToolCallId: 'call_1', + status: 'running', + }, + }, + T0 + 8, + ), + rec('turn.prompt', { + input: [{ type: 'text', text: 'second question' }], + origin: { kind: 'user' }, + promptId: 'p1', + }, T0 + 9), + rec('context.append_message', { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'second question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, T0 + 10), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '1', step: 1 }, T0 + 11), + loopEvent({ type: 'content.part', stepUuid: 'u2', part: { type: 'text', text: 'second answer' } }, T0 + 12), + loopEvent({ type: 'step.end', uuid: 'u2' }, T0 + 13), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 14), +]; + +const SUB_WIRE = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'do sub work' }], + origin: { kind: 'system_trigger', name: 'subagent' }, + }, T0 + 20), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '0', step: 1 }, T0 + 21), + loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'sub answer' } }, T0 + 22), + loopEvent({ type: 'step.end', uuid: 's1' }, T0 + 23), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 24), +]; + +describe('server /api/v1/sessions/{sid}/history', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + let seeds: ScopeSeed | undefined; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-history-')); + const modelCatalog: IModelCatalog = { + _serviceBrand: undefined, + get: () => { + throw new Error('modelCatalog.get not exercised in this test'); + }, + getRequester: () => { + throw new Error('modelCatalog.getRequester not exercised in this test'); + }, + inspect: () => { + throw new Error('modelCatalog.inspect not exercised in this test'); + }, + ping: () => { + throw new Error('modelCatalog.ping not exercised in this test'); + }, + findByName: () => [], + listModels: async () => [], + listProviders: async () => [], + getProvider: async () => { + throw new Error('modelCatalog.getProvider not exercised in this test'); + }, + setDefaultModel: async () => { + throw new Error('modelCatalog.setDefaultModel not exercised in this test'); + }, + }; + seeds = [[IModelCatalog, modelCatalog]]; + await boot(); + }); + + async function boot(): Promise { + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home as string, + logLevel: 'silent', + seeds, + }); + base = `http://127.0.0.1:${server.port}`; + } + + afterAll(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function createSession(): Promise { + const res = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: home as string } }), + } as never); + const body = (await res.json()) as Envelope<{ id: string }>; + expect(body.code).toBe(0); + return body.data.id; + } + + async function workspaceIdOf(sessionId: string): Promise { + const summary = await server!.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) throw new Error(`session ${sessionId} not found in index`); + return summary.workspaceId; + } + + async function writeWire(sessionId: string, agentId: string, lines: readonly string[]): Promise { + const workspaceId = await workspaceIdOf(sessionId); + const path = join(home as string, 'sessions', workspaceId, sessionId, 'agents', agentId, 'wire.jsonl'); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${lines.join('\n')}\n`, 'utf8'); + } + + async function reboot(): Promise { + await server!.close(); + server = undefined; + await boot(); + } + + const entityId = (m: Record): unknown => { + switch (m['type']) { + case 'turn': + return m['turn_id']; + case 'step': + return m['step_id']; + case 'user': + case 'assistant': + case 'thinking': + return m['message_id']; + case 'tool_call': + return m['tool_call_id']; + case 'system': + return m['system_id']; + case 'interaction': + return m['interaction_id']; + case 'task': + return m['task_id']; + default: + return m['type']; + } + }; + + it('returns 40401 for an unknown session', async () => { + const { body } = await getJson('/api/v1/sessions/nope/history'); + expect(body.code).toBe(40401); + }); + + it('returns an empty page without in_flight for a live session with no agent history', async () => { + const id = await createSession(); + const { body } = await getJson(`/api/v1/sessions/${id}/history`); + expect(body.code).toBe(0); + expect(body.data.messages).toEqual([]); + expect(body.data.in_flight).toBeUndefined(); + }); + + it('rejects mutually exclusive cursors and invalid agent ids and page sizes', async () => { + const id = await createSession(); + const both = await getJson(`/api/v1/sessions/${id}/history?before_turn=t1&after_step=t0.1`); + expect(both.body.code).toBe(40001); + const badAgent = await getJson(`/api/v1/sessions/${id}/history?agent_id=..%2Fevil`); + expect(badAgent.body.code).toBe(40001); + const badSize = await getJson(`/api/v1/sessions/${id}/history?page_size=0`); + expect(badSize.body.code).toBe(40001); + }); + + it('cold-rebuilds the main agent timeline from wire records with pagination cursors', async () => { + const id = await createSession(); + await writeWire(id, 'main', MAIN_WIRE); + await writeWire(id, 'sub-1', SUB_WIRE); + await reboot(); + + const all = await getJson(`/api/v1/sessions/${id}/history`); + expect(all.body.code).toBe(0); + expect(all.body.data.in_flight).toBeUndefined(); + expect(all.body.data.has_more).toBe(false); + const ids = all.body.data.messages.map(entityId); + expect(ids).toEqual([ + 't0', + 't0.u0', + 't0.1', + 't0.1.a1', + 'call_1', + 'task-2', + 't1', + 't1.u0', + 't1.1', + 't1.1.a1', + ]); + const turn0 = all.body.data.messages[0]!; + expect(turn0).toMatchObject({ + type: 'turn', + turn_id: 't0', + state: 'completed', + origin: { kind: 'user' }, + user_message_id: 't0.u0', + duration_ms: 700, + }); + const assistant = all.body.data.messages.find((m) => m['message_id'] === 't0.1.a1')!; + expect(assistant).toMatchObject({ type: 'assistant', status: 'completed', text: 'Hi there' }); + const tool = all.body.data.messages.find((m) => m['tool_call_id'] === 'call_1')!; + expect(tool).toMatchObject({ type: 'tool_call', state: 'done', output: 'file.txt', task_id: 'task-2' }); + const task = all.body.data.messages.find((m) => m['type'] === 'task')!; + expect(task).toMatchObject({ type: 'task', kind: 'subagent', child_agent_id: 'sub-1' }); + + const page = await getJson(`/api/v1/sessions/${id}/history?page_size=1`); + expect(page.body.data.messages.map(entityId)).toEqual(['t1', 't1.u0', 't1.1', 't1.1.a1']); + expect(page.body.data.has_more).toBe(true); + + const older = await getJson(`/api/v1/sessions/${id}/history?before_turn=t1`); + expect(older.body.data.messages.map(entityId)).toEqual([ + 't0', + 't0.u0', + 't0.1', + 't0.1.a1', + 'call_1', + 'task-2', + ]); + expect(older.body.data.has_more).toBe(false); + + const newer = await getJson(`/api/v1/sessions/${id}/history?after_step=t0.1`); + expect(newer.body.data.messages.map(entityId)).toEqual(['task-2', 't1', 't1.u0', 't1.1', 't1.1.a1']); + expect(newer.body.data.has_more).toBe(false); + + const missing = await getJson(`/api/v1/sessions/${id}/history?before_turn=t99`); + expect(missing.body.data.messages).toEqual([]); + expect(missing.body.data.has_more).toBe(false); + }); + + it('reads a subagent timeline with agent_id and classifies its turn origin from the main wire', async () => { + const id = await createSession(); + await writeWire(id, 'main', MAIN_WIRE); + await writeWire(id, 'sub-1', SUB_WIRE); + await reboot(); + + const main = await getJson(`/api/v1/sessions/${id}/history?agent_id=main`); + const sub = await getJson(`/api/v1/sessions/${id}/history?agent_id=sub-1`); + expect(sub.body.code).toBe(0); + const ids = sub.body.data.messages.map(entityId); + expect(ids).toEqual(['t0', 't0.u0', 't0.1', 't0.1.a1']); + const turn = sub.body.data.messages[0]!; + expect(turn).toMatchObject({ + type: 'turn', + turn_id: 't0', + agent_id: 'sub-1', + origin: { kind: 'task', task_id: 'task-2' }, + }); + const user = sub.body.data.messages.find((m) => m['type'] === 'user')!; + expect(user).toMatchObject({ message_id: 't0.u0', text: 'do sub work', agent_id: 'sub-1' }); + expect(main.body.data.messages.map(entityId)).toContain('t1'); + }); + + it('marks the in-flight position for a live session once a turn is streaming', async () => { + const id = await createSession(); + const session = getLiveSessionById(server!.core.accessor, id); + if (session === undefined) throw new Error(`session ${id} not live`); + await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; + + const idle = await getJson(`/api/v1/sessions/${id}/history`); + expect(idle.body.data.in_flight).toBeUndefined(); + + const bus = agent.accessor.get(IEventBus); + bus.publish(new TurnStarted({ agentId: 'main', turnId: 0, origin: { kind: 'user' }, prompt: 'hi' })); + bus.publish(new TurnStepStarted({ agentId: 'main', turnId: 0, step: 1 })); + + const streaming = await getJson(`/api/v1/sessions/${id}/history`); + expect(streaming.body.code).toBe(0); + expect(streaming.body.data.in_flight).toEqual({ turn_id: 't0', step_id: 't0.1' }); + }); +}); diff --git a/packages/kap-server/test/protocolMessages.test.ts b/packages/kap-server/test/protocolMessages.test.ts new file mode 100644 index 00000000000..3202cfabc28 --- /dev/null +++ b/packages/kap-server/test/protocolMessages.test.ts @@ -0,0 +1,577 @@ +import { describe, expect, it } from 'vitest'; + +import { + ContractViolation, + ackMessageSchema, + assistantDeltaMessageSchema, + assistantMessageSchema, + capabilityMessageSchema, + clientMessageSchema, + configMessageSchema, + configWarningMessageSchema, + entityId, + entityKey, + errorMessageSchema, + helloMessageSchema, + historyQuerySchema, + historyResponseSchema, + interactionMessageSchema, + modelCatalogMessageSchema, + parseServerMessage, + pluginMessageSchema, + serverMessageSchema, + sessionMessageSchema, + sessionStateMessageSchema, + stepMessageSchema, + subscribeMessageSchema, + systemMessageSchema, + taskMessageSchema, + thinkingDeltaMessageSchema, + thinkingMessageSchema, + todoMessageSchema, + toolCallDeltaMessageSchema, + toolCallMessageSchema, + toolProgressMessageSchema, + turnMessageSchema, + unsubscribeMessageSchema, + userMessageSchema, + workspaceMessageSchema, + type CapabilityChangedMessage, + type ConfigWarningMessage, + type DeltaMessage, + type ModelCatalogChangedMessage, + type PluginChangedMessage, +} from '../src/protocol/messages'; + +const TS = '2026-09-04T08:00:00.000Z'; + +const timeline = { + session_id: 'sess_1', + agent_id: 'agent_1', + timestamp: TS, +}; + +const sessionScope = { + session_id: 'sess_1', + timestamp: TS, +}; + +const globalScope = { + timestamp: TS, +}; + +const sessionInfo = { + id: 'sess_1', + workspace_id: 'wd_example_0123456789ab', + title: 'demo', + created_at: TS, + updated_at: TS, + busy: false, + metadata: { cwd: '/repo' }, + agent_config: { model: 'kimi-k2' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + context_tokens: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, +}; + +const turn = { + type: 'turn', + ...timeline, + turn_id: 't1', + ordinal: 0, + state: 'running', + origin: { kind: 'user' }, +}; + +const step = { + type: 'step', + ...timeline, + step_id: 't1.0', + turn_id: 't1', + ordinal: 0, + state: 'running', +}; + +const user = { + type: 'user', + ...timeline, + message_id: 't1.u0', + turn_id: 't1', + text: 'hello', + status: 'running', + created_at: TS, +}; + +const assistant = { + type: 'assistant', + ...timeline, + message_id: 't1.0.a0', + turn_id: 't1', + step_id: 't1.0', + status: 'streaming', + text: 'partial', +}; + +const assistantDelta = { + type: 'assistant.delta', + ...timeline, + message_id: 't1.0.a0', + text: ' chunk', +}; + +const thinking = { + type: 'thinking', + ...timeline, + message_id: 't1.0.a1', + turn_id: 't1', + step_id: 't1.0', + status: 'completed', + text: 'reasoning', +}; + +const thinkingDelta = { + type: 'thinking.delta', + ...timeline, + message_id: 't1.0.a1', + text: ' bit', +}; + +const toolCall = { + type: 'tool_call', + ...timeline, + tool_call_id: 'tc1', + turn_id: 't1', + step_id: 't1.0', + name: 'Bash', + state: 'running', +}; + +const toolCallDelta = { + type: 'tool_call.delta', + ...timeline, + tool_call_id: 'tc1', + input_text: '{"command":"ls', +}; + +const toolProgress = { + type: 'tool.progress', + ...timeline, + tool_call_id: 'tc1', + progress: { kind: 'stdout', text: 'line' }, +}; + +const systemUndo = { + type: 'system', + ...timeline, + system_id: 'sys1', + subtype: 'undo', + payload: { removed_ids: ['t1'] }, +}; + +const systemNotice = { + type: 'system', + ...timeline, + system_id: 'sys2', + subtype: 'notice', + payload: { text: 'heads up' }, +}; + +const interactionApproval = { + type: 'interaction', + ...timeline, + interaction_id: 'ia1', + kind: 'approval', + state: 'pending', + tool_call_id: 'tc1', + request: { tool_name: 'Bash', action: 'run command', tool_input_display: { command: 'ls' } }, +}; + +const interactionQuestion = { + type: 'interaction', + ...timeline, + interaction_id: 'ia2', + kind: 'question', + state: 'pending', + request: { + questions: [ + { + id: 'q_0', + question: 'pick one', + options: [ + { id: 'opt_0_0', label: 'a' }, + { id: 'opt_0_1', label: 'b' }, + { id: 'opt_0_2', label: 'c' }, + { id: 'opt_0_3', label: 'd' }, + { id: 'opt_0_4', label: 'e' }, + ], + }, + ], + }, +}; + +const task = { + type: 'task', + ...timeline, + task_id: 'task1', + kind: 'shell', + state: 'running', + detached: true, + output_tail: 'tail', +}; + +const todo = { + type: 'todo', + ...timeline, + todo_id: 'todo1', + items: [{ title: 'write tests', status: 'in_progress' }], +}; + +const sessionState = { + type: 'session.state', + ...sessionScope, + busy: true, + main_turn_active: true, + activity: 'turn', + phase: { kind: 'running', turn_id: 1, step: 0, step_id: 't1.0', since: 1756963200000 }, +}; + +const session = { + type: 'session', + ...globalScope, + subtype: 'created', + session: sessionInfo, +}; + +const workspace = { + type: 'workspace', + ...globalScope, + subtype: 'updated', + workspace: { + id: 'wd_example_0123456789ab', + root: '/repo', + name: 'repo', + created_at: TS, + last_opened_at: TS, + session_count: 1, + }, +}; + +const config = { + type: 'config', + ...globalScope, + config: { model: 'kimi-k2' }, +}; + +const configWarning = { + type: 'config.warning', + ...globalScope, + warnings: ['deprecated key'], +}; + +const modelCatalog = { + type: 'model_catalog', + ...globalScope, +}; + +const plugin = { + type: 'plugin', + ...globalScope, +}; + +const capability = { + type: 'capability', + ...globalScope, + capability_id: 'cap1', +}; + +const hello = { + type: 'hello', + protocol_version: '3', + server_id: 'srv1', + capabilities: ['step_replay_v1'], +}; + +const ack = { + type: 'ack', + id: 1, + code: 0, +}; + +const error = { + type: 'error', + code: 40001, + msg: 'validation failed', +}; + +const subscribe = { + type: 'subscribe', + id: 1, + session_id: 'sess_1', +}; + +const unsubscribe = { + type: 'unsubscribe', + id: 2, + session_id: 'sess_1', +}; + +const serverCases = [ + ['turn', turnMessageSchema, turn], + ['step', stepMessageSchema, step], + ['user', userMessageSchema, user], + ['assistant', assistantMessageSchema, assistant], + ['assistant.delta', assistantDeltaMessageSchema, assistantDelta], + ['thinking', thinkingMessageSchema, thinking], + ['thinking.delta', thinkingDeltaMessageSchema, thinkingDelta], + ['tool_call', toolCallMessageSchema, toolCall], + ['tool_call.delta', toolCallDeltaMessageSchema, toolCallDelta], + ['tool.progress', toolProgressMessageSchema, toolProgress], + ['system(undo)', systemMessageSchema, systemUndo], + ['system(notice)', systemMessageSchema, systemNotice], + ['interaction(approval)', interactionMessageSchema, interactionApproval], + ['interaction(question)', interactionMessageSchema, interactionQuestion], + ['task', taskMessageSchema, task], + ['todo', todoMessageSchema, todo], + ['session.state', sessionStateMessageSchema, sessionState], + ['session', sessionMessageSchema, session], + ['workspace', workspaceMessageSchema, workspace], + ['config', configMessageSchema, config], + ['config.warning', configWarningMessageSchema, configWarning], + ['model_catalog', modelCatalogMessageSchema, modelCatalog], + ['plugin', pluginMessageSchema, plugin], + ['capability', capabilityMessageSchema, capability], + ['hello', helloMessageSchema, hello], + ['ack', ackMessageSchema, ack], + ['error', errorMessageSchema, error], +] as const; + +const serverNegativeCases = [ + ['turn missing origin', turnMessageSchema, { ...turn, origin: undefined }], + ['step bad state', stepMessageSchema, { ...step, state: 'cancelled' }], + ['user missing created_at', userMessageSchema, { ...user, created_at: undefined }], + ['assistant missing text', assistantMessageSchema, { ...assistant, text: undefined }], + ['assistant.delta missing text', assistantDeltaMessageSchema, { ...assistantDelta, text: undefined }], + ['thinking bad status', thinkingMessageSchema, { ...thinking, status: 'done' }], + ['thinking.delta missing message_id', thinkingDeltaMessageSchema, { ...thinkingDelta, message_id: undefined }], + ['tool_call missing name', toolCallMessageSchema, { ...toolCall, name: undefined }], + ['tool_call.delta missing input_text', toolCallDeltaMessageSchema, { ...toolCallDelta, input_text: undefined }], + ['tool.progress bad kind', toolProgressMessageSchema, { ...toolProgress, progress: { kind: 'unknown' } }], + ['system undo missing payload', systemMessageSchema, { ...systemUndo, payload: undefined }], + ['interaction wrong kind', interactionMessageSchema, { ...interactionApproval, kind: 'command' }], + ['task missing output_tail', taskMessageSchema, { ...task, output_tail: undefined }], + ['todo bad item status', todoMessageSchema, { ...todo, items: [{ title: 'x', status: 'doing' }] }], + ['session.state missing activity', sessionStateMessageSchema, { ...sessionState, activity: undefined }], + ['session bad subtype', sessionMessageSchema, { ...session, subtype: 'renamed' }], + ['workspace bad id', workspaceMessageSchema, { ...workspace, workspace: { ...workspace.workspace, id: 'ws_1' } }], + ['config missing config', configMessageSchema, { ...config, config: undefined }], + ['config.warning missing warnings', configWarningMessageSchema, { ...configWarning, warnings: undefined }], + ['model_catalog missing timestamp', modelCatalogMessageSchema, { ...modelCatalog, timestamp: undefined }], + ['plugin missing timestamp', pluginMessageSchema, { ...plugin, timestamp: undefined }], + ['capability bad capability_id', capabilityMessageSchema, { ...capability, capability_id: 7 }], + ['hello missing capabilities', helloMessageSchema, { ...hello, capabilities: undefined }], + ['ack bad code', ackMessageSchema, { ...ack, code: '0' }], + ['error missing msg', errorMessageSchema, { ...error, msg: undefined }], +] as const; + +describe('serverMessageSchema', () => { + it.each(serverCases)('accepts %s', (_label, schema, message) => { + expect(schema.safeParse(message).success).toBe(true); + const parsed = serverMessageSchema.safeParse(message); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.type).toBe(message.type); + } + }); + + it.each(serverNegativeCases)('rejects %s', (_label, schema, message) => { + expect(schema.safeParse(message).success).toBe(false); + expect(serverMessageSchema.safeParse(message).success).toBe(false); + }); + + it('rejects unknown message types', () => { + expect(serverMessageSchema.safeParse({ type: 'future.message', ...globalScope }).success).toBe(false); + }); + + it('rejects messages without a type discriminator', () => { + expect(serverMessageSchema.safeParse({ ...globalScope }).success).toBe(false); + }); + + it('tolerates unknown fields for open evolution', () => { + const evolved = { + ...turn, + future_field: 1, + origin: { kind: 'user', future_flag: true }, + usage: { input_tokens: 3, future_counter: 9 }, + }; + const parsed = serverMessageSchema.safeParse(evolved); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect('future_field' in parsed.data).toBe(false); + expect('future_counter' in (parsed.data as { usage?: object }).usage!).toBe(false); + } + }); +}); + +describe('clientMessageSchema', () => { + it('accepts subscribe and unsubscribe', () => { + expect(clientMessageSchema.safeParse(subscribe).success).toBe(true); + expect(clientMessageSchema.safeParse(unsubscribe).success).toBe(true); + expect(subscribeMessageSchema.safeParse({ ...subscribe, agent_ids: ['a1'], omit: ['assistant.delta'] }).success).toBe(true); + }); + + it('rejects entity and server-only control messages', () => { + expect(clientMessageSchema.safeParse(turn).success).toBe(false); + expect(clientMessageSchema.safeParse(hello).success).toBe(false); + expect(clientMessageSchema.safeParse(ack).success).toBe(false); + }); + + it('rejects malformed subscriptions', () => { + expect(subscribeMessageSchema.safeParse({ ...subscribe, id: '1' }).success).toBe(false); + expect(subscribeMessageSchema.safeParse({ ...subscribe, session_id: undefined }).success).toBe(false); + expect(unsubscribeMessageSchema.safeParse({ ...unsubscribe, session_id: '' }).success).toBe(false); + }); +}); + +describe('systemMessageSchema payload discrimination', () => { + it('requires removed_ids for undo and clear', () => { + expect(systemMessageSchema.safeParse(systemUndo).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemUndo, subtype: 'clear' }).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemUndo, payload: {} }).success).toBe(false); + expect(systemMessageSchema.safeParse({ ...systemUndo, payload: { removed_ids: 't1' } }).success).toBe(false); + }); + + it('allows arbitrary payloads for open subtypes', () => { + expect(systemMessageSchema.safeParse(systemNotice).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemNotice, payload: undefined }).success).toBe(true); + expect(systemMessageSchema.safeParse({ ...systemNotice, subtype: 'interruption', payload: { reason: 'aborted' } }).success).toBe(true); + }); + + it('rejects unknown subtypes', () => { + expect(systemMessageSchema.safeParse({ ...systemNotice, subtype: 'explosion' }).success).toBe(false); + }); +}); + +describe('interactionMessageSchema kind discrimination', () => { + it('binds request and response shapes to kind', () => { + expect(interactionMessageSchema.safeParse(interactionApproval).success).toBe(true); + expect(interactionMessageSchema.safeParse(interactionQuestion).success).toBe(true); + expect( + interactionMessageSchema.safeParse({ ...interactionApproval, request: interactionQuestion.request }).success, + ).toBe(false); + expect( + interactionMessageSchema.safeParse({ ...interactionQuestion, request: interactionApproval.request }).success, + ).toBe(false); + expect( + interactionMessageSchema.safeParse({ + ...interactionQuestion, + state: 'answered', + response: { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, + }).success, + ).toBe(true); + expect( + interactionMessageSchema.safeParse({ + ...interactionApproval, + state: 'approved', + response: { answers: {} }, + }).success, + ).toBe(false); + }); +}); + +describe('historyResponseSchema', () => { + it('accepts a timeline entity page with in_flight marker', () => { + const page = { + messages: [turn, step, user, assistant, thinking, toolCall, systemUndo, interactionApproval, task, todo], + has_more: false, + in_flight: { turn_id: 't2', step_id: 't2.0' }, + }; + expect(historyResponseSchema.safeParse(page).success).toBe(true); + expect(historyResponseSchema.safeParse({ messages: [], has_more: true }).success).toBe(true); + expect(historyResponseSchema.safeParse({ messages: [] }).success).toBe(false); + }); + + it('rejects volatile and non-persisted entities in history', () => { + expect(historyResponseSchema.safeParse({ messages: [assistantDelta] }).success).toBe(false); + expect(historyResponseSchema.safeParse({ messages: [toolProgress] }).success).toBe(false); + }); + + it('validates cursor query params', () => { + expect(historyQuerySchema.safeParse({ before_turn: 't4', page_size: 50 }).success).toBe(true); + expect(historyQuerySchema.safeParse({ after_step: 't4.2' }).success).toBe(true); + expect(historyQuerySchema.safeParse({}).success).toBe(true); + expect(historyQuerySchema.safeParse({ page_size: 0 }).success).toBe(false); + expect(historyQuerySchema.safeParse({ page_size: 1.5 }).success).toBe(false); + }); +}); + +describe('timestamp contract', () => { + it('rejects non-ISO-8601 timestamps', () => { + expect(turnMessageSchema.safeParse({ ...turn, timestamp: 'not-a-date' }).success).toBe(false); + expect(turnMessageSchema.safeParse({ ...turn, timestamp: 1756963200000 }).success).toBe(false); + }); + + it('normalizes offset timestamps to UTC', () => { + const parsed = turnMessageSchema.safeParse({ ...turn, timestamp: '2026-09-04T16:00:00+08:00' }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.timestamp).toBe(TS); + } + }); +}); + +describe('parseServerMessage + entityKey', () => { + it('parses valid messages, throws detailed violations and derives replace-by-id keys', () => { + expect(parseServerMessage(turn)).toMatchObject({ type: 'turn', turn_id: 't1' }); + expect(() => parseServerMessage({ type: 'turn' })).toThrow(ContractViolation); + try { + parseServerMessage({ type: 'turn' }); + expect.unreachable(); + } catch (error) { + const violation = error as ContractViolation; + expect(violation.issues.length).toBeGreaterThan(0); + expect(violation.raw).toEqual({ type: 'turn' }); + expect(violation.message).toContain('contract violation'); + } + expect(entityId(assistant as never)).toBe('t1.0.a0'); + expect(entityId(toolCall as never)).toBe('tc1'); + expect(entityId(interactionApproval as never)).toBe('tc1'); + expect(entityId(interactionQuestion as never)).toBe('ia2'); + expect(entityId(task as never)).toBe('task1'); + expect(entityId(todo as never)).toBe('todo1'); + expect(entityId(systemUndo as never)).toBe('sys1'); + expect(entityId(step as never)).toBe('t1.0'); + expect(entityId(turn as never)).toBe('t1'); + expect(entityKey(turn as never)).toBe('agent_1:turn:t1'); + expect(entityKey(sessionState as never)).toBe(':session.state:'); + expect(entityKey(config as never)).toBe(':config:'); + }); + + it('exposes the delta union and client-facing type aliases', () => { + const deltas: DeltaMessage[] = [assistantDelta, thinkingDelta, toolCallDelta, toolProgress].map( + (m) => parseServerMessage(m) as DeltaMessage, + ); + expect(deltas.map((m) => m.type)).toEqual([ + 'assistant.delta', + 'thinking.delta', + 'tool_call.delta', + 'tool.progress', + ]); + const warning: ConfigWarningMessage = configWarningMessageSchema.parse(configWarning); + const catalog: ModelCatalogChangedMessage = modelCatalogMessageSchema.parse(modelCatalog); + const plug: PluginChangedMessage = pluginMessageSchema.parse(plugin); + const cap: CapabilityChangedMessage = capabilityMessageSchema.parse(capability); + expect([warning.type, catalog.type, plug.type, cap.type]).toEqual([ + 'config.warning', + 'model_catalog', + 'plugin', + 'capability', + ]); + }); +}); diff --git a/packages/kap-server/test/services/history.test.ts b/packages/kap-server/test/services/history.test.ts new file mode 100644 index 00000000000..a5e6596b26f --- /dev/null +++ b/packages/kap-server/test/services/history.test.ts @@ -0,0 +1,1352 @@ +import { describe, expect, it } from 'vitest'; + +import { + historyMessageSchema, + type HistoryMessage, + type ServerMessage, + serverMessageSchema, +} from '../../src/protocol/messages'; +import { AgentMessageProjector } from '../../src/services/projection/agentProjector'; +import type { ProjectionBusEvent } from '../../src/services/projection/events'; +import type { ContextRecord } from '../../src/services/projection/heal'; +import { foldTimelineSeed } from '../../src/services/projection/heal'; +import { foldWireHistory, paginateHistory, type ColdFoldOptions } from '../../src/services/history'; + +const SESSION = 's1'; +const T0 = 1_700_000_000_000; + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +function rec(type: string, fields: Record = {}, time = T0): ContextRecord { + return { type, time, ...fields } as ContextRecord; +} + +function loopEvent(event: Record, time = T0): ContextRecord { + return rec('context.append_loop_event', { event }, time); +} + +function fold( + records: readonly ContextRecord[], + opts: Partial = {}, +): HistoryMessage[] { + const out = foldWireHistory(records, { + sessionId: SESSION, + agentId: 'main', + live: false, + fallbackTimestamp: iso(T0), + ...opts, + }); + for (const message of out) historyMessageSchema.parse(message); + return out; +} + +function ofType( + messages: readonly HistoryMessage[], + type: T, +): Extract[] { + return messages.filter((m): m is Extract => m.type === type); +} + +function ev(payload: Record): ProjectionBusEvent { + return { time: T0, ...payload } as unknown as ProjectionBusEvent; +} + +describe('foldWireHistory turn lifecycle', () => { + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'fix the bug' }], + origin: { kind: 'user' }, + promptId: 'p1', + }), + rec( + 'context.append_message', + { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'fix the bug' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 1, + ), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'think', think: 'hmm' } }, T0 + 3), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'Hello' } }, T0 + 4), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }, + T0 + 5, + ), + loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'file.txt' } }, T0 + 6), + loopEvent( + { + type: 'step.end', + uuid: 'u1', + finishReason: 'stop', + usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, + llmFirstTokenLatencyMs: 100, + llmStreamDurationMs: 900, + }, + T0 + 7, + ), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 1500 }, T0 + 8), + ]; + + it('rebuilds a full turn into flat entity messages with shared id rules', () => { + const messages = fold(records); + expect(messages.map((m) => m.type)).toEqual([ + 'turn', + 'user', + 'step', + 'thinking', + 'assistant', + 'tool_call', + ]); + const turn = ofType(messages, 'turn')[0]!; + expect(turn).toMatchObject({ + turn_id: 't0', + ordinal: 0, + state: 'completed', + origin: { kind: 'user' }, + user_message_id: 't0.u0', + started_at: iso(T0), + ended_at: iso(T0 + 8), + duration_ms: 1500, + usage: { input_tokens: 11, output_tokens: 5, cached_tokens: 2 }, + }); + const user = ofType(messages, 'user')[0]!; + expect(user).toMatchObject({ + message_id: 't0.u0', + turn_id: 't0', + text: 'fix the bug', + status: 'completed', + created_at: iso(T0), + finished_at: iso(T0 + 8), + }); + const step = ofType(messages, 'step')[0]!; + expect(step).toMatchObject({ + step_id: 't0.1', + ordinal: 1, + state: 'completed', + started_at: iso(T0 + 2), + ended_at: iso(T0 + 7), + usage: { input_other: 10, output: 5, input_cache_read: 2, input_cache_creation: 1 }, + finish_reason: 'stop', + timing: { llm_first_token_ms: 100, llm_stream_duration_ms: 900 }, + }); + const thinking = ofType(messages, 'thinking')[0]!; + expect(thinking).toMatchObject({ message_id: 't0.1.a1', status: 'completed', text: 'hmm' }); + const assistant = ofType(messages, 'assistant')[0]!; + expect(assistant).toMatchObject({ message_id: 't0.1.a2', status: 'completed', text: 'Hello' }); + const tool = ofType(messages, 'tool_call')[0]!; + expect(tool).toMatchObject({ + tool_call_id: 'call_1', + step_id: 't0.1', + name: 'Bash', + state: 'done', + input: { command: 'ls' }, + output: 'file.txt', + }); + }); + + it('finalizes an unfinished turn by session liveness', () => { + const inFlight: ContextRecord[] = [ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'partial' } }, T0 + 2), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{}' }, + T0 + 3, + ), + rec('interaction.request', { + id: 'apr-1', + kind: 'approval', + toolCallId: 'call_1', + request: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run' }, + }), + ]; + const live = fold(inFlight, { live: true }); + expect(ofType(live, 'turn')[0]).toMatchObject({ state: 'running' }); + expect(ofType(live, 'step')[0]).toMatchObject({ state: 'running' }); + expect(ofType(live, 'assistant')[0]).toMatchObject({ status: 'streaming', text: 'partial' }); + expect(ofType(live, 'tool_call')[0]).toMatchObject({ state: 'running' }); + expect(ofType(live, 'user')[0]).toMatchObject({ status: 'running' }); + expect(ofType(live, 'interaction')[0]).toMatchObject({ state: 'pending' }); + + const dead = fold(inFlight); + expect(ofType(dead, 'turn')[0]).toMatchObject({ state: 'completed' }); + expect(ofType(dead, 'step')[0]).toMatchObject({ state: 'interrupted' }); + expect(ofType(dead, 'assistant')[0]).toMatchObject({ status: 'completed' }); + expect(ofType(dead, 'tool_call')[0]).toMatchObject({ state: 'done' }); + expect(ofType(dead, 'user')[0]).toMatchObject({ status: 'completed' }); + expect(ofType(dead, 'interaction')[0]).toMatchObject({ state: 'cancelled' }); + }); +}); + +describe('foldWireHistory origin classification', () => { + it('maps prompt origins to turn origins and hides non-visible prompts', () => { + const prompts: [number, Record][] = [ + [0, { kind: 'user' }], + [1, { kind: 'cron_job', jobId: 'j1', cron: '*/5 * * * *' }], + [2, { kind: 'task', taskId: 'task-9' }], + [3, { kind: 'hook_result', event: 'SessionStart' }], + [4, { kind: 'system_trigger', name: 'goal_continuation' }], + [5, { kind: 'system_trigger', name: 'subagent' }], + [6, { kind: 'injection', variant: 'reminder' }], + [7, { kind: 'retry' }], + [8, { kind: 'compaction_summary' }], + [9, { kind: 'skill_activation', trigger: 'user-slash', activationId: 'a1', skillName: 'review' }], + [10, { kind: 'shell_command', phase: 'input' }], + [11, { kind: 'background_task', taskId: 'task-7' }], + ]; + const records: ContextRecord[] = prompts.map(([ordinal, origin]) => + rec('turn.prompt', { input: [{ type: 'text', text: `p${ordinal}` }], origin }, T0 + ordinal), + ); + const messages = fold(records); + const turns = ofType(messages, 'turn'); + expect(turns.map((t) => t.turn_id)).toEqual([ + 't0', + 't1', + 't2', + 't3', + 't4', + 't5', + 't9', + 't10', + 't11', + ]); + expect(turns.map((t) => t.origin)).toEqual([ + { kind: 'user' }, + { kind: 'cron' }, + { kind: 'task', task_id: 'task-9' }, + { kind: 'hook' }, + { kind: 'goal' }, + { kind: 'other' }, + { kind: 'user' }, + { kind: 'user' }, + { kind: 'task', task_id: 'task-7' }, + ]); + const cronUser = ofType(messages, 'user').find((u) => u.turn_id === 't1')!; + expect(cronUser.origin).toEqual({ kind: 'cron', cron_id: 'j1', schedule: '*/5 * * * *' }); + expect(ofType(messages, 'user').some((u) => u.turn_id === 't4')).toBe(false); + const skillSystems = ofType(messages, 'system').filter((m) => m.subtype === 'skill'); + expect(skillSystems).toHaveLength(1); + expect(skillSystems[0]!.payload).toMatchObject({ skill_name: 'review' }); + }); + + it('bundles skill activations into the user message and one skill system per activation', () => { + const messages = fold([ + rec('turn.prompt', { + input: [ + { type: 'text', text: '/review args' }, + { type: 'text', text: 'check this' }, + ], + origin: { + kind: 'user', + skillActivations: [ + { activationId: 'a1', skillName: 'review', skillArgs: 'args' }, + ], + }, + }), + ]); + const user = ofType(messages, 'user')[0]!; + expect(user).toMatchObject({ + text: 'check this', + skill_activations: [{ skill_name: 'review', skill_args: 'args' }], + }); + const skill = ofType(messages, 'system').find((m) => m.subtype === 'skill')!; + expect(skill.payload).toMatchObject({ + trigger: 'user-slash', + activation_id: 'a1', + skill_name: 'review', + skill_args: 'args', + text: '/review args', + }); + }); +}); + +describe('foldWireHistory steer', () => { + it('attaches steers to the running step, buffers between steps, and dedupes the turn-opening steer', () => { + const messages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'do A' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + rec('turn.steer', { input: [{ type: 'text', text: 'also B' }], origin: { kind: 'user' } }, T0 + 2), + loopEvent({ type: 'step.end', uuid: 'u1' }, T0 + 3), + rec('turn.steer', { input: [{ type: 'text', text: 'and C' }], origin: { kind: 'user' } }, T0 + 4), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '0', step: 2 }, T0 + 5), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 6), + ]); + const users = ofType(messages, 'user'); + const inStep = users.find((u) => u.message_id === 't0.1.u1')!; + expect(inStep).toMatchObject({ + turn_id: 't0', + step_id: 't0.1', + text: 'also B', + status: 'completed', + steered_at: iso(T0 + 2), + }); + const buffered = users.find((u) => u.message_id === 't0.2.u1')!; + expect(buffered).toMatchObject({ step_id: 't0.2', text: 'and C' }); + + const deduped = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' } }), + rec('turn.steer', { input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' } }, T0 + 1), + ]); + const dedupedUsers = ofType(deduped, 'user'); + expect(dedupedUsers).toHaveLength(1); + expect(dedupedUsers[0]).toMatchObject({ message_id: 't0.u0', text: 'hello' }); + }); + + it('holds steers buffered at turn end on the last step, synthesizing one when none ran', () => { + const attached = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'do A' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent({ type: 'step.end', uuid: 'u1' }, T0 + 2), + rec('turn.steer', { input: [{ type: 'text', text: 'last' }], origin: { kind: 'user' } }, T0 + 3), + rec('turn.ended', { turnId: 0, reason: 'cancelled' }, T0 + 4), + ]); + expect(ofType(attached, 'step').map((s) => s.step_id)).toEqual(['t0.1']); + const steer = ofType(attached, 'user').find((u) => u.message_id === 't0.1.u1')!; + expect(steer).toMatchObject({ step_id: 't0.1', text: 'last', status: 'completed' }); + + const synthesized = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'do A' }], origin: { kind: 'user' } }), + rec('turn.steer', { input: [{ type: 'text', text: 'early' }], origin: { kind: 'user' } }, T0 + 1), + rec('turn.ended', { turnId: 0, reason: 'cancelled' }, T0 + 2), + ]); + const step = ofType(synthesized, 'step').find((s) => s.step_id === 't0.1')!; + expect(step).toMatchObject({ state: 'interrupted' }); + const early = ofType(synthesized, 'user').find((u) => u.message_id === 't0.1.u1')!; + expect(early).toMatchObject({ text: 'early', status: 'completed' }); + }); +}); + +describe('foldWireHistory task notifications', () => { + const xmlFor = (taskId: string, status: string, title: string, severity: string, body: string): string => + `\nTitle: ${title}\nSeverity: ${severity}\n${body}`; + + const notificationMessage = (taskId: string, status: string, xml: string): Record => ({ + message: { + role: 'user', + content: [{ type: 'text', text: xml }], + toolCalls: [], + origin: { kind: 'task', taskId, status, notificationId: `task:${taskId}:${status}` }, + }, + }); + + it('rebuilds notification user messages from idle turns, busy appends and turn-less restores', () => { + const xml1 = xmlFor('task-1', 'completed', 'Task completed', 'info', 'build finished'); + const xml2 = xmlFor('task-2', 'failed', 'Task failed', 'warning', 'tests broke'); + const xml3 = xmlFor('task-9', 'completed', 'Restored', 'info', 'from previous session'); + const messages = fold([ + rec('turn.prompt', { + input: [{ type: 'text', text: xml1 }], + origin: { kind: 'task', taskId: 'task-1', status: 'completed', notificationId: 'task:task-1:completed' }, + }), + rec('context.append_message', notificationMessage('task-1', 'completed', xml1), T0 + 1), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent({ type: 'step.end', uuid: 'u1' }, T0 + 3), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 4), + rec('turn.prompt', { input: [{ type: 'text', text: 'next' }], origin: { kind: 'user' } }, T0 + 5), + loopEvent({ type: 'step.begin', uuid: 'v1', turnId: '1', step: 1 }, T0 + 6), + loopEvent({ type: 'step.end', uuid: 'v1' }, T0 + 7), + rec('context.append_message', notificationMessage('task-2', 'failed', xml2), T0 + 8), + loopEvent({ type: 'step.begin', uuid: 'v2', turnId: '1', step: 2 }, T0 + 9), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 10), + rec('context.append_message', notificationMessage('task-9', 'completed', xml3), T0 + 11), + ]); + const turn0 = ofType(messages, 'turn')[0]!; + expect(turn0).toMatchObject({ + origin: { kind: 'task', task_id: 'task-1' }, + user_message_id: 't0.u0', + }); + const opening = ofType(messages, 'user').find((u) => u.message_id === 't0.u0')!; + expect(opening).toMatchObject({ + turn_id: 't0', + text: 'Task completed\nbuild finished', + status: 'completed', + origin: { kind: 'task', task_id: 'task-1' }, + notification: { + title: 'Task completed', + body: 'build finished', + severity: 'info', + type: 'task.completed', + source_kind: 'background_task', + source_id: 'task-1', + raw: xml1, + }, + }); + expect(ofType(messages, 'user').filter((u) => u.text === 'Task completed\nbuild finished')).toHaveLength(1); + const injected = ofType(messages, 'user').find((u) => u.message_id === 't1.2.u1')!; + expect(injected).toMatchObject({ + turn_id: 't1', + step_id: 't1.2', + text: 'Task failed\ntests broke', + status: 'completed', + origin: { kind: 'task', task_id: 'task-2' }, + notification: { title: 'Task failed', type: 'task.failed', source_id: 'task-2' }, + }); + expect(injected.steered_at).toBe(iso(T0 + 8)); + const phantom = ofType(messages, 'user').find((u) => u.message_id === 't2.u1')!; + expect(phantom).toMatchObject({ + turn_id: 't2', + text: 'Restored\nfrom previous session', + status: 'completed', + origin: { kind: 'task', task_id: 'task-9' }, + notification: { title: 'Restored', source_id: 'task-9' }, + }); + }); + + it('tags user-slash skill prompts and steers with the skill user origin', () => { + const messages = fold([ + rec('turn.prompt', { + input: [{ type: 'text', text: 'review the code' }], + origin: { + kind: 'skill_activation', + skillName: 'review', + skillArgs: 'src/', + trigger: 'user-slash', + activationId: 'a1', + }, + }), + rec( + 'turn.steer', + { + input: [{ type: 'text', text: 'skill body' }], + origin: { kind: 'skill_activation', skillName: 'deploy', trigger: 'user-slash', activationId: 'a2' }, + }, + T0 + 1, + ), + rec( + 'turn.steer', + { + input: [{ type: 'text', text: 'model triggered' }], + origin: { kind: 'skill_activation', skillName: 'internal', trigger: 'model-tool', activationId: 'a3' }, + }, + T0 + 2, + ), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 3), + ]); + const users = ofType(messages, 'user'); + expect(users).toHaveLength(2); + expect(users[0]).toMatchObject({ + message_id: 't0.u0', + origin: { kind: 'skill', skill_name: 'review', args: 'src/', trigger: 'user-slash' }, + skill_activations: [{ skill_name: 'review', skill_args: 'src/' }], + }); + expect(users[1]).toMatchObject({ + message_id: 't0.1.u1', + origin: { kind: 'skill', skill_name: 'deploy', trigger: 'user-slash' }, + }); + }); +}); + +describe('foldWireHistory undo and clear', () => { + function anchorTurn(ordinal: number, promptId: string, time: number): ContextRecord[] { + return [ + rec( + 'turn.prompt', + { input: [{ type: 'text', text: promptId }], origin: { kind: 'user' }, promptId }, + time, + ), + rec( + 'context.append_message', + { + message: { + id: promptId, + role: 'user', + content: [{ type: 'text', text: promptId }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + time + 1, + ), + rec('turn.ended', { turnId: ordinal, reason: 'completed' }, time + 2), + ]; + } + + it('truncates undone turns and emits system(undo) with the removed top-level ids', () => { + const messages = fold([ + ...anchorTurn(0, 'p0', T0), + ...anchorTurn(1, 'p1', T0 + 10), + rec('context.undo', { count: 1 }, T0 + 20), + ]); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t0']); + const undo = ofType(messages, 'system').find((m) => m.subtype === 'undo')!; + expect(undo).toMatchObject({ subtype: 'undo', payload: { removed_ids: ['t1'] } }); + expect(ofType(messages, 'user').map((u) => u.message_id)).toEqual(['t0.u0']); + + const both = fold([ + ...anchorTurn(0, 'p0', T0), + ...anchorTurn(1, 'p1', T0 + 10), + rec('context.undo', { count: 2 }, T0 + 20), + ]); + expect(ofType(both, 'turn')).toHaveLength(0); + const undoBoth = ofType(both, 'system').find((m) => m.subtype === 'undo')!; + expect(undoBoth.payload).toEqual({ removed_ids: ['t0', 't1'] }); + }); + + it('does not cross the compaction anchor floor on undo', () => { + const messages = fold([ + ...anchorTurn(0, 'p0', T0), + rec('context.apply_compaction', { summary: 'summary', compactedCount: 1 }, T0 + 10), + ...anchorTurn(1, 'p1', T0 + 20), + rec('context.undo', { count: 1 }, T0 + 30), + rec('context.undo', { count: 1 }, T0 + 31), + ]); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t0']); + expect(ofType(messages, 'system').filter((m) => m.subtype === 'undo')).toHaveLength(1); + const compaction = ofType(messages, 'system').find((m) => m.subtype === 'compaction')!; + expect(compaction.payload).toEqual({ phase: 'completed', text: 'summary' }); + }); + + it('rewrites the whole timeline on clear with every removed id', () => { + const messages = fold([ + ...anchorTurn(0, 'p0', T0), + rec('goal.create', { objective: 'ship' }, T0 + 10), + rec('context.clear', {}, T0 + 20), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'fresh' }], origin: { kind: 'user' }, promptId: 'p1' }, + T0 + 30, + ), + ]); + const clear = ofType(messages, 'system').find((m) => m.subtype === 'clear')!; + expect(clear).toMatchObject({ subtype: 'clear', payload: { removed_ids: ['t0', 'sys_goal_1'] } }); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t1']); + expect(ofType(messages, 'system').filter((m) => m.subtype === 'goal')).toHaveLength(0); + }); +}); + +describe('foldWireHistory interactions, facts and modes', () => { + it('projects approval interactions and links them to their tool call', () => { + const messages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{}' }, + T0 + 2, + ), + rec( + 'interaction.request', + { + id: 'apr-1', + kind: 'approval', + toolCallId: 'call_1', + request: { + toolCallId: 'call_1', + toolName: 'Bash', + action: 'Run ls', + display: { kind: 'command' }, + }, + }, + T0 + 3, + ), + rec('interaction.resolved', { id: 'apr-1', response: { decision: 'approved', scope: 'session' } }, T0 + 4), + ]); + const interaction = ofType(messages, 'interaction')[0]!; + expect(interaction).toMatchObject({ + interaction_id: 'apr-1', + kind: 'approval', + state: 'approved', + tool_call_id: 'call_1', + request: { tool_name: 'Bash', action: 'Run ls', tool_input_display: { kind: 'command' } }, + response: { decision: 'approved', scope: 'session' }, + }); + expect(ofType(messages, 'tool_call')[0]!.approval_id).toBe('apr-1'); + }); + + it('rewrites question payloads into the contract shape and maps answers back', () => { + const messages = fold([ + rec('interaction.request', { + id: 'q-1', + kind: 'question', + request: { + questions: [ + { + question: 'pick', + header: 'h', + options: [ + { label: 'a', description: 'da' }, + { label: 'b' }, + ], + multiSelect: true, + }, + ], + }, + }), + rec('interaction.resolved', { id: 'q-1', response: { answers: { pick: 'a' }, method: 'click' } }), + ]); + const interaction = ofType(messages, 'interaction')[0]!; + expect(interaction).toMatchObject({ kind: 'question', state: 'answered' }); + expect(interaction.request).toEqual({ + questions: [ + { + id: 'q_0', + question: 'pick', + header: 'h', + options: [ + { id: 'opt_0_0', label: 'a', description: 'da' }, + { id: 'opt_0_1', label: 'b', description: undefined }, + ], + multi_select: true, + allow_other: true, + }, + ], + }); + expect(interaction.response).toEqual({ + answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } }, + method: 'click', + }); + }); + + it('folds goal, plan, swarm and task records into system and task entities', () => { + const messages = fold([ + rec('goal.create', { objective: 'ship', completionCriterion: 'tests pass' }, T0), + rec('goal.update', { status: 'blocked', tokensUsed: 42, budgetLimits: { tokenBudget: 100 } }, T0 + 1), + rec('goal.clear', {}, T0 + 2), + rec('plan_mode.enter', { id: 'plan-1' }, T0 + 3), + rec( + 'plan.revision', + { id: 'r1', version: 2, key: 'plan/x/v2.md', sha256: 'abc', bytes: 10 }, + T0 + 4, + ), + rec('plan_mode.exit', {}, T0 + 5), + rec('plan_mode.enter', { id: 'plan-2' }, T0 + 6), + rec('plan_mode.cancel', {}, T0 + 7), + rec('swarm_mode.enter', { trigger: 'user' }, T0 + 8), + rec('swarm_mode.exit', {}, T0 + 9), + rec( + 'task.started', + { + info: { + taskId: 'task-1', + kind: 'process', + status: 'running', + description: 'dev server', + detached: true, + startedAt: T0, + }, + }, + T0 + 10, + ), + rec( + 'task.terminated', + { + info: { taskId: 'task-1', kind: 'process', status: 'completed', endedAt: T0 + 11 }, + outputTail: 'logs', + }, + T0 + 11, + ), + ], { + resolvePlanRevisionKey: (key) => `resolved/${key}`, + }); + const goals = ofType(messages, 'system').filter((m) => m.subtype === 'goal'); + expect(goals.map((m) => m.payload)).toEqual([ + { objective: 'ship', status: 'active', completion_criterion: 'tests pass', budget_used: 0, budget_limit: undefined }, + { objective: 'ship', status: 'blocked', completion_criterion: 'tests pass', budget_used: 42, budget_limit: 100 }, + undefined, + ]); + expect(ofType(messages, 'system').map((m) => m.subtype)).toEqual([ + 'goal', + 'goal', + 'goal', + 'plan.enter', + 'plan.revision', + 'plan.exit', + 'plan.enter', + 'swarm.enter', + 'swarm.exit', + ]); + const revision = ofType(messages, 'system').find((m) => m.subtype === 'plan.revision')!; + expect(revision.payload).toEqual({ + id: 'r1', + version: 2, + path: 'resolved/plan/x/v2.md', + sha256: 'abc', + bytes: 10, + }); + const task = ofType(messages, 'task')[0]!; + expect(task).toMatchObject({ + task_id: 'task-1', + kind: 'shell', + state: 'completed', + detached: true, + description: 'dev server', + output_tail: 'logs', + started_at: iso(T0), + ended_at: iso(T0 + 11), + }); + }); + + it('links subagent tasks to their parent tool call with agent refs', () => { + const messages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent( + { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_9', name: 'Agent', args: '{}' }, + T0 + 2, + ), + rec( + 'task.started', + { + info: { + taskId: 'task-2', + kind: 'agent', + agentId: 'sub-1', + parentToolCallId: 'call_9', + status: 'running', + model: 'k2', + thinkingEffort: 'high', + }, + }, + T0 + 3, + ), + ]); + const task = ofType(messages, 'task')[0]!; + expect(task).toMatchObject({ + task_id: 'task-2', + kind: 'subagent', + child_agent_id: 'sub-1', + model: 'k2', + thinking_effort: 'high', + }); + const tool = ofType(messages, 'tool_call')[0]!; + expect(tool).toMatchObject({ + task_id: 'task-2', + agent_refs: [{ agent_id: 'sub-1', role: 'child' }], + }); + }); +}); + +describe('foldWireHistory queued prompts and legacy messages', () => { + it('emits queued prompts as turn-less user messages with the reserved turn id', () => { + const messages = fold( + [ + rec('prompt.accepted', { promptId: 'q1', content: [{ type: 'text', text: 'first' }] }, T0), + rec('prompt.accepted', { promptId: 'q2', content: [{ type: 'text', text: 'second' }] }, T0 + 1), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'first' }], origin: { kind: 'user' }, promptId: 'q1' }, + T0 + 2, + ), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 3), + ], + { live: true }, + ); + const users = ofType(messages, 'user'); + expect(users.map((u) => u.message_id)).toEqual(['t0.u0', 't1.u0']); + const queued = users[1]!; + expect(queued).toMatchObject({ turn_id: 't1', text: 'second', status: 'running' }); + expect(queued.step_id).toBeUndefined(); + expect(ofType(messages, 'turn').map((t) => t.turn_id)).toEqual(['t0']); + + const aborted = fold([ + rec('prompt.accepted', { promptId: 'q1', content: [{ type: 'text', text: 'first' }] }, T0), + rec('prompt.aborted', { promptId: 'q1' }, T0 + 1), + ]); + expect(ofType(aborted, 'user')).toHaveLength(0); + }); + + it('rebuilds legacy append-only assistant and tool messages', () => { + const messages = fold([ + rec('context.append_message', { + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }], + }, + }), + rec( + 'context.append_message', + { message: { role: 'tool', content: [{ type: 'text', text: 'file.txt' }], toolCalls: [], toolCallId: 'call_1' } }, + T0 + 1, + ), + ]); + const turn = ofType(messages, 'turn')[0]!; + expect(turn).toMatchObject({ turn_id: 't0', origin: { kind: 'other' } }); + const assistant = ofType(messages, 'assistant')[0]!; + expect(assistant).toMatchObject({ message_id: 't0.1.a1', text: 'hi', status: 'completed' }); + const tool = ofType(messages, 'tool_call')[0]!; + expect(tool).toMatchObject({ tool_call_id: 'call_1', state: 'done', output: 'file.txt' }); + }); +}); + +describe('foldWireHistory todo restoration', () => { + it('restores the todo entity from the last done TodoWrite input and reverts with undo', () => { + const first: ContextRecord[] = [ + rec('turn.prompt', { input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, promptId: 'p0' }), + rec( + 'context.append_message', + { + message: { + id: 'p0', + role: 'user', + content: [{ type: 'text', text: 'one' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 1, + ), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 2), + loopEvent( + { + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_t1', + name: 'TodoList', + args: '{"todos":[{"title":"a","status":"done"}]}', + }, + T0 + 3, + ), + loopEvent({ type: 'tool.result', toolCallId: 'call_t1', result: { output: 'ok' } }, T0 + 4), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 5), + ]; + const second: ContextRecord[] = [ + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'p1' }, + T0 + 10, + ), + rec( + 'context.append_message', + { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'two' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 11, + ), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '1', step: 1 }, T0 + 12), + loopEvent( + { + type: 'tool.call', + stepUuid: 'u2', + toolCallId: 'call_t2', + name: 'TodoList', + args: '{"todos":[{"title":"b","status":"in_progress"}]}', + }, + T0 + 13, + ), + loopEvent({ type: 'tool.result', toolCallId: 'call_t2', result: { output: 'ok' } }, T0 + 14), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 15), + ]; + const restored = fold([...first, ...second]); + const todo = ofType(restored, 'todo')[0]!; + expect(todo).toMatchObject({ + todo_id: 'todo', + items: [{ title: 'b', status: 'in_progress' }], + updated_at: iso(T0 + 14), + }); + expect(restored.at(-1)).toBe(todo); + + const reverted = fold([...first, ...second, rec('context.undo', { count: 1 }, T0 + 20)]); + const revertedTodo = ofType(reverted, 'todo')[0]!; + expect(revertedTodo).toMatchObject({ todo_id: 'todo', items: [{ title: 'a', status: 'done' }] }); + }); +}); + +describe('paginateHistory', () => { + const base: HistoryMessage[] = []; + for (let turn = 0; turn < 3; turn++) { + base.push( + { + type: 'turn', + session_id: SESSION, + agent_id: 'main', + timestamp: iso(T0), + turn_id: `t${turn}`, + ordinal: turn, + state: 'completed', + origin: { kind: 'user' }, + }, + { + type: 'user', + session_id: SESSION, + agent_id: 'main', + timestamp: iso(T0), + message_id: `t${turn}.u0`, + turn_id: `t${turn}`, + text: `p${turn}`, + status: 'completed', + created_at: iso(T0), + }, + { + type: 'step', + session_id: SESSION, + agent_id: 'main', + timestamp: iso(T0), + step_id: `t${turn}.1`, + turn_id: `t${turn}`, + ordinal: 1, + state: 'completed', + }, + { + type: 'assistant', + session_id: SESSION, + agent_id: 'main', + timestamp: iso(T0), + message_id: `t${turn}.1.a1`, + turn_id: `t${turn}`, + step_id: `t${turn}.1`, + status: 'completed', + text: `a${turn}`, + }, + ); + } + + const ids = (messages: readonly HistoryMessage[]): string[] => + messages.map((m) => { + switch (m.type) { + case 'turn': + return m.turn_id; + case 'step': + return m.step_id; + case 'user': + case 'assistant': + return m.message_id; + default: + return m.type; + } + }); + + it('returns the newest page by default and pages older with before_turn', () => { + expect(ids(paginateHistory(base, {}).messages)).toHaveLength(12); + expect(paginateHistory(base, {}).hasMore).toBe(false); + const newest = paginateHistory(base, { page_size: 2 }); + expect(ids(newest.messages)).toEqual(['t1', 't1.u0', 't1.1', 't1.1.a1', 't2', 't2.u0', 't2.1', 't2.1.a1']); + expect(newest.hasMore).toBe(true); + const older = paginateHistory(base, { before_turn: 't2' }); + expect(ids(older.messages)).toEqual(['t0', 't0.u0', 't0.1', 't0.1.a1', 't1', 't1.u0', 't1.1', 't1.1.a1']); + expect(older.hasMore).toBe(false); + const olderCapped = paginateHistory(base, { before_turn: 't2', page_size: 1 }); + expect(ids(olderCapped.messages)).toEqual(['t1', 't1.u0', 't1.1', 't1.1.a1']); + expect(olderCapped.hasMore).toBe(true); + expect(paginateHistory(base, { before_turn: 't99' })).toEqual({ messages: [], hasMore: false }); + }); + + it('catches up newer messages with after_step', () => { + const tail = paginateHistory(base, { after_step: 't1.1' }); + expect(ids(tail.messages)).toEqual(['t2', 't2.u0', 't2.1', 't2.1.a1']); + expect(tail.hasMore).toBe(false); + const tailCapped = paginateHistory(base, { after_step: 't1.1', page_size: 2 }); + expect(ids(tailCapped.messages)).toEqual(['t2', 't2.u0']); + expect(tailCapped.hasMore).toBe(true); + expect(paginateHistory(base, { after_step: 't0.9' })).toEqual({ messages: [], hasMore: false }); + expect(paginateHistory(base, { after_step: 't2.1' })).toEqual({ messages: [], hasMore: false }); + }); +}); + +describe('live and cold rebuild id consistency', () => { + const IDENTITY_KEY_TYPES = new Set([ + 'turn', + 'step', + 'user', + 'assistant', + 'thinking', + 'tool_call', + 'system', + 'interaction', + 'task', + ]); + + function keyOf(message: ServerMessage): string | undefined { + switch (message.type) { + case 'turn': + return `turn:${message.turn_id}`; + case 'step': + return `step:${message.step_id}`; + case 'user': + case 'assistant': + case 'thinking': + return `${message.type}:${message.message_id}`; + case 'tool_call': + return `tool_call:${message.tool_call_id}`; + case 'system': + return `system:${message.system_id}`; + case 'interaction': + return `interaction:${message.interaction_id}`; + case 'task': + return `task:${message.task_id}`; + default: + return undefined; + } + } + + function clientFold(messages: readonly ServerMessage[]): Map { + const store = new Map(); + const removeSubtree = (id: string): void => { + for (const [key, entity] of [...store]) { + if (key.endsWith(`:${id}`)) { + store.delete(key); + continue; + } + if (!IDENTITY_KEY_TYPES.has(entity.type)) continue; + const turnId = 'turn_id' in entity ? (entity.turn_id as string | undefined) : undefined; + if (turnId === id) store.delete(key); + } + }; + for (const message of messages) { + if (message.type === 'system' && (message.subtype === 'undo' || message.subtype === 'clear')) { + for (const id of (message.payload as { removed_ids: string[] }).removed_ids) { + removeSubtree(id); + } + } + const key = keyOf(message); + if (key !== undefined) store.set(key, message); + } + return store; + } + + it('produces the same entity id set from the live projector and the cold fold', () => { + const projector = new AgentMessageProjector('main', SESSION, new Map()); + const live: ServerMessage[] = []; + const feed = (event: ProjectionBusEvent): void => { + for (const message of projector.map(event)) live.push(serverMessageSchema.parse(message)); + }; + + feed(ev({ type: 'turn.started', turnId: 0, promptId: 'p1', origin: { kind: 'user' }, prompt: 'fix the bug' })); + feed(ev({ type: 'turn.step.started', turnId: 0, step: 1 })); + feed(ev({ type: 'thinking.delta', turnId: 0, delta: 'hmm' })); + feed(ev({ type: 'assistant.delta', turnId: 0, delta: 'Hello' })); + feed(ev({ type: 'tool.call.started', turnId: 0, toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' })); + feed(ev({ type: 'tool.result', turnId: 0, toolCallId: 'call_1', output: 'file.txt' })); + feed( + ev({ + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, + finishReason: 'tool_calls', + }), + ); + feed(ev({ type: 'turn.steer', turnId: 0, input: [{ type: 'text', text: 'also B' }], origin: { kind: 'user' } })); + feed(ev({ type: 'turn.step.started', turnId: 0, step: 2 })); + live.push( + ...projector.interactionRequested({ + id: 'apr-1', + kind: 'approval', + payload: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run ls' }, + origin: {}, + createdAt: T0, + }), + ); + live.push(...projector.interactionResolved('apr-1', { decision: 'approved' })); + feed( + ev({ + type: 'goal.updated', + snapshot: { + objective: 'ship', + status: 'active', + tokensUsed: 10, + budget: { tokenBudget: 100 }, + }, + }), + ); + feed(ev({ type: 'turn.step.completed', turnId: 0, step: 2 })); + feed( + ev({ + type: 'task.notified', + notificationType: 'task.completed', + title: 'Task completed', + body: 'build finished', + severity: 'info', + sourceKind: 'background_task', + sourceId: 'task-7', + }), + ); + feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 1500 })); + feed(ev({ type: 'turn.started', turnId: 1, promptId: 'p2', origin: { kind: 'user' }, prompt: 'second' })); + feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + feed(ev({ type: 'assistant.delta', turnId: 1, delta: 'partial' })); + feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + feed(ev({ type: 'context.undone', turns: 1, fromTurnId: 1 })); + feed(ev({ type: 'compaction.completed', result: { summary: 'sum' } })); + + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'fix the bug' }], + origin: { kind: 'user' }, + promptId: 'p1', + }), + rec('context.append_message', { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'fix the bug' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'think', think: 'hmm' } }), + loopEvent({ type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'Hello' } }), + loopEvent({ + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_1', + name: 'Bash', + args: '{"command":"ls"}', + }), + loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'file.txt' } }), + loopEvent({ + type: 'step.end', + uuid: 'u1', + finishReason: 'tool_calls', + usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, + }), + rec('turn.steer', { input: [{ type: 'text', text: 'also B' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u2', turnId: '0', step: 2 }), + rec('interaction.request', { + id: 'apr-1', + kind: 'approval', + toolCallId: 'call_1', + request: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run ls' }, + }), + rec('interaction.resolved', { id: 'apr-1', response: { decision: 'approved' } }), + rec('goal.create', { objective: 'ship' }), + loopEvent({ type: 'step.end', uuid: 'u2' }), + rec('context.append_message', { + message: { + role: 'user', + content: [ + { + type: 'text', + text: '\nTitle: Task completed\nSeverity: info\nbuild finished', + }, + ], + toolCalls: [], + origin: { kind: 'task', taskId: 'task-7', status: 'completed', notificationId: 'task:task-7:completed' }, + }, + }), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 1500 }), + rec('turn.prompt', { + input: [{ type: 'text', text: 'second' }], + origin: { kind: 'user' }, + promptId: 'p2', + }), + rec('context.append_message', { + message: { + id: 'p2', + role: 'user', + content: [{ type: 'text', text: 'second' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }), + loopEvent({ type: 'step.begin', uuid: 'u3', turnId: '1', step: 1 }), + loopEvent({ type: 'content.part', stepUuid: 'u3', part: { type: 'text', text: 'partial' } }), + rec('turn.ended', { turnId: 1, reason: 'completed' }), + rec('context.undo', { count: 1 }), + rec('context.apply_compaction', { summary: 'sum', compactedCount: 2 }), + ]; + const cold = fold(records); + + const liveIds = [...clientFold(live).keys()].toSorted(); + const coldIds = cold + .map((m) => keyOf(m as ServerMessage)) + .filter((k): k is string => k !== undefined) + .toSorted(); + expect(coldIds).toEqual(liveIds); + + const coldUndo = ofType(cold, 'system').find((m) => m.subtype === 'undo')!; + expect(coldUndo.payload).toEqual({ removed_ids: ['t1'] }); + const liveUndo = clientFold(live).get('system:sys_undo_1'); + expect(liveUndo).toMatchObject({ subtype: 'undo', payload: { removed_ids: ['t1'] } }); + }); + + it('keeps system ids deterministic across live-only and multi-phase events', () => { + const projector = new AgentMessageProjector('main', SESSION, new Map()); + const live: ServerMessage[] = []; + const feed = (event: ProjectionBusEvent): void => { + for (const message of projector.map(event)) live.push(serverMessageSchema.parse(message)); + }; + + feed(ev({ type: 'turn.started', turnId: 0, promptId: 'p1', origin: { kind: 'user' }, prompt: 'fix' })); + feed(ev({ type: 'turn.step.started', turnId: 0, step: 1 })); + feed(ev({ type: 'hook.result', turnId: 0, hookEvent: 'PreToolUse', content: 'hook says hi' })); + feed(ev({ type: 'warning', message: 'careful', code: 'W1' })); + feed(ev({ type: 'compaction.started', trigger: 'manual' })); + feed(ev({ type: 'compaction.completed', result: { summary: 'sum' } })); + feed(ev({ type: 'compaction.started', trigger: 'manual' })); + feed(ev({ type: 'compaction.completed', result: { summary: 'sum2' } })); + feed(ev({ type: 'turn.step.completed', turnId: 0, step: 1 })); + feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); + feed( + ev({ + type: 'turn.started', + turnId: 1, + promptId: 'p2', + origin: { kind: 'skill_activation', trigger: 'user-slash', activationId: 'sk-1', skillName: 'review' }, + prompt: 'run review', + }), + ); + feed(ev({ type: 'skill.activated', activationId: 'sk-1', skillName: 'review', trigger: 'user-slash' })); + feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'fix' }], + origin: { kind: 'user' }, + promptId: 'p1', + }), + rec('context.append_message', { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'fix' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }), + rec('context.apply_compaction', { summary: 'sum', compactedCount: 1 }), + rec('context.apply_compaction', { summary: 'sum2', compactedCount: 1 }), + loopEvent({ type: 'step.end', uuid: 'u1' }), + rec('turn.ended', { turnId: 0, reason: 'completed' }), + rec('turn.prompt', { + input: [{ type: 'text', text: 'run review' }], + origin: { kind: 'skill_activation', trigger: 'user-slash', activationId: 'sk-1', skillName: 'review' }, + promptId: 'p2', + }), + rec('context.append_message', { + message: { + id: 'p2', + role: 'user', + content: [{ type: 'text', text: 'run review' }], + toolCalls: [], + origin: { kind: 'skill_activation', trigger: 'user-slash' }, + }, + }), + rec('turn.ended', { turnId: 1, reason: 'completed' }), + ]; + const cold = fold(records); + + const liveSystems = live.filter( + (m): m is Extract => m.type === 'system', + ); + expect(liveSystems.filter((m) => m.subtype === 'compaction')).toHaveLength(2); + const coldSysIds = ofType(cold, 'system').map((m) => m.system_id).toSorted(); + expect(coldSysIds).toEqual(['sys_compaction_1', 'sys_compaction_2', 'sys_skill_1']); + const liveOnlySysIds = liveSystems + .map((m) => m.system_id) + .filter((id) => !coldSysIds.includes(id)) + .toSorted(); + expect(liveOnlySysIds).toEqual(['sys_hook_1', 'sys_notice_1']); + + const liveIds = [...clientFold(live).keys()].toSorted(); + const coldIds = cold + .map((m) => keyOf(m as ServerMessage)) + .filter((k): k is string => k !== undefined) + .toSorted(); + expect(coldIds.every((id) => liveIds.includes(id))).toBe(true); + expect(liveIds.filter((id) => !coldIds.includes(id)).toSorted()).toEqual([ + 'system:sys_hook_1', + 'system:sys_notice_1', + ]); + }); + + it('seeds the same timeline ids and counters as the cold fold', () => { + const records: ContextRecord[] = [ + rec('turn.prompt', { + input: [{ type: 'text', text: 'one' }], + origin: { kind: 'user' }, + promptId: 'p0', + }), + rec( + 'context.append_message', + { + message: { + id: 'p0', + role: 'user', + content: [{ type: 'text', text: 'one' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 1, + ), + rec('turn.ended', { turnId: 0, reason: 'completed' }, T0 + 2), + rec('goal.create', { objective: 'ship' }, T0 + 3), + rec('goal.update', { tokensUsed: 42 }, T0 + 4), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'p1' }, + T0 + 10, + ), + rec( + 'context.append_message', + { + message: { + id: 'p1', + role: 'user', + content: [{ type: 'text', text: 'two' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + T0 + 11, + ), + rec('turn.ended', { turnId: 1, reason: 'completed' }, T0 + 12), + rec('context.undo', { count: 1 }, T0 + 20), + rec('context.apply_compaction', { summary: 'sum', compactedCount: 1 }, T0 + 21), + rec('plan_mode.enter', { id: 'plan-1' }, T0 + 22), + rec('plan_mode.cancel', {}, T0 + 23), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'three' }], origin: { kind: 'cron_job', jobId: 'j1', cron: '* * * * *' } }, + T0 + 24, + ), + rec('turn.ended', { turnId: 2, reason: 'completed' }, T0 + 25), + ]; + const seed = foldTimelineSeed(records); + const cold = fold(records); + const coldTimelineIds = cold + .filter((m) => m.type === 'turn' || m.type === 'system') + .map((m) => (m.type === 'turn' ? m.turn_id : m.system_id)); + expect(seed.timelineIds).toEqual(coldTimelineIds); + expect(seed.timelineIds).toEqual([ + 't0', + 'sys_goal_1', + 'sys_undo_1', + 'sys_compaction_1', + 'sys_plan.enter_1', + 't2', + ]); + expect(seed.nextTurnId).toBe(3); + expect(seed.anchorTurnOrdinals).toEqual([0, 1]); + expect(seed.systemCounts).toEqual( + new Map([['compaction', 1], ['goal', 1], ['plan.enter', 1], ['undo', 1]]), + ); + + const legacy: ContextRecord[] = [ + rec('context.append_message', { + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }], + }, + }), + rec( + 'turn.prompt', + { input: [{ type: 'text', text: 'next' }], origin: { kind: 'user' }, promptId: 'p9' }, + T0 + 10, + ), + ]; + const legacySeed = foldTimelineSeed(legacy); + const legacyColdIds = fold(legacy) + .filter((m) => m.type === 'turn' || m.type === 'system') + .map((m) => (m.type === 'turn' ? m.turn_id : m.system_id)); + expect(legacySeed.timelineIds).toEqual(legacyColdIds); + expect(legacySeed.nextTurnId).toBe(2); + }); +}); diff --git a/packages/kap-server/test/services/projection.test.ts b/packages/kap-server/test/services/projection.test.ts new file mode 100644 index 00000000000..bd36ac18d78 --- /dev/null +++ b/packages/kap-server/test/services/projection.test.ts @@ -0,0 +1,1340 @@ +import { + IAgentActivityView, + IAgentGoalService, + IAgentInteractionService, + IAgentLifecycleService, + IAgentLoopService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentPromptService, + IAgentScopeContext, + IAgentStateService, + IAgentTaskService, + IAgentTodoService, + IEventBus, + ISessionActivityView, + ISessionTokenCountingService, + ISessionUsageService, + makeAgentScopeContext, + type AgentContext, + type Event2, + type Interaction, + type ISessionScopeHandle, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { Emitter, Event } from '@moonshot-ai/agent-core-v2/_base/event'; +import { describe, expect, it, vi } from 'vitest'; + +import { serverMessageSchema, type ServerMessage } from '../../src/protocol/messages'; +import { AgentMessageProjector } from '../../src/services/projection/agentProjector'; +import type { ProjectionBusEvent } from '../../src/services/projection/events'; +import { foldWireTurn, type ContextRecord } from '../../src/services/projection/heal'; +import { SessionProjection } from '../../src/services/projection/sessionProjection'; +import { SessionStateAggregator } from '../../src/services/projection/sessionState'; + +const SESSION = 's1'; +const T0 = 1_700_000_000_000; + +function ev(payload: Record): ProjectionBusEvent { + return { time: T0, ...payload } as unknown as ProjectionBusEvent; +} + +function feed( + projector: AgentMessageProjector, + event: ProjectionBusEvent, + sink: ServerMessage[], +): void { + for (const message of projector.map(event)) { + sink.push(serverMessageSchema.parse(message)); + } +} + +function feedAll( + projector: AgentMessageProjector, + events: readonly ProjectionBusEvent[], +): ServerMessage[] { + const sink: ServerMessage[] = []; + for (const event of events) feed(projector, event, sink); + return sink; +} + +function ofType( + messages: readonly ServerMessage[], + type: T, +): Extract[] { + return messages.filter((m): m is Extract => m.type === type); +} + +function makeProjector(agentId = 'main'): AgentMessageProjector { + return new AgentMessageProjector(agentId, SESSION, new Map()); +} + +function runFullTurn(projector: AgentMessageProjector, sink: ServerMessage[]): void { + feed( + projector, + ev({ type: 'turn.started', turnId: 1, promptId: 'p1', origin: { kind: 'user' }, prompt: 'fix the bug' }), + sink, + ); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), sink); + feed(projector, ev({ type: 'assistant.delta', turnId: 1, delta: 'Hello' }), sink); + feed(projector, ev({ type: 'assistant.delta', turnId: 1, delta: ' world' }), sink); + feed( + projector, + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 'call_1', name: 'Bash', argumentsPart: '{"command":"ls"}' }), + sink, + ); + feed( + projector, + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 'call_1', + name: 'Bash', + args: '{"command":"ls"}', + display: { kind: 'command', command: 'ls' }, + }), + sink, + ); + feed(projector, ev({ type: 'tool.result', turnId: 1, toolCallId: 'call_1', output: 'file.txt' }), sink); + feed( + projector, + ev({ + type: 'turn.step.completed', + turnId: 1, + step: 1, + usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, + finishReason: 'stop', + llmFirstTokenLatencyMs: 100, + llmStreamDurationMs: 900, + }), + sink, + ); + feed(projector, ev({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 1500 }), sink); +} + +describe('AgentMessageProjector', () => { + it('projects a full turn lifecycle into flat entity messages', () => { + const projector = makeProjector(); + const messages = feedAll(projector, []); + runFullTurn(projector, messages); + + const turn = ofType(messages, 'turn')[0]!; + expect(turn).toMatchObject({ + turn_id: 't1', + ordinal: 1, + state: 'running', + origin: { kind: 'user' }, + user_message_id: 't1.u0', + }); + const user = ofType(messages, 'user')[0]!; + expect(user).toMatchObject({ message_id: 't1.u0', turn_id: 't1', text: 'fix the bug', status: 'running' }); + + const step = ofType(messages, 'step')[0]!; + expect(step).toMatchObject({ step_id: 't1.1', turn_id: 't1', ordinal: 1, state: 'running' }); + + const assistantOpen = ofType(messages, 'assistant')[0]!; + expect(assistantOpen).toMatchObject({ message_id: 't1.1.a1', status: 'streaming', text: '' }); + const deltas = ofType(messages, 'assistant.delta'); + expect(deltas.map((d) => d.text)).toEqual(['Hello', ' world']); + const assistantFinal = ofType(messages, 'assistant').at(-1)!; + expect(assistantFinal).toMatchObject({ message_id: 't1.1.a1', status: 'completed', text: 'Hello world' }); + + const toolRunning = ofType(messages, 'tool_call')[0]!; + expect(toolRunning).toMatchObject({ + tool_call_id: 'call_1', + step_id: 't1.1', + name: 'Bash', + state: 'running', + input_text: '{"command":"ls"}', + }); + const toolDeltas = ofType(messages, 'tool_call.delta'); + expect(toolDeltas.map((d) => d.input_text)).toEqual(['{"command":"ls"}']); + const toolDone = ofType(messages, 'tool_call').at(-1)!; + expect(toolDone).toMatchObject({ + state: 'done', + input: { command: 'ls' }, + output: 'file.txt', + display: { kind: 'command', command: 'ls' }, + }); + + const stepDone = ofType(messages, 'step').at(-1)!; + expect(stepDone).toMatchObject({ + state: 'completed', + usage: { input_other: 10, output: 5, input_cache_read: 2, input_cache_creation: 1 }, + finish_reason: 'stop', + timing: { llm_first_token_ms: 100, llm_stream_duration_ms: 900 }, + }); + + const turnDone = ofType(messages, 'turn').at(-1)!; + expect(turnDone).toMatchObject({ + state: 'completed', + duration_ms: 1500, + usage: { input_tokens: 11, output_tokens: 5, cached_tokens: 2 }, + }); + const userDone = ofType(messages, 'user').at(-1)!; + expect(userDone.status).toBe('completed'); + expect(typeof userDone.finished_at).toBe('string'); + expect(projector.takeEndedTurnOrdinals()).toEqual([1]); + }); + + it('folds a step retry into the retry field of the same running step', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }), + ev({ type: 'turn.step.started', turnId: 1, step: 1 }), + ev({ + type: 'turn.step.retrying', + turnId: 1, + step: 1, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 1000, + errorName: 'RateLimitError', + errorMessage: 'slow down', + statusCode: 429, + }), + ]); + const step = ofType(messages, 'step').at(-1)!; + expect(step).toMatchObject({ + step_id: 't1.1', + state: 'running', + retry: { + failed_attempt: 1, + next_attempt: 2, + max_attempts: 3, + delay_ms: 1000, + error_name: 'RateLimitError', + error_message: 'slow down', + status_code: 429, + }, + }); + }); + + it('marks the open step interrupted and emits system(interruption) on user cancel', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }), + ev({ type: 'turn.step.started', turnId: 1, step: 1 }), + ev({ type: 'assistant.delta', turnId: 1, delta: 'partial' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'cancelled', interruptReason: 'user_cancelled' }), + ]); + const step = ofType(messages, 'step').at(-1)!; + expect(step.state).toBe('interrupted'); + const turn = ofType(messages, 'turn').at(-1)!; + expect(turn.state).toBe('completed'); + const interruption = ofType(messages, 'system').find((m) => m.subtype === 'interruption'); + expect(interruption).toMatchObject({ + subtype: 'interruption', + payload: { turn_id: 't1', reason: 'user_cancelled' }, + }); + const assistant = ofType(messages, 'assistant').at(-1)!; + expect(assistant).toMatchObject({ status: 'completed', text: 'partial' }); + }); + + it('links approvals to their tool call and projects pending then resolved interactions', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed(projector, ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }), sink); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), sink); + feed( + projector, + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }), + sink, + ); + sink.push( + ...projector.interactionRequested({ + id: 'apr-1', + kind: 'approval', + payload: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run ls', display: { kind: 'command' } }, + origin: { agentId: 'main', turnId: 1 }, + createdAt: T0, + }), + ); + const pending = ofType(sink, 'interaction').at(-1)!; + expect(pending).toMatchObject({ + interaction_id: 'apr-1', + kind: 'approval', + state: 'pending', + tool_call_id: 'call_1', + request: { tool_name: 'Bash', action: 'Run ls', tool_input_display: { kind: 'command' } }, + }); + const toolLinked = ofType(sink, 'tool_call').at(-1)!; + expect(toolLinked.approval_id).toBe('apr-1'); + + sink.push(...projector.interactionResolved('apr-1', { decision: 'approved', scope: 'session' })); + const resolved = ofType(sink, 'interaction').at(-1)!; + expect(resolved).toMatchObject({ + state: 'approved', + response: { decision: 'approved', scope: 'session' }, + }); + }); + + it('attaches steer user messages to the running step and buffers them between steps', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'do A' }), + ev({ type: 'turn.step.started', turnId: 1, step: 1 }), + ev({ type: 'turn.steer', turnId: 1, input: [{ type: 'text', text: 'also B' }], origin: { kind: 'user' } }), + ev({ type: 'turn.step.completed', turnId: 1, step: 1 }), + ev({ type: 'turn.steer', turnId: 1, input: [{ type: 'text', text: 'and C' }], origin: { kind: 'user' } }), + ev({ type: 'turn.step.started', turnId: 1, step: 2 }), + ]); + const users = ofType(messages, 'user'); + const steerInStep = users.find((u) => u.message_id === 't1.1.u1'); + expect(steerInStep).toMatchObject({ + turn_id: 't1', + step_id: 't1.1', + text: 'also B', + status: 'running', + }); + expect(typeof steerInStep?.steered_at).toBe('string'); + const buffered = users.find((u) => u.message_id === 't1.2.u1'); + expect(buffered).toMatchObject({ step_id: 't1.2', text: 'and C' }); + }); + + it('maps cron turns and busy cron steers to the cron user origin', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ + type: 'turn.started', + turnId: 1, + origin: { kind: 'cron_job', jobId: 'job-1', cron: '*/5 * * * *', recurring: true, coalescedCount: 0, stale: false }, + prompt: 'check the queue', + }), + ]); + const turn = ofType(messages, 'turn')[0]!; + expect(turn.origin).toEqual({ kind: 'cron' }); + const user = ofType(messages, 'user')[0]!; + expect(user).toMatchObject({ + text: 'check the queue', + origin: { kind: 'cron', cron_id: 'job-1', schedule: '*/5 * * * *' }, + }); + + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), messages); + feed( + projector, + ev({ + type: 'turn.steer', + turnId: 1, + input: [{ type: 'text', text: 'fire now' }], + origin: { kind: 'cron_job', jobId: 'job-2', cron: '0 * * * *', recurring: false, coalescedCount: 1, stale: false }, + }), + messages, + ); + const steered = ofType(messages, 'user').at(-1)!; + expect(steered).toMatchObject({ + message_id: 't1.1.u1', + origin: { kind: 'cron', cron_id: 'job-2', schedule: '0 * * * *' }, + }); + expect(typeof steered.steered_at).toBe('string'); + + feed( + projector, + ev({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'cron_missed', count: 3 }, + prompt: 'missed cron runs', + }), + messages, + ); + const missedTurn = ofType(messages, 'turn').at(-1)!; + expect(missedTurn.origin).toEqual({ kind: 'cron' }); + const missedUser = ofType(messages, 'user').at(-1)!; + expect(missedUser).toMatchObject({ text: 'missed cron runs', origin: { kind: 'cron' } }); + expect(missedUser.origin).not.toHaveProperty('cron_id'); + }); + + it('projects task notifications as user messages on the idle, busy and turn-less paths', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed( + projector, + ev({ + type: 'turn.started', + turnId: 1, + origin: { kind: 'task', taskId: 'task-1', status: 'completed', notificationId: 'task:task-1:completed' }, + }), + sink, + ); + feed( + projector, + ev({ + type: 'task.notified', + notificationType: 'task.completed', + title: 'Task completed', + body: 'build finished', + severity: 'info', + sourceKind: 'background_task', + sourceId: 'task-1', + }), + sink, + ); + const turn = ofType(sink, 'turn').at(-1)!; + expect(turn).toMatchObject({ + origin: { kind: 'task', task_id: 'task-1' }, + user_message_id: 't1.u0', + }); + const opening = ofType(sink, 'user')[0]!; + expect(opening).toMatchObject({ + message_id: 't1.u0', + turn_id: 't1', + text: 'Task completed\nbuild finished', + status: 'running', + origin: { kind: 'task', task_id: 'task-1' }, + notification: { + title: 'Task completed', + body: 'build finished', + severity: 'info', + type: 'task.completed', + source_kind: 'background_task', + source_id: 'task-1', + }, + }); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), sink); + feed(projector, ev({ type: 'turn.step.completed', turnId: 1, step: 1 }), sink); + feed( + projector, + ev({ + type: 'task.notified', + notificationType: 'task.failed', + title: 'Task failed', + body: 'tests broke', + severity: 'warning', + sourceKind: 'background_task', + sourceId: 'task-2', + }), + sink, + ); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 2 }), sink); + const injected = ofType(sink, 'user').find((u) => u.message_id === 't1.2.u1')!; + expect(injected).toMatchObject({ + turn_id: 't1', + step_id: 't1.2', + text: 'Task failed\ntests broke', + status: 'running', + origin: { kind: 'task', task_id: 'task-2' }, + }); + expect(typeof injected.steered_at).toBe('string'); + feed(projector, ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), sink); + expect(ofType(sink, 'user').filter((u) => u.status === 'completed')).toHaveLength(2); + + const turnless = makeProjector(); + const phantom = feedAll(turnless, [ + ev({ + type: 'task.notified', + notificationType: 'task.completed', + title: 'Restored', + body: 'from previous session', + severity: 'info', + sourceKind: 'background_task', + sourceId: 'task-9', + }), + ]); + expect(ofType(phantom, 'user')[0]).toMatchObject({ + message_id: 't0.u1', + turn_id: 't0', + status: 'completed', + origin: { kind: 'task', task_id: 'task-9' }, + }); + }); + + it('tags user-slash skill prompts and steers with the skill user origin', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed( + projector, + ev({ + type: 'turn.started', + turnId: 1, + origin: { + kind: 'skill_activation', + skillName: 'review', + skillArgs: 'src/', + trigger: 'user-slash', + activationId: 'a1', + }, + prompt: 'review the code', + }), + sink, + ); + expect(ofType(sink, 'turn')[0]!.origin).toEqual({ kind: 'user' }); + const user = ofType(sink, 'user')[0]!; + expect(user).toMatchObject({ + message_id: 't1.u0', + origin: { kind: 'skill', skill_name: 'review', args: 'src/', trigger: 'user-slash' }, + skill_activations: [{ skill_name: 'review', skill_args: 'src/' }], + }); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), sink); + feed( + projector, + ev({ + type: 'turn.steer', + turnId: 1, + input: [{ type: 'text', text: 'skill body' }], + origin: { kind: 'skill_activation', skillName: 'deploy', trigger: 'user-slash', activationId: 'a2' }, + }), + sink, + ); + const steered = ofType(sink, 'user').at(-1)!; + expect(steered).toMatchObject({ + message_id: 't1.1.u1', + origin: { kind: 'skill', skill_name: 'deploy', trigger: 'user-slash' }, + }); + feed( + projector, + ev({ + type: 'turn.steer', + turnId: 1, + input: [{ type: 'text', text: 'model triggered' }], + origin: { kind: 'skill_activation', skillName: 'internal', trigger: 'model-tool', activationId: 'a3' }, + }), + sink, + ); + expect(ofType(sink, 'user')).toHaveLength(2); + }); + + it('covers the three subagent wait modes around the parent tool call', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed(projector, ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }), sink); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), sink); + feed( + projector, + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_a', name: 'Agent', args: '{}' }), + sink, + ); + feed( + projector, + ev({ type: 'subagent.spawned', subagentId: 'sub-a', parentToolCallId: 'call_a', runInBackground: false }), + sink, + ); + const toolA = ofType(sink, 'tool_call').at(-1)!; + expect(toolA.agent_refs).toEqual([{ agent_id: 'sub-a', role: 'child' }]); + expect(ofType(sink, 'task')).toHaveLength(0); + feed( + projector, + ev({ type: 'subagent.completed', subagentId: 'sub-a', resultSummary: 'done' }), + sink, + ); + expect(ofType(sink, 'task')).toHaveLength(0); + + feed( + projector, + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_b', name: 'Agent', args: '{}' }), + sink, + ); + feed( + projector, + ev({ + type: 'subagent.spawned', + subagentId: 'sub-b', + parentToolCallId: 'call_b', + runInBackground: true, + taskId: 'task-b', + description: 'watch logs', + }), + sink, + ); + const taskB = ofType(sink, 'task').at(-1)!; + expect(taskB).toMatchObject({ + task_id: 'task-b', + kind: 'subagent', + state: 'running', + detached: true, + child_agent_id: 'sub-b', + description: 'watch logs', + }); + const toolB = ofType(sink, 'tool_call').at(-1)!; + expect(toolB.task_id).toBe('task-b'); + feed( + projector, + ev({ type: 'subagent.completed', subagentId: 'sub-b', resultSummary: 'tail', usage: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 } }), + sink, + ); + const taskBDone = ofType(sink, 'task').at(-1)!; + expect(taskBDone).toMatchObject({ state: 'completed', result_summary: 'tail' }); + + feed( + projector, + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_c', name: 'Agent', args: '{}' }), + sink, + ); + feed( + projector, + ev({ type: 'subagent.spawned', subagentId: 'sub-c', parentToolCallId: 'call_c', runInBackground: false }), + sink, + ); + feed( + projector, + ev({ + type: 'task.started', + info: { + taskId: 'task-c', + kind: 'agent', + agentId: 'sub-c', + parentToolCallId: 'call_c', + status: 'running', + description: 'detached mid-flight', + detached: true, + startedAt: T0 + 5000, + endedAt: null, + }, + }), + sink, + ); + const taskC = ofType(sink, 'task').at(-1)!; + expect(taskC).toMatchObject({ + task_id: 'task-c', + kind: 'subagent', + detached: true, + child_agent_id: 'sub-c', + started_at: new Date(T0).toISOString(), + }); + const toolC = ofType(sink, 'tool_call').at(-1)!; + expect(toolC.task_id).toBe('task-c'); + }); + + it('drives todo entities from the todo emitter and links TodoList tool calls', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed(projector, ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }), sink); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), sink); + feed( + projector, + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 'call_1', + name: 'TodoList', + args: '{"todos":[{"title":"write tests","status":"in_progress"}]}', + }), + sink, + ); + const tool = ofType(sink, 'tool_call').at(-1)!; + expect(tool.todo_id).toBe('todo'); + sink.push(...projector.todoChanged([{ title: 'write tests', status: 'in_progress' }])); + const todo = ofType(sink, 'todo').at(-1)!; + expect(todo).toMatchObject({ + todo_id: 'todo', + items: [{ title: 'write tests', status: 'in_progress' }], + }); + expect(typeof todo.updated_at).toBe('string'); + }); + + it('truncates the timeline on context.undone with removed top-level ids', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), + ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' }, prompt: 'two' }), + ev({ type: 'turn.ended', turnId: 2, reason: 'completed' }), + ev({ type: 'skill.activated', activationId: 'sk-1', skillName: 'review', trigger: 'user-slash' }), + ev({ type: 'context.undone', turns: 1, fromTurnId: 2 }), + ]); + const undo = ofType(messages, 'system').find((m) => m.subtype === 'undo'); + expect(undo).toBeDefined(); + const payload = undo!.payload as { removed_ids: string[] }; + expect(payload.removed_ids[0]).toBe('t2'); + expect(payload.removed_ids.some((id) => id.startsWith('sys_'))).toBe(true); + expect(payload.removed_ids).not.toContain('t1'); + + const fold = { + steps: new Map([[1, { state: 'completed' as const }]]), + texts: new Map([[1, { assistant: 'answer', thinking: '', first: 'assistant' as const }]]), + tools: new Map(), + }; + expect(projector.healTurn(2, fold)).toEqual([]); + expect(projector.healTurn(1, fold).length).toBeGreaterThan(0); + }); + + it('settles a full-cut splice as system(clear) unless a context.undone follows', () => { + const projector = makeProjector(); + const before = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), + ]); + expect(ofType(before, 'system')).toHaveLength(0); + const after = feedAll(projector, [ + ev({ type: 'context.spliced', start: 0, deleteCount: 4, messages: [] }), + ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' }, prompt: 'fresh' }), + ]); + const clear = ofType(after, 'system').find((m) => m.subtype === 'clear'); + expect(clear).toMatchObject({ subtype: 'clear', payload: { removed_ids: ['t1'] } }); + + const projector2 = makeProjector(); + feedAll(projector2, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), + ]); + const undoOnly = feedAll(projector2, [ + ev({ type: 'context.spliced', start: 0, deleteCount: 4, messages: [] }), + ev({ type: 'context.undone', turns: 1, fromTurnId: 1 }), + ]); + expect(ofType(undoOnly, 'system').some((m) => m.subtype === 'clear')).toBe(false); + expect(ofType(undoOnly, 'system').some((m) => m.subtype === 'undo')).toBe(true); + }); + + it('replays in-flight entities plus state entities as recovery payload', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed(projector, ev({ type: 'turn.started', turnId: 1, promptId: 'p1', origin: { kind: 'user' }, prompt: 'go' }), sink); + feed(projector, ev({ type: 'turn.step.started', turnId: 1, step: 1 }), sink); + feed(projector, ev({ type: 'assistant.delta', turnId: 1, delta: 'Hello' }), sink); + feed( + projector, + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }), + sink, + ); + sink.push( + ...projector.interactionRequested({ + id: 'apr-1', + kind: 'approval', + payload: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run ls' }, + origin: {}, + createdAt: T0, + }), + ); + sink.push( + ...projector.seedTask({ + taskId: 'task-1', + kind: 'process', + status: 'running', + description: 'dev server', + detached: true, + startedAt: T0, + endedAt: null, + command: 'pnpm dev', + pid: 1, + exitCode: null, + } as never), + ); + sink.push( + ...projector.seedTask({ + taskId: 'task-2', + kind: 'process', + status: 'running', + description: 'watcher', + startedAt: T0, + endedAt: null, + } as never), + ); + sink.push(...projector.seedTodo([{ title: 'write tests', status: 'pending' }])); + feed(projector, ev({ type: 'tool.result', turnId: 1, toolCallId: 'call_1', output: 'file.txt' }), sink); + feed( + projector, + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_2', name: 'Bash', args: '{"command":"pwd"}' }), + sink, + ); + + const recovery = projector.recoveryMessages().map((m) => serverMessageSchema.parse(m)); + const turn = ofType(recovery, 'turn')[0]!; + expect(turn).toMatchObject({ turn_id: 't1', state: 'running', user_message_id: 't1.u0' }); + const step = ofType(recovery, 'step')[0]!; + expect(step).toMatchObject({ step_id: 't1.1', state: 'running' }); + const assistant = ofType(recovery, 'assistant')[0]!; + expect(assistant).toMatchObject({ message_id: 't1.1.a1', status: 'streaming', text: 'Hello' }); + const tools = ofType(recovery, 'tool_call'); + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ tool_call_id: 'call_1', state: 'done', output: 'file.txt' }); + expect(tools[1]).toMatchObject({ tool_call_id: 'call_2', state: 'running' }); + const interaction = ofType(recovery, 'interaction')[0]!; + expect(interaction).toMatchObject({ interaction_id: 'apr-1', state: 'pending' }); + const tasks = ofType(recovery, 'task'); + expect(tasks).toHaveLength(2); + expect(tasks[0]).toMatchObject({ task_id: 'task-1', kind: 'shell', state: 'running', detached: true }); + expect(tasks[1]).toMatchObject({ task_id: 'task-2', kind: 'shell', state: 'running', detached: false }); + const todo = ofType(recovery, 'todo')[0]!; + expect(todo.items).toEqual([{ title: 'write tests', status: 'pending' }]); + expect(ofType(recovery, 'user')).toHaveLength(0); + }); + + it('emits a reserved user message for queued prompts and converges on dequeue or abort', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed(projector, ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'first' }), sink); + feed( + projector, + ev({ + type: 'prompt.submitted', + promptId: 'q1', + userMessageId: 'q1', + status: 'queued', + content: [{ type: 'text', text: 'second' }], + createdAt: new Date(T0).toISOString(), + }), + sink, + ); + feed( + projector, + ev({ type: 'prompt.queued', promptId: 'q1', content: [{ type: 'text', text: 'second' }], queueLength: 1 }), + sink, + ); + const queued = ofType(sink, 'user').at(-1)!; + expect(queued).toMatchObject({ message_id: 't1.u0', turn_id: 't1', text: 'second', status: 'running' }); + expect(queued.step_id).toBeUndefined(); + expect([...new Set(ofType(sink, 'turn').map((t) => t.turn_id))]).toEqual(['t0']); + + feed( + projector, + ev({ type: 'prompt.queued', promptId: 'q2', content: [{ type: 'text', text: 'third' }], queueLength: 2 }), + sink, + ); + expect(ofType(sink, 'user').at(-1)).toMatchObject({ message_id: 't2.u0', status: 'running' }); + + feed(projector, ev({ type: 'prompt.aborted', promptId: 'q2', abortedAt: new Date(T0 + 1).toISOString() }), sink); + expect(ofType(sink, 'user').at(-1)).toMatchObject({ message_id: 't2.u0', status: 'completed' }); + + feed(projector, ev({ type: 'turn.ended', turnId: 0, reason: 'completed' }), sink); + feed( + projector, + ev({ type: 'turn.started', turnId: 1, promptId: 'q1', origin: { kind: 'user' }, prompt: 'second' }), + sink, + ); + const dequeued = ofType(sink, 'user').at(-1)!; + expect(dequeued).toMatchObject({ message_id: 't1.u0', turn_id: 't1', text: 'second', status: 'running' }); + expect([...new Set(ofType(sink, 'turn').map((t) => t.turn_id))]).toEqual(['t0', 't1']); + }); + + it('does not masquerade goal continuation turns as user messages', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ + type: 'turn.started', + turnId: 1, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + prompt: 'continue the goal', + }), + ]); + const turn = ofType(messages, 'turn')[0]!; + expect(turn).toMatchObject({ turn_id: 't1', origin: { kind: 'goal' } }); + expect(ofType(messages, 'user')).toHaveLength(0); + }); + + it('dedupes the turn-opening steer that repeats the prompt input', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'hello' }), + ev({ type: 'turn.steer', turnId: 1, input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' } }), + ev({ type: 'turn.step.started', turnId: 1, step: 1 }), + ]); + const users = ofType(messages, 'user'); + expect(users).toHaveLength(1); + expect(users[0]).toMatchObject({ message_id: 't1.u0', text: 'hello' }); + }); + + it('settles a full-cut splice as system(clear) after a bounded wait when no undo follows', () => { + vi.useFakeTimers(); + try { + const deferred: ServerMessage[] = []; + const projector = new AgentMessageProjector('main', SESSION, new Map(), undefined, { + onDeferred: (messages) => deferred.push(...messages), + }); + const before = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), + ev({ type: 'context.spliced', start: 0, deleteCount: 4, messages: [] }), + ]); + expect(ofType(before, 'system')).toHaveLength(0); + expect(deferred).toHaveLength(0); + vi.advanceTimersByTime(150); + const clear = ofType(deferred, 'system').find((m) => m.subtype === 'clear'); + expect(serverMessageSchema.parse(clear)).toMatchObject({ + subtype: 'clear', + payload: { removed_ids: ['t1'] }, + }); + projector.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('counts undo anchors instead of timeline turns when fromTurnId is missing', () => { + const projector = makeProjector(); + const messages = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), + ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' }, prompt: 'two' }), + ev({ type: 'turn.ended', turnId: 2, reason: 'completed' }), + ev({ + type: 'turn.started', + turnId: 3, + origin: { kind: 'cron_job', jobId: 'j1', cron: '* * * * *' }, + prompt: 'cron', + }), + ev({ type: 'turn.ended', turnId: 3, reason: 'completed' }), + ev({ type: 'context.undone', turns: 1 }), + ]); + const undo = ofType(messages, 'system').find((m) => m.subtype === 'undo')!; + expect(undo.payload).toEqual({ removed_ids: ['t2', 't3'] }); + }); + + it('extends undo removal to turns and counters seeded from the wire at bind', () => { + const projector = makeProjector(); + projector.applyTimelineSeed({ + timelineIds: ['t0', 't1'], + systemCounts: new Map([['goal', 1]]), + anchorTurnOrdinals: [0, 1], + nextTurnId: 2, + }); + const messages = feedAll(projector, [ + ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' }, prompt: 'live' }), + ev({ type: 'turn.ended', turnId: 2, reason: 'completed' }), + ev({ + type: 'goal.updated', + snapshot: { objective: 'g', status: 'active', tokensUsed: 0, budget: { tokenBudget: null } }, + }), + ev({ type: 'context.undone', turns: 1 }), + ]); + const undo = ofType(messages, 'system').find((m) => m.subtype === 'undo')!; + expect(undo.payload).toEqual({ removed_ids: ['t2', 'sys_goal_2'] }); + }); +}); + +describe('foldWireTurn + healTurn', () => { + const stepBegin = { type: 'step.begin', uuid: 'u1', turnId: '3', step: 1 }; + const records: ContextRecord[] = [ + { type: 'context.append_loop_event', event: stepBegin, time: T0 }, + { type: 'context.append_loop_event', event: { type: 'content.part', stepUuid: 'u1', part: { type: 'think', think: 'hmm' } }, time: T0 + 1 }, + { type: 'context.append_loop_event', event: { type: 'content.part', stepUuid: 'u1', part: { type: 'text', text: 'Hello world' } }, time: T0 + 2 }, + { type: 'context.append_loop_event', event: { type: 'tool.call', stepUuid: 'u1', toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }, time: T0 + 3 }, + { type: 'context.append_loop_event', event: { type: 'tool.result', toolCallId: 'call_1', result: { output: 'interrupted before result', isError: true } }, time: T0 + 4 }, + { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 'u1', finishReason: 'tool_calls', usage: { inputOther: 3, output: 1, inputCacheRead: 0, inputCacheCreation: 0 } }, time: T0 + 5 }, + ]; + + it('folds loop-event records into per-turn step, text and tool facts', () => { + const fold = foldWireTurn(records, 3); + expect(fold.steps.get(1)).toMatchObject({ state: 'completed', finishReason: 'tool_calls' }); + expect(fold.texts.get(1)).toEqual({ assistant: 'Hello world', thinking: 'hmm', first: 'thinking' }); + expect(fold.tools.get('call_1')).toMatchObject({ + step: 1, + name: 'Bash', + output: 'interrupted before result', + isError: true, + }); + expect(foldWireTurn(records, 4).steps.size).toBe(0); + }); + + it('overrides only divergent domains: missing tool outcome and truncated live text', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed(projector, ev({ type: 'turn.started', turnId: 3, origin: { kind: 'user' }, prompt: 'go' }), sink); + feed(projector, ev({ type: 'turn.step.started', turnId: 3, step: 1 }), sink); + feed(projector, ev({ type: 'assistant.delta', turnId: 3, delta: 'Hello' }), sink); + feed( + projector, + ev({ type: 'tool.call.started', turnId: 3, toolCallId: 'call_1', name: 'Bash', args: '{"command":"ls"}' }), + sink, + ); + feed(projector, ev({ type: 'turn.ended', turnId: 3, reason: 'cancelled', interruptReason: 'aborted' }), sink); + + const healed = projector.healTurn(3, foldWireTurn(records, 3)).map((m) => serverMessageSchema.parse(m)); + const tool = ofType(healed, 'tool_call').at(-1)!; + expect(tool).toMatchObject({ + tool_call_id: 'call_1', + state: 'error', + output: 'interrupted before result', + error: 'interrupted before result', + input: { command: 'ls' }, + }); + const assistant = ofType(healed, 'assistant').at(-1)!; + expect(assistant).toMatchObject({ message_id: 't3.1.a1', status: 'completed', text: 'Hello world' }); + expect(ofType(healed, 'step')).toHaveLength(0); + }); + + it('rebuilds steps the live projection never saw', () => { + const projector = makeProjector(); + const sink: ServerMessage[] = []; + feed(projector, ev({ type: 'turn.started', turnId: 3, origin: { kind: 'user' }, prompt: 'go' }), sink); + feed(projector, ev({ type: 'turn.ended', turnId: 3, reason: 'completed' }), sink); + const healed = projector.healTurn(3, foldWireTurn(records, 3)).map((m) => serverMessageSchema.parse(m)); + const step = ofType(healed, 'step')[0]!; + expect(step).toMatchObject({ step_id: 't3.1', state: 'completed', finish_reason: 'tool_calls' }); + const assistant = ofType(healed, 'assistant')[0]!; + expect(assistant).toMatchObject({ step_id: 't3.1', status: 'completed', text: 'Hello world' }); + const tool = ofType(healed, 'tool_call')[0]!; + expect(tool).toMatchObject({ tool_call_id: 'call_1', step_id: 't3.1', state: 'error' }); + }); +}); + +describe('SessionStateAggregator', () => { + it('aggregates session.state slices and dedupes identical emissions', () => { + const agg = new SessionStateAggregator(); + agg.feedSessionActivity({ busy: true, mainTurnActive: true, pendingInteraction: 'approval' }); + agg.feedSeed({ model: 'kimi-k2', contextTokens: 500, maxContextTokens: 1000, permission: 'yolo' }); + agg.feedMainStatus({ thinkingEffort: 'on', usage: { currentTurn: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 } } }); + agg.feedMainActivity({ + lifecycle: 'ready', + turn: { + turnId: 2, + origin: { kind: 'user' }, + phase: 'running', + step: 1, + ending: false, + pendingApprovals: [], + activeToolCalls: [], + since: T0, + }, + background: [], + }); + const first = agg.changed(SESSION)!; + expect(first).toMatchObject({ + type: 'session.state', + busy: true, + main_turn_active: true, + pending_interaction: 'approval', + activity: 'turn', + model: 'kimi-k2', + thinking_effort: 'on', + permission: 'yolo', + context_tokens: 500, + max_context_tokens: 1000, + context_usage: 0.5, + phase: { kind: 'running', turn_id: 2, step: 1, step_id: 't2.1', since: T0 }, + }); + serverMessageSchema.parse(first); + expect(agg.changed(SESSION)).toBeUndefined(); + agg.feedMainStatus({ model: 'kimi-k2-turbo' }); + const second = agg.changed(SESSION)!; + expect(second.model).toBe('kimi-k2-turbo'); + expect(agg.snapshot(SESSION).busy).toBe(true); + + agg.feedSessionActivity({ busy: false, mainTurnActive: false, pendingInteraction: 'none', lastTurnReason: 'failed' }); + expect(agg.changed(SESSION)!.last_turn_reason).toBe('failed'); + agg.feedMainActivity({ + lifecycle: 'ready', + lastTurn: { turnId: 3, reason: 'blocked', at: T0 }, + background: [], + }); + const blocked = agg.changed(SESSION)!; + expect(blocked.last_turn_reason).toBe('blocked'); + expect(serverMessageSchema.parse(blocked).type).toBe('session.state'); + agg.feedMainActivity({ lifecycle: 'ready', background: [] }); + expect(agg.changed(SESSION)!.last_turn_reason).toBe('failed'); + }); +}); + +describe('SessionProjection', () => { + class FakeBus { + private readonly handlers = new Set<(event: Event2) => void>(); + subscribe(cb: (event: Event2) => void): { dispose: () => void } { + this.handlers.add(cb); + return { dispose: () => this.handlers.delete(cb) }; + } + emit(event: Event2): void { + for (const cb of this.handlers) cb(event); + } + } + + interface FakeAgent { + readonly id: string; + readonly bus: FakeBus; + readonly todoEmitter: Emitter; + readonly interactionEmitter: Emitter<{ pending: readonly string[] }>; + readonly resolveEmitter: Emitter<{ id: string; response: unknown }>; + pendings: Interaction[]; + planActive: boolean; + swarmTrigger: string | null; + readonly accessor: { get: (token: unknown) => unknown }; + } + + function makeAgent(id: string): FakeAgent { + const bus = new FakeBus(); + const scope = makeAgentScopeContext({ agentId: id, agentScope: `agents/${id}`, generation: 1 }); + const todoEmitter = new Emitter(); + const interactionEmitter = new Emitter<{ pending: readonly string[] }>(); + const resolveEmitter = new Emitter<{ id: string; response: unknown }>(); + const agent: FakeAgent = { + id, + bus, + todoEmitter, + interactionEmitter, + resolveEmitter, + pendings: [], + planActive: false, + swarmTrigger: null, + accessor: { + get: (token: unknown) => { + if (token === IEventBus) return bus; + if (token === IAgentScopeContext) { + return { + ...scope, + scope: (subKey?: string) => { + if (subKey === 'boom') throw new Error('scope boom'); + return scope.scope(subKey); + }, + }; + } + if (token === IAgentLoopService) return { status: () => ({ state: 'idle' }) }; + if (token === IAgentPromptService) return { list: () => ({ active: undefined, pending: [] }) }; + if (token === IAgentTaskService) { + return { list: () => [], readOutput: async () => 'task tail window' }; + } + if (token === IAgentTodoService) { + return { get: () => [], onDidChange: todoEmitter.event }; + } + if (token === IAgentStateService) { + return { + has: (key: { name: string }) => key.name === 'plan' || key.name === 'swarm', + get: (key: { name: string }) => + key.name === 'plan' ? { active: agent.planActive } : agent.swarmTrigger, + }; + } + if (token === IAgentInteractionService) { + return { + listPending: () => agent.pendings, + onDidChangePending: interactionEmitter.event, + onDidResolve: resolveEmitter.event, + }; + } + if (token === IAgentActivityView) return { state: () => ({ lifecycle: 'ready', background: [] }) }; + if (token === IAgentPermissionModeService) { + return { mode: 'manual', onDidChangeMode: Event.None }; + } + if (token === IAgentProfileService) { + return { + getModel: () => 'kimi-k2', + getEffectiveThinkingLevel: () => 'on', + getModelCapabilities: () => ({ max_input_tokens: 100_000 }), + }; + } + if (token === ISessionUsageService) return { status: () => ({}) }; + if (token === ISessionTokenCountingService) return { statusSize: () => 500 }; + if (token === IAgentGoalService) return { getGoal: () => ({ goal: null }) }; + return undefined; + }, + }, + }; + return agent; + } + + function makeSession(agent: FakeAgent): { + session: ISessionScopeHandle; + core: Scope; + activityEmitter: Emitter<{ state: { busy: boolean; mainTurnActive: boolean; pendingInteraction: 'none' | 'approval' | 'question' }; cause: string }>; + } { + const manager = { + list: () => [agent.accessor.get(IAgentScopeContext) as { agentContext: AgentContext }], + get: (agentId: string) => + agentId === agent.id + ? (agent.accessor.get(IAgentScopeContext) as { agentContext: AgentContext }).agentContext + : undefined, + handleOf: (agentId: string) => + agentId === agent.id ? { id: agent.id, accessor: agent.accessor } : undefined, + onDidCreate: Event.None, + onDidClose: Event.None, + }; + const agents = { + ...manager, + list: () => [(agent.accessor.get(IAgentScopeContext) as { agentContext: AgentContext }).agentContext], + }; + const activityEmitter = new Emitter<{ + state: { busy: boolean; mainTurnActive: boolean; pendingInteraction: 'none' | 'approval' | 'question' }; + cause: string; + }>(); + const session = { + accessor: { + get: (token: unknown) => { + if (token === IAgentLifecycleService) return agents; + if (token === ISessionActivityView) { + return { + state: () => ({ busy: false, mainTurnActive: false, pendingInteraction: 'none' }), + onDidChange: activityEmitter.event, + }; + } + return undefined; + }, + }, + } as unknown as ISessionScopeHandle; + const core = { + accessor: { + get: (token: unknown) => { + if (token === IAgentLifecycleService) { + throw new Error('strict DI: IAgentLifecycleService is not registered at app scope'); + } + return undefined; + }, + }, + } as unknown as Scope; + return { session, core, activityEmitter }; + } + + function makeProjection(agent: FakeAgent): { + projection: SessionProjection; + received: ServerMessage[]; + logger: { warn: ReturnType }; + activityEmitter: Emitter<{ state: { busy: boolean; mainTurnActive: boolean; pendingInteraction: 'none' | 'approval' | 'question' }; cause: string }>; + } { + const { session, core, activityEmitter } = makeSession(agent); + const received: ServerMessage[] = []; + const logger = { warn: vi.fn() }; + const projection = new SessionProjection(SESSION, session, { + homeDir: '/nonexistent', + core, + logger, + }); + projection.onMessage((message) => received.push(message)); + return { projection, received, logger, activityEmitter }; + } + + it('streams validated timeline messages and session.state through one sequence', async () => { + const agent = makeAgent('main'); + const { projection, received, logger } = makeProjection(agent); + agent.bus.emit(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }) as Event2); + agent.bus.emit(ev({ type: 'turn.step.started', turnId: 1, step: 1 }) as Event2); + agent.bus.emit(ev({ type: 'assistant.delta', turnId: 1, delta: 'Hi' }) as Event2); + agent.bus.emit(ev({ type: 'agent.status.updated', agentId: 'main', model: 'kimi-k2' }) as Event2); + agent.bus.emit(ev({ type: 'agent.status.updated', agentId: 'main', planMode: true }) as Event2); + agent.bus.emit( + ev({ type: 'plan.revision', agentId: 'main', id: 'r0', version: 1, key: 'boom', sha256: 'x', bytes: 1 }) as Event2, + ); + agent.bus.emit( + ev({ type: 'plan.revision', agentId: 'main', id: 'r1', version: 2, key: 'plan/x.md', sha256: 'abc', bytes: 10 }) as Event2, + ); + agent.bus.emit(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }) as Event2); + agent.bus.emit( + ev({ + type: 'task.started', + info: { taskId: 'task-1', kind: 'process', status: 'running', description: 'dev', startedAt: T0, endedAt: null }, + }) as Event2, + ); + agent.bus.emit( + ev({ + type: 'task.terminated', + info: { taskId: 'task-1', kind: 'process', status: 'completed', startedAt: T0, endedAt: T0 + 1 }, + }) as Event2, + ); + + expect(ofType(received, 'turn')[0]).toMatchObject({ turn_id: 't1', state: 'running' }); + expect(ofType(received, 'system').some((m) => m.subtype === 'plan.enter')).toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(1); + const revision = ofType(received, 'system').find((m) => m.subtype === 'plan.revision'); + expect(revision).toMatchObject({ + subtype: 'plan.revision', + payload: { id: 'r1', version: 2, path: 'agents/main/plan/x.md' }, + }); + const states = ofType(received, 'session.state'); + expect(states.length).toBeGreaterThan(0); + expect(states.at(-1)).toMatchObject({ + model: 'kimi-k2', + context_tokens: 500, + modes: { plan: { review_path: 'agents/main/plan/x.md', version: 2 } }, + }); + await vi.waitFor(() => { + expect(ofType(received, 'task').at(-1)).toMatchObject({ + task_id: 'task-1', + state: 'completed', + output_tail: 'task tail window', + }); + }); + const recovery = projection.recoveryMessages(); + expect(recovery[0]!.type).toBe('session.state'); + projection.dispose(); + }); + + it('emits the interaction lifecycle and drops outbound messages that fail schema validation', () => { + const agent = makeAgent('main'); + const { projection, received, logger } = makeProjection(agent); + agent.pendings = [ + { + id: 'q-1', + kind: 'question', + payload: { + questions: [ + { + question: 'pick many', + options: [{ label: 42 }], + }, + ], + }, + origin: { agentId: 'main' }, + createdAt: T0, + }, + ]; + agent.interactionEmitter.fire({ pending: ['q-1'] }); + expect(ofType(received, 'interaction')).toHaveLength(0); + expect(logger.warn).toHaveBeenCalled(); + + agent.bus.emit(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }) as Event2); + agent.pendings = [ + { + id: 'apr-1', + kind: 'approval', + payload: { toolCallId: 'call_1', toolName: 'Bash', action: 'Run ls' }, + origin: { agentId: 'main', turnId: 1 }, + createdAt: T0, + }, + ]; + agent.interactionEmitter.fire({ pending: ['apr-1'] }); + const pending = ofType(received, 'interaction').at(-1)!; + expect(pending).toMatchObject({ interaction_id: 'apr-1', state: 'pending', kind: 'approval' }); + + agent.pendings = []; + agent.resolveEmitter.fire({ id: 'apr-1', response: { decision: 'rejected', feedback: 'no' } }); + const resolved = ofType(received, 'interaction').at(-1)!; + expect(resolved).toMatchObject({ + state: 'rejected', + response: { decision: 'rejected', feedback: 'no' }, + }); + projection.dispose(); + }); + + it('seeds plan and swarm modes from agent state at bind without re-emitting enter', () => { + const agent = makeAgent('main'); + agent.planActive = true; + agent.swarmTrigger = 'tool'; + const { projection, received } = makeProjection(agent); + const recovery = projection.recoveryMessages(); + const state = ofType(recovery, 'session.state')[0]!; + expect(state.modes).toEqual({ plan: {}, swarm: {} }); + expect( + ofType(received, 'system').filter( + (m) => m.subtype === 'plan.enter' || m.subtype === 'swarm.enter', + ), + ).toHaveLength(0); + + agent.bus.emit(ev({ type: 'agent.status.updated', agentId: 'main', planMode: false, swarmMode: false }) as Event2); + const subtypes = ofType(received, 'system').map((m) => m.subtype); + expect(subtypes).not.toContain('plan.exit'); + expect(subtypes).toContain('swarm.exit'); + + agent.bus.emit(ev({ type: 'agent.status.updated', agentId: 'main', planMode: true }) as Event2); + agent.bus.emit(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'go' }) as Event2); + agent.bus.emit(ev({ type: 'turn.step.started', turnId: 1, step: 1 }) as Event2); + agent.bus.emit( + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_plan', name: 'ExitPlanMode', args: '{}' }) as Event2, + ); + agent.pendings = [ + { + id: 'apr-plan', + kind: 'approval', + payload: { toolCallId: 'call_plan', toolName: 'ExitPlanMode', action: 'review plan' }, + origin: { agentId: 'main', turnId: 1 }, + createdAt: T0, + }, + ]; + agent.interactionEmitter.fire({ pending: ['apr-plan'] }); + agent.pendings = []; + agent.resolveEmitter.fire({ id: 'apr-plan', response: { decision: 'rejected', feedback: 'revise' } }); + agent.bus.emit(ev({ type: 'agent.status.updated', agentId: 'main', planMode: false }) as Event2); + expect(ofType(received, 'system').map((m) => m.subtype)).not.toContain('plan.exit'); + + agent.bus.emit(ev({ type: 'agent.status.updated', agentId: 'main', planMode: true }) as Event2); + agent.pendings = [ + { + id: 'apr-plan-2', + kind: 'approval', + payload: { toolCallId: 'call_plan', toolName: 'ExitPlanMode', action: 'review plan' }, + origin: { agentId: 'main', turnId: 1 }, + createdAt: T0, + }, + ]; + agent.interactionEmitter.fire({ pending: ['apr-plan-2'] }); + agent.pendings = []; + agent.resolveEmitter.fire({ id: 'apr-plan-2', response: { decision: 'approved' } }); + agent.bus.emit(ev({ type: 'agent.status.updated', agentId: 'main', planMode: false }) as Event2); + expect(ofType(received, 'system').map((m) => m.subtype)).toContain('plan.exit'); + projection.dispose(); + }); +}); diff --git a/packages/kap-server/test/wsV3.test.ts b/packages/kap-server/test/wsV3.test.ts new file mode 100644 index 00000000000..9fba541589a --- /dev/null +++ b/packages/kap-server/test/wsV3.test.ts @@ -0,0 +1,855 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { IDisposable, Workspace } from '@moonshot-ai/agent-core-v2'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { ErrorCode } from '../src/protocol/error-codes'; +import type { ServerMessage, WorkspaceInfo } from '../src/protocol/messages'; +import { startServer, type RunningServer } from '../src/start'; +import { + WsConnectionV3, + type WsConnectionV3Options, +} from '../src/transport/ws/v3/wsConnectionV3'; +import type { WsV3CoreEvent, WsV3Logger } from '../src/transport/ws/v3/wsV3Deps'; +import { WsV3Hub } from '../src/transport/ws/v3/wsV3Hub'; +import { authHeaders } from './helpers/auth'; +import { fixedTokenAuth } from './helpers/fixedAuth'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +const TS = '2026-01-01T00:00:00.000Z'; +const WS_ID = 'wd_test_0123456789ab'; + +class FakeSocket { + readonly OPEN = 1; + readonly CLOSED = 3; + readyState = 1; + bufferedAmount = 0; + sent: string[] = []; + pingCalls = 0; + terminateCalls = 0; + closeCalls: Array<{ code?: number; reason?: string }> = []; + private readonly handlers = new Map void>>(); + + on(event: string, cb: (...a: unknown[]) => void): this { + const list = this.handlers.get(event) ?? []; + list.push(cb); + this.handlers.set(event, list); + return this; + } + + send(data: string): void { + this.sent.push(data); + } + + ping(): void { + this.pingCalls += 1; + } + + close(code?: number, reason?: string): void { + this.closeCalls.push({ code, reason }); + this.readyState = this.CLOSED; + this.emit('close'); + } + + terminate(): void { + this.terminateCalls += 1; + this.readyState = this.CLOSED; + this.emit('close'); + } + + emit(event: string, ...a: unknown[]): void { + for (const cb of this.handlers.get(event) ?? []) cb(...a); + } + + frames(): Array> { + return this.sent.map((s) => JSON.parse(s) as Record); + } +} + +class FakeProjection { + readonly live = new Set(); + readonly recovery = new Map(); + readonly listeners = new Map void>>(); + + onMessage( + sessionId: string, + listener: (message: ServerMessage) => void, + ): IDisposable | undefined { + if (!this.live.has(sessionId)) return undefined; + let set = this.listeners.get(sessionId); + if (set === undefined) { + set = new Set(); + this.listeners.set(sessionId, set); + } + set.add(listener); + return { + dispose: () => { + set.delete(listener); + }, + }; + } + + recoveryMessages(sessionId: string): ServerMessage[] { + return this.recovery.get(sessionId) ?? []; + } + + emit(sessionId: string, message: ServerMessage): void { + for (const listener of [...(this.listeners.get(sessionId) ?? [])]) listener(message); + } +} + +class FakeLifecycle { + readonly existing = new Set(); + private readonly cbs = new Set<(event: { sessionId: string }) => void>(); + + onDidCreateSession(cb: (event: { sessionId: string }) => void): IDisposable { + this.cbs.add(cb); + return { + dispose: () => { + this.cbs.delete(cb); + }, + }; + } + + async sessionExists(sessionId: string): Promise { + return this.existing.has(sessionId); + } + + fireCreated(sessionId: string): void { + for (const cb of [...this.cbs]) cb({ sessionId }); + } +} + +class FakeGlobalSource { + workspaces: Workspace[] = []; + sessionInfoResult: unknown; + private readonly cbs = new Set<(event: WsV3CoreEvent) => void>(); + + subscribe(cb: (event: WsV3CoreEvent) => void): IDisposable { + this.cbs.add(cb); + return { + dispose: () => { + this.cbs.delete(cb); + }, + }; + } + + fire(event: WsV3CoreEvent): void { + for (const cb of [...this.cbs]) cb(event); + } + + async listWorkspaces(): Promise { + return this.workspaces; + } + + async workspaceInfo(workspace: Workspace): Promise { + return { + id: workspace.id, + root: workspace.root, + name: workspace.name, + created_at: new Date(workspace.createdAt).toISOString(), + last_opened_at: new Date(workspace.lastOpenedAt).toISOString(), + session_count: 0, + }; + } + + async sessionInfo(): Promise { + return this.sessionInfoResult; + } +} + +interface Harness { + projection: FakeProjection; + lifecycle: FakeLifecycle; + globalSource: FakeGlobalSource; + hub: WsV3Hub; + logger: WsV3Logger; + warnings: string[]; +} + +function makeHarness(): Harness { + const projection = new FakeProjection(); + const lifecycle = new FakeLifecycle(); + const globalSource = new FakeGlobalSource(); + const warnings: string[] = []; + const logger: WsV3Logger = { + warn: (_obj, msg) => { + warnings.push(msg); + }, + }; + const hub = new WsV3Hub({ projection, lifecycle, globalSource, logger }); + return { projection, lifecycle, globalSource, hub, logger, warnings }; +} + +function makeConn( + hub: WsV3Hub, + socket: FakeSocket, + opts: Partial = {}, +): WsConnectionV3 { + return new WsConnectionV3({ + socket: socket as unknown as WebSocket, + hub, + remoteAddress: null, + userAgent: null, + serverId: 'srv_test', + ...opts, + }); +} + +async function settle(rounds = 5): Promise { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function sessionStateMessage(sessionId: string): ServerMessage { + return { + type: 'session.state', + session_id: sessionId, + timestamp: TS, + busy: false, + main_turn_active: false, + activity: 'idle', + }; +} + +function assistantMessage(sessionId: string, agentId: string, text = 'hello'): ServerMessage { + return { + type: 'assistant', + session_id: sessionId, + agent_id: agentId, + timestamp: TS, + message_id: `t1.1.a0.${agentId}.${text}`, + turn_id: 't1', + step_id: 't1.1', + status: 'streaming', + text, + }; +} + +function assistantDeltaMessage(sessionId: string, agentId: string): ServerMessage { + return { + type: 'assistant.delta', + session_id: sessionId, + agent_id: agentId, + timestamp: TS, + message_id: `t1.1.a0.${agentId}.delta`, + text: 'chunk', + }; +} + +function sessionInfoWire(id: string): Record { + return { + id, + workspace_id: WS_ID, + title: 'session title', + created_at: TS, + updated_at: TS, + busy: false, + metadata: { cwd: '/tmp' }, + agent_config: { model: 'model-x' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + context_tokens: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }; +} + +function frameTypes(socket: FakeSocket): unknown[] { + return socket.frames().map((frame) => frame['type']); +} + +describe('WsConnectionV3 handshake and recovery', () => { + it('sends hello immediately with protocol version, server id and capabilities', () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + expect(socket.frames()[0]).toEqual({ + type: 'hello', + protocol_version: '3', + server_id: 'srv_test', + capabilities: ['step_replay_v1'], + }); + }); + + it('acks subscribe and delivers recovery before live messages in one session sequence', async () => { + const { projection, lifecycle, hub, logger, warnings } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1'), assistantMessage('s1', 'main')]); + const socket = new FakeSocket(); + makeConn(hub, socket, { logger }); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 7, session_id: 's1' })); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant']); + expect(socket.frames()[1]).toEqual({ type: 'ack', id: 7, code: ErrorCode.SUCCESS }); + + projection.emit('s1', assistantMessage('s1', 'main', 'live')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant', 'assistant']); + expect(socket.frames()[4]).toMatchObject({ text: 'live' }); + + projection.emit('s1', { type: 'assistant', session_id: 's1' } as ServerMessage); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant', 'assistant']); + expect(warnings.some((msg) => msg.includes('failed schema validation'))).toBe(true); + }); + + it('acks SESSION_NOT_FOUND when subscribing to an unknown session', async () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 3, session_id: 'ghost' })); + await settle(); + expect(socket.frames()[1]).toMatchObject({ + type: 'ack', + id: 3, + code: ErrorCode.SESSION_NOT_FOUND, + }); + }); + + it('replies error for unknown frame types and malformed JSON', () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'bogus_frame', id: 1 })); + socket.emit('message', 'not json at all'); + expect(socket.frames()[1]).toMatchObject({ + type: 'error', + code: ErrorCode.VALIDATION_FAILED, + }); + expect(socket.frames()[2]).toMatchObject({ + type: 'error', + code: ErrorCode.REQUEST_MALFORMED, + }); + }); + + it('filters recovery and live messages by agent_ids and omit at the fanout point', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [ + sessionStateMessage('s1'), + assistantMessage('s1', 'main'), + assistantMessage('s1', 'sub'), + ]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit( + 'message', + JSON.stringify({ + type: 'subscribe', + id: 1, + session_id: 's1', + agent_ids: ['main'], + omit: ['assistant.delta'], + }), + ); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant']); + + projection.emit('s1', assistantMessage('s1', 'sub', 'filtered')); + projection.emit('s1', assistantDeltaMessage('s1', 'main')); + projection.emit('s1', assistantMessage('s1', 'main', 'kept')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant', 'assistant']); + expect(socket.frames()[4]).toMatchObject({ text: 'kept' }); + }); + + it('acks unsubscribe and stops further delivery', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + socket.emit('message', JSON.stringify({ type: 'unsubscribe', id: 2, session_id: 's1' })); + await settle(); + expect(socket.frames()[3]).toEqual({ type: 'ack', id: 2, code: ErrorCode.SUCCESS }); + + projection.emit('s1', assistantMessage('s1', 'main', 'late')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'ack']); + }); + + it('implicitly unsubscribes on disconnect and disposes the lane listener', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + expect(projection.listeners.get('s1')?.size).toBe(1); + + socket.close(); + await settle(); + expect(projection.listeners.get('s1')?.size ?? 0).toBe(0); + projection.emit('s1', assistantMessage('s1', 'main', 'late')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state']); + }); + + it('delivers recovery again when a fresh connection resubscribes after disconnect', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + + const first = new FakeSocket(); + makeConn(hub, first); + first.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + expect(frameTypes(first)).toEqual(['hello', 'ack', 'session.state']); + first.close(); + await settle(); + + const second = new FakeSocket(); + makeConn(hub, second); + second.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + expect(frameTypes(second)).toEqual(['hello', 'ack', 'session.state']); + }); + + it('replaces the subscription when the same session is subscribed again', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recovery.set('s1', [sessionStateMessage('s1')]); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's1' })); + await settle(); + socket.emit( + 'message', + JSON.stringify({ type: 'subscribe', id: 2, session_id: 's1', agent_ids: ['sub'] }), + ); + await settle(); + expect(frameTypes(socket)).toEqual([ + 'hello', + 'ack', + 'session.state', + 'ack', + 'session.state', + ]); + expect(socket.frames()[3]).toEqual({ type: 'ack', id: 2, code: ErrorCode.SUCCESS }); + + projection.emit('s1', assistantMessage('s1', 'main', 'filtered')); + projection.emit('s1', assistantMessage('s1', 'sub', 'kept')); + await settle(); + expect(frameTypes(socket)).toEqual([ + 'hello', + 'ack', + 'session.state', + 'ack', + 'session.state', + 'assistant', + ]); + expect(socket.frames()[5]).toMatchObject({ text: 'kept' }); + }); + + it('serves a minimal recovery for non-live sessions and backfills one when the session becomes live', async () => { + const { projection, lifecycle, hub } = makeHarness(); + lifecycle.existing.add('s2'); + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 1, session_id: 's2' })); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack']); + expect(socket.frames()[1]).toMatchObject({ code: ErrorCode.SUCCESS }); + + projection.live.add('s2'); + projection.recovery.set('s2', [sessionStateMessage('s2')]); + lifecycle.fireCreated('s2'); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state']); + + projection.emit('s2', assistantMessage('s2', 'main', 'after')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'session.state', 'assistant']); + }); + + it('acks INTERNAL_ERROR and keeps live traffic flowing when the recovery payload throws', async () => { + const { projection, lifecycle, hub, warnings } = makeHarness(); + lifecycle.existing.add('s1'); + projection.live.add('s1'); + projection.recoveryMessages = () => { + throw new Error('recovery boom'); + }; + const socket = new FakeSocket(); + makeConn(hub, socket); + + socket.emit('message', JSON.stringify({ type: 'subscribe', id: 9, session_id: 's1' })); + await settle(); + expect(socket.frames()[1]).toEqual({ type: 'ack', id: 9, code: ErrorCode.SUCCESS }); + expect(socket.frames()[2]).toMatchObject({ type: 'ack', id: 9, code: ErrorCode.INTERNAL_ERROR }); + expect(warnings.some((msg) => msg.includes('recovery failed'))).toBe(true); + + projection.emit('s1', assistantMessage('s1', 'main', 'after')); + await settle(); + expect(frameTypes(socket)).toEqual(['hello', 'ack', 'ack', 'assistant']); + expect(socket.frames()[3]).toMatchObject({ text: 'after' }); + }); +}); + +describe('WsConnectionV3 backpressure and heartbeat', () => { + it('closes slow consumers with a dedicated error code when the outbound queue overflows', () => { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + socket.bufferedAmount = 1 << 21; + const conn = makeConn(hub, socket, { maxOutboundMessages: 3 }); + + for (let i = 0; i < 4; i++) conn.enqueue(sessionStateMessage('s1')); + + expect(socket.frames()[1]).toEqual({ + type: 'error', + code: ErrorCode.WS_SLOW_CONSUMER, + msg: 'outbound queue overflow: slow consumer', + }); + expect(socket.closeCalls).toEqual([{ code: 1008, reason: 'slow consumer' }]); + }); + + it('overflows a stalled queue that never drains within the stall timeout', () => { + vi.useFakeTimers(); + try { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + socket.bufferedAmount = 1 << 21; + const conn = makeConn(hub, socket, { + maxOutboundMessages: 100, + stallTimeoutMs: 50, + backpressureRetryMs: 5, + heartbeatIntervalMs: 60_000, + }); + conn.enqueue(sessionStateMessage('s1')); + vi.advanceTimersByTime(200); + expect(socket.frames()[1]).toMatchObject({ + type: 'error', + code: ErrorCode.WS_SLOW_CONSUMER, + }); + expect(socket.closeCalls).toEqual([{ code: 1008, reason: 'slow consumer' }]); + } finally { + vi.useRealTimers(); + } + }); + + it('pings on the heartbeat interval and terminates after missed pongs', () => { + vi.useFakeTimers(); + try { + const { hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket, { heartbeatIntervalMs: 100 }); + + vi.advanceTimersByTime(100); + expect(socket.pingCalls).toBe(1); + socket.emit('pong'); + vi.advanceTimersByTime(100); + expect(socket.pingCalls).toBe(2); + expect(socket.terminateCalls).toBe(0); + vi.advanceTimersByTime(100); + expect(socket.terminateCalls).toBe(1); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('WsV3 global message fanout', () => { + it('translates config, capability, plugin and catalog events into global messages', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.fire({ + type: 'event.config.warning', + payload: { warnings: [{ domain: 'model', message: 'bad field' }, { message: 'plain' }] }, + }); + globalSource.fire({ + type: 'event.config.changed', + payload: { changedFields: ['default_model'], config: { default_model: 'm2' } }, + }); + globalSource.fire({ + type: 'event.capability.changed', + payload: { capability_id: 'cap-1', install: { running: true } }, + }); + globalSource.fire({ type: 'event.plugin.changed', payload: {} }); + globalSource.fire({ type: 'event.model_catalog.changed', payload: { changed: [] } }); + await settle(); + + const frames = socket.frames(); + expect(frames[1]).toEqual({ + type: 'config.warning', + timestamp: expect.any(String), + warnings: ['model: bad field', 'plain'], + }); + expect(frames[2]).toEqual({ + type: 'config', + timestamp: expect.any(String), + config: { default_model: 'm2' }, + changed_fields: ['default_model'], + }); + expect(frames[3]).toEqual({ + type: 'capability', + timestamp: expect.any(String), + capability_id: 'cap-1', + }); + expect(frames[4]).toEqual({ type: 'plugin', timestamp: expect.any(String) }); + expect(frames[5]).toEqual({ type: 'model_catalog', timestamp: expect.any(String) }); + }); + + it('translates workspace lifecycle events, using the cached entity for deletions', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + const workspace = { + id: WS_ID, + root: '/tmp/ws-root', + name: 'ws-root', + createdAt: 1_700_000_000_000, + lastOpenedAt: 1_700_000_100_000, + }; + globalSource.fire({ type: 'event.workspace.created', payload: { workspace } }); + globalSource.fire({ + type: 'event.workspace.deleted', + payload: { workspaceId: WS_ID, root: '/tmp/ws-root' }, + }); + globalSource.fire({ + type: 'event.workspace.deleted', + payload: { workspaceId: 'wd_gone_0123456789ab', root: '/tmp/gone-dir' }, + }); + await settle(); + + const frames = socket.frames(); + expect(frames[1]).toMatchObject({ + type: 'workspace', + subtype: 'created', + workspace: { id: WS_ID, name: 'ws-root', session_count: 0 }, + }); + expect(frames[2]).toMatchObject({ + type: 'workspace', + subtype: 'deleted', + workspace: { id: WS_ID, name: 'ws-root' }, + }); + expect(frames[3]).toMatchObject({ + type: 'workspace', + subtype: 'deleted', + workspace: { id: 'wd_gone_0123456789ab', name: 'gone-dir', session_count: 0 }, + }); + }); + + it('translates session lifecycle events into session messages with entities', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.sessionInfoResult = sessionInfoWire('s1'); + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's1', session: sessionInfoWire('s1') }, + }); + globalSource.fire({ + type: 'session.meta.updated', + payload: { sessionId: 's1', patch: { title: 'new title' } }, + }); + globalSource.fire({ + type: 'event.session.archived', + payload: { sessionId: 's1', workspaceId: WS_ID }, + }); + await settle(); + + const frames = socket.frames(); + expect(frames[1]).toMatchObject({ + type: 'session', + subtype: 'created', + session: { id: 's1' }, + }); + expect(frames[2]).toMatchObject({ + type: 'session', + subtype: 'updated', + session: { id: 's1' }, + changed_fields: ['title'], + }); + expect(frames[3]).toMatchObject({ + type: 'session', + subtype: 'archived', + session: { id: 's1' }, + }); + }); + + it('drops global messages that fail outbound schema validation and logs telemetry', async () => { + const { globalSource, hub, warnings } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's1', session: { id: 's1' } }, + }); + await settle(); + expect(frameTypes(socket)).toEqual(['hello']); + expect(warnings.some((msg) => msg.includes('failed schema validation'))).toBe(true); + + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's2', session: { id: 's2' } }, + }); + await settle(); + expect(frameTypes(socket)).toEqual(['hello']); + expect(warnings.filter((msg) => msg.includes('failed schema validation'))).toHaveLength(1); + }); +}); + +function rawToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +describe('WsV3 endpoint over a real server', () => { + let home: string; + let server: RunningServer; + let base: string; + const sockets: WebSocket[] = []; + + beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v3-ws-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + authTokenService: fixedTokenAuth('v3-token'), + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterAll(async () => { + await server.close(); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + afterEach(() => { + for (const ws of sockets.splice(0)) { + try { + ws.close(); + } catch { + } + } + }); + + function v3Url(): string { + return `${base.replace(/^http/, 'ws')}/api/v3/ws`; + } + + function openV3(): Promise<{ ws: WebSocket; frames: Array> }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(v3Url(), { headers: authHeaders(server) }); + const frames: Array> = []; + ws.on('message', (data: RawData) => { + try { + frames.push(JSON.parse(rawToString(data)) as Record); + } catch { + } + }); + ws.once('message', () => resolve({ ws, frames })); + ws.once('error', reject); + }); + } + + function expectRejected(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const done = (err?: Error): void => { + clearTimeout(timer); + ws.removeAllListeners(); + try { + ws.terminate(); + } catch { + } + if (err !== undefined) reject(err); + else resolve(); + }; + const timer = setTimeout(() => done(new Error('connection was not rejected')), 1500); + ws.once('open', () => done(new Error('connection unexpectedly opened'))); + ws.once('error', () => done()); + ws.once('close', () => done()); + }); + } + + it('rejects upgrade without credentials', async () => { + await expectRejected(v3Url()); + }); + + it('serves hello, global session messages, ack and recovery over the real stack', async () => { + const { ws, frames } = await openV3(); + sockets.push(ws); + expect(frames[0]).toMatchObject({ + type: 'hello', + protocol_version: '3', + capabilities: expect.arrayContaining(['step_replay_v1']), + }); + expect(typeof frames[0]?.['server_id']).toBe('string'); + + const created = await fetch(`${base}/api/v1/sessions`, { + method: 'POST', + headers: authHeaders(server, { 'content-type': 'application/json' }), + body: JSON.stringify({ metadata: { cwd: home } }), + } as never); + const body = (await created.json()) as { data: { id: string } }; + const sessionId = body.data.id; + + await vi.waitFor( + () => { + const sessionFrames = frames.filter((frame) => frame['type'] === 'session'); + expect(sessionFrames).toHaveLength(1); + expect(sessionFrames[0]).toMatchObject({ + subtype: 'created', + session: { id: sessionId }, + }); + }, + { timeout: 5000 }, + ); + + ws.send(JSON.stringify({ type: 'subscribe', id: 1, session_id: sessionId })); + await vi.waitFor( + () => { + const ackIndex = frames.findIndex( + (frame) => frame['type'] === 'ack' && frame['id'] === 1, + ); + expect(ackIndex).toBeGreaterThan(0); + expect(frames[ackIndex]).toMatchObject({ code: ErrorCode.SUCCESS }); + const stateIndex = frames.findIndex((frame) => frame['type'] === 'session.state'); + expect(stateIndex).toBeGreaterThan(ackIndex); + expect(frames[stateIndex]).toMatchObject({ session_id: sessionId, activity: 'idle' }); + }, + { timeout: 5000 }, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da5d1b62fb3..53c00e815c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,9 +172,9 @@ importers: '@moonshot-ai/agent-core-v2': specifier: workspace:^ version: link:../../packages/agent-core-v2 - '@moonshot-ai/transcript': + '@moonshot-ai/kap-server': specifier: workspace:^ - version: link:../../packages/transcript + version: link:../../packages/kap-server '@tanstack/react-query': specifier: ^5.74.4 version: 5.99.2(react@19.2.5)