fix(transcript): preserve turn prompt identity (#3377)
Some checks are pending
CI / lint (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Publish VS Code extension / Publish VSIX to marketplaces (push) Waiting to run

* fix(transcript): preserve turn prompt identity

* fix(transcript): backfill active prompt identity

* fix(transcript): seed late-bound turn identity

* fix(transcript): persist turn prompt identity

* fix(transcript): skip undone prompt identities

* fix(transcript): hide undone continuation turns

* docs(agent-core): update wire manifest

* fix(transcript): match cold turns by prompt identity

* fix(transcript): match internal prompt turns by origin

* fix(transcript): align undo anchors with context

* fix(transcript): prioritize cold turn matches

* fix(transcript): retire unmatched turn boundaries

* style(transcript): align cold matcher
This commit is contained in:
Luyu Cheng 2026-08-31 06:41:56 +08:00 committed by GitHub
parent cbe0a77f3d
commit 58b74cfeab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 566 additions and 45 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix duplicate user messages in transcript clients.

View file

@ -791,6 +791,7 @@ interface TurnPromptPayload {
input: readonly ContentPart[];
/** PromptOrigin */
origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry';
promptId?: string;
}
/**

View file

@ -465,7 +465,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
private startTurn(job: TurnJob): void {
const origin = job.seed.origin;
void this.dispatcher.dispatch(
new TurnPrompt({ agentId: this.scopeContext.agentId, input: job.seed.input, origin }),
new TurnPrompt({
agentId: this.scopeContext.agentId,
input: job.seed.input,
origin,
promptId: job.seed.promptId,
}),
);
job.turn.state = 'running';
this.activeTurnJob = job;
@ -473,6 +478,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
new TurnStarted({
agentId: this.scopeContext.agentId,
turnId: job.turn.id,
promptId: job.seed.promptId,
origin,
prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input, origin) : undefined,
promptAttachments: turnPromptAttachments(job.seed.input, origin),

View file

@ -31,6 +31,7 @@ export type TurnPromptAttachment =
export interface TurnStartedPayload {
readonly agentId: string;
readonly turnId: number;
readonly promptId?: string;
readonly origin: PromptOrigin;
readonly prompt?: string;
readonly promptAttachments?: readonly TurnPromptAttachment[];

View file

@ -33,7 +33,12 @@ const turnInputShape = {
origin: z.custom<PromptOrigin>(),
};
const turnPromptSchema = z.object(turnInputShape);
const turnPromptSchema = z.object({
agentId: z.string(),
input: z.custom<readonly ContentPart[]>(),
origin: z.custom<PromptOrigin>(),
promptId: z.string().optional(),
});
export class TurnPrompt extends AgentEvent2<z.infer<typeof turnPromptSchema>> {
static override readonly type = 'turn.prompt';
@ -44,6 +49,7 @@ export interface TurnPrompt {
readonly agentId: string;
readonly input: readonly ContentPart[];
readonly origin: PromptOrigin;
readonly promptId?: string;
}
const turnSteerSchema = z.object(turnInputShape);

View file

@ -72,8 +72,8 @@ describe('Agent loop', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Hello" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Hello" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "promptId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "promptId": "<msg-1>", "origin": { "kind": "user" }, "prompt": "Hello" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
@ -132,8 +132,8 @@ describe('Agent loop', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Hello" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Hello" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "promptId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "promptId": "<msg-1>", "origin": { "kind": "user" }, "prompt": "Hello" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
@ -387,8 +387,8 @@ describe('Agent loop', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up moon" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up moon" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "promptId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "promptId": "<msg-1>", "origin": { "kind": "user" }, "prompt": "Look up moon" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }

View file

@ -326,8 +326,8 @@ describe('Agent config', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up before config changes" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up before config changes" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up before config changes" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up before config changes" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up before config changes" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up before config changes" } ], "origin": { "kind": "user" }, "promptId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "promptId": "<msg-1>", "origin": { "kind": "user" }, "prompt": "Look up before config changes" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
@ -422,8 +422,8 @@ describe('Agent config', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Start a fresh turn" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "userMessageId": "<msg-2>", "status": "running", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Start a fresh turn" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "origin": { "kind": "user" }, "prompt": "Start a fresh turn" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Start a fresh turn" } ], "origin": { "kind": "user" }, "promptId": "<msg-2>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "promptId": "<msg-2>", "origin": { "kind": "user" }, "prompt": "Start a fresh turn" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-2>" }

View file

@ -716,8 +716,8 @@ describe('Plan service', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Inspect without mutating files" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Inspect without mutating files" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Inspect without mutating files" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Inspect without mutating files" } ], "origin": { "kind": "user" }, "promptId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "promptId": "<msg-1>", "origin": { "kind": "user" }, "prompt": "Inspect without mutating files" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
@ -799,8 +799,8 @@ describe('Plan service', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Remove forbidden.txt" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Remove forbidden.txt" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Remove forbidden.txt" } ], "origin": { "kind": "user" }, "promptId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "promptId": "<msg-1>", "origin": { "kind": "user" }, "prompt": "Remove forbidden.txt" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }

View file

@ -3874,8 +3874,8 @@ describe('Agent tools', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up moon" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up moon" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "promptId": "<msg-1>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "promptId": "<msg-1>", "origin": { "kind": "user" }, "prompt": "Look up moon" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" }
@ -3955,8 +3955,8 @@ describe('Agent tools', () => {
[wire] prompt.accepted { "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "time": "<time>" }
[emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ] }
[emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "userMessageId": "<msg-2>", "status": "running", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "createdAt": "<time>" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Can you still use Lookup?" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "origin": { "kind": "user" }, "prompt": "Can you still use Lookup?" }
[wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Can you still use Lookup?" } ], "origin": { "kind": "user" }, "promptId": "<msg-2>", "time": "<time>" }
[emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "promptId": "<msg-2>", "origin": { "kind": "user" }, "prompt": "Can you still use Lookup?" }
[emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" }
[emit] context.spliced { "time": "<time>", "agentId": "main", "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" } ] }
[emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-2>" }

View file

@ -1,6 +1,8 @@
import {
IAgentLifecycleService,
IAgentActivityView,
IAgentLoopService,
IAgentPromptService,
IAgentTaskService,
IEventBus,
ISessionMetadata,
@ -126,6 +128,11 @@ export function bindSessionTranscript(
const busD = bus.subscribe((event) =>
applyOps(handle.id, projector.map(event as ProjectorBusEvent)),
);
const loopStatus = handle.accessor.get(IAgentLoopService)?.status();
if (loopStatus?.state === 'running' && loopStatus.activeTurnId !== undefined) {
const promptId = handle.accessor.get(IAgentPromptService)?.list().active?.id;
projector.seedActiveTurn({ turnId: loopStatus.activeTurnId, promptId });
}
const list = agentDisposables.get(handle.id) ?? [];
list.push(busD);
agentDisposables.set(handle.id, list);

View file

@ -230,6 +230,22 @@ export class AgentTranscriptProjector {
}));
return [{ op: 'task.upsert', task }];
}
seedActiveTurn(info: { turnId: number; promptId?: string }): void {
const turnId = `t${info.turnId}`;
const prev = this.lookups?.turn?.(turnId);
this.currentTurn = {
kind: 'turn',
turnId,
ordinal: info.turnId,
state: 'running',
triggerPromptId: info.promptId ?? prev?.triggerPromptId,
origin: prev?.origin ?? { kind: 'other' },
prompt: prev?.prompt,
attachmentIds: prev?.attachmentIds,
startedAt: prev?.startedAt,
};
}
private readonly interactions = new Map<string, TranscriptInteraction>();
private readonly prompts = new Map<string, TranscriptPrompt>();
private readonly stepUsageByTurn = new Map<string, StepUsage[]>();
@ -343,6 +359,7 @@ export class AgentTranscriptProjector {
private onTurnStarted(event: {
turnId: number;
promptId?: string;
origin: unknown;
prompt?: string;
promptAttachments?: readonly (
@ -374,6 +391,7 @@ export class AgentTranscriptProjector {
this.currentTurn = {
kind: 'turn',
turnId,
triggerPromptId: event.promptId,
ordinal: n,
state: 'running',
origin: mapTurnOrigin(event.origin),
@ -442,6 +460,7 @@ export class AgentTranscriptProjector {
turnId,
ordinal: event.turnId,
state,
triggerPromptId: prev?.triggerPromptId,
origin: prev?.origin ?? { kind: 'other' },
prompt: prev?.prompt,
attachmentIds: prev?.attachmentIds,

View file

@ -323,6 +323,8 @@ export class TranscriptService {
: session.accessor.get(IAgentLifecycleService).handleOf(agentId);
const status = agent?.accessor.get(IAgentLoopService).status();
if (status?.state !== 'running' || status.activeTurnId === undefined) return undefined;
const promptService = agent?.accessor.get(IAgentPromptService);
const activePromptId = promptService?.list().active?.id;
const ordinal = status.activeTurnId;
const turnId = `t${ordinal}`;
const existing = transcript.getTurn(turnId);
@ -336,6 +338,7 @@ export class TranscriptService {
turnId,
ordinal,
state: 'running',
triggerPromptId: existing?.triggerPromptId ?? snapshotTurn?.triggerPromptId ?? activePromptId,
origin: existing?.origin ?? snapshotTurn?.origin ?? { kind: 'other' },
prompt: existing?.prompt ?? snapshotTurn?.prompt,
attachmentIds: existing?.attachmentIds ?? snapshotTurn?.attachmentIds,
@ -691,6 +694,7 @@ export function healTurnOps(
turn: {
...header,
state: liveTurn.state,
triggerPromptId: liveTurn.triggerPromptId ?? header.triggerPromptId,
prompt: liveTurn.prompt ?? header.prompt,
attachmentIds: liveTurn.attachmentIds ?? header.attachmentIds,
startedAt: liveTurn.startedAt ?? header.startedAt,

View file

@ -8,6 +8,7 @@ import {
AgentInteraction,
IAgentLifecycleService,
IAgentLoopService,
IAgentPromptService,
IAgentScopeContext,
IAgentTaskService,
IEventBus,
@ -166,11 +167,18 @@ describe('AgentTranscriptProjector', () => {
tx.apply(projector.map(event));
};
feed(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'fix the bug' }));
feed(ev({
type: 'turn.started',
turnId: 0,
promptId: 'prompt-1',
origin: { kind: 'user' },
prompt: 'fix the bug',
}));
feed(ev({ type: 'assistant.delta', turnId: 0, delta: 'on it' }));
feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' }));
const turn = turnOps('t0', tx.getItems());
expect(turn.triggerPromptId).toBe('prompt-1');
expect(turn.prompt).toBe('fix the bug');
expect(turn.state).toBe('completed');
});
@ -2585,14 +2593,16 @@ describe('AgentTranscriptProjector', () => {
await mkdir(wireDir, { recursive: true });
const write = async (records: unknown[]): Promise<void> =>
writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`);
const user = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 };
const user = { type: 'context.append_message', message: { id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 };
const assistant = { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, time: 2000 };
const boundary = { type: 'turn.prompt', input: [{ type: 'text', text: 'hi' }], origin: { kind: 'user' }, promptId: 'prompt-1', time: 500 };
await write([user, assistant, { type: 'turn.ended', turnId: 0, reason: 'completed', time: 3000 }]);
await write([boundary, user, assistant, { type: 'turn.ended', turnId: 0, reason: 'completed', time: 3000 }]);
const ended = await coldTranscriptService(home).readColdSnapshot('s1', 'main');
expect(ended!.meta.activity).toBe('idle');
expect(ended!.items[0]).toMatchObject({ triggerPromptId: 'prompt-1' });
await write([user, assistant]);
await write([boundary, user, assistant]);
const dangling = await coldTranscriptService(home).readColdSnapshot('s1', 'main');
expect(dangling!.meta.activity).toBe('idle');
} finally {
@ -3216,7 +3226,7 @@ describe('bindSessionTranscript', () => {
this.closeHandlers.add(cb);
return { dispose: () => this.closeHandlers.delete(cb) };
}
add(id: string, opts?: { loopStatus?: unknown; tasks?: readonly unknown[] }): FakeAgentHandle {
add(id: string, opts?: { loopStatus?: unknown; tasks?: readonly unknown[]; activePromptId?: string }): FakeAgentHandle {
const bus = new FakeBus();
const scope = makeAgentScopeContext({
agentId: id,
@ -3236,6 +3246,27 @@ describe('bindSessionTranscript', () => {
if (token === IAgentLoopService) {
return { status: () => opts?.loopStatus ?? { state: 'idle' } };
}
if (token === IAgentPromptService) {
return {
list: () => ({
active: opts?.activePromptId === undefined
? undefined
: {
id: opts.activePromptId,
userMessageId: opts.activePromptId,
createdAt: '2026-01-01T00:00:00.000Z',
state: 'running',
message: {
role: 'user',
content: [{ type: 'text', text: 'hi' }],
toolCalls: [],
origin: { kind: 'user' },
},
},
pending: [],
}),
};
}
if (token === IAgentTaskService) {
return { list: () => opts?.tasks ?? [] };
}
@ -3553,6 +3584,22 @@ describe('bindSessionTranscript', () => {
expect(fallback).toMatchObject({ turn: { attachmentIds: ['att_1'] } });
});
it('heal keeps the live trigger prompt id over a cold turn without one', () => {
const makeTurn = (triggerPromptId: string | undefined): TranscriptTurn => ({
kind: 'turn',
turnId: 't0',
triggerPromptId,
ordinal: 0,
state: 'completed',
origin: { kind: 'user' },
steps: [],
});
const header = healTurnOps(makeTurn(undefined), makeTurn('prompt-1')).find(
(op) => op.op === 'turn.upsert',
);
expect(header).toMatchObject({ turn: { triggerPromptId: 'prompt-1' } });
});
it('terminal turn.upsert inherits the backfilled header when the projector missed turn.started', () => {
const agents = new FakeAgents();
const store = new TranscriptStore('s1');
@ -3755,11 +3802,32 @@ describe('bindSessionTranscript', () => {
binding.dispose();
});
it('seeds active prompt identity before a late-bound turn ends', () => {
const agents = new FakeAgents();
const main = agents.add('main', {
loopStatus: { state: 'running', activeTurnId: 0 },
activePromptId: 'prompt-1',
});
const store = new TranscriptStore('s1');
const binding = bindSessionTranscript(store, fakeSession(agents));
main.bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' }));
expect(store.getAgent('main')?.getTurn('t0')).toMatchObject({
state: 'completed',
triggerPromptId: 'prompt-1',
});
binding.dispose();
});
it('overlays the in-flight turn as running after a backfill', async () => {
const home = await seedWireHome();
try {
const agents = new FakeAgents();
agents.add('main', { loopStatus: { state: 'running', activeTurnId: 0 } });
agents.add('main', {
loopStatus: { state: 'running', activeTurnId: 0 },
activePromptId: 'prompt-1',
});
const service = new TranscriptService({
homeDir: home,
core: fakeCoreWithAgents(agents),
@ -3768,6 +3836,7 @@ describe('bindSessionTranscript', () => {
await service.whenReady('s1');
expect(store?.getAgent('main')?.getTurn('t0')).toMatchObject({
state: 'running',
triggerPromptId: 'prompt-1',
prompt: 'hi',
});
service.dropSession('s1');

View file

@ -169,6 +169,7 @@ export const transcriptStepSchema = z.object({
export const transcriptTurnSchema = z.object({
kind: z.literal('turn'),
turnId: turnIdSchema,
triggerPromptId: z.string().min(1).optional(),
ordinal: z.number().int(),
state: turnStateSchema,
origin: turnOriginSchema,

View file

@ -65,6 +65,17 @@ interface TurnEndedPayload {
interface TurnPromptPayload {
readonly origin?: unknown;
readonly promptId?: unknown;
}
interface ContextUndoPayload {
readonly count?: unknown;
}
interface ContextAppendMessagePayload {
readonly message?: {
readonly id?: unknown;
readonly role?: unknown;
readonly origin?: unknown;
};
}
interface TurnCancelPayload {
readonly turnId?: unknown;
@ -85,6 +96,36 @@ function isVisibleTurnOrigin(origin: unknown): boolean {
return true;
}
function isUndoAnchorTurnOrigin(origin: unknown): boolean {
const payload = origin as { kind?: unknown; trigger?: unknown } | undefined;
if (payload?.kind === undefined || payload.kind === 'user') return true;
return (
(payload.kind === 'skill_activation' || payload.kind === 'plugin_command') &&
payload.trigger === 'user-slash'
);
}
function turnOriginKind(origin: unknown): TranscriptTurn['origin']['kind'] {
const payload = origin as { kind?: unknown; taskId?: unknown } | undefined;
switch (payload?.kind) {
case 'user':
case 'shell_command':
return 'user';
case 'cron_job':
case 'cron_missed':
return 'cron';
case 'task':
case 'background_task':
return typeof payload.taskId === 'string' ? 'task' : 'other';
case 'hook_result':
return 'hook';
case 'compaction_summary':
return 'compaction';
default:
return 'other';
}
}
function mapTaskKind(kind: unknown): TranscriptTask['kind'] {
switch (kind) {
case 'process':
@ -177,9 +218,14 @@ export function foldWireRecordFacts(
const tasks = new Map<string, TranscriptTask>();
const interactions = new Map<string, TranscriptInteraction>();
const endedTurns = new Map<number, HistoryWireRecord>();
const turnPromptIds = new Map<number, string>();
const turnOrigins = new Map<number, unknown>();
let nextTurnId = 0;
const cancelledTurnIds = new Set<number>();
const hiddenTurnIds = new Set<number>();
const undoAnchors: { firstRawTurnId: number }[] = [];
const pendingUndoAnchorTurnIds: number[] = [];
let undoAnchorFloor = 0;
const skipCancelledTurnIds = (): void => {
while (cancelledTurnIds.delete(nextTurnId)) {
hiddenTurnIds.add(nextTurnId);
@ -380,6 +426,57 @@ export function foldWireRecordFacts(
pushMarker('interruption', record);
break;
}
case 'context.undo': {
const count = (record as ContextUndoPayload).count;
if (typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) break;
let firstUndoneTurnId: number | undefined;
for (let i = 0; i < count && undoAnchors.length > undoAnchorFloor; i++) {
const anchor = undoAnchors.pop();
if (anchor !== undefined) firstUndoneTurnId = anchor.firstRawTurnId;
}
if (firstUndoneTurnId !== undefined) {
for (let turnId = firstUndoneTurnId; turnId < nextTurnId; turnId++) {
hiddenTurnIds.add(turnId);
}
}
break;
}
case 'context.clear':
case 'context.apply_compaction': {
undoAnchorFloor = undoAnchors.length;
break;
}
case 'context.append_message': {
const message = (record as ContextAppendMessagePayload).message;
if (message?.role !== 'user' || !isUndoAnchorTurnOrigin(message.origin)) break;
const matchingIndex =
typeof message.id === 'string'
? pendingUndoAnchorTurnIds.findIndex(
(turnId) => turnPromptIds.get(turnId) === message.id,
)
: -1;
const legacyIndex =
matchingIndex < 0 && typeof message.id === 'string'
? pendingUndoAnchorTurnIds.findIndex((turnId) => !turnPromptIds.has(turnId))
: -1;
const matchedTurnId =
matchingIndex >= 0
? pendingUndoAnchorTurnIds.splice(matchingIndex, 1)[0]
: legacyIndex >= 0
? pendingUndoAnchorTurnIds.splice(legacyIndex, 1)[0]
: typeof message.id !== 'string'
? pendingUndoAnchorTurnIds.shift()
: undefined;
if (
matchedTurnId !== undefined &&
!turnPromptIds.has(matchedTurnId) &&
typeof message.id === 'string'
) {
turnPromptIds.set(matchedTurnId, message.id);
}
undoAnchors.push({ firstRawTurnId: matchedTurnId ?? nextTurnId });
break;
}
case 'interaction.request': {
const payload = record as InteractionRequestPayload;
if (payload.kind !== 'approval' && payload.kind !== 'question') break;
@ -415,14 +512,22 @@ export function foldWireRecordFacts(
}
case 'turn.ended': {
const payload = record as TurnEndedPayload;
if (typeof payload.turnId === 'number') endedTurns.set(payload.turnId, record);
if (typeof payload.turnId === 'number') {
endedTurns.set(payload.turnId, record);
const pendingIndex = pendingUndoAnchorTurnIds.indexOf(payload.turnId);
if (pendingIndex >= 0) pendingUndoAnchorTurnIds.splice(pendingIndex, 1);
}
break;
}
case 'turn.prompt': {
skipCancelledTurnIds();
const turnId = nextTurnId;
nextTurnId += 1;
if (!isVisibleTurnOrigin((record as TurnPromptPayload).origin)) hiddenTurnIds.add(turnId);
const payload = record as TurnPromptPayload;
turnOrigins.set(turnId, payload.origin);
if (typeof payload.promptId === 'string') turnPromptIds.set(turnId, payload.promptId);
if (isUndoAnchorTurnOrigin(payload.origin)) pendingUndoAnchorTurnIds.push(turnId);
if (!isVisibleTurnOrigin(payload.origin)) hiddenTurnIds.add(turnId);
break;
}
default:
@ -436,12 +541,79 @@ export function foldWireRecordFacts(
}
}
const baseTurns = base.items.filter((item): item is TranscriptTurn => item.kind === 'turn');
const ordinalByPromptId = new Map(
baseTurns.flatMap((turn) =>
turn.triggerPromptId === undefined ? [] : [[turn.triggerPromptId, turn.ordinal] as const],
),
);
const claimedOrdinals = new Set<number>();
const ordinalByRawTurnId = new Map<number, number>();
const claimBaseOrdinal = (
predicate: (turn: TranscriptTurn) => boolean = () => true,
): number | undefined => {
const turn = baseTurns.find(
(candidate) =>
!claimedOrdinals.has(candidate.ordinal) &&
predicate(candidate),
);
if (turn === undefined) return undefined;
claimedOrdinals.add(turn.ordinal);
return turn.ordinal;
};
const lastRawTurnId = Math.max(nextTurnId - 1, ...endedTurns.keys());
const rawTurnIds = Array.from(
{ length: lastRawTurnId + 1 },
(_, turnId) => turnId,
).filter((turnId) => !hiddenTurnIds.has(turnId));
for (const turnId of rawTurnIds) {
const promptId = turnPromptIds.get(turnId);
const matchedOrdinal = promptId === undefined ? undefined : ordinalByPromptId.get(promptId);
if (matchedOrdinal !== undefined && !claimedOrdinals.has(matchedOrdinal)) {
claimedOrdinals.add(matchedOrdinal);
ordinalByRawTurnId.set(turnId, matchedOrdinal);
}
}
for (const turnId of rawTurnIds) {
if (ordinalByRawTurnId.has(turnId)) continue;
const origin = turnOrigins.get(turnId);
const strictOrigin = turnOrigins.has(turnId) && !isUndoAnchorTurnOrigin(origin);
if (!strictOrigin) continue;
const fallbackOrdinal = claimBaseOrdinal(
(candidate) =>
candidate.triggerPromptId === undefined &&
candidate.origin.kind === turnOriginKind(origin),
);
if (fallbackOrdinal !== undefined) ordinalByRawTurnId.set(turnId, fallbackOrdinal);
}
for (const turnId of rawTurnIds) {
if (ordinalByRawTurnId.has(turnId)) continue;
const promptId = turnPromptIds.get(turnId);
const origin = turnOrigins.get(turnId);
const strictOrigin = turnOrigins.has(turnId) && !isUndoAnchorTurnOrigin(origin);
if (promptId === undefined || strictOrigin) continue;
const emptyPromptOrdinal = claimBaseOrdinal(
(candidate) =>
candidate.triggerPromptId === undefined && candidate.origin.kind === 'other',
);
if (emptyPromptOrdinal !== undefined) {
ordinalByRawTurnId.set(turnId, emptyPromptOrdinal);
}
}
for (const turnId of rawTurnIds) {
if (ordinalByRawTurnId.has(turnId)) continue;
const promptId = turnPromptIds.get(turnId);
const origin = turnOrigins.get(turnId);
const strictOrigin = turnOrigins.has(turnId) && !isUndoAnchorTurnOrigin(origin);
if (promptId !== undefined || strictOrigin) continue;
const fallbackOrdinal = claimBaseOrdinal();
if (fallbackOrdinal !== undefined) ordinalByRawTurnId.set(turnId, fallbackOrdinal);
}
const endedByOrdinal = new Map<number, HistoryWireRecord>();
for (const [turnId, record] of endedTurns) {
if (hiddenTurnIds.has(turnId)) continue;
let hidden = 0;
for (const id of hiddenTurnIds) if (id < turnId) hidden += 1;
endedByOrdinal.set(turnId - hidden, record);
const ordinal = ordinalByRawTurnId.get(turnId);
if (ordinal !== undefined) endedByOrdinal.set(ordinal, record);
}
const items =

View file

@ -31,6 +31,7 @@ export interface HistoryToolCall {
}
export interface HistoryMessage {
readonly id?: string;
readonly role: string;
readonly content?: readonly HistoryContentPart[];
readonly toolCalls?: readonly HistoryToolCall[];
@ -42,6 +43,7 @@ export interface HistoryMessage {
interface TurnDraft {
turnId: string;
ordinal: number;
triggerPromptId?: string;
origin: TurnOrigin;
prompt?: string;
attachmentIds?: string[];
@ -192,12 +194,17 @@ export function groupMessagesIntoSnapshot(
}
};
const startTurn = (origin: TurnOrigin, prompt?: string, attachmentIds?: string[]): TurnDraft => {
const startTurn = (
origin: TurnOrigin,
prompt?: string,
attachmentIds?: string[],
triggerPromptId?: string,
): TurnDraft => {
flushSteeredLeftovers();
const ordinal = nextOrdinal;
nextOrdinal += 1;
pendingNotificationFrames = [];
turn = { turnId: `t${ordinal}`, ordinal, origin, prompt, attachmentIds, steps: [] };
turn = { turnId: `t${ordinal}`, ordinal, triggerPromptId, origin, prompt, attachmentIds, steps: [] };
items.push(draftToTurnItem(turn));
return turn;
};
@ -262,7 +269,7 @@ export function groupMessagesIntoSnapshot(
const opening = isUserSlashPrompt(message) ? foldTurnOpeningInput(message) : undefined;
pushMarker(markerKey, { text: opening?.text ?? textOf(message), origin: message.origin });
if (opening !== undefined) {
startTurn(mapOrigin(message), opening.text, opening.attachmentIds);
startTurn(mapOrigin(message), opening.text, opening.attachmentIds, triggerPromptIdOf(message));
}
continue;
}
@ -294,11 +301,11 @@ export function groupMessagesIntoSnapshot(
});
const callerMessage = { ...message, content: parts.slice(bundled.length) };
const opening = foldTurnOpeningInput(callerMessage);
startTurn(mapOrigin(message), opening.text, opening.attachmentIds);
startTurn(mapOrigin(message), opening.text, opening.attachmentIds, triggerPromptIdOf(message));
continue;
}
const opening = foldTurnOpeningInput(message);
startTurn(mapOrigin(message), opening.text, opening.attachmentIds);
startTurn(mapOrigin(message), opening.text, opening.attachmentIds, triggerPromptIdOf(message));
continue;
}
@ -418,6 +425,18 @@ function isUserSlashPrompt(message: HistoryMessage): boolean {
);
}
function triggerPromptIdOf(message: HistoryMessage): string | undefined {
if (typeof message.id !== 'string' || message.id.length === 0) return undefined;
const origin = message.origin as { kind?: unknown; trigger?: unknown } | undefined;
if (origin?.kind === undefined || origin.kind === 'user') return message.id;
return (
(origin.kind === 'skill_activation' || origin.kind === 'plugin_command') &&
origin.trigger === 'user-slash'
)
? message.id
: undefined;
}
function mapOrigin(message: HistoryMessage): TurnOrigin {
const origin = message.origin;
switch (origin?.kind) {
@ -509,6 +528,7 @@ function draftToTurnItem(draft: TurnDraft): TranscriptItem {
return {
kind: 'turn',
turnId: draft.turnId,
triggerPromptId: draft.triggerPromptId,
ordinal: draft.ordinal,
state: 'completed',
origin: draft.origin,

View file

@ -50,6 +50,7 @@ export interface StepRetry {
export interface TranscriptTurn {
readonly kind: 'turn';
readonly turnId: TurnId;
readonly triggerPromptId?: string;
readonly ordinal: number;
readonly state: TurnState;
readonly origin: TurnOrigin;

View file

@ -176,6 +176,7 @@ function applyTurnUpsert(state: AgentState, header: TurnHeader): ApplyResult {
function turnEquals(turn: TranscriptTurn, header: TurnHeader): boolean {
return (
turn.ordinal === header.ordinal &&
turn.triggerPromptId === header.triggerPromptId &&
turn.state === header.state &&
turn.prompt === header.prompt &&
turn.attachmentIds === header.attachmentIds &&

View file

@ -316,7 +316,7 @@ describe('contract schemas', () => {
{
op: 'turn.upsert',
turn: {
kind: 'turn', turnId: 't1', ordinal: 1, state: 'failed', origin: { kind: 'user' },
kind: 'turn', turnId: 't1', triggerPromptId: 'prompt-1', ordinal: 1, state: 'failed', origin: { kind: 'user' },
usage: { inputTokens: 12, outputTokens: 5, cachedTokens: 3 },
durationMs: 1500,
error: 'boom',
@ -1847,20 +1847,152 @@ describe('foldWireRecordFacts (cold facts)', () => {
expect(folded.items).toHaveLength(base.items.length);
});
it('matches durable turns by prompt identity after a context-only blocked prompt', () => {
const base = groupMessagesIntoSnapshot([
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
{ id: 'prompt-live', role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'failed later' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [{ type: 'text', text: 'run' }], origin: { kind: 'user' }, promptId: 'prompt-live', time: 1 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'later failure' }, time: 2 },
],
base,
);
const blocked = folded.items[0];
const live = folded.items[1];
if (blocked?.kind !== 'turn' || live?.kind !== 'turn') throw new Error('expected turns');
expect(blocked.triggerPromptId).toBe('prompt-blocked');
expect(blocked.error).toBeUndefined();
expect(live.triggerPromptId).toBe('prompt-live');
expect(live.state).toBe('failed');
expect(live.error).toBe('later failure');
});
it('recovers legacy boundary identity past a context-only blocked prompt', () => {
const base = groupMessagesIntoSnapshot([
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
{ id: 'prompt-real', role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'failed later' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 1 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'run' }], origin: { kind: 'user' }, time: 2 },
{ type: 'context.append_message', message: { id: 'prompt-real', role: 'user', origin: { kind: 'user' } }, time: 3 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'legacy failure' }, time: 4 },
],
base,
);
const blocked = folded.items[0];
const real = folded.items[1];
if (blocked?.kind !== 'turn' || real?.kind !== 'turn') throw new Error('expected turns');
expect(blocked.error).toBeUndefined();
expect(real.triggerPromptId).toBe('prompt-real');
expect(real.state).toBe('failed');
expect(real.error).toBe('legacy failure');
});
it('retires an unmatched legacy empty boundary before a later blocked prompt', () => {
const base = groupMessagesIntoSnapshot([
{ role: 'assistant', content: [{ type: 'text', text: 'partial' }], toolCalls: [] },
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [], origin: { kind: 'user' }, time: 1 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'empty failure' }, time: 2 },
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 3 },
],
base,
);
const empty = folded.items[0];
const blocked = folded.items[1];
if (empty?.kind !== 'turn' || blocked?.kind !== 'turn') throw new Error('expected turns');
expect(empty.state).toBe('failed');
expect(empty.error).toBe('empty failure');
expect(blocked.triggerPromptId).toBe('prompt-blocked');
expect(blocked.error).toBeUndefined();
});
it('folds terminal facts for an empty prompt turn without a context identity', () => {
const base = groupMessagesIntoSnapshot([
{ role: 'assistant', content: [{ type: 'text', text: 'partial' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [], origin: { kind: 'user' }, promptId: 'prompt-empty', time: 1 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'empty failure' }, time: 2 },
],
base,
);
const turn = folded.items[0];
if (turn?.kind !== 'turn') throw new Error('expected turn');
expect(turn.triggerPromptId).toBeUndefined();
expect(turn.state).toBe('failed');
expect(turn.error).toBe('empty failure');
});
it('does not let an empty prompt claim a later system continuation turn', () => {
const base = groupMessagesIntoSnapshot([
{ id: 'internal-prompt', role: 'user', content: [{ type: 'text', text: 'continue' }], toolCalls: [], origin: { kind: 'system_trigger', name: 'goal_continuation' } as { kind: string } },
{ role: 'assistant', content: [{ type: 'text', text: 'stopped' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [], origin: { kind: 'user' }, promptId: 'prompt-empty', time: 1 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'empty failure' }, time: 2 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'continue' }], origin: { kind: 'system_trigger', name: 'goal_continuation' }, promptId: 'internal-prompt', time: 3 },
{ type: 'turn.ended', turnId: 1, reason: 'cancelled', durationMs: 10, time: 4 },
],
base,
);
const system = folded.items[0];
if (system?.kind !== 'turn') throw new Error('expected turn');
expect(system.triggerPromptId).toBeUndefined();
expect(system.state).toBe('cancelled');
expect(system.durationMs).toBe(10);
expect(system.error).toBeUndefined();
});
it('matches a system turn with internal prompt identity past a context-only blocked prompt', () => {
const base = groupMessagesIntoSnapshot([
{ id: 'prompt-blocked', role: 'user', content: [{ type: 'text', text: 'blocked' }], toolCalls: [], origin: { kind: 'user' } },
{ id: 'internal-prompt', role: 'user', content: [{ type: 'text', text: 'continue' }], toolCalls: [], origin: { kind: 'system_trigger', name: 'goal_continuation' } as { kind: string } },
{ role: 'assistant', content: [{ type: 'text', text: 'continuation failed' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [{ type: 'text', text: 'continue' }], origin: { kind: 'system_trigger', name: 'goal_continuation' }, promptId: 'internal-prompt', time: 1 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'system failure' }, time: 2 },
],
base,
);
const blocked = folded.items[0];
const system = folded.items[1];
if (blocked?.kind !== 'turn' || system?.kind !== 'turn') throw new Error('expected turns');
expect(blocked.triggerPromptId).toBe('prompt-blocked');
expect(blocked.error).toBeUndefined();
expect(system.triggerPromptId).toBeUndefined();
expect(system.state).toBe('failed');
expect(system.error).toBe('system failure');
});
it('maps turn.ended around hidden retry turns replayed from the turn-clock records', () => {
const base = groupMessagesIntoSnapshot([
{ role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
{ id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
{ id: 'prompt-2', role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'a2' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, time: 1 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, promptId: 'prompt-1', time: 1 },
{ type: 'turn.ended', turnId: 0, reason: 'completed', time: 2 },
{ type: 'turn.prompt', input: [], origin: { kind: 'retry' }, time: 3 },
{ type: 'turn.ended', turnId: 1, reason: 'failed', error: { message: 'retry boom' }, time: 4 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, time: 5 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'prompt-2', time: 5 },
{ type: 'turn.ended', turnId: 2, reason: 'cancelled', durationMs: 20, time: 6 },
],
base,
@ -1868,18 +2000,20 @@ describe('foldWireRecordFacts (cold facts)', () => {
const first = folded.items[0];
if (first?.kind !== 'turn') throw new Error('expected turn');
expect(first.state).toBe('completed');
expect(first.triggerPromptId).toBe('prompt-1');
const second = folded.items[1];
if (second?.kind !== 'turn') throw new Error('expected turn');
expect(second.state).toBe('cancelled');
expect(second.triggerPromptId).toBe('prompt-2');
expect(second.error).toBeUndefined();
expect(second.durationMs).toBe(20);
});
it('maps turn.ended across queued-then-cancelled turn reservations', () => {
const base = groupMessagesIntoSnapshot([
{ role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
{ id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
{ id: 'prompt-2', role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'a2' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
@ -1887,7 +2021,7 @@ describe('foldWireRecordFacts (cold facts)', () => {
{ type: 'turn.prompt', input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, time: 1 },
{ type: 'turn.ended', turnId: 0, reason: 'completed', time: 2 },
{ type: 'turn.cancel', turnId: 1, target: 'queued', time: 3 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, time: 4 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'two' }], origin: { kind: 'user' }, promptId: 'prompt-2', time: 4 },
{ type: 'turn.ended', turnId: 2, reason: 'failed', error: { message: 'boom' }, time: 5 },
],
base,
@ -1896,5 +2030,79 @@ describe('foldWireRecordFacts (cold facts)', () => {
if (second?.kind !== 'turn') throw new Error('expected turn');
expect(second.state).toBe('failed');
expect(second.error).toBe('boom');
expect(second.triggerPromptId).toBe('prompt-2');
});
it('skips undone turn boundaries when mapping prompt ids and turn endings', () => {
const base = groupMessagesIntoSnapshot([
{ id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'a1' }], toolCalls: [] },
{ id: 'prompt-3', role: 'user', content: [{ type: 'text', text: 'replacement' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'a3' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [{ type: 'text', text: 'one' }], origin: { kind: 'user' }, promptId: 'prompt-1', time: 1 },
{ type: 'context.append_message', message: { id: 'prompt-1', role: 'user', origin: { kind: 'user' } }, time: 1.5 },
{ type: 'turn.ended', turnId: 0, reason: 'completed', time: 2 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'undone' }], origin: { kind: 'user' }, promptId: 'prompt-2', time: 3 },
{ type: 'context.append_message', message: { id: 'prompt-2', role: 'user', origin: { kind: 'user' } }, time: 3.5 },
{ type: 'turn.ended', turnId: 1, reason: 'completed', time: 4 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'continue' }], origin: { kind: 'system_trigger', name: 'goal_continuation' }, time: 5 },
{ type: 'turn.ended', turnId: 2, reason: 'completed', time: 6 },
{ type: 'context.undo', count: 1, time: 7 },
{ type: 'turn.prompt', input: [{ type: 'text', text: 'replacement' }], origin: { kind: 'user' }, promptId: 'prompt-3', time: 8 },
{ type: 'turn.ended', turnId: 3, reason: 'failed', error: { message: 'replacement failed' }, time: 9 },
],
base,
);
const replacement = folded.items[1];
if (replacement?.kind !== 'turn') throw new Error('expected turn');
expect(replacement.triggerPromptId).toBe('prompt-3');
expect(replacement.state).toBe('failed');
expect(replacement.error).toBe('replacement failed');
});
it('counts context-only blocked prompts as undo anchors', () => {
const base = groupMessagesIntoSnapshot([
{ id: 'prompt-real', role: 'user', content: [{ type: 'text', text: 'real' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'failed' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [{ type: 'text', text: 'real' }], origin: { kind: 'user' }, promptId: 'prompt-real', time: 1 },
{ type: 'context.append_message', message: { id: 'prompt-real', role: 'user', origin: { kind: 'user' } }, time: 2 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'real failure' }, time: 3 },
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 4 },
{ type: 'context.undo', count: 1, time: 5 },
],
base,
);
const real = folded.items[0];
if (real?.kind !== 'turn') throw new Error('expected turn');
expect(real.triggerPromptId).toBe('prompt-real');
expect(real.state).toBe('failed');
expect(real.error).toBe('real failure');
});
it('does not consume a pending identity boundary for a different context-only anchor', () => {
const base = groupMessagesIntoSnapshot([
{ id: 'prompt-real', role: 'user', content: [{ type: 'text', text: 'real' }], toolCalls: [], origin: { kind: 'user' } },
{ role: 'assistant', content: [{ type: 'text', text: 'failed' }], toolCalls: [] },
]);
const folded = foldWireRecordFacts(
[
{ type: 'turn.prompt', input: [{ type: 'text', text: 'real' }], origin: { kind: 'user' }, promptId: 'prompt-real', time: 1 },
{ type: 'context.append_message', message: { id: 'prompt-blocked', role: 'user', origin: { kind: 'user' } }, time: 2 },
{ type: 'context.undo', count: 1, time: 3 },
{ type: 'context.append_message', message: { id: 'prompt-real', role: 'user', origin: { kind: 'user' } }, time: 4 },
{ type: 'turn.ended', turnId: 0, reason: 'failed', error: { message: 'real failure' }, time: 5 },
],
base,
);
const real = folded.items[0];
if (real?.kind !== 'turn') throw new Error('expected turn');
expect(real.state).toBe('failed');
expect(real.error).toBe('real failure');
});
});