kimi-code/packages/agent-core/test/agent/context.test.ts
Kai 743f66e547
refactor: move tool-result metadata into a structured note side channel (#1437)
* fix: stop rendering <system> notes from tool results in the terminal and web UIs

Tool results carry <system> blocks as side-channel notes for the model (ReadMediaFile summaries, Read status, MCP image captions, error/empty sentinels). Keep them in history for the model, but strip them at every core-to-UI boundary so they no longer render as plain text. vis is intentionally left untouched to preserve the model's-eye view for debugging.

* fix: keep error/empty status text visible when stripping tool-result <system> tags

Unwrap the tool error/empty sentinels (<system>ERROR: ...</system>, <system>Tool output is empty.</system>) instead of deleting them: keep the human-readable text and drop only the tags. Otherwise a failed or empty tool result rendered as a blank output, indistinguishable from a rendering bug. The model still reads the wrapped form in history.

* refactor: move tool-result metadata into a structured note side channel

Tool-produced model-facing metadata (ReadMediaFile summaries, Read status
lines, MCP image-compression captions) was baked into tool output as
<system> text, so every UI had to strip it back out and three copies of
the model-view normalization had silently drifted apart.

- ExecutableToolResult gains `note`: content rendered to the model but
  never to UIs; records and history now store the raw output plus the
  structured isError/note fields
- the model view is rendered exactly once at the LLM projection boundary
  by renderToolResultForModel; the transcript and vis hand-copies are
  deleted (vis now calls the same function for its model view, fixing
  their drifted empty-output checks)
- ReadMediaFile / Read / MCP captions write `note`; tool outputs stay
  pure data, and text-only results keep a single text part (note joined
  with a newline) so provider tool content stays a plain string
- all UI-side <system> stripping is removed; failed tools show their own
  error text with the structured isError flag
- wire protocol 1.4 -> 1.5 migrates existing records' tool-produced
  <system> blocks into `note` on resume

* fix: provider-neutral wording, no wire migration, direct optional fields

- "The attached image was downsampled" replaces directional wording that
  depended on provider serialization order (inline media vs flatten-and-
  re-attach)
- drop the 1.4 -> 1.5 wire migration: legacy records replay verbatim, so
  the model view of old sessions stays byte-identical to what the model
  originally saw and UIs show the legacy <system> text as-is; this also
  removes the risk of the migration misclassifying user data that quotes
  tool metadata, and the additive note field needs no version bump
- pass optional result fields as undefined instead of conditional spreads
  (repo convention)

* fix: enforce the note contract at the trust boundary; narrow the TUI system-tag guard

- normalizeToolResult now keeps a note only when it is a non-empty string:
  tools and finalize hooks are arbitrary JS, and a malformed note (null,
  number, object) would previously persist into the record and crash every
  subsequent LLM projection of the session. Everything downstream now
  trusts note to be string | undefined.
- the TUI tool body suppression matches the full <system-reminder> tag
  instead of any <system prefix: reminder piggy-backing stays hidden,
  while real output that merely starts with a literal <system> tag (file
  contents, MCP text) stays visible, covered through the real
  ToolCallComponent path.

* fix: return MCP compression captions as data instead of extracting them from text

compressImageContentParts now returns { parts, captions } — captions come
back from the compressor as structured data and are never inserted into
the parts, so the MCP pipeline no longer pattern-matches text to move
them into the note side channel. Tool output that merely quotes a
caption (a doc, a log, a test fixture) stays verbatim in the output.
Also corrects the stale claim that prompt ingestion uses this helper
(it compresses per image while constructing the part).

* docs: correct the image-compression re-export comment; export CompressedContentParts

The package-root comment still described compressImageContentParts as the
input-stage helper every ingestion site calls; prompt ingestion compresses
per image with compressBase64ForModel / compressImageForModel, and the MCP
pipeline is the walker's only caller. Also export the walker's
CompressedContentParts return type so public-API consumers can name it.

* feat: wrap tool status sentinels in <system> so the model can tell harness verdicts from tool output

The error/empty status text is model-only after the note refactor (UIs
render the raw output and style failures via the structured isError
flag), so the earlier plain-text wording served no remaining audience.
Wrapping the statuses in <system> gives every piece of system-generated
text inside a tool result the same marker:

- failed calls get '<system>ERROR: Tool execution failed.</system>'
  unconditionally — the ERROR:-prefix guard is removed, so the harness
  verdict can no longer be confused with tool output that happens to
  start with error-like text
- empty outputs render as '<system>Tool output is empty.</system>'; the
  plain placeholder the loop layer bakes into records is still
  recognized and upgraded at projection time

* style: collapse an internal helper docstring per the services subtree convention
2026-07-07 11:40:27 +08:00

1544 lines
50 KiB
TypeScript

import { Readable, type Writable } from 'node:stream';
import type { KaosProcess } from '@moonshot-ai/kaos';
import type { Message } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { renderNotificationXml } from '../../src/agent/context/notification-xml';
import { project } from '../../src/agent/context/projector';
import type { ContextMessage } from '../../src/agent/context/types';
import { buildImageCompressionCaption } from '../../src/tools/support/image-compress';
import { estimateTokensForMessages } from '../../src/utils/tokens';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry';
import { testAgent } from './harness/agent';
describe('Agent context', () => {
it('stores prompt origins without leaking them to LLM projection', () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'hello' }]);
ctx.agent.context.appendSystemReminder('Remember this.', { kind: 'injection', variant: 'host' });
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'origin-step', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'origin-tool',
turnId: '',
step: 1,
stepUuid: 'origin-step',
toolCallId: 'call_origin',
name: 'Run',
args: {},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.end', uuid: 'origin-step', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'origin-tool',
toolCallId: 'call_origin',
result: { output: 'tool output' },
},
});
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'user' } },
{ role: 'user', origin: { kind: 'injection', variant: 'host' } },
{ role: 'assistant', origin: undefined },
{ role: 'tool', origin: undefined },
]);
expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false);
});
it('preserves tool call extras (Gemini thought_signature) through to projection', () => {
// Regression: Gemini 3 requires the thought_signature returned on a
// functionCall to be echoed back when the call is re-sent in the next turn.
// The signature travels as ToolCall.extras.thought_signature_b64; it must
// survive the loop tool.call event -> context recording -> projection so
// the provider can put it back on the outbound functionCall part.
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'run' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'sig-step', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'sig-tool',
turnId: '',
step: 1,
stepUuid: 'sig-step',
toolCallId: 'call_sig',
name: 'Bash',
args: { command: 'echo hi' },
extras: { thought_signature_b64: 'c2lnbmF0dXJl' },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.end', uuid: 'sig-step', turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'sig-tool',
toolCallId: 'call_sig',
result: { output: 'hi' },
},
});
const expectedExtras = { thought_signature_b64: 'c2lnbmF0dXJl' };
const recorded = ctx.agent.context.history.find((m) => m.role === 'assistant');
expect(recorded?.toolCalls[0]?.extras).toEqual(expectedExtras);
const projected = ctx.agent.context.messages.find((m) => m.role === 'assistant');
expect(projected?.toolCalls[0]?.extras).toEqual(expectedExtras);
});
it('reroutes an inline image-compression caption into a hidden system reminder', () => {
const ctx = testAgent();
ctx.configure();
const caption = buildImageCompressionCaption({
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },
final: { width: 2000, height: 408, byteLength: 282 * 1024, mimeType: 'image/png' },
originalPath: '/tmp/originals/shot.png',
});
// The TUI merges the caption into the preceding text segment; the server
// route emits it as a standalone part. Cover the merged (harder) shape.
ctx.agent.context.appendUserMessage([
{ type: 'text', text: `能展示但是没有快捷键提示${caption}` },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
]);
const textOf = (message: ContextMessage): string =>
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'injection', variant: 'image_compression' } },
{ role: 'user', origin: { kind: 'user' } },
]);
const [reminder, userMessage] = ctx.agent.context.history;
expect(textOf(reminder!)).toContain('<system-reminder>');
expect(textOf(reminder!)).toContain('Image compressed to fit model limits');
expect(textOf(reminder!)).toContain('/tmp/originals/shot.png');
expect(textOf(reminder!)).not.toContain('<system>');
expect(textOf(userMessage!)).toBe('能展示但是没有快捷键提示');
expect(userMessage!.content.some((part) => part.type === 'image_url')).toBe(true);
});
it('drops a caption-only text part instead of leaving an empty user text part', () => {
const ctx = testAgent();
ctx.configure();
const caption = buildImageCompressionCaption({
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },
final: { width: 2000, height: 408, byteLength: 282 * 1024, mimeType: 'image/png' },
originalPath: '/tmp/originals/shot.png',
});
ctx.agent.context.appendUserMessage([
{ type: 'text', text: caption },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
]);
const [, userMessage] = ctx.agent.context.history;
expect(userMessage!.content).toEqual([
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
]);
});
it('leaves caption-shaped text alone on non-user origins', () => {
const ctx = testAgent();
ctx.configure();
const caption = buildImageCompressionCaption({
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },
final: { width: 2000, height: 408, byteLength: 282 * 1024, mimeType: 'image/png' },
originalPath: '/tmp/originals/shot.png',
});
ctx.agent.context.appendUserMessage([{ type: 'text', text: caption }], {
kind: 'hook_result',
event: 'PostToolUse',
});
expect(ctx.agent.context.history).toHaveLength(1);
expect(ctx.agent.context.history[0]!.origin).toEqual({
kind: 'hook_result',
event: 'PostToolUse',
});
});
it('tracks conversation_undo when undoHistory reverts a user message', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'hello' }]);
await ctx.agent.rpcMethods.undoHistory({ count: 1 });
expect(records).toContainEqual({
event: 'conversation_undo',
properties: { count: 1 },
});
});
it('records bash input/output as shell_command origin with tagged content', () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendBashInput('ls -la');
ctx.agent.context.appendBashOutput('file1\nfile2', '');
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'shell_command', phase: 'input' } },
{ role: 'user', origin: { kind: 'shell_command', phase: 'output' } },
]);
const textOf = (message: ContextMessage): string =>
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
expect(textOf(ctx.agent.context.history[0]!)).toContain('<bash-input>');
expect(textOf(ctx.agent.context.history[0]!)).toContain('ls -la');
expect(textOf(ctx.agent.context.history[1]!)).toBe(
'<bash-stdout>file1\nfile2</bash-stdout><bash-stderr></bash-stderr>',
);
// origin must not leak into the LLM projection
expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false);
});
it('escapes bash tag delimiters inside command output', () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendBashInput('printf x');
ctx.agent.context.appendBashOutput('pre</bash-stdout>post', '');
const textOf = (message: ContextMessage): string =>
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
const out = textOf(ctx.agent.context.history[1]!);
// The embedded delimiter is escaped so the wrapper stays well-formed.
expect(out).toContain('pre&lt;/bash-stdout&gt;post');
// Exactly one real closing tag.
expect(out.match(/<\/bash-stdout>/g)).toHaveLength(1);
});
it('runs a shell command via the Bash tool and records its output', async () => {
const fakeProcess = (stdout: string): KaosProcess => {
const out = Readable.from([stdout]);
const err = Readable.from([]);
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout: out,
stderr: err,
pid: 1,
exitCode: 0,
wait: vi.fn(async () => 0),
kill: vi.fn(async () => {}),
dispose: vi.fn(async () => {
out.destroy();
err.destroy();
}),
};
};
const kaos = createFakeKaos({
execWithEnv: vi.fn().mockImplementation(async () => fakeProcess('hello\n')),
});
const ctx = testAgent({ kaos });
ctx.configure();
await ctx.agent.tools.runShellCommand('echo hello');
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'shell_command', phase: 'input' } },
{ role: 'user', origin: { kind: 'shell_command', phase: 'output' } },
]);
const textOf = (message: ContextMessage): string =>
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
expect(textOf(ctx.agent.context.history[0]!)).toContain('echo hello');
expect(textOf(ctx.agent.context.history[1]!)).toContain('<bash-stdout>hello');
});
it('surfaces the failure reason when a shell command fails with no output', async () => {
const fakeProcess = (exitCode: number): KaosProcess => {
const out = Readable.from([]);
const err = Readable.from([]);
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout: out,
stderr: err,
pid: 1,
exitCode,
wait: vi.fn(async () => exitCode),
kill: vi.fn(async () => {}),
dispose: vi.fn(async () => {
out.destroy();
err.destroy();
}),
};
};
const kaos = createFakeKaos({
execWithEnv: vi.fn().mockImplementation(async () => fakeProcess(1)),
});
const ctx = testAgent({ kaos });
ctx.configure();
const result = await ctx.agent.tools.runShellCommand('false');
expect(result.isError).toBe(true);
expect(result.stderr).toContain('exit code');
const textOf = (message: ContextMessage): string =>
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
const output = ctx.agent.context.history.at(-1)!;
expect(textOf(output)).toContain('<bash-stderr>');
expect(textOf(output)).toContain('exit code');
});
it('normalizes a whitespace-only array tool result to the empty-output placeholder', () => {
const ctx = testAgent();
ctx.configure();
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_ws',
turnId: 't',
step: 1,
stepUuid: 's1',
toolCallId: 'call_ws',
name: 'Run',
args: {},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_ws',
toolCallId: 'call_ws',
// Array (ContentPart[]) output whose only block is whitespace. The tool
// contract allows arbitrary content arrays (e.g. MCP tools), so this must
// be normalized to the empty placeholder rather than left to be stripped
// empty by projection (which would throw on every send).
result: { output: [{ type: 'text', text: ' \n' }] },
},
});
expect(() => ctx.agent.context.messages).not.toThrow();
expect(ctx.agent.context.messages).toMatchObject([
{ role: 'assistant', toolCalls: [{ id: 'call_ws' }] },
{
role: 'tool',
content: [{ type: 'text', text: '<system>Tool output is empty.</system>' }],
toolCallId: 'call_ws',
},
]);
});
it('renders tool error and empty-output status as model-visible text', () => {
const ctx = testAgent();
ctx.configure();
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 },
});
for (const toolCallId of ['call_error', 'call_empty']) {
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: 't',
step: 1,
stepUuid: 's1',
toolCallId,
name: 'Run',
args: {},
},
});
}
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_error',
toolCallId: 'call_error',
result: { output: 'permission denied', isError: true },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_empty',
toolCallId: 'call_empty',
result: { output: '' },
},
});
expect(ctx.agent.context.messages).toMatchObject([
{ role: 'assistant', toolCalls: [{ id: 'call_error' }, { id: 'call_empty' }] },
{
role: 'tool',
content: [
{
type: 'text',
text: '<system>ERROR: Tool execution failed.</system>\npermission denied',
},
],
toolCallId: 'call_error',
},
{
role: 'tool',
content: [{ type: 'text', text: '<system>Tool output is empty.</system>' }],
toolCallId: 'call_empty',
},
]);
});
it('keeps raw tool result output in history; error status is added only in projection', () => {
const ctx = testAgent();
ctx.configure();
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_raw',
turnId: 't',
step: 1,
stepUuid: 's1',
toolCallId: 'call_raw',
name: 'Run',
args: {},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_raw',
toolCallId: 'call_raw',
result: { output: 'permission denied', isError: true },
},
});
// History stores the fact: the tool's own output plus the structured
// isError flag. The ERROR status line is a model-projection concern and
// must not be materialized here (UIs read history/replay verbatim).
const stored = ctx.agent.context.history.find((m) => m.toolCallId === 'call_raw')!;
expect(stored.content).toEqual([{ type: 'text', text: 'permission denied' }]);
expect(stored.isError).toBe(true);
const projected = ctx.agent.context.messages.find((m) => m.toolCallId === 'call_raw')!;
expect(projected.content).toEqual([
{
type: 'text',
text: '<system>ERROR: Tool execution failed.</system>\npermission denied',
},
]);
});
it('carries a tool result note into history and appends it in the LLM projection', () => {
const ctx = testAgent();
ctx.configure();
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_note',
turnId: 't',
step: 1,
stepUuid: 's1',
toolCallId: 'call_note',
name: 'Run',
args: {},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_note',
toolCallId: 'call_note',
result: { output: 'file body', note: '<system>10 lines read.</system>' },
},
});
// The note rides the message as a structured field (like origin/isError):
// output stays pure data, so UIs can render it without any stripping.
const stored = ctx.agent.context.history.find((m) => m.toolCallId === 'call_note')!;
expect(stored.content).toEqual([{ type: 'text', text: 'file body' }]);
expect(stored.note).toBe('<system>10 lines read.</system>');
// The model projection joins the note into the text-only output (single
// part keeps providers on the plain-string tool content path) and drops
// the structured field from the wire message.
const projected = ctx.agent.context.messages.find((m) => m.toolCallId === 'call_note')!;
expect(projected.content).toEqual([
{ type: 'text', text: 'file body\n<system>10 lines read.</system>' },
]);
expect('note' in projected).toBe(false);
});
it('drops empty and whitespace-only text parts in LLM projection', () => {
const history: ContextMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: '' },
{ type: 'text', text: 'Run the tool' },
],
toolCalls: [],
},
{
role: 'assistant',
content: [{ type: 'text', text: '' }],
toolCalls: [],
},
{
role: 'assistant',
content: [{ type: 'text', text: '' }],
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
},
{
role: 'tool',
content: [{ type: 'text', text: 'result' }],
toolCalls: [],
toolCallId: 'call_empty',
},
{
role: 'assistant',
content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }],
toolCalls: [],
},
{
// Whitespace-only message: strict providers reject the block, so the
// whole message is dropped from the projection.
role: 'user',
content: [{ type: 'text', text: ' ' }],
toolCalls: [],
},
];
expect(project(history)).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Run the tool' }],
toolCalls: [],
},
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
},
{
role: 'tool',
content: [{ type: 'text', text: 'result' }],
toolCalls: [],
toolCallId: 'call_empty',
},
{
role: 'assistant',
content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }],
toolCalls: [],
},
]);
expect(history[0]?.content).toEqual([
{ type: 'text', text: '' },
{ type: 'text', text: 'Run the tool' },
]);
expect(history[1]?.content).toEqual([{ type: 'text', text: '' }]);
});
it('heals an empty tool result into the placeholder during LLM projection', () => {
const history: ContextMessage[] = [
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
},
{
role: 'tool',
content: [{ type: 'text', text: '' }],
toolCallId: 'call_empty',
toolCalls: [],
},
];
// History stores the empty output as a fact; the projection renders it
// into the model-visible placeholder so strict providers never see an
// empty tool message.
expect(project(history)).toMatchObject([
{ role: 'assistant' },
{
role: 'tool',
content: [{ type: 'text', text: '<system>Tool output is empty.</system>' }],
toolCallId: 'call_empty',
},
]);
});
it('projects hook result messages into LLM projection', async () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'hooked input' }]);
ctx.agent.context.appendMessage({
role: 'user',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>',
},
],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
});
ctx.agent.context.appendMessage({
role: 'assistant',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true },
});
ctx.agent.context.appendMessage({
role: 'user',
content: [{ type: 'text', text: 'continue from stop hook' }],
toolCalls: [],
origin: { kind: 'hook_result', event: 'Stop' },
});
expect(ctx.agent.context.history).toHaveLength(4);
expect(ctx.agent.context.messages).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'hooked input' }],
toolCalls: [],
},
{
role: 'user',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>',
},
],
toolCalls: [],
},
{
role: 'assistant',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: 'continue from stop hook' }],
toolCalls: [],
},
]);
await ctx.expectResumeMatches();
});
it('projects blocked UserPromptSubmit prompts into LLM projection', async () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'blocked prompt' }]);
ctx.agent.context.appendMessage({
role: 'assistant',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
toolCalls: [],
origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true },
});
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'safe followup' }]);
expect(ctx.agent.context.history).toHaveLength(3);
expect(ctx.agent.context.messages).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'blocked prompt' }],
toolCalls: [],
},
{
role: 'assistant',
content: [
{
type: 'text',
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
},
],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: 'safe followup' }],
toolCalls: [],
},
]);
await ctx.expectResumeMatches();
});
it('projects user, assistant, tool call, and tool result records into LLM history', async () => {
const ctx = testAgent();
ctx.configure();
ctx.appendAssistantText(1, 'earlier assistant');
ctx.appendToolExchange();
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] });
await ctx.untilTurnEnd();
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "user before step 1"
assistant: text "earlier assistant"
user: text "lookup something"
assistant: text "I will call Lookup." calls call_lookup:Lookup { "query": "moon" }
tool[call_lookup]: text "lookup result"
user: text "continue"
`);
await ctx.expectResumeMatches();
});
it('keeps system reminders separate from real user prompts', async () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendSystemReminder('Remember the host note.', {
kind: 'injection',
variant: 'host',
});
ctx.mockNextResponse({ type: 'text', text: 'noted' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Real user prompt' }] });
await ctx.untilTurnEnd();
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "<system-reminder>\\nRemember the host note.\\n</system-reminder>"
user: text "Real user prompt"
`);
});
it('defers system reminders until pending tool results are recorded and resumed', async () => {
const ctx = testAgent();
ctx.configure();
const stepUuid = 'skill-batch-step';
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'load a skill' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 },
});
for (const [toolCallId, name] of [
['call_write', 'Write'],
['call_skill', 'Skill'],
] as const) {
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: '0',
step: 1,
stepUuid,
toolCallId,
name,
args: {},
},
});
}
ctx.dispatch({
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: '<system-reminder>\nskill body\n</system-reminder>' }],
toolCalls: [],
origin: {
kind: 'skill_activation',
activationId: 'act_skill',
skillName: 'demo',
trigger: 'model-tool',
},
},
});
expect(ctx.agent.context.history.map((message) => message.role)).toEqual(['user', 'assistant']);
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '0',
step: 1,
finishReason: 'tool_use',
},
});
expect(ctx.agent.context.history.map((message) => message.role)).toEqual(['user', 'assistant']);
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_write',
toolCallId: 'call_write',
result: { output: 'wrote file' },
},
});
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
]);
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_skill',
toolCallId: 'call_skill',
result: { output: 'skill loaded' },
},
});
expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([
'user',
'assistant',
'tool',
'tool',
'user',
]);
expect(ctx.agent.context.messages[4]?.content).toEqual([
{ type: 'text', text: '<system-reminder>\nskill body\n</system-reminder>' },
]);
await ctx.expectResumeMatches();
});
// Regression: a user message injected after `step.begin` but before the first
// `tool.call` (e.g. a background-task notification flushed mid-step) lands
// between the assistant `tool_use` and its `tool_result` in history, which
// strict providers (Anthropic) reject with HTTP 400. The projector must repair
// the adjacency so the `tool_result` immediately follows the `tool_use`. Micro
// compaction exposed this latent misordering by busting the prompt cache.
it('repairs a tool_use/tool_result adjacency broken by an injected user message', async () => {
const ctx = testAgent();
ctx.configure();
const stepUuid = 'mid-step-notify-step';
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'drive the tank' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 },
});
// Notification arrives in the gap between step.begin and tool.call, when no
// tool result is yet pending, so it is pushed directly into history.
ctx.agent.context.appendUserMessage([{ type: 'text', text: '<notification>bg done</notification>' }], {
kind: 'background_task',
taskId: 'task-1',
status: 'completed',
notificationId: 'task:task-1:completed',
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_drive',
turnId: '0',
step: 1,
stepUuid,
toolCallId: 'call_drive',
name: 'Drive',
args: {},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '0',
step: 1,
finishReason: 'tool_use',
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_drive',
toolCallId: 'call_drive',
result: { output: 'drove forward' },
},
});
// History preserves the original (misordered) sequence: the notification sits
// between the assistant tool_use and its tool_result.
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
'user',
'assistant',
'user',
'tool',
]);
// Projection repairs the adjacency: the tool_result immediately follows the
// assistant tool_use, and the sandwiched notification is moved after it.
const projected = ctx.agent.context.messages;
expect(projected.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'user']);
const assistantIndex = projected.findIndex(
(message) => message.role === 'assistant' && message.toolCalls.length > 0,
);
expect(projected[assistantIndex]?.toolCalls.map((toolCall) => toolCall.id)).toEqual([
'call_drive',
]);
expect(projected[assistantIndex + 1]).toMatchObject({
role: 'tool',
toolCallId: 'call_drive',
});
expect(projected[assistantIndex + 2]?.content).toEqual([
{ type: 'text', text: '<notification>bg done</notification>' },
]);
await ctx.expectResumeMatches();
});
it('drops deferred reminders when compaction drops a pending tool exchange', async () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old prompt' }]);
ctx.appendContextPartiallyResolvedParallelToolExchange();
ctx.agent.context.appendSystemReminder('first reminder', {
kind: 'injection',
variant: 'host',
});
ctx.agent.context.applyCompaction({
summary: 'summary of old prompt',
compactedCount: 4,
tokensBefore: 100,
});
ctx.agent.context.appendSystemReminder('second reminder', {
kind: 'injection',
variant: 'host',
});
// Compaction keeps only the real user prompt plus the summary; the deferred
// first reminder is dropped because initial context is rebuilt every turn.
// The second reminder, appended after compaction, is preserved.
expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([
'user',
'user',
'user',
]);
expect(ctx.agent.context.messages[2]?.content).toEqual([
{ type: 'text', text: '<system-reminder>\nsecond reminder\n</system-reminder>' },
]);
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_two',
toolCallId: 'call_open_two',
result: { output: 'two result' },
},
});
// The pending tool exchange was dropped by compaction, so the late tool
// result is ignored and the history is unchanged.
expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([
'user',
'user',
'user',
]);
await ctx.expectResumeMatches();
});
it('applyCompaction keeps only real user input from mixed user-role history', () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'real prompt' }]);
ctx.agent.context.appendBashInput('pwd');
ctx.agent.context.appendBashOutput('/tmp/repo', '', false);
ctx.agent.context.appendLocalCommandStdout('local command output');
ctx.agent.context.appendSystemReminder('stale reminder', {
kind: 'injection',
variant: 'host',
});
const result = ctx.agent.context.applyCompaction({
summary: 'summary of mixed history',
compactedCount: 5,
tokensBefore: 100,
});
ctx.agent.context.appendSystemReminder('fresh reminder', {
kind: 'injection',
variant: 'host',
});
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
{ role: 'user', origin: { kind: 'user' } },
{ role: 'user', origin: { kind: 'compaction_summary' } },
{ role: 'user', origin: { kind: 'injection', variant: 'host' } },
]);
expect(result.keptUserMessageCount).toBe(1);
});
it('clears context before the next LLM request', async () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'stale user message' }]);
await ctx.rpc.clearContext({});
ctx.mockNextResponse({ type: 'text', text: 'fresh' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'fresh prompt' }] });
await ctx.untilTurnEnd();
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "fresh prompt"
`);
await ctx.expectResumeMatches();
});
it('uses compacted summary plus recent messages', async () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old user message' }]);
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
ctx.agent.context.applyCompaction({
summary: 'summary of old context',
compactedCount: 1,
tokensBefore: 100,
});
expect(ctx.agent.context.history.at(-1)?.origin).toEqual({ kind: 'compaction_summary' });
ctx.mockNextResponse({ type: 'text', text: 'after compaction' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'new prompt' }] });
await ctx.untilTurnEnd();
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
system: <system-prompt>
tools: []
messages:
user: text "old user message\\n\\nrecent user message"
user: text "summary of old context"
user: text "new prompt"
`);
await ctx.expectResumeMatches();
});
it('includes new user messages as pending until the next usage update', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000);
expect(ctx.agent.context.tokenCountWithPending).toBe(1_000);
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'next user prompt'.repeat(20) }]);
const pendingMessages = ctx.agent.context.history.slice(-1);
expect(ctx.agent.context.tokenCountWithPending).toBe(
ctx.agent.context.tokenCount + estimateTokensForMessages(pendingMessages),
);
});
it('keeps tool results pending when step usage covers only through the assistant message', () => {
const ctx = testAgent();
ctx.configure();
const stepUuid = 'context-pending-tool-step';
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'lookup pending tokens' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_pending_tokens',
turnId: '0',
step: 1,
stepUuid,
toolCallId: 'call_pending_tokens',
name: 'Lookup',
args: {},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_pending_tokens',
toolCallId: 'call_pending_tokens',
result: { output: 'large tool result '.repeat(50) },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '0',
step: 1,
usage: {
inputOther: 1_200,
output: 80,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'tool_use',
},
});
const pendingMessages = ctx.agent.context.history.slice(-1);
expect(ctx.agent.context.tokenCount).toBe(1_280);
expect(ctx.agent.context.tokenCountWithPending).toBe(
1_280 + estimateTokensForMessages(pendingMessages),
);
});
it('does not zero tokenCount when a filtered step reports zero usage', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000);
expect(ctx.agent.context.tokenCount).toBe(1_000);
const stepUuid = 'context-filtered-step';
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'next prompt' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 2 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '0',
step: 2,
usage: {
inputOther: 0,
output: 0,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'filtered',
},
});
expect(ctx.agent.context.tokenCount).toBeGreaterThan(1_000);
expect(ctx.agent.context.tokenCountWithPending).toBeGreaterThanOrEqual(
ctx.agent.context.tokenCount,
);
});
it('undo only counts real user prompts, skipping background notifications', () => {
const ctx = testAgent();
ctx.configure();
ctx.appendAssistantText(1, 'first response');
ctx.appendAssistantText(2, 'second response');
// Append a background task notification (role: 'user' but not a real prompt)
ctx.agent.context.appendMessage({
role: 'user',
content: [{ type: 'text', text: 'background task completed' }],
toolCalls: [],
origin: {
kind: 'background_task',
taskId: 'bash-001',
status: 'completed',
notificationId: 'task:bash-001:completed',
},
});
expect(ctx.agent.context.history.map((m) => m.role)).toEqual([
'user',
'assistant',
'user',
'assistant',
'user',
]);
ctx.agent.context.undo(1);
// Should remove the background notification, the second assistant, and the second user prompt
expect(ctx.agent.context.history.map((m) => m.role)).toEqual(['user', 'assistant']);
});
it('stops at compaction summary and records the requested undo count', () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old user message' }]);
ctx.agent.context.applyCompaction({
summary: 'summary of compacted context',
compactedCount: 1,
tokensBefore: 100,
});
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
ctx.agent.context.appendMessage({
role: 'assistant',
content: [{ type: 'text', text: 'recent answer' }],
toolCalls: [],
});
ctx.newEvents();
expect(() => {
ctx.agent.context.undo(2);
}).toThrow(
'Cannot undo 2 prompts; only 1 prompt can be undone in the active context after the last compaction.',
);
expect(ctx.agent.context.history).toEqual([
expect.objectContaining({
role: 'user',
content: [{ type: 'text', text: 'old user message' }],
}),
expect.objectContaining({
role: 'user',
origin: { kind: 'compaction_summary' },
content: [{ type: 'text', text: 'summary of compacted context' }],
}),
]);
expect(ctx.newEvents()).toContainEqual(
expect.objectContaining({
type: '[wire]',
event: 'context.undo',
args: expect.objectContaining({ count: 2 }),
}),
);
});
it('does not throw while restoring an undo that stops at compaction summary', () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old user message' }]);
ctx.agent.context.applyCompaction({
summary: 'summary of compacted context',
compactedCount: 1,
tokensBefore: 100,
});
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
ctx.agent.context.appendMessage({
role: 'assistant',
content: [{ type: 'text', text: 'recent answer' }],
toolCalls: [],
});
expect(() => {
ctx.agent.records.restore({ type: 'context.undo', count: 2 });
}).not.toThrow();
expect(ctx.agent.context.history).toEqual([
expect.objectContaining({
role: 'user',
content: [{ type: 'text', text: 'old user message' }],
}),
expect.objectContaining({
role: 'user',
origin: { kind: 'compaction_summary' },
content: [{ type: 'text', text: 'summary of compacted context' }],
}),
]);
});
it('preserves injection messages when undo removes the surrounding turn', () => {
const ctx = testAgent();
ctx.configure();
ctx.dispatch({
type: 'context.append_message',
message: userMessage('do the work', { kind: 'user' }),
});
ctx.dispatch({
type: 'context.append_message',
message: userMessage('Plan mode is active', {
kind: 'injection',
variant: 'plan_mode',
}),
});
ctx.dispatch({
type: 'context.append_message',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'work done' }],
toolCalls: [],
},
});
ctx.agent.context.undo(1);
expect(ctx.agent.context.history).toEqual([
expect.objectContaining({
role: 'user',
origin: { kind: 'injection', variant: 'plan_mode' },
}),
]);
expect(ctx.agent.replayBuilder.buildResult()).toEqual([
expect.objectContaining({
type: 'message',
message: expect.objectContaining({
origin: { kind: 'injection', variant: 'plan_mode' },
}),
}),
]);
});
});
describe('Agent context notification projection', () => {
it('renders task notifications with escaped attributes and generic children', () => {
const text = renderNotificationXml({
id: 'n_"1&2',
category: 'task',
type: 'task.done',
source_kind: 'background_task',
source_id: 'bg&1',
title: 'Task finished',
severity: 'info',
body: 'The task completed.',
children: [
[
'<output-file path="/tmp/logs/a&amp;b/output.log" bytes="1234">',
'Read the output file to retrieve the result: /tmp/logs/a&amp;b/output.log',
'</output-file>',
].join('\n'),
],
});
expect(text).toContain('id="n_&quot;1&amp;2"');
expect(text).toContain('source_id="bg&amp;1"');
expect(text).toContain('Title: Task finished');
expect(text).toContain('Severity: info');
expect(text).toContain('<output-file path="/tmp/logs/a&amp;b/output.log" bytes="1234">');
expect(text).toContain(
'Read the output file to retrieve the result: /tmp/logs/a&amp;b/output.log',
);
expect(text).not.toContain('<task-notification>');
expect(text.trimEnd()).toMatch(/<\/notification>$/);
});
it('renders an agent_id attribute when the notification carries one', () => {
// Background agent tasks (taskId starts with `agent-`) own a separate
// `agent_id` for the spawned subagent. Surfacing it as a top-level XML
// attribute lets the LLM resume the right thing without having to dig
// it out of the body or cross-reference the spawn-success ToolResult.
const text = renderNotificationXml({
id: 'n_lost1',
category: 'task',
type: 'task.lost',
source_kind: 'background_task',
source_id: 'agent-w7gq3wwj',
agent_id: 'agent-0',
title: 'Background agent lost',
severity: 'warning',
body: 'Background agent 1 lost.',
});
expect(text).toContain('source_id="agent-w7gq3wwj"');
expect(text).toContain('agent_id="agent-0"');
});
it('omits the agent_id attribute when the notification does not carry one', () => {
const text = renderNotificationXml({
id: 'n_bash',
category: 'task',
type: 'task.completed',
source_kind: 'background_task',
source_id: 'bash-abcdef00',
title: 'Background task completed',
severity: 'info',
body: 'echo done completed.',
});
expect(text).not.toContain('agent_id=');
});
it('does not render task output blocks for non-task notifications', () => {
const text = renderNotificationXml({
id: '',
source_kind: 'host',
output_path: '/tmp/output.log',
});
expect(text).toContain('id="unknown"');
expect(text).toContain('category="unknown"');
expect(text).not.toContain('<task-notification>');
expect(text).not.toContain('<output-file');
expect(text).not.toContain('/tmp/output.log');
});
it('does not merge a cron-fire envelope into an adjacent user message', () => {
const cronEnvelope =
'<cron-fire jobId="deadbeef" cron="*/5 * * * *" recurring="true" coalescedCount="1" stale="false">\n<prompt>\ncheck the deploy\n</prompt>\n</cron-fire>';
const messages = project([
userMessage(cronEnvelope, {
kind: 'cron_job',
jobId: 'deadbeef',
cron: '*/5 * * * *',
recurring: true,
coalescedCount: 1,
stale: false,
}),
userMessage('Actual follow-up from the user', { kind: 'user' }),
]);
expect(messages).toHaveLength(2);
expect(textOf(messages[0]!)).toBe(cronEnvelope);
expect(textOf(messages[1]!)).toBe('Actual follow-up from the user');
});
it('uses message origin to keep non-user-origin messages separate', () => {
const messages = project([
userMessage('Host reminder without an XML prefix', {
kind: 'injection',
variant: 'host',
}),
userMessage('Actual follow-up from the user', { kind: 'user' }),
]);
expect(messages).toHaveLength(2);
expect(textOf(messages[0]!)).toBe('Host reminder without an XML prefix');
expect(textOf(messages[1]!)).toBe('Actual follow-up from the user');
});
it('only merges user-role messages with user origin', () => {
const messages = project([
userMessage('First real prompt', { kind: 'user' }),
userMessage('Second real prompt', { kind: 'user' }),
userMessage('No origin prompt'),
userMessage('Third real prompt', { kind: 'user' }),
]);
expect(messages).toHaveLength(3);
expect(textOf(messages[0]!)).toBe('First real prompt\n\nSecond real prompt');
expect(textOf(messages[1]!)).toBe('No origin prompt');
expect(textOf(messages[2]!)).toBe('Third real prompt');
});
});
function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
origin,
};
}
function textOf(message: Message): string {
return message.content
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
.map((part) => part.text)
.join('');
}
describe('strictMessages duplicate tool call ids', () => {
it('keeps duplicates on the normal projection but dedupes them in the strict one', () => {
const ctx = testAgent();
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'run the tool twice' }]);
// A provider with per-response counter ids reuses `call_dup` in two steps;
// both exchanges record their own result.
for (const step of [1, 2]) {
const stepUuid = `dup-step-${String(step)}`;
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: `dup-call-${String(step)}`,
turnId: '',
step,
stepUuid,
toolCallId: 'call_dup',
name: 'Run',
args: { attempt: step },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.end', uuid: stepUuid, turnId: '', step },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: `dup-call-${String(step)}`,
toolCallId: 'call_dup',
result: { output: `result ${String(step)}` },
},
});
}
// Normal projection: the lax provider that produced the duplicate ids
// accepts them, so nothing is dropped.
const normal = ctx.agent.context.messages;
expect(
normal.filter((message) => message.role === 'assistant').flatMap((m) => m.toolCalls),
).toHaveLength(2);
expect(normal.filter((message) => message.role === 'tool')).toHaveLength(2);
// Strict resend projection: one call, one result.
const strict = ctx.agent.context.strictMessages;
expect(
strict.filter((message) => message.role === 'assistant').flatMap((m) => m.toolCalls),
).toHaveLength(1);
const strictResults = strict.filter((message) => message.role === 'tool');
expect(strictResults).toHaveLength(1);
expect(textOf(strictResults[0]!)).toBe('result 1');
});
});