kimi-code/packages/agent-core/test/tools/bash.test.ts
Kai 9c9716125e
feat: Harden the default system prompt and built-in tool descriptions (#1102)
* feat(agent-core): strengthen default system prompt

Add high-confidence, prompt-only guardrails to the default agent system prompt:

- Personality/candor: extend the HELPFUL/CONCISE/ACCURATE line with CANDID, and
  require plainly stating what could not be run, reproduced, or verified.
- Reminders: avoid cheerleading; voice evidence-based disagreement; deliver
  complete code with no placeholders; update now-stale comments/docstrings after
  a change; re-check the user's latest request before finalizing a reply.
- Context Management: explain automatic compaction — continue from the summary,
  re-establish transient state with tools, do not restart from scratch.
- Output formatting: replies render as Markdown in the terminal; keep lists flat;
  no emojis unless the user uses them first.
- Project Information: frame injected AGENTS.md as project context, not a
  privileged instruction channel that can override system rules.

Prompt text only; no code or template-variable changes.

* feat(agent-core): hoist key working rules into the system prompt

Lift a few high-leverage rules from individual tool descriptions up into the
default system prompt, so they shape default behavior before any specific tool
is in play (kept terse and integrated, not bolted on):

- Planning: for multi-step or multi-file work, maintain a `TodoList` (one item
  in_progress, mark done as it finishes) and prefer `EnterPlanMode` first when
  the approach isn't settled.
- Default to making progress, not asking: once the goal is clear and sanctioned,
  carry it through and work blockers yourself; ask only when the answer would
  change the next step. Explicitly does not override stopping to discuss an
  unclear goal or waiting for go-ahead before writing code.
- Tool routing: prefer dedicated tools (Read/Glob/Grep/Write/Edit) over raw
  shell when one fits; keep Bash for genuine shell work.
- Definition of done: verify with the checks that cover the change before
  marking it complete, independent of whether a TodoList is in use.
- Delegation: explore subagents also keep intermediate file contents out of your
  own context — you get a conclusion back, not a pile of dumps.

Prompt text only; no code or template-variable changes.

* fix: clarify guidelines for file pattern matching and tool usage in explore.yaml and system.md

* fix(agent-core): hide the Skills section from agents without the Skill tool

Subagents (coder/explore/plan) inherit the root system prompt but lack the Skill tool, yet KIMI_SKILLS was rendered unconditionally — leaking the full skill listing into agents that cannot invoke any skill. Gate KIMI_SKILLS on the profile's tool set and wrap the '# Skills' section in {% if KIMI_SKILLS %} so it disappears for those profiles.

Also note in the Working Directory section that Bash enforces none of the workspace/secret-file guards, so the model must hold that discipline itself.

Tests: assert the Skills section renders for the root agent and is absent for Skill-less subagents; update the prompt-rendering fixtures for the new gating.

* fix(agent-core): gate Agent, background-task, and TodoList guidance by tool availability

Subagents (coder/explore/plan) inherit the root system prompt but lack the Agent, TaskList, and TodoList tools, so they were shown usage guidance for tools they cannot call. Derive HAS_AGENT/HAS_TASKLIST/HAS_TODOLIST from each profile's tool set and gate those sections with inline {% if %}, so they render only for agents that hold the tool.

Root rendering is byte-identical (the inline tags collapse to the original text when the flag is set). The cross-tool secret-file guard stays shared, since explore/plan still hold Read/Grep/Glob.

Tests: assert the gated guidance is present for the root agent and absent for explore/plan, while the shared secret-file guard remains.

* refactor(agent-core): move Agent-delegation and Glob-anchor guidance into the tool descriptions

The Agent-delegation paragraph in the system prompt duplicated mechanics already documented on the Agent tool itself (new-vs-resume, zero-context briefing, foreground default / run_in_background threshold), so remove it. HAS_AGENT still gates the explore-delegation bullet, which carries the 'when to delegate' nudge the tool description deliberately omits.

Move the proactive 'anchor the pattern up front' guidance into the Glob tool description (it previously only described the reactive 'refine after hitting the cap' path) and drop the now-redundant Glob bullet from the system prompt.

Tests: drop the assertions tied to the removed Agent paragraph; HAS_AGENT gating stays covered via the explore bullet.

* test(agent-core): add guidance for blast-radius and concrete examples in agent profiles

* docs: update descriptions for skill-tool and fetch-url; enhance web-search citation instructions

* feat(agent-core): disclose enforced constraints in tool descriptions; fix GetGoal field doc

Surface runtime-enforced behavior in the Agent / AgentSwarm / AskUserQuestion / Goal
tool descriptions so the model learns the rules from the tool, not from a failed call:

- Agent: resuming excludes subagent_type (setting both is rejected)
- AgentSwarm: at least 2 items unless resuming, prompt_template required and must
  contain {{item}}, distinct resulting prompts; plus Agent-vs-AgentSwarm fan-out note
- AskUserQuestion: result is {answers}; an empty answers with a dismissal note means
  the user declined — fall back to best judgment instead of re-asking
- CreateGoal: creating fails when a goal already exists (use replace)
- SetGoalBudget: state the hard 1s-24h time-budget band
- UpdateGoal: do not mark blocked merely because work is hard/slow/incomplete
- GetGoal: drop the advertised self-report / evaluator-verdict fields — GoalSnapshot
  never held them, so the tool never returned them

Each change is covered by a description assertion.

* fix(agent-core): soften AskUserQuestion answers-keying wording to match the code

The answers object is passed through from the host/RPC layer (QuestionAnswers is
Record<string, string | true>); this code does not key it by question text. Describe
what the keys identify instead of asserting a guarantee the code does not provide.

* feat(agent-core): tighten Bash/Grep/Write/Edit tool descriptions

- Bash: prefer the cwd argument (or absolute paths) over a cd from an earlier
  call, since each call runs in a fresh shell
- Grep: note that files_with_matches is ordered most-recently-modified first
- Write: do not create documentation/README files unless the user asks
- Edit: frame replace_all with its rename-across-file use-case

Each change is covered by a description assertion.

* feat(agent-core): refine plan-mode/todo/cron tool descriptions

- ExitPlanMode: describe what a good plan contains (specific, verifiable steps
  grounded in the codebase, not vague filler)
- TodoList: stop calling it useful 'in Plan mode' — plan-mode planning goes to
  the plan file; TodoList tracks execution progress
- CronCreate: warn that a one-shot whose pinned day/month already passed this
  year is rejected; document the 50-task session cap and the 8 KiB prompt cap
- CronCreate: drop the bench-only KIMI_CRON_NO_STALE / KIMI_CRON_NO_JITTER env
  knobs from the model-facing description (CI-only; the model never sets them)

Each change is covered by a description assertion.

* refactor(agent-core): dedupe ExitPlanMode options docs into the param schema; trim EnterPlanMode workflow

- ExitPlanMode: the options field mechanics (label format, recommended, count,
  single-option=plain-approval, reserved labels) now live only in the options
  param describe; the tool description routes to it and keeps the yolo/manual UI
  behavior it uniquely documents. The options consistency test now enforces a
  single source of truth (describe) plus the schema-consistency guard, instead
  of requiring the same facts in both surfaces.
- EnterPlanMode: trim the duplicated 'What Happens in Plan Mode' steps to a
  pointer (the full workflow is injected unconditionally once plan mode is
  active), keeping the explore-subagent recommendation.

* fix(agent-core): correct prompt/code inaccuracies found in the final audit

Every item below was re-verified against the live code:

- Skill: drop the never-fired recursion-depth cap (production never seeds depth);
  keep the <kimi-skill-loaded> 'already loaded, don't re-invoke' guard
- TaskOutput: terminal_reason can also be `failed`, not just timed_out/stopped
- Grep: count_matches emits per-file `path:count`, with the total reported separately
- Plan mode: the reminder names TaskStop/CronCreate/CronDelete as blocked (they are
  hard-denied by plan-mode-guard-deny)
- Bash: the failure trailer is non-zero-exit-specific; timeout/interrupt differ
- CreateGoal: replace also covers a blocked goal, not just active/paused
- UpdateGoal: it also injects the completion/blocked outcome prompt, so it does more
  than 'only record the status'
- FetchURL: state the universal http/https contract instead of provider-internal SSRF
  and 10 MiB limits (the primary Moonshot fetcher enforces neither)
- TodoList: query mode triggers on omitting `todos`, not on zero args
- TaskList: command/PID/exit code are shell-task fields only
- CronCreate: the returned fields include `cron`
- SetGoalBudget: turn/token budgets are rounded up to >= 1, not rejected below 1

Each change is covered by a description/param assertion; plan.test.ts snapshots
refreshed for the longer plan-mode reminder.

* fix(agent-core): gate prompt tool guidance on runtime availability, not declared profile tools

The HAS_* / Skills gating computed flags from the profile's declared tools, but
Agent/AgentSwarm only register when a subagentHost exists (ToolManager
.initializeBuiltinTools). A runtime built without a subagentHost (e.g. direct SDK
construction) therefore rendered the explore-delegation guidance for an Agent
tool the model could not call.

SystemPromptContext now carries an optional availableTools; buildTemplateVars
gates on it when present and falls back to the declared tools otherwise. useProfile
passes the profile tools minus Agent/AgentSwarm when no subagentHost is wired, so
the render reflects what the model can actually call. The normal session path
(subagentHost always defaulted) is unchanged.

* fix(agent-core): exempt the plan-mode plan file from the Write *.md ban

Plan mode writes its plan to plans/<id>.md (plan/index.ts) and the reminder tells
the model to create it with Write when missing, which contradicted Write's blanket
'do not create *.md unless asked' guard. Carve the plan file out of the ban.

* fix(agent-core): scope plan-mode prompt guidance and the Write *.md ban to runtime reality

- Gate the TodoList bullet's "enter plan mode via EnterPlanMode" suggestion
  on a new HAS_ENTERPLANMODE flag. A custom profile that keeps TodoList but
  drops EnterPlanMode no longer steers the model toward a tool it cannot call;
  the default profile render is unchanged.
- Reframe the Write *.md prohibition around intent (unsolicited docs) instead
  of a blanket extension ban, so artifacts a task or project instruction
  requires — the plan-mode plan file, a repo-mandated changeset — are no
  longer contradicted by the tool's own rules.

* refactor(agent-core): move tool-coupled guidance into tool descriptions

The default system prompt carried tool-usage guidance behind {% if HAS_* %}
gates that re-derived, in prose, the availability the tool schema already
encodes — and the same guidance was duplicated in each tool's own
description. Drop the four gated blocks (background Bash, Agent/explore,
TodoList, EnterPlanMode) and the compaction TaskList/TodoList bullets; the
tool descriptions, shipped only when the tool is registered, already carry
the same instructions, so subagents and tool-trimmed profiles are no longer
pointed at tools they lack.

Fold the two genuinely unique lines into the tool descriptions: bash.md
gains "return control after starting a background task", agent.md gains the
context-hygiene reason to delegate. Collapse the compaction bullets into one
tool-agnostic sentence. Remove the now-unused availableTools / HAS_* render
machinery.

* fix(skill-tool): clarify no-reinvoke guard and argument handling in tool description

* feat(fetch-url): indicate content retrieval mode in output for better model context

* fix(agent-core): correct goal-budget rounding and task-output failure docs

set-goal-budget.md said turn/token budgets are "rounded up", but the code uses
Math.round — say "rounded to the nearest whole number" instead. task-output.md
implied every failed task carries terminal_reason/stop_reason, but a plain
non-zero command exit carries only status plus exit_code; describe that exit_code
path and reserve terminal_reason for non-exit endings (timeout, explicit stop,
or an internal error with no exit code).

* fix(agent-core): scope free-work guidance by role and steer one-shots near-term

The blast-radius paragraph told every profile that local work — including
editing files — may be done freely, but the read-only explore/plan subagents
render it too; scope it to "work your role permits" so it no longer undercuts
their read-only constraints.

The one-shot cron guidance leaned on a year-boundary heuristic ("avoid a
day/month already passed this year") that misfires across Dec 31 to Jan 1 and
duplicated a limit the code already enforces. Replace it with a plain near-term
nudge and leave the hard future-window guard in code.

* fix(enter-plan-mode): clarify availability of Agent tool in plan mode description

* fix(agent-core): surface Grep count_matches total and pagination in output

count_matches put the aggregate "Found N occurrences" summary and the
"Results truncated... use offset=N to see more" notice on the result's
message field, which normalizeToolResult drops before the result reaches the
model. The model saw only the path:count lines and could miss the total and,
worse, the pagination cue — so it would not know to page through truncated
counts. Append both to output after the path:count lines, the same way the
content and files_with_matches modes already inline their notices.

* fix(grep): reorder count summary and results in output for clarity

* chore(changeset): consolidate prompt-hardening changesets into one

Squash the five per-change changesets for this PR into a single concise
entry; they all bump @moonshot-ai/kimi-code (patch) for the same
system-prompt and tool-description hardening work.

* fix(agent-core): stop the agent from blocking on background tasks

Both the Agent and Bash background-launch messages invited the model to "peek
at progress" via TaskOutput, and the foreground-vs-background guidance had been
thinned to a single parameter hint. Together that led the model to launch a
background subagent and then immediately wait on it through TaskOutput —
defeating the point of background execution.

Make both launch messages take the same anti-wait stance the user-detach path
already uses (do NOT wait, poll, or call TaskOutput on it), restore
foreground-by-default guidance in the Agent background description (run in the
background only when you have other work and do not need the result to proceed),
and add a TaskOutput backstop against using it to sit and wait. Also fold the
fix into the consolidated changeset.
2026-06-26 16:56:40 +08:00

1304 lines
44 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough, Readable, type Writable } from 'node:stream';
import type { Environment, KaosProcess } from '@moonshot-ai/kaos';
import { describe, expect, it, vi } from 'vitest';
import { type BashInput, BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash';
import { createBackgroundManager, registerProcess } from '../agent/background/helpers';
import { createFakeKaos } from './fixtures/fake-kaos';
import { executeTool } from './fixtures/execute-tool';
const posixEnv: Environment = {
osKind: 'Linux',
osArch: 'arm64',
osVersion: 'test',
shellPath: '/bin/bash',
shellName: 'bash',
};
const windowsBashEnv: Environment = {
osKind: 'Windows',
osArch: 'x64',
osVersion: 'test',
shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe',
shellName: 'bash',
};
function processWithOutput(
options: {
readonly stdout?: string | Buffer;
readonly stderr?: string | Buffer;
readonly exitCode?: number | null;
readonly wait?: () => Promise<number>;
readonly kill?: (signal?: NodeJS.Signals) => Promise<void>;
} = {},
): KaosProcess {
const exitCode = options.exitCode ?? 0;
const stdout = Readable.from(options.stdout === undefined ? [] : [options.stdout]);
const stderr = Readable.from(options.stderr === undefined ? [] : [options.stderr]);
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 123,
exitCode,
wait: vi.fn(options.wait ?? (async () => exitCode)),
kill: vi.fn(options.kill ?? (async () => {})),
dispose: vi.fn(async () => {
stdout.destroy();
stderr.destroy();
}),
};
}
function processWithInterleavedOutput(
events: ReadonlyArray<{
readonly stream: 'stdout' | 'stderr';
readonly text: string;
readonly delayMs: number;
}>,
exitCode = 0,
): KaosProcess {
const stdout = new PassThrough();
const stderr = new PassThrough();
const lastDelay = Math.max(...events.map((event) => event.delayMs), 0);
const waitPromise = new Promise<number>((resolve) => {
for (const event of events) {
setTimeout(() => {
const target = event.stream === 'stdout' ? stdout : stderr;
target.write(event.text);
}, event.delayMs);
}
setTimeout(() => {
stdout.end();
stderr.end();
resolve(exitCode);
}, lastDelay + 1);
});
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 124,
exitCode,
wait: vi.fn(async () => waitPromise),
kill: vi.fn(async () => {}),
dispose: vi.fn(async () => {
stdout.destroy();
stderr.destroy();
}),
};
}
function pendingProcess(): {
readonly proc: KaosProcess;
readonly finish: (exitCode?: number) => void;
} {
const stdout = new PassThrough();
const stderr = new PassThrough();
let resolveWait: (exitCode: number) => void = () => {};
let currentExitCode: number | null = null;
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const finish = (exitCode = 0): void => {
if (currentExitCode !== null) return;
currentExitCode = exitCode;
stdout.end();
stderr.end();
resolveWait(exitCode);
};
return {
proc: {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 125,
get exitCode(): number | null {
return currentExitCode;
},
wait: vi.fn(async () => waitPromise),
kill: vi.fn(async () => {
finish(143);
}) as KaosProcess['kill'],
dispose: vi.fn(async () => {}),
},
finish,
};
}
function processWithVisibleExitBeforeWait(exitCode = 0): {
proc: KaosProcess;
finishWait: () => void;
markExited: () => void;
} {
let currentExitCode: number | null = null;
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const proc: KaosProcess = {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout: Readable.from([]),
stderr: Readable.from([]),
pid: 125,
get exitCode(): number | null {
return currentExitCode;
},
wait: vi.fn(async () => waitPromise),
kill: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
};
return {
proc,
finishWait: () => {
resolveWait(exitCode);
},
markExited: () => {
currentExitCode = exitCode;
},
};
}
function processThatNeverExits(): KaosProcess {
const stdout = new PassThrough();
const stderr = new PassThrough();
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 126,
exitCode: null,
wait: vi.fn(async () => new Promise<number>(() => {})),
kill: vi.fn(async () => {}),
dispose: vi.fn(async () => {
stdout.destroy();
stderr.destroy();
}),
};
}
function processWithStreamError(options: {
readonly stdoutError?: Error;
readonly stderrError?: Error;
readonly exitCode?: number;
} = {}): KaosProcess {
const exitCode = options.exitCode ?? 0;
const stdout = new PassThrough();
const stderr = new PassThrough();
const waitPromise = new Promise<number>((resolve) => {
setTimeout(() => {
if (options.stdoutError !== undefined) {
stdout.emit('error', options.stdoutError);
} else {
stdout.end();
}
if (options.stderrError !== undefined) {
stderr.emit('error', options.stderrError);
} else {
stderr.end();
}
resolve(exitCode);
}, 1);
});
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 128,
exitCode,
wait: vi.fn(async () => waitPromise),
kill: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
};
}
function processWithOpenStreamsThatExitOnKill(): KaosProcess {
let currentExitCode: number | null = null;
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const stdout = new PassThrough();
const stderr = new PassThrough();
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 127,
get exitCode(): number | null {
return currentExitCode;
},
wait: vi.fn(async () => waitPromise),
kill: vi.fn(async () => {
currentExitCode = 143;
resolveWait(143);
}),
dispose: vi.fn(async () => {
stdout.destroy();
stderr.destroy();
}),
};
}
function context(args: BashInput, signal = new AbortController().signal) {
return { turnId: '0', toolCallId: 'call_bash', args, signal };
}
function bashTool(
kaos: ConstructorParameters<typeof BashTool>[0],
cwd = '/workspace',
manager = createBackgroundManager().manager,
options?: ConstructorParameters<typeof BashTool>[3],
): BashTool {
return new BashTool(kaos, cwd, manager, options);
}
describe('BashTool', () => {
it('exposes current metadata and schema', () => {
const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace');
expect(tool.name).toBe('Bash');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: { command: { type: 'string' } },
});
expect(BashInputSchema.safeParse({ command: 'echo hello' }).success).toBe(true);
expect(BashInputSchema.safeParse({ command: '' }).success).toBe(false);
expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 0 }).success).toBe(false);
expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 300 }).success).toBe(true);
expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 301 }).success).toBe(false);
expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 300_000 }).success).toBe(false);
expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 300_001 }).success).toBe(false);
expect(
BashInputSchema.safeParse({
command: 'watch',
run_in_background: true,
description: 'watch files',
timeout: 86_400,
}).success,
).toBe(true);
expect(
BashInputSchema.safeParse({
command: 'watch',
run_in_background: true,
description: 'watch files',
timeout: 86_401,
}).success,
).toBe(false);
expect(
BashInputSchema.safeParse({
command: 'watch',
run_in_background: true,
description: 'watch files',
timeout: 600_000,
}).success,
).toBe(false);
expect(
BashInputSchema.safeParse({
command: 'watch',
run_in_background: true,
description: 'watch files',
disable_timeout: true,
}).success,
).toBe(true);
});
it('describes the cwd, command, run_in_background, description, and disable_timeout parameters', () => {
const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace');
const properties = (tool.parameters as { properties: Record<string, { description?: string }> })
.properties;
for (const name of [
'cwd',
'command',
'run_in_background',
'description',
'disable_timeout',
] as const) {
const description = properties[name]?.description;
expect(description, `${name} should have a non-empty description`).toBeTruthy();
expect((description ?? '').trim().length).toBeGreaterThan(0);
}
});
it('exposes a default timeout in the JSON Schema', () => {
const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace');
const properties = (tool.parameters as { properties: Record<string, { default?: number }> })
.properties;
expect(properties['timeout']?.default).toBe(60);
});
it('interprets small timeout values as seconds at runtime', async () => {
vi.useFakeTimers();
try {
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const proc = processWithOutput({
wait: async () => waitPromise,
kill: async () => {
resolveWait(143);
},
});
const tool = bashTool(
createFakeKaos({ execWithEnv: vi.fn().mockResolvedValue(proc), osEnv: posixEnv }),
'/workspace',
);
const running = executeTool(tool, context({ command: 'sleep 3', timeout: 2 }));
await vi.advanceTimersByTimeAsync(1_999);
expect(proc.kill).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
const result = await running;
expect(proc.kill).toHaveBeenCalled();
expect(result.output).toContain('Command killed by timeout (2s)');
} finally {
vi.useRealTimers();
}
});
it('renders the available commands section and the /tasks hint', () => {
const tool = bashTool(
createFakeKaos({ osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
);
expect(tool.description).toContain('Commands available');
expect(tool.description).toContain('/tasks');
});
it('points at the cwd argument instead of relying on cross-call cd', () => {
const tool = bashTool(
createFakeKaos({ osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
);
// Each call is a fresh shell (cwd not preserved), and there is a first-class
// cwd param — the description must steer toward it rather than cross-call cd.
expect(tool.description).toContain('cwd');
expect(tool.description).toContain('absolute paths');
// The failure trailer is non-zero-exit-specific; timeout/interrupt differ.
expect(tool.description).toContain('exits non-zero');
});
it('runs through execWithEnv, injects cwd, noninteractive env, and closes stdin', async () => {
const proc = processWithOutput({ stdout: 'ok\n' });
const execWithEnv = vi.fn().mockResolvedValue(proc);
const tool = bashTool(
createFakeKaos({ execWithEnv, osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
);
const result = await executeTool(tool, context({ command: 'printf ok', timeout: 60 }));
expect(execWithEnv).toHaveBeenCalledTimes(1);
const [argv, env] = execWithEnv.mock.calls[0]!;
expect(argv).toEqual(['/bin/bash', '-c', "cd '/workspace' && printf ok"]);
expect(env).toMatchObject({
NO_COLOR: '1',
TERM: 'dumb',
});
expect(proc.stdin.end).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
output: 'ok\n',
isError: false,
message: 'Command executed successfully.',
});
});
it('uses args.cwd when provided', async () => {
const execWithEnv = vi.fn().mockResolvedValue(processWithOutput({ stdout: 'sub\n' }));
const tool = bashTool(
createFakeKaos({ execWithEnv, osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
);
await executeTool(tool, context({ command: 'pwd', cwd: '/tmp/project', timeout: 60 }));
expect(execWithEnv.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', "cd '/tmp/project' && pwd"]);
});
it('uses Git Bash semantics on Windows', async () => {
const proc = processWithOutput({ stdout: 'ok\n' });
const execWithEnv = vi.fn().mockResolvedValue(proc);
const tool = bashTool(
createFakeKaos({ execWithEnv, osEnv: windowsBashEnv }),
'C:\\Users\\me\\project',
);
const result = await executeTool(tool, context({ command: 'echo ok 2>nul', timeout: 60 }));
expect(execWithEnv).toHaveBeenCalledTimes(1);
const [argv, env] = execWithEnv.mock.calls[0]!;
expect(argv).toEqual([
'C:\\Program Files\\Git\\bin\\bash.exe',
'-c',
"cd '/c/Users/me/project' && echo ok 2>/dev/null",
]);
expect(env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' });
expect(result).toMatchObject({
output: 'ok\n',
isError: false,
message: 'Command executed successfully.',
});
});
it('returns stderr and marks non-zero exit codes as tool errors', async () => {
const tool = bashTool(
createFakeKaos({
execWithEnv: vi
.fn()
.mockResolvedValue(processWithOutput({ stderr: 'boom\n', exitCode: 2 })),
osEnv: posixEnv,
}),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'exit 2', timeout: 60 }));
expect(result).toMatchObject({
isError: true,
message: 'Command failed with exit code: 2.',
brief: 'Failed with exit code: 2',
});
expect(result.output).toContain('boom\n');
expect(result.output).toContain('Command failed with exit code: 2.');
});
it('returns both stdout and stderr when a command succeeds', async () => {
const tool = bashTool(
createFakeKaos({
execWithEnv: vi
.fn()
.mockResolvedValue(processWithOutput({ stdout: 'out\n', stderr: 'warn\n' })),
osEnv: posixEnv,
}),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'mixed', timeout: 60 }));
expect(result).toMatchObject({
output: 'out\nwarn\n',
isError: false,
message: 'Command executed successfully.',
});
});
it('returns both stdout and stderr when a command fails', async () => {
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(
processWithOutput({
stdout: 'partial\n',
stderr: 'boom\n',
exitCode: 2,
}),
),
osEnv: posixEnv,
}),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'mixed fail', timeout: 60 }));
expect(result).toMatchObject({
isError: true,
message: 'Command failed with exit code: 2.',
brief: 'Failed with exit code: 2',
});
expect(result.output).toContain('partial\nboom\n');
expect(result.output).toContain('Command failed with exit code: 2.');
});
it('returns the manager failure reason when foreground process wait rejects', async () => {
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(
processWithOutput({
stdout: 'partial output\n',
exitCode: null,
wait: async () => {
throw new Error('wait failed');
},
}),
),
osEnv: posixEnv,
}),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'wait fails', timeout: 60 }));
expect(result).toMatchObject({
isError: true,
message: 'wait failed',
brief: 'wait failed',
});
expect(result.output).toContain('partial output\nwait failed');
expect(result.output).not.toContain('exit code: null');
});
it('preserves foreground stdout and stderr arrival order', async () => {
vi.useFakeTimers();
try {
const proc = processWithInterleavedOutput([
{ stream: 'stderr', text: 'err-first\n', delayMs: 0 },
{ stream: 'stdout', text: 'out-second\n', delayMs: 5 },
{ stream: 'stderr', text: 'err-third\n', delayMs: 10 },
]);
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(proc),
osEnv: posixEnv,
}),
'/workspace',
);
const resultPromise = executeTool(tool, context({ command: 'mixed', timeout: 60 }));
await vi.advanceTimersByTimeAsync(11);
const result = await resultPromise;
expect(result).toMatchObject({
isError: false,
output: 'err-first\nout-second\nerr-third\n',
message: 'Command executed successfully.',
});
} finally {
vi.useRealTimers();
}
});
it('can detach a foreground command through the background manager', async () => {
const { proc, finish } = pendingProcess();
const manager = createBackgroundManager().manager;
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(proc),
osEnv: posixEnv,
}),
'/workspace',
manager,
);
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }));
await vi.waitFor(() => {
expect(manager.list(false)).toHaveLength(1);
});
const task = manager.list(false)[0]!;
await vi.waitFor(() => {
expect((proc.stdout as PassThrough).listenerCount('data')).toBeGreaterThanOrEqual(1);
});
(proc.stdout as PassThrough).write('before detach\n');
expect(task).toMatchObject({
kind: 'process',
detached: false,
command: 'sleep 10',
});
manager.detach(task.taskId);
const result = await running;
(proc.stdout as PassThrough).write('after detach\n');
expect(result).toMatchObject({ isError: false });
expect(result.output).toContain('before detach\n');
expect(result.output).not.toContain('after detach\n');
expect(result.output).toContain(`task_id: ${task.taskId}`);
expect(result.output).toContain('automatic_notification: true');
// Detach must steer the model away from blocking on the task it just
// backgrounded — otherwise the whole point of detaching is defeated.
expect(result.output).toContain('do NOT wait, poll, or call TaskOutput');
expect(manager.getTask(task.taskId)).toMatchObject({ detached: true });
await vi.waitFor(async () => {
await expect(manager.readOutput(task.taskId)).resolves.toContain('after detach\n');
});
finish();
await expect(manager.wait(task.taskId)).resolves.toMatchObject({
status: 'completed',
});
});
it('does not recommend disabled task tools when a foreground command is detached', async () => {
const { proc, finish } = pendingProcess();
const manager = createBackgroundManager().manager;
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(proc),
osEnv: posixEnv,
}),
'/workspace',
manager,
{ allowBackground: false },
);
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }));
await vi.waitFor(() => {
expect(manager.list(false)).toHaveLength(1);
});
const task = manager.list(false)[0]!;
manager.detach(task.taskId);
const result = await running;
expect(result.output).toContain(`task_id: ${task.taskId}`);
expect(result.output).toContain('You will be automatically notified when it completes');
expect(result.output).toContain('do NOT wait or poll');
expect(result.output).not.toContain('TaskOutput');
expect(result.output).not.toContain('TaskStop');
finish();
await expect(manager.wait(task.taskId)).resolves.toMatchObject({
status: 'completed',
});
});
it('keeps task metadata independent when noisy foreground output is capped before detach', async () => {
const { proc, finish } = pendingProcess();
const manager = createBackgroundManager().manager;
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(proc),
osEnv: posixEnv,
}),
'/workspace',
manager,
);
const running = executeTool(tool, context({ command: 'yes noisy', timeout: 60 }));
await vi.waitFor(() => {
expect(manager.list(false)).toHaveLength(1);
});
const task = manager.list(false)[0]!;
await vi.waitFor(() => {
expect((proc.stdout as PassThrough).listenerCount('data')).toBeGreaterThanOrEqual(1);
});
(proc.stdout as PassThrough).write(
Array.from({ length: 6000 }, (_, index) => `noisy output line ${String(index)}\n`).join(''),
);
manager.detach(task.taskId);
const result = await running;
expect(result).toMatchObject({ isError: false });
expect(typeof result.output).toBe('string');
const output = result.output as string;
expect(output).toContain(`task_id: ${task.taskId}`);
expect(output).toContain('automatic_notification: true');
expect(output).toContain('foreground_output:');
expect(output).toContain('noisy output line 0');
expect(output).toContain('[...truncated]');
expect(output).toContain('Output is truncated to fit in the message.');
expect(output.indexOf(`task_id: ${task.taskId}`)).toBeLessThan(
output.indexOf('foreground_output:'),
);
finish();
await expect(manager.wait(task.taskId)).resolves.toMatchObject({
status: 'completed',
detached: true,
});
});
it('does not spawn when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const execWithEnv = vi.fn();
const tool = bashTool(
createFakeKaos({ execWithEnv, osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
);
const result = await executeTool(tool, context({ command: 'echo nope' }, controller.signal));
expect(result).toEqual({ isError: true, output: 'Aborted before command started' });
expect(execWithEnv).not.toHaveBeenCalled();
});
it('kills the process and returns an abort result when aborted while running', async () => {
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const proc = processWithOutput({
wait: async () => waitPromise,
kill: async () => {
resolveWait(143);
},
});
const execWithEnv = vi.fn().mockResolvedValue(proc);
const controller = new AbortController();
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace');
const running = executeTool(tool, context({ command: 'sleep 10' }, controller.signal));
await vi.waitFor(() => {
expect(proc.stdin.end).toHaveBeenCalled();
});
controller.abort();
const result = await running;
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('Interrupted by user');
});
it('requires background tools to be enabled and description for background commands', async () => {
const proc = processWithOutput();
const execWithEnv = vi.fn().mockResolvedValue(proc);
const backgroundDisabled = bashTool(
createFakeKaos({ execWithEnv, osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
{ allowBackground: false },
);
const unavailable = await executeTool(backgroundDisabled,
context({ command: 'sleep 10', run_in_background: true, description: 'watch' }),
);
expect(unavailable).toMatchObject({ isError: true });
expect(unavailable.output).toContain('Background execution is not available');
expect(execWithEnv).not.toHaveBeenCalled();
const manager = createBackgroundManager().manager;
const withManager = bashTool(
createFakeKaos({ execWithEnv, osEnv: posixEnv }),
'/workspace',
manager,
);
const missingDescription = await executeTool(withManager,
context({ command: 'sleep 10', run_in_background: true }),
);
expect(missingDescription).toMatchObject({ isError: true });
expect(missingDescription.output).toContain('description is required');
expect(execWithEnv).not.toHaveBeenCalled();
});
it('registers background commands and returns a task id', async () => {
const proc = processWithOutput();
const execWithEnv = vi.fn().mockResolvedValue(proc);
const manager = createBackgroundManager().manager;
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const result = await executeTool(tool,
context({ command: 'sleep 10', run_in_background: true, description: 'long running task' }),
);
expect(result.output).toMatch(/task_id: bash-[0-9a-z]{8}/);
expect(result.output).toContain('automatic_notification: true');
// The launch message must steer away from waiting, not invite a TaskOutput peek.
expect(result.output).toContain('do NOT wait, poll, or call TaskOutput on it');
expect(result.output).not.toContain('block=false');
expect(manager.list(false)).toHaveLength(1);
});
it('kills a spawned background command when the task limit is reached', async () => {
const manager = createBackgroundManager({ maxRunningTasks: 1 }).manager;
registerProcess(manager, processWithOutput(), 'sleep 10', 'existing task');
const rejectedProc = processWithOutput();
const execWithEnv = vi.fn().mockResolvedValue(rejectedProc);
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const result = await executeTool(tool,
context({ command: 'sleep 10', run_in_background: true, description: 'second task' }),
);
expect(result).toMatchObject({
isError: true,
output: 'Too many background tasks are already running.',
});
expect(execWithEnv).toHaveBeenCalledTimes(1);
expect(rejectedProc.kill).toHaveBeenCalledWith('SIGTERM');
});
it('rejects one of two concurrent background commands when the task limit is reached', async () => {
const manager = createBackgroundManager({ maxRunningTasks: 1 }).manager;
const firstProc = processWithOutput({
wait: () => new Promise(() => {}),
});
const secondProc = processWithOutput();
const execWithEnv = vi
.fn()
.mockResolvedValueOnce(firstProc)
.mockResolvedValueOnce(secondProc);
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const first = executeTool(tool,
context({ command: 'sleep 10', run_in_background: true, description: 'first task' }),
);
const second = executeTool(tool,
context({ command: 'sleep 10', run_in_background: true, description: 'second task' }),
);
const results = await Promise.all([first, second]);
expect(execWithEnv).toHaveBeenCalledTimes(2);
expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM');
expect(results).toContainEqual(expect.objectContaining({ isError: false }));
expect(results).toContainEqual(
expect.objectContaining({
isError: true,
output: 'Too many background tasks are already running.',
}),
);
});
it('uses Git Bash semantics and rejects the concurrent command at the task limit', async () => {
const manager = createBackgroundManager({ maxRunningTasks: 1 }).manager;
const firstProc = processWithOutput({
wait: () => new Promise(() => {}),
});
const secondProc = processWithOutput();
const execWithEnv = vi
.fn()
.mockResolvedValueOnce(firstProc)
.mockResolvedValueOnce(secondProc);
const tool = bashTool(
createFakeKaos({ execWithEnv, osEnv: windowsBashEnv }),
'C:\\Users\\me\\project',
manager,
);
const first = executeTool(tool,
context({
command: 'echo ok 2>nul',
run_in_background: true,
description: 'first task',
}),
);
const second = executeTool(tool,
context({
command: 'echo second',
run_in_background: true,
description: 'second task',
}),
);
const results = await Promise.all([first, second]);
expect(execWithEnv).toHaveBeenCalledTimes(2);
const [argv, env] = execWithEnv.mock.calls[0]!;
expect(argv).toEqual([
'C:\\Program Files\\Git\\bin\\bash.exe',
'-c',
"cd '/c/Users/me/project' && echo ok 2>/dev/null",
]);
expect(env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' });
expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM');
expect(results).toContainEqual(expect.objectContaining({ isError: false }));
expect(results).toContainEqual(
expect.objectContaining({
isError: true,
output: 'Too many background tasks are already running.',
}),
);
});
it('timeout-stops a background task that has not settled even if process exit is visible', async () => {
vi.useFakeTimers();
try {
const { proc, finishWait, markExited } = processWithVisibleExitBeforeWait(0);
const execWithEnv = vi.fn().mockResolvedValue(proc);
const manager = createBackgroundManager().manager;
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const result = await executeTool(tool,
context({
command: 'sleep 10',
run_in_background: true,
description: 'exit before close',
timeout: 1,
}),
);
expect(typeof result.output).toBe('string');
if (typeof result.output !== 'string') throw new Error('Expected string tool output.');
const taskId = result.output.match(/task_id: (bash-[0-9a-z]{8})/)?.[1];
expect(taskId).toBeDefined();
markExited();
await vi.advanceTimersByTimeAsync(1_000);
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
finishWait();
await vi.runAllTimersAsync();
expect(manager.getTask(taskId!)?.status).toBe('timed_out');
} finally {
vi.useRealTimers();
}
});
it('timeout-stops a background task after the default 10 minute deadline', async () => {
vi.useFakeTimers();
try {
const proc = processThatNeverExits();
const execWithEnv = vi.fn().mockResolvedValue(proc);
const manager = createBackgroundManager().manager;
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const result = await executeTool(tool,
context({
command: 'sleep 999',
run_in_background: true,
description: 'default deadline',
}),
);
expect(result).toMatchObject({ isError: false });
await vi.advanceTimersByTimeAsync(600_000);
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
} finally {
vi.useRealTimers();
}
});
it('does not timeout-stop a background task when disable_timeout is true', async () => {
vi.useFakeTimers();
try {
const proc = processThatNeverExits();
const execWithEnv = vi.fn().mockResolvedValue(proc);
const manager = createBackgroundManager().manager;
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const result = await executeTool(tool,
context({
command: 'sleep 999',
run_in_background: true,
description: 'no deadline',
disable_timeout: true,
}),
);
expect(result).toMatchObject({ isError: false });
await vi.advanceTimersByTimeAsync(600_000 + 10_000);
expect(proc.kill).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('adds a truncation note when stdout exceeds the cap', async () => {
const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'x');
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(processWithOutput({ stdout: huge })),
osEnv: posixEnv,
}),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'yes', timeout: 60 }));
expect(result.output).toContain('[...truncated]');
expect(result.output).toContain('Output is truncated');
expect((result as { message?: string }).message).toContain('Output is truncated');
});
it('saves full foreground output when the inline result is truncated', async () => {
const sessionDir = mkdtempSync(join(tmpdir(), 'bash-truncated-'));
try {
const fullOutput = `${'short line\n'.repeat(6_000)}tail survives\n`;
const { manager } = createBackgroundManager({ sessionDir });
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(processWithOutput({ stdout: fullOutput })),
osEnv: posixEnv,
}),
'/workspace',
manager,
);
const result = await executeTool(tool, context({ command: 'flood', timeout: 60 }));
const output = result.output as string;
const outputPath = /^output_path: (.+)$/m.exec(output)?.[1];
expect(output).toContain('[...truncated]');
expect(output).toContain('task_id: bash-');
expect(outputPath).toBeTruthy();
expect(readFileSync(outputPath!, 'utf8')).toBe(fullOutput);
} finally {
rmSync(sessionDir, { recursive: true, force: true });
}
});
it('marks the truncated output buffer with a "[...truncated]" sentinel at the cut point', async () => {
const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'x');
const tool = bashTool(
createFakeKaos({
execWithEnv: vi.fn().mockResolvedValue(processWithOutput({ stdout: huge })),
osEnv: posixEnv,
}),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'yes', timeout: 60 }));
expect(typeof result.output).toBe('string');
const output = result.output as string;
expect(output).toContain('[...truncated]');
});
it('truncates output with the sentinel even when the command fails', async () => {
const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'E');
const tool = bashTool(
createFakeKaos({
execWithEnv: vi
.fn()
.mockResolvedValue(processWithOutput({ stdout: huge, exitCode: 1 })),
osEnv: posixEnv,
}),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'fail-and-flood', timeout: 60 }));
expect(result).toMatchObject({ isError: true });
expect(typeof result.output).toBe('string');
const output = result.output as string;
expect(output).toContain('[...truncated]');
expect(output).toContain('Output is truncated');
});
it('reports a timed-out command with both message and brief lines', async () => {
vi.useFakeTimers();
try {
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const proc = processWithOutput({
wait: async () => waitPromise,
kill: async () => {
resolveWait(143);
},
});
const tool = bashTool(
createFakeKaos({ execWithEnv: vi.fn().mockResolvedValue(proc), osEnv: posixEnv }),
'/workspace',
);
const running = executeTool(tool, context({ command: 'sleep 2', timeout: 1 }));
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(250);
const result = await running;
expect(result).toMatchObject({
isError: true,
brief: 'Killed by timeout (1s)',
});
expect(result.output).toContain('Command killed by timeout (1s)');
} finally {
vi.useRealTimers();
}
});
it('reports timeout instead of premature close when cleanup destroys open output streams', async () => {
vi.useFakeTimers();
try {
const proc = processWithOpenStreamsThatExitOnKill();
const tool = bashTool(
createFakeKaos({ execWithEnv: vi.fn().mockResolvedValue(proc), osEnv: posixEnv }),
'/workspace',
);
const running = executeTool(tool, context({ command: 'sleep 2', timeout: 1 }));
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(250);
const result = await running;
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(result).toMatchObject({
isError: true,
brief: 'Killed by timeout (1s)',
});
expect(result.output).toContain('Command killed by timeout (1s)');
expect(result.output).not.toContain('Premature close');
} finally {
vi.useRealTimers();
}
});
it('reports a stream read error as a tool error even when the process exits with code 0', async () => {
const proc = processWithStreamError({
stdoutError: new Error('SSH channel read failed'),
exitCode: 0,
});
const tool = bashTool(
createFakeKaos({ execWithEnv: vi.fn().mockResolvedValue(proc), osEnv: posixEnv }),
'/workspace',
);
const result = await executeTool(tool, context({ command: 'remote-cmd', timeout: 60 }));
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('SSH channel read failed');
});
it('rejects empty-string commands at the schema layer', () => {
expect(BashInputSchema.safeParse({ command: '' }).success).toBe(false);
});
it('does not inject GIT_SSH_COMMAND into the spawn environment', async () => {
const previous = process.env['GIT_SSH_COMMAND'];
delete process.env['GIT_SSH_COMMAND'];
try {
const execWithEnv = vi.fn().mockResolvedValue(processWithOutput({ stdout: 'ok\n' }));
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace');
await executeTool(tool, context({ command: 'true', timeout: 60 }));
const env = execWithEnv.mock.calls[0]?.[1] as Record<string, string>;
expect(Object.prototype.hasOwnProperty.call(env, 'GIT_SSH_COMMAND')).toBe(false);
} finally {
if (previous !== undefined) process.env['GIT_SSH_COMMAND'] = previous;
}
});
it('reports background task startup with task_id, status, automatic_notification, and a human-shell hint', async () => {
const proc = processWithOutput();
const execWithEnv = vi.fn().mockResolvedValue(proc);
const manager = createBackgroundManager().manager;
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const result = await executeTool(
tool,
context({ command: 'sleep 1', run_in_background: true, description: 'sleep task' }),
);
expect(typeof result.output).toBe('string');
const output = result.output as string;
expect(output).toContain('task_id:');
expect(output).toContain('status: running');
expect(output).toContain('automatic_notification: true');
expect(output).toContain('human_shell_hint:');
expect(output).toContain('/tasks');
});
it('rejects background command without description (description-required guard)', async () => {
const manager = createBackgroundManager().manager;
const execWithEnv = vi.fn().mockResolvedValue(processWithOutput());
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager);
const result = await executeTool(
tool,
context({ command: 'sleep 1', run_in_background: true }),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('description is required');
expect(execWithEnv).not.toHaveBeenCalled();
});
it('rewrites nul-redirect on Windows so the spawned argv has /dev/null', async () => {
const execWithEnv = vi.fn().mockResolvedValue(processWithOutput({ stdout: '' }));
const tool = bashTool(
createFakeKaos({ execWithEnv, osEnv: windowsBashEnv }),
'C:\\Users\\me\\project',
);
await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 }));
const argv = execWithEnv.mock.calls[0]?.[0] as readonly string[];
expect(argv[2]).toBe("cd '/c/Users/me/project' && ls 2>/dev/null");
});
it('passes nul-redirect through unchanged on Linux so the argv keeps the literal file target', async () => {
const execWithEnv = vi.fn().mockResolvedValue(processWithOutput({ stdout: '' }));
const tool = bashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace');
await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 }));
const argv = execWithEnv.mock.calls[0]?.[0] as readonly string[];
expect(argv[2]).toBe("cd '/workspace' && ls 2>nul");
});
it('exposes a shell description that documents /bin/bash, TaskOutput/TaskStop, safety and efficiency sections, and background semantics', () => {
const tool = bashTool(
createFakeKaos({ osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
);
const description = tool.description;
expect(description).toContain('`bash`');
expect(description).toContain('TaskOutput');
expect(description).toContain('TaskStop');
expect(description).toContain('**Guidelines for safety and security:**');
expect(description).toContain('**Guidelines for efficiency:**');
expect(description).toContain('run_in_background=true');
expect(description).toContain('automatically notified');
// Moved here from system.md: the "don't block on a background task" nudge belongs in
// the background-enabled Bash description, the only place that documents it.
expect(description).toContain('returning control to the user');
});
});
describe('BashTool prompt / runtime consistency', () => {
it('reports unavailable background using only tools the prompt documents', async () => {
const execWithEnv = vi.fn();
// The set of background tools the prompt actually introduces — taken from
// the background-enabled prompt, which is the only variant that documents
// any Task* tool.
const enabledTool = bashTool(
createFakeKaos({ execWithEnv, osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
);
const promptToolNames = new Set(
[...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]),
);
const tool = bashTool(
createFakeKaos({ execWithEnv, osEnv: posixEnv }),
'/workspace',
createBackgroundManager().manager,
{ allowBackground: false },
);
const result = await executeTool(tool,
context({ command: 'sleep 10', run_in_background: true, description: 'watch' }),
);
expect(result).toMatchObject({ isError: true });
expect(typeof result.output).toBe('string');
const errorToolNames = [...(result.output as string).matchAll(/\b(Task[A-Za-z]+)\b/g)].map(
(match) => match[1],
);
// The unavailable-background error message must not name a tool that the
// prompt never introduces, otherwise the model is told about a tool it
// has no guidance for.
for (const name of errorToolNames) {
expect(promptToolNames).toContain(name);
}
expect(errorToolNames.length).toBeGreaterThan(0);
});
it('does not claim failure exit codes appear in a system tag', () => {
const tool = bashTool(createFakeKaos({ osEnv: posixEnv }), '/workspace');
// The implementation reports failures as plain text inside the output
// (`Command failed with exit code: N`), never via a system tag.
expect(tool.description).not.toMatch(/exit code will be provided in a system tag/);
});
});