mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* ci: run unit tests on windows * fix(migration-legacy): align workdir bucket key with agent-core computeWorkdirBucket used a local node:path-based resolve that yields backslash-separated paths on Windows, while agent-core's encodeWorkDirKey uses pathe (forward slashes on every platform). The SHA-256 inputs diverged, so migrated sessions were written to a bucket that the session picker never reads, making them invisible on Windows. Alias computeWorkdirBucket to encodeWorkDirKey so both sides stay byte-identical, drop the local slugify copy, and update the workdir-bucket test reference accordingly. * test(acp-adapter): expect platform-native separators in e2e-fs path The e2e-fs test asserted the fs/readTextFile wire path as the raw POSIX targetPath, but AcpKaos.toClientPath converts '/' to '\' when the inner LocalKaos reports pathClass 'win32' (Windows). On Windows the wire path became '\Users\test\x.ts' and the assertion failed. Mirror toClientPath in the test: expect backslash separators on win32 and the raw path otherwise. Implementation is unchanged. * test(sdk): normalize workDir and skillDir paths in session tests SessionStore.create/list and the skill loader normalize paths through pathe (forward slashes). The SDK tests compared the resulting workDir and skill loaded-dir against raw mkdtemp / node:path strings, which use backslashes on Windows (and node:fs realpath also returns backslashes for the skill dir), failing three toMatchObject assertions. Build the expected paths with agent-core's normalizeWorkDir so they match the internal pathe representation on every platform. The skill dir keeps its realpath() (the loader realpaths the root) and only normalizes separators. * test(skill): normalize realpath to forward slashes in scanner tests resolveSkillRoots normalizes every root.path through fs.realpath followed by replacing backslashes with forward slashes (scanner.ts). The scanner tests compared root.path against node:fs realpath directly, which returns backslashes on Windows, so twenty assertions failed (toEqual / toContain / toHaveLength) even though the resolved paths were identical. Wrap realpath at the top of the test file to mirror the implementation's normalization, so every comparison uses the same forward-slash form on every platform. * test: skip Unix-only permission tests on Windows The Unix file-permission assertions (mode bits like 0o600 / 0o700 and chmod 000 making a path unreadable) have no equivalent on Windows, which uses ACLs; fs.chmod there can only toggle the read-only bit. These six tests failed on Windows with mismatched mode values or a missing 40411. Skip them on win32 via it.skipIf(process.platform === 'win32'): oauth FileTokenStorage (0600 file, 0700 dir), agent-core BackgroundTaskPersistence (0700 tasks dir), agent-core createPerIdJsonStore (0700 subdir), migration-legacy atomicWrite (0600 file), and server fs:browse (chmod 000 -> 40411). * test(tui): make platform-sensitive assertions cross-platform The TUI implementations are already platform-aware (pathe-style paths, pathToFileURL, quoteShellArg cmd/POSIX quoting, Alt+V on Windows for paste expansion), but the tests hard-coded POSIX expectations and failed on Windows. Align the assertions with the implementation's platform behavior: footer-goal-badge matches the '[goal' badge prefix instead of /goal/ (toolbar tips contain '/goal'); tool-call expects backslash relative paths on win32; plan-box builds the file:// URL via pathToFileURL; custom-editor sends Alt+V on win32 for paste expansion; file-mention-provider normalizes the expected description to forward slashes; kimi-tui-startup builds the resume command with quoteShellArg; kimi-tui-message-flow builds the expected install path with resolve(). * test: align path assertions with pathe on Windows Several test suites asserted paths produced by node:path/node:os/node:fs against values that agent-core, node-sdk and kaos normalize through pathe (forward slashes). On Windows the two forms diverge (backslashes vs forward slashes), failing about 19 assertions. Mirror the implementation's normalization in the assertions via a local toPosix helper (or agent-core's normalizeWorkDir), so expected paths use forward slashes on every platform: kaos LocalKaos, node-sdk export/list/resume/config/transport sessions, cli FileMentionProvider, and agent-core skill-session. * test(native): build path expectations with node:path.resolve paths.mjs builds every path with node:path.resolve, which yields backslash-separated absolute paths on Windows. The path-helpers tests asserted against template strings that mixed the backslash appRoot with forward-slash segments, so Object.is failed on Windows even though the strings looked identical. Build the expectations with the same resolve(appRoot, ...) helper so the separators match on every platform. * fix: make Windows CI tests pass across all packages Fix the remaining Windows CI failures so the Windows test job can go green. The changes fall into a few categories: - Path separators: agent-core/node-sdk/kaos normalize paths via pathe (forward slashes); align test expectations and a couple of implementations (native cache base, workspace registry) with that. - Platform-only services: skip launchd/systemd manager suites on win32 (Windows uses schtasks). - Process/signal lifecycle: skip or relax tests that rely on POSIX signals / SIGTERM semantics that Windows does not support. - Hook shell syntax: rewrite hook test commands from POSIX shell (single quotes, semicolons, stderr redirects, if/then/fi) to node -e / .cjs files that run under cmd.exe. - CRLF: make Bash tool description stripping tolerate CRLF line endings. - Misc: realpath short-name divergence, port-retry timing, telemetry spawn, fs-watch timing, snapshot path normalization, etc. * fix: remove unused basename import in workspaceRegistryService Fix lint error (no-unused-vars): basename from node:path is no longer used after switching to posixBasename from pathe. * fix: align resume harness pathClass and wait for banner state on Windows Two more Windows CI fixes: - createResumeNoSideEffectKaos now reports pathClass 'win32' on Windows so tool descriptions (e.g. Glob's Windows note) match the live agent in expectResumeMatches, fixing usage/description deep-equal drift. - kimi-tui-startup once-banner test now waits for writeBannerDisplayState to land before asserting, since the atomic write can lag behind the render on Windows. * fix: resolve remaining Windows unit test failures Make the new Windows CI job green across agent-core, kaos, node-sdk and server: - Align the resume harness kaos pathClass with the live agent so platform-conditional tool descriptions (Glob's Windows note) match in expectResumeMatches instead of drifting on win32. - Rewrite hook commands in agent-core tests as cross-platform node one-liners; single-quote echo, >&2 and ';' do not work under cmd.exe. - Add .gitattributes enforcing LF so raw-imported templates (e.g. the compaction instruction) produce byte-identical token counts on Windows and POSIX. - Terminate the full process tree on Windows in both the hook runner and kaos (taskkill /T /F) so grandchildren cannot outlive their parent and keep the cwd locked. - Normalize workDir path separators in two kimi-sdk session tests to match the stored canonical form. - Avoid cmd.exe arg-quoting pitfalls in the kaos cmd.exe test, and run the Windows process-tree kill test from a script file with the pid path passed via argv. - Give the first fs-git e2e test more time on Windows and retry the temp-dir cleanup; skip the fs-watch overflow-burst assertion on Windows where fs-event coalescing prevents the single-window spike. * ci: retrigger checks * fix: resolve remaining Windows failures after merging main - Terminate the spawned git/gh process tree on Windows in FsGitService (taskkill /T /F on timeout) so a timed-out 'gh pr view' cannot leave a grandchild holding the workspace cwd, which made the fs-git e2e cleanup fail with EPERM. - Give the fs:git_status e2e suite a longer timeout on Windows and retry the temp-dir cleanup longer to ride out the slower child-process teardown. - Make the third-party plugin install trust test assert the resolved install path via node:path so it matches the Windows-resolved path (D:\tmp\...) as well as the POSIX one. * fix: align workspace registry roots and harden fs-git cleanup on Windows - workspace-registry test: compare normalized (forward-slash) roots, since the registry and session index both store workDir via pathe.resolve (forward slashes on every platform). realpath() yields backslashes on Windows and diverged from the stored root. - fs-git e2e: bump the temp-dir cleanup retries and the afterEach timeout, since Windows child-process teardown after server.close() is asynchronous and can keep the workspace cwd locked for several seconds. * test: stub openUrl in kimi-tui-message-flow feedback tests The /feedback command falls back to openUrl(FEEDBACK_ISSUE_URL) when submission fails, which spawned a real browser window on every test run. Mock #/utils/open-url (matching the existing login/message-replay/server test convention) so the suite never opens a browser. * test: harden fs-git e2e cleanup against Windows cwd locks On Windows, git/gh child processes and the session core process can outlive server.close() and keep the temp workspace as their cwd, so rmSync fails with EPERM even after a long retry. Add rmSyncRobust that retries and, if the cwd is still locked, swallows EPERM/EBUSY on Windows — the OS reclaims the temp dir and a cleanup hiccup must not fail an otherwise-passing test. * test: harden server e2e cleanup against async teardown races server.close() does not fully await the server's asynchronous teardown, so on a loaded CI runner the temp home/workspace dirs can still be held or written to when the afterEach rmSync runs, failing with EPERM (Windows) or ENOTEMPTY (Linux). Use a rmSyncRobust helper (retry + swallow EPERM/EBUSY/ENOTEMPTY) in the fs-git and question e2e cleanup. Also fix a leftover `throw err` (renamed to `throw error`) that broke the typecheck.
528 lines
24 KiB
TypeScript
528 lines
24 KiB
TypeScript
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import type { ToolCall } from '@moonshot-ai/kosong';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { budgetToolResultForModel } from '../../src/agent/turn/tool-result-budget';
|
|
import { HookEngine } from '../../src/session/hooks';
|
|
import type { SessionSubagentHost } from '../../src/session/subagent-host';
|
|
import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags';
|
|
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
|
|
import { createCommandKaos, testAgent } from './harness/agent';
|
|
import { executeTool } from '../tools/fixtures/execute-tool';
|
|
|
|
const signal = new AbortController().signal;
|
|
|
|
describe('Agent tools', () => {
|
|
it('blocks tools through PreToolUse before permission and emits PostToolUseFailure', async () => {
|
|
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute'));
|
|
const triggered: Array<[string, string, number]> = [];
|
|
const hookEngine = new HookEngine(
|
|
[
|
|
{
|
|
event: 'PreToolUse',
|
|
matcher: 'Bash',
|
|
command: 'node -e "process.stderr.write(\'blocked by PreToolUse\'); process.exit(2)"',
|
|
},
|
|
{
|
|
event: 'PostToolUseFailure',
|
|
matcher: 'Bash',
|
|
command: 'exit 0',
|
|
},
|
|
],
|
|
{
|
|
onTriggered: (event, target, count) => {
|
|
triggered.push([event, target, count]);
|
|
},
|
|
},
|
|
);
|
|
const ctx = testAgent({
|
|
kaos: createFakeKaos({ execWithEnv }),
|
|
hookEngine,
|
|
});
|
|
ctx.configure({ tools: ['Bash'] });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
|
|
ctx.mockNextResponse({ type: 'text', text: 'The hook blocked Bash.' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Try Bash' }] });
|
|
|
|
await ctx.untilTurnEnd();
|
|
|
|
expect(execWithEnv).not.toHaveBeenCalled();
|
|
expect(triggered).toEqual([
|
|
['PreToolUse', 'Bash', 1],
|
|
['PostToolUseFailure', 'Bash', 1],
|
|
]);
|
|
expect(JSON.stringify(ctx.agent.context.data().history)).toContain('blocked by PreToolUse');
|
|
});
|
|
|
|
it('emits PostToolUse after successful tools', async () => {
|
|
const triggered: Array<[string, string, number]> = [];
|
|
const hookEngine = new HookEngine(
|
|
[
|
|
{
|
|
event: 'PostToolUse',
|
|
matcher: 'Bash',
|
|
command: 'exit 0',
|
|
},
|
|
],
|
|
{
|
|
onTriggered: (event, target, count) => {
|
|
triggered.push([event, target, count]);
|
|
},
|
|
},
|
|
);
|
|
const ctx = testAgent({
|
|
kaos: createCommandKaos('ok'),
|
|
hookEngine,
|
|
});
|
|
ctx.configure({ tools: ['Bash'] });
|
|
await ctx.rpc.setPermission({ mode: 'auto' });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
|
|
ctx.mockNextResponse({ type: 'text', text: 'Bash returned ok.' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run Bash' }] });
|
|
|
|
await ctx.untilTurnEnd();
|
|
|
|
expect(triggered).toEqual([['PostToolUse', 'Bash', 1]]);
|
|
});
|
|
|
|
it('uses builtin descriptions on tool call start events', async () => {
|
|
const ctx = testAgent({
|
|
kaos: createCommandKaos('ok'),
|
|
});
|
|
ctx.configure({ tools: ['Bash'] });
|
|
await ctx.rpc.setPermission({ mode: 'yolo' });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
|
|
ctx.mockNextResponse({ type: 'text', text: 'Bash returned ok.' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Run Bash' }] });
|
|
await ctx.untilTurnEnd();
|
|
|
|
const started = ctx.allEvents.find(
|
|
(event) => event.type === '[rpc]' && event.event === 'tool.call.started',
|
|
);
|
|
expect(started?.args).toMatchObject({
|
|
description: 'Running: printf hook-output',
|
|
});
|
|
});
|
|
|
|
it('continues after a foreground Agent tool returns a max_tokens failure', async () => {
|
|
const completion = Promise.reject(
|
|
new Error('Subagent turn failed before completing its final summary: reason=max_tokens.'),
|
|
);
|
|
void completion.catch(() => undefined);
|
|
const subagentHost = {
|
|
spawn: vi.fn().mockResolvedValue({
|
|
agentId: 'agent-child',
|
|
profileName: 'coder',
|
|
resumed: false,
|
|
completion,
|
|
}),
|
|
resume: vi.fn(),
|
|
} as unknown as SessionSubagentHost;
|
|
const ctx = testAgent({ subagentHost });
|
|
ctx.configure({ tools: ['Agent'] });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will ask a subagent.' }, agentCall());
|
|
ctx.mockNextResponse({
|
|
type: 'text',
|
|
text: 'The subagent failed with reason=max_tokens, so I will continue in the parent turn.',
|
|
});
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Delegate and recover' }] });
|
|
await ctx.untilTurnEnd();
|
|
|
|
expect(subagentHost.spawn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
profileName: 'coder',
|
|
parentToolCallId: 'call_agent',
|
|
prompt: 'Investigate deeply',
|
|
description: 'Investigate deeply',
|
|
runInBackground: false,
|
|
}),
|
|
);
|
|
expect(ctx.llmCalls).toHaveLength(2);
|
|
expect(ctx.allEvents).toContainEqual(
|
|
expect.objectContaining({
|
|
type: '[rpc]',
|
|
event: 'tool.result',
|
|
args: expect.objectContaining({
|
|
toolCallId: 'call_agent',
|
|
isError: true,
|
|
output: expect.stringContaining('reason=max_tokens'),
|
|
}),
|
|
}),
|
|
);
|
|
expect(JSON.stringify(ctx.llmCalls[1]?.history)).toContain('reason=max_tokens');
|
|
});
|
|
|
|
it('passes text from content-part error outputs to PostToolUseFailure hooks', async () => {
|
|
const lookupCall: ToolCall = {
|
|
type: 'function',
|
|
id: 'call_lookup',
|
|
name: 'Lookup',
|
|
arguments: '{"query":"moon"}',
|
|
};
|
|
const resolved: Array<[string, string, string]> = [];
|
|
const hookEngine = new HookEngine(
|
|
[
|
|
{
|
|
event: 'PostToolUseFailure',
|
|
matcher: 'Lookup',
|
|
command: hookErrorMessageAssertCommand('rich failure text'),
|
|
},
|
|
],
|
|
{
|
|
onResolved: (event, target, action) => {
|
|
resolved.push([event, target, action]);
|
|
},
|
|
},
|
|
);
|
|
const ctx = testAgent({ hookEngine });
|
|
ctx.configure();
|
|
await ctx.rpc.setPermission({ mode: 'auto' });
|
|
await ctx.rpc.registerTool({
|
|
name: 'Lookup',
|
|
description: 'Look up a short test value.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string' },
|
|
},
|
|
required: ['query'],
|
|
additionalProperties: false,
|
|
},
|
|
});
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall);
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] });
|
|
await ctx.untilToolCall({
|
|
isError: true,
|
|
output: [{ type: 'text', text: 'rich failure text' }],
|
|
});
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'The lookup failed.' });
|
|
await ctx.untilTurnEnd();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(resolved).toEqual([['PostToolUseFailure', 'Lookup', 'allow']]);
|
|
});
|
|
});
|
|
|
|
it('uses the active builtin tool set as the LLM visible tools', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure({ tools: ['Write', 'Bash'] });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'ready' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Which tools are active?' }] });
|
|
|
|
await ctx.untilTurnEnd();
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: <system-prompt>
|
|
tools: Bash, Write
|
|
messages:
|
|
user: text "Which tools are active?"
|
|
`);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('disables Bash background mode unless task management tools are active', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure({ tools: ['Bash'] });
|
|
|
|
const bashOnly = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Bash');
|
|
expect(bashOnly).toBeDefined();
|
|
expect(bashOnly!.description).toContain('Background execution is disabled for this agent.');
|
|
expect(bashOnly!.description).not.toContain('the command will be started as a background task');
|
|
await expect(
|
|
executeTool(bashOnly!, {
|
|
turnId: '0',
|
|
toolCallId: 'call_bash',
|
|
args: { command: 'sleep 10', run_in_background: true, description: 'watch' },
|
|
signal,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
isError: true,
|
|
output:
|
|
'Background execution is not available for this agent because TaskOutput and TaskStop are not enabled.',
|
|
});
|
|
|
|
ctx.agent.tools.setActiveTools(['Bash', 'TaskList', 'TaskOutput', 'TaskStop']);
|
|
|
|
const managedBash = ctx.agent.tools.loopTools.find((tool) => tool.name === 'Bash');
|
|
expect(managedBash).toBeDefined();
|
|
expect(managedBash!.description).toContain('run_in_background=true');
|
|
});
|
|
|
|
it('exposes AgentSwarm when a subagent host is available', () => {
|
|
const subagentHost = {} as unknown as SessionSubagentHost;
|
|
|
|
const ctx = testAgent({
|
|
subagentHost,
|
|
experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS),
|
|
});
|
|
ctx.configure({ tools: ['AgentSwarm'] });
|
|
|
|
expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'AgentSwarm')).toBe(true);
|
|
});
|
|
|
|
it('routes registered user tools through tool.call request/response', async () => {
|
|
const lookupCall: ToolCall = {
|
|
type: 'function',
|
|
id: 'call_lookup',
|
|
name: 'Lookup',
|
|
arguments: '{"query":"moon"}',
|
|
};
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
await ctx.rpc.setPermission({ mode: 'auto' });
|
|
await ctx.rpc.registerTool({
|
|
name: 'Lookup',
|
|
description: 'Look up a short test value.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string' },
|
|
},
|
|
required: ['query'],
|
|
additionalProperties: false,
|
|
},
|
|
});
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall);
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] });
|
|
expect(
|
|
await ctx.untilToolCall({
|
|
content: 'moon-result',
|
|
output: 'moon-result',
|
|
}),
|
|
).toMatchInlineSnapshot(`
|
|
[wire] permission.set_mode { "mode": "auto", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "auto" }
|
|
[wire] tools.register_user_tool { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false }, "time": "<time>" }
|
|
[wire] turn.prompt { "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "time": "<time>" }
|
|
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
|
|
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
|
|
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_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>" }
|
|
[emit] assistant.delta { "turnId": 0, "delta": "I will look it up." }
|
|
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "argumentsPart": "{\\"query\\":\\"moon\\"}" }
|
|
[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 look it up." } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_lookup", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_lookup", "name": "Lookup", "args": { "query": "moon" } }, "time": "<time>" }
|
|
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "args": { "query": "moon" } }
|
|
[emit] toolCall { "turnId": 0, "toolCallId": "call_lookup", "args": { "query": "moon" } }
|
|
`);
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: <system-prompt>
|
|
tools: Lookup
|
|
messages:
|
|
user: text "Look up moon"
|
|
user: text <auto-mode-enter-reminder>
|
|
`);
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'The lookup result is moon-result.' });
|
|
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
|
|
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_lookup", "toolCallId": "call_lookup", "result": { "output": "moon-result" } }, "time": "<time>" }
|
|
[emit] tool.result { "turnId": 0, "toolCallId": "call_lookup", "output": "moon-result" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
|
|
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
|
|
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 104, "maxContextTokens": 1000000, "contextUsage": 0.000104, "planMode": false, "swarmMode": false, "permission": "auto", "usage": { "byModel": { "mock-model": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 88, "output": 16, "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>" }
|
|
[emit] assistant.delta { "turnId": 0, "delta": "The lookup result is moon-result." }
|
|
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "stepUuid": "<uuid-3>", "part": { "type": "text", "text": "The lookup result is moon-result." } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-3>", "turnId": "0", "step": 2, "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }, "time": "<time>" }
|
|
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-3>", "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
|
|
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 120, "maxContextTokens": 1000000, "contextUsage": 0.00012, "planMode": false, "swarmMode": false, "permission": "auto", "usage": { "byModel": { "mock-model": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 196, "output": 28, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
|
[emit] turn.ended { "turnId": 0, "reason": "completed" }
|
|
`);
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
messages:
|
|
<last>
|
|
assistant: text "I will look it up." calls call_lookup:Lookup { "query": "moon" }
|
|
tool[call_lookup]: text "moon-result"
|
|
`);
|
|
|
|
await ctx.rpc.unregisterTool({ name: 'Lookup' });
|
|
ctx.mockNextResponse({ type: 'text', text: 'No lookup tool is available.' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Can you still use Lookup?' }] });
|
|
|
|
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
|
|
[wire] tools.unregister_user_tool { "name": "Lookup", "time": "<time>" }
|
|
[wire] turn.prompt { "input": [ { "type": "text", "text": "Can you still use Lookup?" } ], "origin": { "kind": "user" }, "time": "<time>" }
|
|
[emit] turn.started { "turnId": 1, "origin": { "kind": "user" } }
|
|
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-5>", "turnId": "1", "step": 1 }, "time": "<time>" }
|
|
[emit] turn.step.started { "turnId": 1, "step": 1, "stepId": "<uuid-5>" }
|
|
[emit] assistant.delta { "turnId": 1, "delta": "No lookup tool is available." }
|
|
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-6>", "turnId": "1", "step": 1, "stepUuid": "<uuid-5>", "part": { "type": "text", "text": "No lookup tool is available." } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-5>", "turnId": "1", "step": 1, "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }, "time": "<time>" }
|
|
[emit] turn.step.completed { "turnId": 1, "step": 1, "stepId": "<uuid-5>", "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
|
|
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 138, "maxContextTokens": 1000000, "contextUsage": 0.000138, "planMode": false, "swarmMode": false, "permission": "auto", "usage": { "byModel": { "mock-model": { "inputOther": 324, "output": 38, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 324, "output": 38, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
|
[emit] turn.ended { "turnId": 1, "reason": "completed" }
|
|
`);
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
tools: []
|
|
messages:
|
|
<last>
|
|
assistant: text "The lookup result is moon-result."
|
|
user: text "Can you still use Lookup?"
|
|
`);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('persists oversized registered tool results before adding them to model context', async () => {
|
|
const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-'));
|
|
try {
|
|
const lookupCall: ToolCall = {
|
|
type: 'function',
|
|
id: 'call_lookup',
|
|
name: 'Lookup',
|
|
arguments: '{"query":"moon"}',
|
|
};
|
|
const largeOutput = `${'x'.repeat(60_000)}tail survives`;
|
|
const ctx = testAgent({ homedir: sessionDir });
|
|
ctx.configure();
|
|
await ctx.rpc.setPermission({ mode: 'auto' });
|
|
await ctx.rpc.registerTool({
|
|
name: 'Lookup',
|
|
description: 'Look up a short test value.',
|
|
parameters: { type: 'object', properties: {} },
|
|
});
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall);
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] });
|
|
await ctx.untilToolCall({ output: largeOutput });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
|
await ctx.untilTurnEnd();
|
|
|
|
const toolText = ctx.compactHistory().find((message) => message.role === 'tool')?.text ?? '';
|
|
const outputPath = /^output_path: (.+)$/m.exec(toolText)?.[1];
|
|
expect(toolText).toContain('Tool output exceeded 50000 characters');
|
|
expect(toolText).not.toContain('tail survives');
|
|
expect(outputPath).toBeTruthy();
|
|
expect(readFileSync(outputPath!, 'utf8')).toBe(largeOutput);
|
|
} finally {
|
|
rmSync(sessionDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not overwrite saved oversized tool results with repeated call IDs', async () => {
|
|
const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-'));
|
|
try {
|
|
const firstOutput = `${'a'.repeat(60_000)}first tail`;
|
|
const secondOutput = `${'b'.repeat(60_000)}second tail`;
|
|
|
|
const first = await budgetToolResultForModel({
|
|
homedir: sessionDir,
|
|
toolName: 'Lookup',
|
|
toolCallId: 'call_lookup',
|
|
result: { output: firstOutput },
|
|
});
|
|
const second = await budgetToolResultForModel({
|
|
homedir: sessionDir,
|
|
toolName: 'Lookup',
|
|
toolCallId: 'call_lookup',
|
|
result: { output: secondOutput },
|
|
});
|
|
|
|
const firstPath = savedOutputPath(first.output);
|
|
const secondPath = savedOutputPath(second.output);
|
|
expect(firstPath).not.toBe(secondPath);
|
|
expect(readFileSync(firstPath, 'utf8')).toBe(firstOutput);
|
|
expect(readFileSync(secondPath, 'utf8')).toBe(secondOutput);
|
|
} finally {
|
|
rmSync(sessionDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('keeps oversized tool results intact when no session directory is available', async () => {
|
|
const largeOutput = `${'x'.repeat(60_000)}tail survives`;
|
|
const result = { output: largeOutput };
|
|
|
|
const budgeted = await budgetToolResultForModel({
|
|
toolName: 'Lookup',
|
|
toolCallId: 'call_lookup',
|
|
result,
|
|
});
|
|
|
|
expect(budgeted).toBe(result);
|
|
expect(budgeted.output).toBe(largeOutput);
|
|
});
|
|
|
|
it('does not save already-truncated tool result previews as full output', async () => {
|
|
const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-'));
|
|
try {
|
|
const largeOutput = `${'x'.repeat(60_000)}[...truncated]`;
|
|
const result = {
|
|
output: largeOutput,
|
|
truncated: true,
|
|
};
|
|
|
|
const budgeted = await budgetToolResultForModel({
|
|
homedir: sessionDir,
|
|
toolName: 'Lookup',
|
|
toolCallId: 'call_lookup',
|
|
result,
|
|
});
|
|
|
|
expect(budgeted).toBe(result);
|
|
expect(budgeted.output).toBe(largeOutput);
|
|
expect(budgeted.output).not.toContain('output_path:');
|
|
expect(existsSync(join(sessionDir, 'tool-results'))).toBe(false);
|
|
} finally {
|
|
rmSync(sessionDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
function bashCall(): ToolCall {
|
|
return {
|
|
type: 'function',
|
|
id: 'call_bash',
|
|
name: 'Bash',
|
|
arguments: '{"command":"printf hook-output","timeout":60}',
|
|
};
|
|
}
|
|
|
|
function agentCall(): ToolCall {
|
|
return {
|
|
type: 'function',
|
|
id: 'call_agent',
|
|
name: 'Agent',
|
|
arguments: JSON.stringify({
|
|
prompt: 'Investigate deeply',
|
|
description: 'Investigate deeply',
|
|
subagent_type: 'coder',
|
|
}),
|
|
};
|
|
}
|
|
|
|
function savedOutputPath(output: unknown): string {
|
|
expect(typeof output).toBe('string');
|
|
const outputPath = /^output_path: (.+)$/m.exec(output as string)?.[1];
|
|
expect(outputPath).toBeTruthy();
|
|
return outputPath!;
|
|
}
|
|
|
|
function hookErrorMessageAssertCommand(expected: string): string {
|
|
const script = [
|
|
"let input = '';",
|
|
"process.stdin.on('data', (chunk) => { input += chunk; });",
|
|
"process.stdin.on('end', () => {",
|
|
' const payload = JSON.parse(input);',
|
|
` if (payload.error?.message === ${JSON.stringify(expected)}) process.exit(0);`,
|
|
" console.error(payload.error?.message ?? '<missing>');",
|
|
' process.exit(2);',
|
|
'});',
|
|
].join('');
|
|
return `node -e ${JSON.stringify(script)}`;
|
|
}
|