diff --git a/packages/kap-server/src/protocol/question-wire.ts b/packages/kap-server/src/protocol/question-wire.ts new file mode 100644 index 000000000..dce68021a --- /dev/null +++ b/packages/kap-server/src/protocol/question-wire.ts @@ -0,0 +1,54 @@ +import type { + QuestionItem, + QuestionOption, + QuestionRequest, +} from '@moonshot-ai/agent-core-v2'; + +import type { + QuestionItem as ProtocolQuestionItem, + QuestionOption as ProtocolQuestionOption, + QuestionRequest as ProtocolQuestionRequest, +} from './question'; + +export interface WireQuestionSource { + readonly id: string; + readonly createdAt: number; + readonly payload: unknown; +} + +function buildOption(opt: QuestionOption, itemIdx: number, optIdx: number): ProtocolQuestionOption { + const base: ProtocolQuestionOption = { id: `opt_${itemIdx}_${optIdx}`, label: opt.label }; + return opt.description === undefined ? base : { ...base, description: opt.description }; +} + +function buildItem(item: QuestionItem, itemIdx: number): ProtocolQuestionItem { + const out: ProtocolQuestionItem = { + id: `q_${itemIdx}`, + question: item.question, + options: item.options.map((o, oi) => buildOption(o, itemIdx, oi)), + }; + if (item.header !== undefined) out.header = item.header; + if (item.body !== undefined) out.body = item.body; + if (item.multiSelect !== undefined) out.multi_select = item.multiSelect; + out.allow_other = true; + if (item.otherLabel !== undefined) out.other_label = item.otherLabel; + if (item.otherDescription !== undefined) out.other_description = item.otherDescription; + return out; +} + +export function toWireQuestion( + interaction: WireQuestionSource, + sessionId: string, +): ProtocolQuestionRequest { + const req = interaction.payload as QuestionRequest; + const createdAt = new Date(interaction.createdAt).toISOString(); + const out: ProtocolQuestionRequest = { + question_id: interaction.id, + session_id: sessionId, + questions: req.questions.map((q, i) => buildItem(q, i)), + created_at: createdAt, + }; + if (req.turnId !== undefined) out.turn_id = req.turnId; + if (req.toolCallId !== undefined) out.tool_call_id = req.toolCallId; + return out; +} diff --git a/packages/kap-server/src/routes/questions.ts b/packages/kap-server/src/routes/questions.ts index 5a77c3dec..f0241b2df 100644 --- a/packages/kap-server/src/routes/questions.ts +++ b/packages/kap-server/src/routes/questions.ts @@ -6,19 +6,16 @@ import { listSessionPendingInteractions, resumeSessionById, type QuestionAnswers, - type QuestionItem, - type QuestionOption, - type QuestionRequest, type QuestionResult, type Scope, } from '@moonshot-ai/agent-core-v2'; import { ErrorCode } from '../protocol/error-codes'; import { type QuestionItem as ProtocolQuestionItem, - type QuestionOption as ProtocolQuestionOption, type QuestionRequest as ProtocolQuestionRequest, type QuestionResponse as ProtocolQuestionResponse, } from '../protocol/question'; +import { toWireQuestion } from '../protocol/question-wire'; import { listPendingQuestionsQuerySchema, listPendingQuestionsResponseSchema, @@ -247,43 +244,6 @@ async function dismissQuestionAction(ctx: QuestionActionCtx): Promise { }); } -function buildOption(opt: QuestionOption, itemIdx: number, optIdx: number): ProtocolQuestionOption { - const base: ProtocolQuestionOption = { id: `opt_${itemIdx}_${optIdx}`, label: opt.label }; - return opt.description === undefined ? base : { ...base, description: opt.description }; -} - -function buildItem(item: QuestionItem, itemIdx: number): ProtocolQuestionItem { - const out: ProtocolQuestionItem = { - id: `q_${itemIdx}`, - question: item.question, - options: item.options.map((o, oi) => buildOption(o, itemIdx, oi)), - }; - if (item.header !== undefined) out.header = item.header; - if (item.body !== undefined) out.body = item.body; - if (item.multiSelect !== undefined) out.multi_select = item.multiSelect; - out.allow_other = true; - if (item.otherLabel !== undefined) out.other_label = item.otherLabel; - if (item.otherDescription !== undefined) out.other_description = item.otherDescription; - return out; -} - -export function toWireQuestion( - interaction: Interaction, - sessionId: string, -): ProtocolQuestionRequest { - const req = interaction.payload as QuestionRequest; - const createdAt = new Date(interaction.createdAt).toISOString(); - const out: ProtocolQuestionRequest = { - question_id: interaction.id, - session_id: sessionId, - questions: req.questions.map((q, i) => buildItem(q, i)), - created_at: createdAt, - }; - if (req.turnId !== undefined) out.turn_id = req.turnId; - if (req.toolCallId !== undefined) out.tool_call_id = req.toolCallId; - return out; -} - function toInProcessResponse( resp: ProtocolQuestionResponse, request?: ProtocolQuestionRequest, diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index af2639a1c..6ccb1bd47 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -28,7 +28,7 @@ import { import { loadMessageHistory } from '../services/messages/messageHistory'; import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; import { toWireApproval } from './approvals'; -import { toWireQuestion } from './questions'; +import { toWireQuestion } from '../protocol/question-wire'; import { resolveSessionFacts, toWireSession } from './sessions'; const SNAPSHOT_MESSAGE_PAGE_SIZE = 100; diff --git a/packages/kap-server/src/services/transcript/coreBinding.ts b/packages/kap-server/src/services/transcript/coreBinding.ts index d9cee65d5..b655e1fa8 100644 --- a/packages/kap-server/src/services/transcript/coreBinding.ts +++ b/packages/kap-server/src/services/transcript/coreBinding.ts @@ -65,7 +65,7 @@ export function bindSessionTranscript( const projectorFor = (agentId: string): AgentTranscriptProjector => { let projector = projectors.get(agentId); if (projector === undefined) { - projector = new AgentTranscriptProjector(agentId, { + projector = new AgentTranscriptProjector(agentId, store.sessionId, { stepFrames: (turnId, stepId) => store.getAgent(agentId)?.getTurn(turnId)?.steps.find((s) => s.stepId === stepId)?.frames, toolFrame: (toolCallId) => { @@ -149,6 +149,7 @@ export function bindSessionTranscript( kind: interaction.kind, payload: interaction.payload, origin: interaction.origin, + createdAt: interaction.createdAt, }; applyOps(agentId, projectorFor(agentId).mapInteractionRequested(request)); }; diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index dc63d5f5a..607785a29 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -80,6 +80,7 @@ import type { } from '@moonshot-ai/transcript'; import { toLegacyPhase } from '../legacyStatus/legacyStatus'; +import { toWireQuestion } from '../../protocol/question-wire'; import { projectPromptContentParts } from '../messages/messageProjection'; export interface ProjectorInteraction { @@ -87,6 +88,7 @@ export interface ProjectorInteraction { readonly kind: 'approval' | 'question'; readonly payload: unknown; readonly origin: { readonly agentId?: string; readonly turnId?: number }; + readonly createdAt: number; } type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision; @@ -230,6 +232,7 @@ export class AgentTranscriptProjector { constructor( readonly agentId: string, + private readonly sessionId: string, private readonly lookups?: ProjectorLookups, ) {} @@ -1443,12 +1446,21 @@ export class AgentTranscriptProjector { interactionKind: interaction.kind, toolCallId, state: 'pending', - request: interaction.payload, + request: this.wireInteractionRequest(interaction), }; this.interactions.set(interaction.id, entity); return [{ op: 'interaction.upsert', interaction: entity }]; } + private wireInteractionRequest(interaction: ProjectorInteraction): unknown { + if (interaction.kind !== 'question') return interaction.payload; + try { + return toWireQuestion(interaction, this.sessionId); + } catch { + return interaction.payload; + } + } + mapInteractionResolved(id: string, response: unknown): TranscriptOperation[] { const record = this.interactions.get(id); if (record === undefined) return []; diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 86cf13844..49db89cb0 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -40,7 +40,8 @@ import { type TranscriptTurn, } from '@moonshot-ai/transcript'; -import { readWireRecords } from './wireRecords'; +import { readWireRecords, type ContextRecord } from './wireRecords'; +import { toWireQuestion } from '../../protocol/question-wire'; import { projectPromptContentParts } from '../messages/messageProjection'; import { bindSessionTranscript, @@ -513,7 +514,7 @@ export class TranscriptService { messages, sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined, ); - const folded = foldWireRecordFacts(records, base); + const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base); const status = getLiveSessionById(this.deps.core.accessor, sessionId) ?.accessor.get(IAgentLifecycleService) .handleOf(agentId) @@ -618,6 +619,38 @@ const TERMINAL_TURN_STATES: ReadonlySet = new Set([ 'cancelled', ]); +function projectQuestionInteractionRecords( + records: readonly ContextRecord[], + sessionId: string, +): ContextRecord[] { + return records.map((record) => { + if (record.type !== 'interaction.request' || record['kind'] !== 'question') return record; + const id = record['id']; + const request = record['request']; + const time = record['time']; + if (typeof id !== 'string' || typeof time !== 'number' || !Number.isFinite(time)) { + return record; + } + if (request === null || typeof request !== 'object') return record; + try { + const innerToolCallId = (request as { toolCallId?: unknown }).toolCallId; + const toolCallId = + typeof record['toolCallId'] === 'string' + ? record['toolCallId'] + : typeof innerToolCallId === 'string' + ? innerToolCallId + : undefined; + return { + ...record, + toolCallId, + request: toWireQuestion({ id, createdAt: time, payload: request }, sessionId), + }; + } catch { + return record; + } + }); +} + function supersededColdAttachmentIds( snapshot: AgentTranscriptSnapshot, transcript: AgentTranscript, diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 6888ef22d..d24712e70 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -49,7 +49,7 @@ import { } from '@moonshot-ai/transcript'; import { toWireApproval } from '../../../routes/approvals'; -import { toWireQuestion } from '../../../routes/questions'; +import { toWireQuestion } from '../../../protocol/question-wire'; import { toWireWorkspace } from '../../../routes/workspaces'; import { projectPromptContentParts } from '../../../services/messages/messageProjection'; import { readLegacyStatus, toLegacyPhase } from '../../../services/legacyStatus/legacyStatus'; diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 21008c6e2..932377b07 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -50,6 +50,7 @@ import { import { describe, expect, it } from 'vitest'; import { bindSessionTranscript } from '../../src/services/transcript/coreBinding'; +import { toWireQuestion } from '../../src/protocol/question-wire'; import { AgentTranscriptProjector, type ProjectorBusEvent, @@ -69,6 +70,8 @@ function ev(payload: Record): ProjectorBusEvent { return payload as unknown as ProjectorBusEvent; } +const TEST_SESSION_ID = 'session-test'; + function turnOps(turnId: string, items: ReturnType): TranscriptTurn { const turn = items.find( (item): item is TranscriptTurn => item.kind === 'turn' && item.turnId === turnId, @@ -97,7 +100,7 @@ function coldTranscriptService(home: string): TranscriptService { describe('AgentTranscriptProjector', () => { it('projects a full turn: headers, delta appends, flush, tool frames', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const ops: TranscriptOperation[] = []; const feed = (event: ProjectorBusEvent): void => { @@ -157,7 +160,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects the live prompt from turn.started and keeps it through turn.ended', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => { tx.apply(projector.map(event)); @@ -173,7 +176,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects turn.started promptAttachments into attachment entities and turn.attachmentIds', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const ops: TranscriptOperation[] = []; const feed = (event: ProjectorBusEvent): void => { @@ -216,7 +219,7 @@ describe('AgentTranscriptProjector', () => { it('places late-attach deltas into the engine-reported active step', () => { const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { stepOrdinal: (turnId) => (turnId === 't0' ? 2 : undefined), }); @@ -247,7 +250,7 @@ describe('AgentTranscriptProjector', () => { frame: { kind: 'text', frameId: 't0.1.f1', role: 'assistant', text: 'Hello ' }, }, ]); - const projector = new AgentTranscriptProjector('main', { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { stepFrames: (turnId, stepId) => tx.getTurn(turnId)?.steps.find((s) => s.stepId === stepId)?.frames, }); @@ -292,7 +295,7 @@ describe('AgentTranscriptProjector', () => { }, }, ]); - const projector = new AgentTranscriptProjector('main', { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { toolFrame: (toolCallId) => { for (const item of tx.getItems()) { if (item.kind !== 'turn') continue; @@ -342,7 +345,7 @@ describe('AgentTranscriptProjector', () => { }, }, ]); - const projector = new AgentTranscriptProjector('main', { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { toolFrame: (toolCallId) => { for (const item of tx.getItems()) { if (item.kind !== 'turn') continue; @@ -374,7 +377,7 @@ describe('AgentTranscriptProjector', () => { }); it('gives live markers their own namespace so they never collide with backfilled markers', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply([{ op: 'marker.upsert', item: { kind: 'marker', markerId: 'm1', marker: 'skill' } }]); @@ -483,7 +486,7 @@ describe('AgentTranscriptProjector', () => { }); it('flushes open frames on turn.ended even without step completion', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -506,7 +509,7 @@ describe('AgentTranscriptProjector', () => { }); it('marks a user-cancelled turn with an interruption marker, but not programmatic aborts', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -530,7 +533,7 @@ describe('AgentTranscriptProjector', () => { }); it('carries usage / finishReason / the full timing breakdown on turn.step.completed', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -590,7 +593,7 @@ describe('AgentTranscriptProjector', () => { }); it('carries endReason / endMessage on turn.step.interrupted', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -613,7 +616,7 @@ describe('AgentTranscriptProjector', () => { }); it('sets retry on turn.step.retrying and clears it at the terminal upsert', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); const step = (): TranscriptTurn['steps'][number] => turnOps('t1', tx.getItems()).steps[0]!; @@ -652,7 +655,7 @@ describe('AgentTranscriptProjector', () => { }); it('fills durationMs / error / accumulated step usage on turn.ended', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -698,7 +701,7 @@ describe('AgentTranscriptProjector', () => { }); it('takes the turn header endedAt from the turn.ended event time', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -712,7 +715,7 @@ describe('AgentTranscriptProjector', () => { }); it('accumulates tool.call.delta into inputText, kept across tool.call.started', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); const toolFrame = (toolCallId: string): TranscriptFrame | undefined => @@ -762,7 +765,7 @@ describe('AgentTranscriptProjector', () => { }); it('overwrites tool frame progress and drops progress for unknown calls', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -807,7 +810,7 @@ describe('AgentTranscriptProjector', () => { }); it('marks tool.result errors and keeps the display payload', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -835,7 +838,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects process tasks as shell tasks with streaming output', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const ops: TranscriptOperation[] = []; const feed = (event: ProjectorBusEvent): void => { @@ -887,7 +890,7 @@ describe('AgentTranscriptProjector', () => { }); it('fills the shell task output from late stderr chunks before completing', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply(projector.map(ev({ type: 'shell.started', commandId: 'c1', taskId: 'task-1' }))); @@ -900,7 +903,7 @@ describe('AgentTranscriptProjector', () => { }); it('routes shell output/completion via the event taskId when shell.started was missed', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply( @@ -916,7 +919,7 @@ describe('AgentTranscriptProjector', () => { }); it('emits a taskref when only shell.completed arrives for a command', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply(projector.map(ev({ type: 'shell.completed', commandId: 'c1', taskId: 'task-1', isError: true }))); @@ -926,7 +929,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects no-taskId shell failures under a synthetic per-command task id', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply( @@ -940,7 +943,7 @@ describe('AgentTranscriptProjector', () => { }); it('marks a foreground shell task terminal on shell.completed', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply(projector.map(ev({ type: 'shell.started', commandId: 'c1', taskId: 'task-1' }))); @@ -956,12 +959,12 @@ describe('AgentTranscriptProjector', () => { }); it('ignores task.notified (it re-surfaces as an origin:task turn)', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); expect(projector.map(ev({ type: 'task.notified', taskId: 't' }))).toEqual([]); }); it('links spawned subagents to the spawning tool frame (member for swarm)', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1008,7 +1011,7 @@ describe('AgentTranscriptProjector', () => { }); it('keys an Agent-tool subagent row by its registered task id and folds the lifecycle', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1067,7 +1070,7 @@ describe('AgentTranscriptProjector', () => { }); it('drops the stale task mapping when a child respawns without a task id', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1100,7 +1103,7 @@ describe('AgentTranscriptProjector', () => { }); it('recovers the agent → task association from a backfilled task.started', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1126,7 +1129,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects goal updates into meta.goal plus an inline marker', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const snapshot = { goalId: 'g1', @@ -1158,7 +1161,7 @@ describe('AgentTranscriptProjector', () => { }); it('mirrors plan / swarm mode slices into meta.modes (only when provided)', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply(projector.map(ev({ type: 'agent.status.updated', planMode: true }))); @@ -1172,7 +1175,7 @@ describe('AgentTranscriptProjector', () => { }); it('mirrors the tower mode slice into meta.modes', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply(projector.map(ev({ type: 'agent.status.updated', towerMode: true }))); @@ -1183,7 +1186,7 @@ describe('AgentTranscriptProjector', () => { }); it('mirrors status slices into meta.agent (shallow-merged across slices)', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1224,7 +1227,7 @@ describe('AgentTranscriptProjector', () => { }); it('maps agent.activity.updated into meta.agent.phase', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); const turn = (overrides: Record): Record => ({ @@ -1298,7 +1301,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects plan.revision as a marker and refines the active plan badge', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const revision = { @@ -1347,7 +1350,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects skill / plugin-command / cron / compaction / hook / undo markers', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1404,7 +1407,7 @@ describe('AgentTranscriptProjector', () => { it('removes trailing turns and the undo marker on context.undone', () => { const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { items: () => tx.getItems(), }); const feed = (event: ProjectorBusEvent): void => { @@ -1427,7 +1430,7 @@ describe('AgentTranscriptProjector', () => { it('removes multiple trailing turns and keeps taskrefs appended during them', () => { const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { items: () => tx.getItems(), }); const feed = (event: ProjectorBusEvent): void => { @@ -1463,16 +1466,16 @@ describe('AgentTranscriptProjector', () => { }); it('ignores context.undone when no removable turns exist', () => { - const bare = new AgentTranscriptProjector('main'); + const bare = new AgentTranscriptProjector('main', TEST_SESSION_ID); expect(bare.map(ev({ type: 'context.undone', agentId: 'main', turns: 1 }))).toEqual([]); - const projector = new AgentTranscriptProjector('main', { items: () => [] }); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { items: () => [] }); expect(projector.map(ev({ type: 'context.undone', agentId: 'main', turns: 1 }))).toEqual([]); }); it('removes every turn from fromTurnId onward, including trailing non-anchor turns', () => { const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { items: () => tx.getItems(), }); const feed = (event: ProjectorBusEvent): void => { @@ -1498,7 +1501,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects error / warning events as notice markers outside any step', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply( @@ -1521,7 +1524,7 @@ describe('AgentTranscriptProjector', () => { }); it('emits interactions as global entities only (no inline frame), back-links on resolve', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1549,6 +1552,7 @@ describe('AgentTranscriptProjector', () => { kind: 'approval', payload: request, origin: { agentId: 'main', turnId: 2 }, + createdAt: 1000, }), ); @@ -1575,7 +1579,7 @@ describe('AgentTranscriptProjector', () => { }); it('surfaces a mid-turn task notification as a user input frame linked to the task', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1604,7 +1608,7 @@ describe('AgentTranscriptProjector', () => { }); it('attaches a between-steps task notification to the following step', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1633,7 +1637,7 @@ describe('AgentTranscriptProjector', () => { }); it('drops a task notification that is the turn prompt itself', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1654,7 +1658,7 @@ describe('AgentTranscriptProjector', () => { }); it('keeps a different task’s notification in a task-origin turn', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1676,7 +1680,7 @@ describe('AgentTranscriptProjector', () => { }); it('drops a buffered task notification when the turn ends before the next step', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1702,7 +1706,7 @@ describe('AgentTranscriptProjector', () => { }); it('replaces the global todo document on a confirmed TodoList write', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1747,7 +1751,7 @@ describe('AgentTranscriptProjector', () => { }); it('emits an unanchored entity when the payload has no toolCallId', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply( @@ -1756,6 +1760,7 @@ describe('AgentTranscriptProjector', () => { kind: 'question', payload: { questions: [{ question: 'Pick', options: [] }] }, origin: { agentId: 'main', turnId: 3 }, + createdAt: 1000, }), ); expect(tx.getItems()).toHaveLength(0); @@ -1769,8 +1774,85 @@ describe('AgentTranscriptProjector', () => { expect(tx.listPendingInteractions()).toEqual([]); }); + it('projects question requests onto the wire shape with stable question/option ids', () => { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); + const tx = new AgentTranscript('main'); + + tx.apply( + projector.mapInteractionRequested({ + id: 'q-wire', + kind: 'question', + payload: { + toolCallId: 'call_q', + turnId: 3, + questions: [ + { + question: 'Pick one', + header: 'h', + body: 'b', + multiSelect: false, + otherLabel: 'Other', + otherDescription: 'free text', + options: [{ label: 'A', description: 'first' }, { label: 'B' }], + }, + ], + }, + origin: { agentId: 'main', turnId: 3 }, + createdAt: 7000, + }), + ); + + const entity = tx.getInteraction('q-wire'); + expect(entity?.toolCallId).toBe('call_q'); + expect(entity?.request).toEqual({ + question_id: 'q-wire', + session_id: TEST_SESSION_ID, + questions: [ + { + id: 'q_0', + question: 'Pick one', + header: 'h', + body: 'b', + multi_select: false, + allow_other: true, + other_label: 'Other', + other_description: 'free text', + options: [ + { id: 'opt_0_0', label: 'A', description: 'first' }, + { id: 'opt_0_1', label: 'B' }, + ], + }, + ], + created_at: new Date(7000).toISOString(), + turn_id: 3, + tool_call_id: 'call_q', + }); + + tx.apply(projector.mapInteractionResolved('q-wire', { q_0: 'A' })); + expect(tx.getInteraction('q-wire')).toMatchObject({ state: 'answered' }); + }); + + it('keeps a malformed question payload raw instead of failing the projection', () => { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); + const tx = new AgentTranscript('main'); + + tx.apply( + projector.mapInteractionRequested({ + id: 'q-raw', + kind: 'question', + payload: { toolCallId: 'call_x' }, + origin: { agentId: 'main' }, + createdAt: 1000, + }), + ); + + const entity = tx.getInteraction('q-raw'); + expect(entity?.toolCallId).toBe('call_x'); + expect(entity?.request).toEqual({ toolCallId: 'call_x' }); + }); + it('projects prompt submitted/completed/aborted/steered as global queue entities', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1873,7 +1955,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects prompt.steered media content to the wire shape (no daemon ref or path leak)', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1901,7 +1983,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects turn.steer as a user frame at the next step start, pairing promptIds from prompt.steered', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -1938,7 +2020,7 @@ describe('AgentTranscriptProjector', () => { }); it('projects turn.steer into the running step immediately, with daemon media as attachments', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const ops: TranscriptOperation[] = []; const feed = (event: ProjectorBusEvent): void => { @@ -1990,7 +2072,7 @@ describe('AgentTranscriptProjector', () => { }); it('ignores turn.steer for non-user origins and for turns that are not running', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -2020,7 +2102,7 @@ describe('AgentTranscriptProjector', () => { }); it('flushes a pending steer into the last step when the turn ends before the next step', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -2056,7 +2138,7 @@ describe('AgentTranscriptProjector', () => { }); it('buffers turn.steer seen before the projector ever saw turn.started (mid-turn attach)', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const ops: TranscriptOperation[] = []; const feed = (event: ProjectorBusEvent): void => { @@ -2253,6 +2335,92 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot projects question requests onto the wire shape without rewriting the log', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-question-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { + type: 'interaction.request', + id: 'q-cold', + kind: 'question', + toolCallId: 'call_q', + request: { + toolCallId: 'call_q', + questions: [{ question: 'Pick', options: [{ label: 'A' }, { label: 'B' }] }], + }, + time: 7000, + }, + { + type: 'interaction.request', + id: 'q-inner', + kind: 'question', + request: { + toolCallId: 'call_inner', + questions: [{ question: 'Inner', options: [{ label: 'X' }] }], + }, + time: 8500, + }, + { + type: 'interaction.request', + id: 'q-bad', + kind: 'question', + request: { toolName: 'nope' }, + time: 8000, + }, + { + type: 'interaction.request', + id: 'apr-1', + kind: 'approval', + toolCallId: 'call_1', + request: { toolName: 'Bash' }, + time: 9000, + }, + ]; + const wireFile = join(wireDir, 'wire.jsonl'); + const content = `${records.map((r) => JSON.stringify(r)).join('\n')}\n`; + await writeFile(wireFile, content); + + const service = coldTranscriptService(home); + const snapshot = await service.readColdSnapshot('s1', 'main'); + const byId = new Map(snapshot!.interactions.map((i) => [i.interactionId, i])); + expect(byId.get('q-cold')).toMatchObject({ + interactionKind: 'question', + toolCallId: 'call_q', + state: 'cancelled', + }); + expect(byId.get('q-cold')?.request).toEqual({ + question_id: 'q-cold', + session_id: 's1', + questions: [ + { + id: 'q_0', + question: 'Pick', + options: [ + { id: 'opt_0_0', label: 'A' }, + { id: 'opt_0_1', label: 'B' }, + ], + allow_other: true, + }, + ], + created_at: new Date(7000).toISOString(), + tool_call_id: 'call_q', + }); + expect(byId.get('q-bad')?.request).toEqual({ toolName: 'nope' }); + expect(byId.get('q-inner')).toMatchObject({ toolCallId: 'call_inner' }); + expect(byId.get('q-inner')?.request).toMatchObject({ + question_id: 'q-inner', + tool_call_id: 'call_inner', + }); + expect(byId.get('apr-1')?.request).toEqual({ toolName: 'Bash' }); + await expect(readFile(wireFile, 'utf-8')).resolves.toBe(content); + service.dropSession('s1'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot derives meta.activity from the final turn state when no live session exists', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-activity-')); try { @@ -2489,7 +2657,7 @@ describe('AgentTranscriptProjector', () => { }); it('folds blocked turn endings into failed (engine wire contract)', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); tx.apply(projector.map(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' } }))); tx.apply(projector.map(ev({ type: 'turn.ended', turnId: 0, reason: 'blocked' }))); @@ -2497,7 +2665,7 @@ describe('AgentTranscriptProjector', () => { }); it('tracks the prompt queue from accepted/queued through terminal', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -2511,7 +2679,7 @@ describe('AgentTranscriptProjector', () => { expect(tx.getPrompt('p1')).toMatchObject({ status: 'aborted' }); }); - it('mirrors turn liveness into meta.activity', () => { const projector = new AgentTranscriptProjector('main'); + it('mirrors turn liveness into meta.activity', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -2526,7 +2694,7 @@ describe('AgentTranscriptProjector', () => { expect(tx.getMeta().activity).toBe('idle'); }); - it('maps cron / task origins onto the turn header', () => { const projector = new AgentTranscriptProjector('main'); + it('maps cron / task origins onto the turn header', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -2558,7 +2726,7 @@ describe('AgentTranscriptProjector', () => { }); it('treats subagent.started/failed/suspended within the running→failed vocabulary', () => { - const projector = new AgentTranscriptProjector('main'); + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); @@ -3207,6 +3375,32 @@ describe('bindSessionTranscript', () => { binding.dispose(); }); + it('projects live question entities with the same wire shape as the legacy question event', () => { + const interactions = new FakeInteractionHub(); + const asked = interactions.enqueue({ + id: 'q-parity', + kind: 'question', + payload: { + questions: [ + { + question: 'Pick one', + options: [{ label: 'A', description: 'first' }, { label: 'B' }], + }, + ], + }, + origin: { agentId: 'main', turnId: 0 }, + }); + const store = new TranscriptStore('s1'); + const binding = bindSessionTranscript(store, fakeSession(interactions)); + + binding.seedPendingInteractions('main'); + + const entity = store.getAgent('main')?.getInteraction('q-parity'); + expect(entity?.state).toBe('pending'); + expect(entity?.request).toEqual(toWireQuestion(asked, 's1')); + binding.dispose(); + }); + it('defers pendings created before their owning agent is seeded', () => { const interactions = new FakeInteractionHub(); const store = new TranscriptStore('s1');