kimi-code/packages/agent-core/test/agent/plan.test.ts
Kai 65d30177ad
feat(agent-core): record llm request trace in wire.jsonl (#1448)
* feat(agent-core): record llm request trace in wire.jsonl

Add three observability record types so every request sent to the model
can be reconstructed from the wire log at the logical-request level:

- llm.tools_snapshot: content-addressed snapshot of the top-level tools
  table as sent (post deferred-strip), written once per unique table
- llm.request: one record per outbound request (retries, strict resends,
  and compaction rounds included) carrying the effective request params
  and hash links to the system prompt and tools snapshot
- mcp.tools_discovered: the server's verbatim tools/list result plus the
  agent's gating (allow-list, collisions), deduplicated by content hash

Observability records never feed state rebuild; replay only restores the
write-dedup cursors. The records/types.ts contract now documents the two
record classes explicitly (persisted is not the same as replayed).

Recording happens at the single Agent.generate choke point. The
LLMRequestLogFields side channel gains kind/projection/maxTokens/
droppedCount, chatWithRetry preserves caller-set fields, and compaction
tags its requests. The vis wire view renders the new record kinds.

* fix(agent-core): record the provider-clamped completion cap in the request trace

The llm.request trace recorded the client-requested budget cap, but
chat-completions providers tighten the actual wire value inside
withMaxCompletionTokens (remaining-context sizing, transport ceilings,
model-default resolution) — with the default budget the clamp is active
on nearly every non-empty-context request, so the recorded value did not
match what was sent.

Providers now expose the effective cap they computed as a readonly
maxCompletionTokens field on the clone, and the recorder reads it from
the effective provider at the Agent.generate choke point. This replaces
the side-channel recomputation, which is removed along with the
appliedCompletionBudgetCap helper.

* fix(agent-core): park pre-replay MCP discovery records and hash the collision outcome

Two wire-hygiene fixes for the mcp.tools_discovered trace:

Parking: the real Session ordering connects MCP servers concurrently with
agent construction, so ToolManager can observe a connected server before
agent.resume() has replayed the wire. Recording at that point bypassed
the restored dedup cursor (duplicating a 1-50KB record on every resume)
and appended a stray metadata record ahead of replay. AgentRecords now
exposes a one-shot opened latch — set when replay completes (after the
migration rewrite flushes) or when the first live record is logged — and
ToolManager parks discoveries until then, re-running the dedup check at
drain time. A frozen range-limited replay never opens; those agents are
transient previews.

Collision hashing: the dedup hash now covers the collision outcome, not
just the raw list and allow-list. Collisions depend on which other
servers hold a sanitized qualified name at registration time, so a
server can re-register with identical tools but a flipped outcome; that
gating change must produce a new record instead of being suppressed.

* fix(agent-core): skip the request trace for pre-flight-aborted calls

Mirror kosong generate()'s pre-flight abort check at the Agent.generate
choke point: a call whose signal is already aborted never reaches the
wire (generate throws before dispatching), so it must not leave an
llm.request/llm.tools_snapshot trace or a diagnostic log line claiming a
request was sent. Recording stays before dispatch for every call that
passes the gate, preserving the crash-safety of the trace.

* chore(agent-core): remove a leftover adaptive-thinking override hook

The adaptiveThinkingOverride option was a temporary local hook explicitly
marked for removal before commit. Nothing passes it, so resolution falls
back to the alias-level adaptiveThinking value in all cases; drop the
option and the dead indirection.

* fix(kosong): derive the exposed completion cap from generation kwargs

maxCompletionTokens was a field stored only by withMaxCompletionTokens,
so caps that reach the wire through other paths were invisible to the
request trace: with completion budgeting disabled via env, Anthropic
still sends the constructor-resolved max_tokens (required by the
Messages API), and constructor-level kwargs like OpenAILegacyOptions
maxTokens were likewise unreported.

Replace the stored field with a getter derived from each provider's
generation kwargs — the single source the request body reads — covering
constructor defaults, direct withGenerationKwargs configuration, and
budget application in one place. Kimi mirrors its request-time legacy
max_tokens alias normalization; openai-legacy reuses the same
normalizeGenerationKwargs the request path uses.

* feat(agent-core): add thinkingKeep passthrough for Kimi providers and update tests
2026-07-07 14:09:19 +08:00

646 lines
46 KiB
TypeScript

import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createCommandKaos, testAgent } from './harness/agent';
function createPlanKaos(overrides: Parameters<typeof createFakeKaos>[0] = {}) {
return createFakeKaos({
mkdir: vi.fn().mockResolvedValue(undefined),
...overrides,
});
}
describe('manual plan entry', () => {
it('keeps permission gating out of the PlanMode state object', () => {
const ctx = testAgent();
expect('beforeToolCall' in ctx.agent.planMode).toBe(false);
});
it('enters plan mode without starting a model turn and prepares the plan directory', async () => {
const mkdir = vi.fn().mockResolvedValue(undefined);
const writeText = vi.fn().mockResolvedValue(0);
const ctx = testAgent({
kaos: createFakeKaos({ mkdir, writeText }),
});
await ctx.rpc.enterPlan({});
await delay(10);
expect(ctx.agent.planMode.isActive).toBe(true);
expect(ctx.agent.planMode.planFilePath).toMatch(/\.md$/);
expect(mkdir).toHaveBeenCalledWith('/workspace/plan', { parents: true, existOk: true });
expect(writeText).not.toHaveBeenCalled();
expect(ctx.allEvents.some((event) => event.event === 'turn.started')).toBe(false);
expect(ctx.llmCalls).toHaveLength(0);
});
it('derives the no-homedir plan path from cwd on enter and restore', async () => {
const ctx = testAgent({
kaos: createPlanKaos({
writeText: vi.fn(async (_path: string, content: string) => content.length),
}),
});
await ctx.agent.planMode.enter('stable-plan');
const livePath = ctx.agent.planMode.planFilePath;
if (livePath === null) throw new Error('expected active plan path');
expect(livePath).toBe('/workspace/plan/stable-plan.md');
const enterRecord = ctx.allEvents.find(
(event) => event.type === '[wire]' && event.event === 'plan_mode.enter',
);
expect(enterRecord?.args).toEqual({
id: 'stable-plan',
time: expect.any(Number),
});
const resumed = testAgent({ kaos: createFakeKaos() });
resumed.dispatch({
type: 'plan_mode.enter',
id: 'stable-plan',
});
expect(resumed.agent.planMode.planFilePath).toBe(livePath);
});
it('enters plan mode through the EnterPlanMode tool and reminds the next step', async () => {
const enterPlanModeCall: ToolCall = {
type: 'function',
id: 'call_enter_plan',
name: 'EnterPlanMode',
arguments: '{}',
};
const ctx = testAgent({
kaos: createPlanKaos({
writeText: vi.fn(async (_path: string, content: string) => content.length),
}),
});
ctx.configure({ tools: ['EnterPlanMode'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
ctx.mockNextResponse({ type: 'text', text: 'I will enter plan mode.' }, enterPlanModeCall);
ctx.mockNextResponse({ type: 'text', text: 'Plan mode is active now.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Plan first' }] });
await ctx.untilTurnEnd();
await delay(10);
expect(ctx.agent.planMode.isActive).toBe(true);
expect(ctx.llmCalls).toHaveLength(2);
expect(toolResultText(ctx.llmCalls[1]!.history)).toContain('Plan mode is now active');
await ctx.expectResumeMatches();
});
});
describe('plan clear', () => {
it('empties the current plan file without leaving plan mode', async () => {
const files = new Map<string, string>();
const mkdir = vi.fn().mockResolvedValue(undefined);
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const writeText = vi.fn(async (path: string, content: string) => {
files.set(path, content);
return content.length;
});
const ctx = testAgent({
kaos: createPlanKaos({ mkdir, readText, writeText }),
});
await ctx.agent.planMode.enter('test-plan', false);
const planPath = ctx.agent.planMode.planFilePath;
if (planPath === null) throw new Error('expected active plan path');
files.set(planPath, '# Plan\n\n- Step 1');
await ctx.rpc.clearPlan({});
expect(writeText).toHaveBeenCalledWith(planPath, '');
expect(files.get(planPath)).toBe('');
expect(ctx.agent.planMode.isActive).toBe(true);
expect(ctx.agent.planMode.planFilePath).toBe(planPath);
await expect(ctx.rpc.getPlan({})).resolves.toMatchObject({
id: 'test-plan',
content: '',
path: planPath,
});
await ctx.expectResumeMatches();
});
});
describe('plan exit tool', () => {
it('reads the current plan file and exits plan mode directly in auto mode', async () => {
const files = new Map<string, string>();
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const ctx = testAgent({
kaos: createPlanKaos({ readText }),
});
ctx.configure({ tools: ['ExitPlanMode'] });
await ctx.rpc.setPermission({ mode: 'auto' });
await ctx.agent.planMode.enter('test-plan', false);
const planPath = ctx.agent.planMode.planFilePath;
if (planPath === null) throw new Error('expected active plan path');
files.set(planPath, '# Plan\n\n- Inspect\n- Change\n- Verify');
const exitPlanModeCall: ToolCall = {
type: 'function',
id: 'call_exit_plan',
name: 'ExitPlanMode',
arguments: '{}',
};
ctx.mockNextResponse({ type: 'text', text: 'I will present the plan.' }, exitPlanModeCall);
ctx.mockNextResponse({ type: 'text', text: 'I can execute after approval.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Show the plan' }] });
await ctx.untilTurnEnd();
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'requestApproval'),
).toBe(false);
expect(readText).toHaveBeenCalledWith(planPath);
expect(ctx.agent.planMode.isActive).toBe(false);
const llmInput = ctx.llmCalls[1]!;
expect(toolResultText(llmInput.history)).toContain('Plan mode deactivated');
expect(toolResultText(llmInput.history)).toContain('# Plan');
await ctx.expectResumeMatches();
});
it('stops the turn and stays in plan mode when the user rejects the plan', async () => {
const files = new Map<string, string>();
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const ctx = testAgent({
kaos: createPlanKaos({ readText }),
});
ctx.configure({ tools: ['ExitPlanMode'] });
await ctx.rpc.setPermission({ mode: 'manual' });
await ctx.agent.planMode.enter('reject-plan', false);
const planPath = ctx.agent.planMode.planFilePath;
if (planPath === null) throw new Error('expected active plan path');
files.set(planPath, '# Plan\n\n- Inspect\n- Change\n- Verify');
const exitPlanModeCall: ToolCall = {
type: 'function',
id: 'call_exit_reject',
name: 'ExitPlanMode',
arguments: '{}',
};
ctx.mockNextResponse({ type: 'text', text: 'I will present the plan.' }, exitPlanModeCall);
ctx.mockNextResponse({ type: 'text', text: 'This response must not be requested.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Show the plan' }] });
const approval = await ctx.takeApprovalRequest();
approval.respond({ decision: 'rejected', selectedLabel: 'Reject' });
await ctx.untilTurnEnd();
expect(readText).toHaveBeenCalledWith(planPath);
expect(ctx.agent.planMode.isActive).toBe(true);
expect(ctx.llmCalls).toHaveLength(1);
expect(toolResultText(ctx.agent.context.history)).toContain('Plan rejected by user');
await ctx.expectResumeMatches();
});
it('does not execute later tool calls in the same batch after plan rejection', async () => {
const files = new Map<string, string>();
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const execWithEnv = vi.fn(() => {
throw new Error('Bash should not execute after plan rejection');
});
const ctx = testAgent({
kaos: createPlanKaos({ readText, execWithEnv }),
});
ctx.configure({ tools: ['ExitPlanMode', 'Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
await ctx.agent.planMode.enter('reject-and-exit-plan', false);
const planPath = ctx.agent.planMode.planFilePath;
if (planPath === null) throw new Error('expected active plan path');
files.set(planPath, '# Plan\n\n- Inspect\n- Change\n- Verify');
const exitPlanModeCall: ToolCall = {
type: 'function',
id: 'call_exit_reject_and_exit',
name: 'ExitPlanMode',
arguments: '{}',
};
const bashCall: ToolCall = {
type: 'function',
id: 'call_bash_after_reject',
name: 'Bash',
arguments: '{"command":"touch should-not-run","timeout":60}',
};
ctx.mockNextResponse(
{ type: 'text', text: 'I will present the plan and then run a command.' },
exitPlanModeCall,
bashCall,
);
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Show the plan' }] });
const approval = await ctx.takeApprovalRequest();
approval.respond({ decision: 'rejected', selectedLabel: 'Reject' });
await ctx.untilTurnEnd();
expect(ctx.agent.planMode.isActive).toBe(true);
expect(execWithEnv).not.toHaveBeenCalled();
expect(ctx.llmCalls).toHaveLength(1);
expect(toolResultText(ctx.agent.context.history)).toContain('Plan rejected by user');
expect(toolResultText(ctx.agent.context.history)).toContain(
'Tool skipped because a previous tool call stopped the turn.',
);
await ctx.expectResumeMatches();
});
it('refuses to exit when the current plan file is empty', async () => {
const readText = vi.fn(async () => '');
const ctx = testAgent({
kaos: createPlanKaos({ readText }),
});
ctx.configure({ tools: ['ExitPlanMode'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
await ctx.agent.planMode.enter('empty-plan', false);
const exitPlanModeCall: ToolCall = {
type: 'function',
id: 'call_exit_empty_plan',
name: 'ExitPlanMode',
arguments: '{}',
};
ctx.mockNextResponse(
{ type: 'text', text: 'I will present the empty plan.' },
exitPlanModeCall,
);
ctx.mockNextResponse({ type: 'text', text: 'I need to write the plan first.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Show an empty plan' }] });
await ctx.untilTurnEnd();
expect(ctx.agent.planMode.isActive).toBe(true);
expect(toolResultText(ctx.llmCalls[1]!.history)).toContain('No plan file found');
await ctx.expectResumeMatches();
});
});
describe('plan exit tool options', () => {
it('keeps options for approval when an option omits the optional description', async () => {
const files = new Map<string, string>();
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const ctx = testAgent({
kaos: createPlanKaos({ readText }),
});
ctx.configure({ tools: ['ExitPlanMode'] });
await ctx.rpc.setPermission({ mode: 'manual' });
await ctx.agent.planMode.enter('options-plan', false);
const planPath = ctx.agent.planMode.planFilePath;
if (planPath === null) throw new Error('expected active plan path');
files.set(planPath, '# Plan\n\n- Inspect\n- Change\n- Verify');
const exitPlanModeCall: ToolCall = {
type: 'function',
id: 'call_exit_options',
name: 'ExitPlanMode',
// The second option omits `description` — valid input after the
// schema relaxation. The approval policy must still surface both.
arguments: JSON.stringify({
options: [
{ label: 'Approach A', description: 'Smaller refactor.' },
{ label: 'Approach B' },
],
}),
};
ctx.mockNextResponse({ type: 'text', text: 'I will present the plan.' }, exitPlanModeCall);
ctx.mockNextResponse({ type: 'text', text: 'I can execute after approval.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Show the plan' }] });
const approval = await ctx.takeApprovalRequest();
const rpcArgs = (
ctx.allEvents.find(
(event) => event.type === '[rpc]' && event.event === 'requestApproval',
) as { args: { action?: string; display?: { options?: readonly unknown[] } } } | undefined
)?.args;
expect(rpcArgs?.action).toBe('Presenting plan and exiting plan mode');
expect(rpcArgs?.display?.options).toHaveLength(2);
approval.respond({ decision: 'approved', selectedLabel: 'Approach A' });
await ctx.untilTurnEnd();
});
});
describe('plan allows safe tool flow', () => {
it.each(['Write', 'Edit'] as const)(
'runs %s on the active plan file without approval in manual mode',
async (toolName) => {
const files = new Map<string, string>();
const readText = vi.fn(async (path: string) => files.get(path) ?? '');
const writeText = vi.fn(async (path: string, content: string) => {
files.set(path, content);
return content.length;
});
const ctx = testAgent({
kaos: createPlanKaos({ readText, writeText }),
});
ctx.configure({ tools: [toolName] });
await ctx.agent.planMode.enter('test-plan', false);
const planPath = ctx.agent.planMode.planFilePath;
if (planPath === null) throw new Error('expected active plan path');
files.set(planPath, '# Plan\n\n- Draft');
const expectedContent =
toolName === 'Write' ? '# Plan\n\n- Inspect\n- Verify' : '# Plan\n\n- Draft\n- Verify';
const args =
toolName === 'Write'
? { path: planPath, content: expectedContent }
: { path: planPath, old_string: '- Draft', new_string: '- Draft\n- Verify' };
const writePlanCall: ToolCall = {
type: 'function',
id: `call_${toolName.toLowerCase()}_plan`,
name: toolName,
arguments: JSON.stringify(args),
};
ctx.mockNextResponse({ type: 'text', text: 'I will update the plan file.' }, writePlanCall);
ctx.mockNextResponse({ type: 'text', text: 'Plan file updated.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Update the plan file' }] });
await ctx.untilTurnEnd();
expect(files.get(planPath)).toBe(expectedContent);
expect(writeText).toHaveBeenCalledWith(planPath, expectedContent);
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'requestApproval'),
).toBe(false);
await ctx.expectResumeMatches();
},
);
it('keeps explicit deny rules above active plan file writes', async () => {
const files = new Map<string, string>();
const writeText = vi.fn(async (path: string, content: string) => {
files.set(path, content);
return content.length;
});
const ctx = testAgent({
kaos: createPlanKaos({ writeText }),
});
ctx.configure({ tools: ['Write'] });
ctx.agent.permission.rules.push({
decision: 'deny',
scope: 'user',
pattern: 'Write',
reason: 'blocked by test',
});
await ctx.agent.planMode.enter('test-plan', false);
const planPath = ctx.agent.planMode.planFilePath;
if (planPath === null) throw new Error('expected active plan path');
const content = '# Plan\n\n- Inspect\n- Verify';
const writePlanCall: ToolCall = {
type: 'function',
id: 'call_write_plan_with_deny',
name: 'Write',
arguments: JSON.stringify({ path: planPath, content }),
};
ctx.mockNextResponse({ type: 'text', text: 'I will update the plan file.' }, writePlanCall);
ctx.mockNextResponse({ type: 'text', text: 'Plan file updated.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Update the plan file' }] });
await ctx.untilTurnEnd();
expect(files.get(planPath)).toBeUndefined();
expect(writeText).not.toHaveBeenCalled();
expect(toolResultText(ctx.agent.context.history)).toContain(
'Tool "Write" was denied by permission rule. Reason: blocked by test',
);
expect(
ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'requestApproval'),
).toBe(false);
});
it('allows read-only Bash to continue through permission and execution', async () => {
const bashCall: ToolCall = {
type: 'function',
id: 'call_bash',
name: 'Bash',
arguments: '{"command":"printf plan-safe","timeout":60}',
};
const ctx = testAgent({ kaos: createCommandKaos('plan-safe') });
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
await ctx.agent.planMode.enter('test-plan', false);
ctx.mockNextResponse({ type: 'text', text: 'I will inspect safely.' }, bashCall);
ctx.mockNextResponse({ type: 'text', text: 'The safe command printed plan-safe.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Inspect without mutating files' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.set_mode { "mode": "yolo", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "yolo" }
[wire] plan_mode.enter { "id": "test-plan", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": true, "swarmMode": false, "permission": "yolo" }
[wire] turn.prompt { "input": [ { "type": "text", "text": "Inspect without mutating files" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[wire] llm.tools_snapshot { "hash": "878fc967171856c1b535c0bc43b4b06aa8141d637871c13f40f965cdaaa45df9", "tools": [ { "name": "Bash", "description": "Execute a \`bash\` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step.\\n\\n**Translate these to a dedicated tool instead:**\\n- \`cat\` / \`head\` / \`tail\` (known path) → \`Read\`\\n- \`sed\` / \`awk\` (in-place edit) → \`Edit\`\\n- \`echo > file\` / \`cat <<EOF\`\`Write\`\\n- \`find\` / recursive \`ls\` to locate files by name pattern → \`Glob\` (plain \`ls <known-directory>\` is fine for listing a directory)\\n- \`grep\` / \`rg\` (search file contents) → \`Grep\`\\n- \`echo\` / \`printf\` (talk to the user) → just output text directly\\n\\nThe dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits.\\n\\n**Output:**\\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a \`Command failed with exit code: N\` line; a command killed by its timeout or interrupted by the user ends with its own message instead.\\n\\nBackground execution is disabled for this agent. Do not set \`run_in_background=true\`.\\n\\n**Guidelines for safety and security:**\\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the \`cwd\` argument (or use absolute paths) rather than relying on a \`cd\` from an earlier call.\\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, set the \`timeout\` argument in seconds. The default is 60s; foreground commands allow up to 300s.\\n- Avoid using \`..\` to access files or directories outside of the working directory.\\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\\n\\n**Guidelines for efficiency:**\\n- Use \`&&\` to chain commands that genuinely depend on each other, e.g. \`npm install && npm test\`. Independent read-only commands (separate \`git show\`, \`ls\`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with \`echo\` separators.\\n- Use \`;\` to run commands sequentially regardless of success/failure\\n- Use \`||\` for conditional execution (run second command only if first fails)\\n- Use pipe operations (\`|\`) and redirections (\`>\`, \`>>\`) to chain input and output between commands\\n- Always quote file paths containing spaces with double quotes (e.g., cd \\"/path with spaces/\\")\\n- Compose multi-step logic in a single call with \`if\` / \`case\` / \`for\` / \`while\` control flows.\\n- Do not set \`run_in_background=true\`; background task management tools are not available.\\n\\n**Commands available:**\\nThe following common command categories are usually available. Availability still depends on the host, so when in doubt run \`which <command>\` first to confirm a command exists before relying on it.\\n- Navigation and inspection: \`ls\`, \`pwd\`, \`cd\`, \`stat\`, \`file\`, \`du\`, \`df\`, \`tree\`\\n- File and directory management: \`cp\`, \`mv\`, \`rm\`, \`mkdir\`, \`touch\`, \`ln\`, \`chmod\`, \`chown\`\\n- Text and data processing: \`wc\`, \`sort\`, \`uniq\`, \`cut\`, \`tr\`, \`diff\`, \`xargs\`\\n- Archives and compression: \`tar\`, \`gzip\`, \`gunzip\`, \`zip\`, \`unzip\`\\n- Networking and transfer: \`curl\`, \`wget\`, \`ping\`, \`ssh\`, \`scp\`\\n- Version control: \`git\`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the \`gh\` CLI when installed — it carries the user's GitHub auth and can return structured JSON\\n- Process and system: \`ps\`, \`kill\`, \`top\`, \`env\`, \`date\`, \`uname\`, \`whoami\`\\n- Language and package toolchains: \`node\`, \`npm\`, \`pnpm\`, \`yarn\`, \`python\`, \`pip\` (use whichever the project actually relies on)\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "command": { "type": "string", "minLength": 1, "description": "The command to execute." }, "cwd": { "description": "The working directory in which to run the command. When omitted, the command runs in the session's working directory.", "type": "string" }, "timeout": { "default": 60, "description": "Optional timeout in seconds for the command to execute. Foreground default 60s, max 300s. Background default 600s, max 86400s. Ignored for background commands when disable_timeout=true.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, "description": { "description": "A short description for the background task. Required when run_in_background is true.", "type": "string" }, "run_in_background": { "description": "Whether to run the command as a background task.", "type": "boolean" }, "disable_timeout": { "description": "If true, do not apply a timeout to the command. Only applies when run_in_background is true.", "type": "boolean" } }, "required": [ "command" ], "additionalProperties": false } } ], "time": "<time>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "878fc967171856c1b535c0bc43b4b06aa8141d637871c13f40f965cdaaa45df9", "messageCount": 2, "turnStep": "0.1", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will inspect safely." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf plan-safe\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will inspect safely." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe", "display": { "kind": "command", "command": "printf plan-safe", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe", "display": { "kind": "command", "command": "printf plan-safe", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "plan-safe" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "plan-safe" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "plan-safe" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use", "messageId": "mock-1" }, "time": "<time>" }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 588, "maxContextTokens": 1000000, "contextUsage": 0.000588, "planMode": true, "swarmMode": false, "permission": "yolo", "usage": { "byModel": { "mock-model": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 565, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-3>", "turnId": "0", "step": 2 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-3>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 999412, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "878fc967171856c1b535c0bc43b4b06aa8141d637871c13f40f965cdaaa45df9", "messageCount": 4, "turnStep": "0.2", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "The safe command printed plan-safe." }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "stepUuid": "<uuid-3>", "part": { "type": "text", "text": "The safe command printed plan-safe." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-3>", "turnId": "0", "step": 2, "usage": { "inputOther": 592, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn", "messageId": "mock-2" }, "time": "<time>" }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-3>", "usage": { "inputOther": 592, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 592, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 604, "maxContextTokens": 1000000, "contextUsage": 0.000604, "planMode": true, "swarmMode": false, "permission": "yolo", "usage": { "byModel": { "mock-model": { "inputOther": 1157, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1157, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 1157, "output": 35, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
await ctx.expectResumeMatches();
});
});
describe('plan mode Bash ordinary permission behavior', () => {
it('allows Bash through ordinary yolo permission behavior', async () => {
const bashCall: ToolCall = {
type: 'function',
id: 'call_bash',
name: 'Bash',
arguments: '{"command":"rm forbidden.txt","timeout":60}',
};
const ctx = testAgent({ kaos: createCommandKaos('removed') });
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
await ctx.agent.planMode.enter('test-plan', false);
ctx.mockNextResponse({ type: 'text', text: 'I will mutate a file.' }, bashCall);
ctx.mockNextResponse({ type: 'text', text: 'The command completed.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Remove forbidden.txt' }] });
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
[wire] permission.set_mode { "mode": "yolo", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "yolo" }
[wire] plan_mode.enter { "id": "test-plan", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": true, "swarmMode": false, "permission": "yolo" }
[wire] turn.prompt { "input": [ { "type": "text", "text": "Remove forbidden.txt" } ], "origin": { "kind": "user" }, "time": "<time>" }
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
[wire] llm.tools_snapshot { "hash": "878fc967171856c1b535c0bc43b4b06aa8141d637871c13f40f965cdaaa45df9", "tools": [ { "name": "Bash", "description": "Execute a \`bash\` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step.\\n\\n**Translate these to a dedicated tool instead:**\\n- \`cat\` / \`head\` / \`tail\` (known path) → \`Read\`\\n- \`sed\` / \`awk\` (in-place edit) → \`Edit\`\\n- \`echo > file\` / \`cat <<EOF\`\`Write\`\\n- \`find\` / recursive \`ls\` to locate files by name pattern → \`Glob\` (plain \`ls <known-directory>\` is fine for listing a directory)\\n- \`grep\` / \`rg\` (search file contents) → \`Grep\`\\n- \`echo\` / \`printf\` (talk to the user) → just output text directly\\n\\nThe dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits.\\n\\n**Output:**\\nThe stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a \`Command failed with exit code: N\` line; a command killed by its timeout or interrupted by the user ends with its own message instead.\\n\\nBackground execution is disabled for this agent. Do not set \`run_in_background=true\`.\\n\\n**Guidelines for safety and security:**\\n- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the \`cwd\` argument (or use absolute paths) rather than relying on a \`cd\` from an earlier call.\\n- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running commands, set the \`timeout\` argument in seconds. The default is 60s; foreground commands allow up to 300s.\\n- Avoid using \`..\` to access files or directories outside of the working directory.\\n- Avoid modifying files outside of the working directory unless explicitly instructed to do so.\\n- Never run commands that require superuser privileges unless explicitly instructed to do so.\\n\\n**Guidelines for efficiency:**\\n- Use \`&&\` to chain commands that genuinely depend on each other, e.g. \`npm install && npm test\`. Independent read-only commands (separate \`git show\`, \`ls\`, or status checks) should be issued as separate parallel Bash calls in one response, not chained into a single call — chaining serializes their execution and mixes their output. Do not stitch outputs together with \`echo\` separators.\\n- Use \`;\` to run commands sequentially regardless of success/failure\\n- Use \`||\` for conditional execution (run second command only if first fails)\\n- Use pipe operations (\`|\`) and redirections (\`>\`, \`>>\`) to chain input and output between commands\\n- Always quote file paths containing spaces with double quotes (e.g., cd \\"/path with spaces/\\")\\n- Compose multi-step logic in a single call with \`if\` / \`case\` / \`for\` / \`while\` control flows.\\n- Do not set \`run_in_background=true\`; background task management tools are not available.\\n\\n**Commands available:**\\nThe following common command categories are usually available. Availability still depends on the host, so when in doubt run \`which <command>\` first to confirm a command exists before relying on it.\\n- Navigation and inspection: \`ls\`, \`pwd\`, \`cd\`, \`stat\`, \`file\`, \`du\`, \`df\`, \`tree\`\\n- File and directory management: \`cp\`, \`mv\`, \`rm\`, \`mkdir\`, \`touch\`, \`ln\`, \`chmod\`, \`chown\`\\n- Text and data processing: \`wc\`, \`sort\`, \`uniq\`, \`cut\`, \`tr\`, \`diff\`, \`xargs\`\\n- Archives and compression: \`tar\`, \`gzip\`, \`gunzip\`, \`zip\`, \`unzip\`\\n- Networking and transfer: \`curl\`, \`wget\`, \`ping\`, \`ssh\`, \`scp\`\\n- Version control: \`git\`; for GitHub-hosted work (PRs, issues, CI runs, API queries) prefer the \`gh\` CLI when installed — it carries the user's GitHub auth and can return structured JSON\\n- Process and system: \`ps\`, \`kill\`, \`top\`, \`env\`, \`date\`, \`uname\`, \`whoami\`\\n- Language and package toolchains: \`node\`, \`npm\`, \`pnpm\`, \`yarn\`, \`python\`, \`pip\` (use whichever the project actually relies on)\\n", "parameters": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "command": { "type": "string", "minLength": 1, "description": "The command to execute." }, "cwd": { "description": "The working directory in which to run the command. When omitted, the command runs in the session's working directory.", "type": "string" }, "timeout": { "default": 60, "description": "Optional timeout in seconds for the command to execute. Foreground default 60s, max 300s. Background default 600s, max 86400s. Ignored for background commands when disable_timeout=true.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, "description": { "description": "A short description for the background task. Required when run_in_background is true.", "type": "string" }, "run_in_background": { "description": "Whether to run the command as a background task.", "type": "boolean" }, "disable_timeout": { "description": "If true, do not apply a timeout to the command. Only applies when run_in_background is true.", "type": "boolean" } }, "required": [ "command" ], "additionalProperties": false } } ], "time": "<time>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "878fc967171856c1b535c0bc43b4b06aa8141d637871c13f40f965cdaaa45df9", "messageCount": 2, "turnStep": "0.1", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will mutate a file." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"rm forbidden.txt\\",\\"timeout\\":60}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will mutate a file." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt", "display": { "kind": "command", "command": "rm forbidden.txt", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt", "display": { "kind": "command", "command": "rm forbidden.txt", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "removed" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "removed" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "removed" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use", "messageId": "mock-1" }, "time": "<time>" }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 585, "maxContextTokens": 1000000, "contextUsage": 0.000585, "planMode": true, "swarmMode": false, "permission": "yolo", "usage": { "byModel": { "mock-model": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 562, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-3>", "turnId": "0", "step": 2 }, "time": "<time>" }
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-3>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 999415, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "878fc967171856c1b535c0bc43b4b06aa8141d637871c13f40f965cdaaa45df9", "messageCount": 4, "turnStep": "0.2", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "The command completed." }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "stepUuid": "<uuid-3>", "part": { "type": "text", "text": "The command completed." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-3>", "turnId": "0", "step": 2, "usage": { "inputOther": 588, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn", "messageId": "mock-2" }, "time": "<time>" }
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-3>", "usage": { "inputOther": 588, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 588, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 597, "maxContextTokens": 1000000, "contextUsage": 0.000597, "planMode": true, "swarmMode": false, "permission": "yolo", "usage": { "byModel": { "mock-model": { "inputOther": 1150, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1150, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 1150, "output": 32, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[emit] turn.ended { "turnId": 0, "reason": "completed" }
`);
expect(toolResultText(ctx.agent.context.history)).toContain('removed');
await ctx.expectResumeMatches();
});
});
describe('plan mode injection cadence', () => {
it('dedupes immediate repeats and emits sparse reminders after assistant turns', async () => {
const ctx = testAgent();
ctx.configure();
await ctx.agent.planMode.enter('test-plan', false);
await ctx.agent.injection.inject();
const afterFull = ctx.agent.context.history.length;
expect(lastUserText(ctx.agent.context.history)).toContain('Plan mode is active');
expect(lastUserText(ctx.agent.context.history)).toContain('Plan file:');
await ctx.agent.injection.inject();
expect(ctx.agent.context.history).toHaveLength(afterFull);
ctx.appendAssistantTurn(1, 'assistant one');
ctx.appendAssistantTurn(2, 'assistant two');
await ctx.agent.injection.inject();
expect(lastUserText(ctx.agent.context.history)).toContain('Plan mode still active');
expect(lastUserText(ctx.agent.context.history)).toContain('Plan file:');
await ctx.expectResumeMatches();
});
it('emits a reentry reminder when restored plan mode already has plan content', async () => {
const ctx = testAgent({
kaos: createFakeKaos({
readText: vi.fn(async () => '# Existing Plan\n\n- Keep this context'),
}),
});
ctx.configure();
ctx.dispatch({
type: 'plan_mode.enter',
id: 'restored-plan',
});
await ctx.agent.injection.inject();
expect(lastUserText(ctx.agent.context.history)).toContain('Re-entering Plan Mode');
expect(lastUserText(ctx.agent.context.history)).toContain('Read the existing plan file');
await ctx.expectResumeMatches();
});
it('emits one exit reminder after leaving plan mode', async () => {
const ctx = testAgent();
ctx.configure();
await ctx.agent.planMode.enter('test-plan', false);
await ctx.agent.injection.inject();
ctx.agent.planMode.exit();
await ctx.agent.injection.inject();
const afterExit = ctx.agent.context.history.length;
expect(lastUserText(ctx.agent.context.history)).toContain('Plan mode is no longer active');
await ctx.agent.injection.inject();
expect(ctx.agent.context.history).toHaveLength(afterExit);
await ctx.expectResumeMatches();
});
it('keeps the preserved injection index aligned after undo removes earlier messages', async () => {
const ctx = testAgent();
ctx.configure();
await ctx.agent.planMode.enter('test-plan', false);
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'draft the plan' }]);
await ctx.agent.injection.inject();
ctx.appendAssistantTurn(1, 'Plan drafted.');
ctx.agent.context.undo(1);
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'new plan request' }]);
await ctx.agent.injection.inject();
expect(lastUserText(ctx.agent.context.history)).toContain('Plan mode is active');
});
});
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function lastUserText(history: readonly { role: string; content: readonly unknown[] }[]): string {
const message = history.findLast((item) => item.role === 'user');
if (message === undefined) return '';
return message.content
.map((part) => {
if (
part !== null &&
typeof part === 'object' &&
(part as { type?: unknown }).type === 'text'
) {
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
}
return '';
})
.join('');
}
function toolResultText(history: readonly { role: string; content: readonly unknown[] }[]): string {
return history
.filter((message) => message.role === 'tool')
.flatMap((message) => message.content)
.map((part) => {
if (
part !== null &&
typeof part === 'object' &&
(part as { type?: unknown }).type === 'text'
) {
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
}
return '';
})
.join('\n');
}