mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-31 02:14:58 +00:00
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
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:
parent
cbe0a77f3d
commit
58b74cfeab
19 changed files with 566 additions and 45 deletions
5
.changeset/fix-turn-prompt-identity.md
Normal file
5
.changeset/fix-turn-prompt-identity.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix duplicate user messages in transcript clients.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue