fix: preserve long tool output (#1062)

* fix: persist truncated foreground bash output

* fix: persist oversized tool results

* fix: link background task notifications to saved output

* fix: avoid lossy tool result budgeting

* fix

* fix: include fallback task output previews

* fix

* fix
This commit is contained in:
_Kerman 2026-06-24 14:42:11 +08:00 committed by GitHub
parent 4b837d6bfb
commit ea6a4bfe6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 450 additions and 83 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output.

View file

@ -99,7 +99,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill
## Background Tasks
Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and trailing output are automatically delivered back to the Agent; use `TaskOutput` to check progress early.
Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path are automatically delivered back to the Agent; use `TaskOutput` to check progress early.
| Tool | Default Approval | Description |
| --- | --- | --- |

View file

@ -99,7 +99,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只
## 后台任务
后台任务工具用于管理通过 `Bash``Agent``AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和末尾输出送回 Agent如需提前检查进度使用 `TaskOutput`
后台任务工具用于管理通过 `Bash``Agent``AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径送回 Agent如需提前检查进度使用 `TaskOutput`
| 工具 | 默认审批 | 说明 |
| --- | --- | --- |

View file

@ -18,6 +18,7 @@ import type { ContentPart } from '@moonshot-ai/kosong';
import type { Agent } from '../..';
import { errorMessage } from '../../loop/errors';
import { timeoutOutcome } from '../../utils/promise';
import { escapeXml, escapeXmlAttr } from '../../utils/xml-escape';
import type { BackgroundTaskOrigin } from '../context';
import { renderNotificationXml } from '../context/notification-xml';
import { type BackgroundTaskPersistence } from './persist';
@ -105,6 +106,7 @@ interface ManagedTask {
* reads the persisted log when available.
*/
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB
const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
const SIGTERM_GRACE_MS = 5_000;
const USER_INTERRUPT_REASON = 'Interrupted by user';
@ -157,7 +159,7 @@ type BackgroundTaskNotification = Record<string, unknown> & {
readonly title: string;
readonly severity: 'info' | 'warning';
readonly body: string;
readonly tail_output: string;
readonly children?: readonly string[] | undefined;
};
interface BackgroundTaskNotificationContext {
@ -166,8 +168,6 @@ interface BackgroundTaskNotificationContext {
readonly notification: BackgroundTaskNotification;
}
const NOTIFICATION_TAIL_BYTES = 3_000;
export interface RegisterBackgroundTaskOptions {
/**
* When false, the task is tracked by the manager but a foreground tool call
@ -427,6 +427,12 @@ export class BackgroundManager {
return this.toInfo(entry);
}
persistOutput(taskId: string): void {
const entry = this.tasks.get(taskId);
if (entry === undefined) return;
this.startOutputPersist(entry);
}
/** Stop a running task. SIGTERM → 5s grace → SIGKILL. */
async stop(taskId: string, reason?: string): Promise<BackgroundTaskInfo | undefined> {
const entry = this.tasks.get(taskId);
@ -661,8 +667,10 @@ export class BackgroundManager {
if (this.deliveredNotificationKeys.has(key)) return;
this.scheduledNotificationKeys.add(key);
const tailOutput = (await this.getOutputSnapshot(info.taskId, NOTIFICATION_TAIL_BYTES))
.preview;
let output = await this.getOutputSnapshot(info.taskId, 0);
if (!output.fullOutputAvailable) {
output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES);
}
if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined;
const notification: BackgroundTaskNotification = {
id: origin.notificationId,
@ -674,7 +682,7 @@ export class BackgroundManager {
title: `Background ${info.kind} ${info.status}`,
severity: info.status === 'completed' ? 'info' : 'warning',
body: buildBackgroundTaskNotificationBody(info),
tail_output: tailOutput,
children: backgroundTaskNotificationChildren(output),
};
const content = [
{
@ -853,6 +861,35 @@ export class BackgroundManager {
}
}
function backgroundTaskNotificationChildren(
output: BackgroundTaskOutputSnapshot,
): readonly string[] | undefined {
if (output.fullOutputAvailable && output.outputPath !== undefined) {
return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)];
}
if (output.preview.length === 0) return undefined;
return [renderOutputPreviewBlock(output)];
}
function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string {
return [
`<output-file path="${escapeXmlAttr(outputPath)}" bytes="${String(outputSizeBytes)}">`,
`Read the output file to retrieve the result: ${escapeXml(outputPath)}`,
'</output-file>',
].join('\n');
}
function renderOutputPreviewBlock(output: BackgroundTaskOutputSnapshot): string {
return [
`<output-preview bytes="${String(output.previewBytes)}" total_bytes="${String(output.outputSizeBytes)}" truncated="${String(output.truncated)}">`,
output.truncated
? `Showing the last ${String(output.previewBytes)} bytes. No persisted full output is available.`
: 'No persisted full output is available; this preview is the currently buffered task output.',
escapeXml(output.preview),
'</output-preview>',
].join('\n');
}
function notificationKey(origin: BackgroundTaskOrigin): string {
return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`;
}

View file

@ -7,14 +7,11 @@
* Title: ...
* Severity: ...
* <body>
* <task-notification> (only when source_kind === 'background_task' and tail_output is non-empty)
* <truncated tail>
* </task-notification>
* <children...>
* </notification>
*
* The opening-tag names (`<notification ` / `<task-notification>`) are
* load-bearing for the projector's `mergeAdjacentUserMessages` detector
* rename requires updating the detector too.
* The opening tag name (`<notification `) is load-bearing for notification
* consumers that detect chat-history injections.
*
* `agent_id` is emitted only for background_task notifications whose
* source task is an agent subagent surfacing it structurally lets the
@ -36,6 +33,7 @@ export function renderNotificationXml(data: Record<string, unknown>): string {
const title = typeof data['title'] === 'string' ? data['title'] : '';
const severity = typeof data['severity'] === 'string' ? data['severity'] : '';
const body = typeof data['body'] === 'string' ? data['body'] : '';
const children = childBlocks(data['children'] ?? data['extraBlocks']);
const agentIdAttr = agentId === undefined ? '' : ` agent_id="${agentId}"`;
const lines: string[] = [
@ -44,36 +42,12 @@ export function renderNotificationXml(data: Record<string, unknown>): string {
if (title.length > 0) lines.push(`Title: ${title}`);
if (severity.length > 0) lines.push(`Severity: ${severity}`);
if (body.length > 0) lines.push(body);
if (data['source_kind'] === 'background_task') {
const tailRaw = typeof data['tail_output'] === 'string' ? data['tail_output'] : '';
if (tailRaw.length > 0) {
const truncated = truncateTailOutput(tailRaw, 20, 3000);
lines.push('<task-notification>');
lines.push(truncated);
lines.push('</task-notification>');
}
}
lines.push(...children);
lines.push('</notification>');
return lines.join('\n');
}
/**
* Truncate tail output to at most `maxLines` lines and `maxChars`
* characters. Takes the *last* N lines, then trims from the front if
* the character budget is exceeded.
*/
function truncateTailOutput(raw: string, maxLines: number, maxChars: number): string {
const allLines = raw.split('\n');
const tailLines = allLines.length > maxLines ? allLines.slice(-maxLines) : allLines;
let result = tailLines.join('\n');
if (result.length > maxChars) {
result = result.slice(-maxChars);
}
return result;
}
function stringAttr(value: unknown, fallback: string): string {
if (typeof value !== 'string' || value.length === 0) return fallback;
return escapeXmlAttr(value);
@ -85,3 +59,9 @@ function optionalStringAttr(value: unknown): string | undefined {
if (typeof value !== 'string' || value.length === 0) return undefined;
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}
function childBlocks(value: unknown): string[] {
if (typeof value === 'string' && value.length > 0) return [value];
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
}

View file

@ -40,6 +40,7 @@ import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks';
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
import { ToolCallDeduplicator } from './tool-dedup';
import { budgetToolResultForModel } from './tool-result-budget';
interface ActiveTurn {
readonly turnId: number;
@ -747,7 +748,12 @@ export class TurnFlow {
toolOutput: isError === true ? undefined : toolOutputText(output).slice(0, 2000),
},
});
return finalResult;
return budgetToolResultForModel({
homedir: this.agent.homedir,
toolName: ctx.toolCall.name,
toolCallId: ctx.toolCall.id,
result: finalResult,
});
},
},
});

View file

@ -0,0 +1,91 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import type { ContentPart } from '@moonshot-ai/kosong';
import { join } from 'pathe';
import type { ExecutableToolResult } from '../../loop';
const TOOL_RESULT_MAX_CHARS = 50_000;
const TOOL_RESULT_PREVIEW_CHARS = 2_000;
interface BudgetToolResultOptions {
readonly homedir?: string;
readonly toolName: string;
readonly toolCallId: string;
readonly result: ExecutableToolResult;
}
export async function budgetToolResultForModel(
options: BudgetToolResultOptions,
): Promise<ExecutableToolResult> {
const text = persistableToolResultText(options.result.output);
if (text === undefined || text.length <= TOOL_RESULT_MAX_CHARS) return options.result;
if (options.result.truncated === true) return options.result;
if (options.homedir === undefined) return options.result;
const outputPath = await saveToolResult(
{ homedir: options.homedir, toolName: options.toolName, toolCallId: options.toolCallId },
text,
);
if (outputPath === undefined) return options.result;
const output = renderPersistedToolResult(options.toolName, options.toolCallId, text, outputPath);
return options.result.isError === true
? { ...options.result, output, isError: true }
: { ...options.result, output };
}
function persistableToolResultText(output: ExecutableToolResult['output']): string | undefined {
if (typeof output === 'string') return output;
if (
!output.every((part): part is Extract<ContentPart, { type: 'text' }> => part.type === 'text')
) {
return undefined;
}
return output.map((part) => part.text).join('');
}
async function saveToolResult(
options: { readonly homedir: string; readonly toolName: string; readonly toolCallId: string },
text: string,
): Promise<string | undefined> {
try {
const dir = join(options.homedir, 'tool-results');
await mkdir(dir, { recursive: true, mode: 0o700 });
const outputPath = join(
dir,
`${safeToolResultFileStem(options.toolName, options.toolCallId)}-${randomUUID()}.txt`,
);
await writeFile(outputPath, text, { encoding: 'utf8', flag: 'wx' });
return outputPath;
} catch {
return undefined;
}
}
function renderPersistedToolResult(
toolName: string,
toolCallId: string,
text: string,
outputPath: string,
): string {
const lines = [
`Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; showing a preview only.`,
`tool_name: ${toolName}`,
`tool_call_id: ${toolCallId}`,
`output_size_chars: ${String(text.length)}`,
`output_size_bytes: ${String(Buffer.byteLength(text, 'utf8'))}`,
`output_path: ${outputPath}`,
'next_step: Use Read with output_path to page through the full output.',
];
lines.push('', '[preview]', text.slice(0, TOOL_RESULT_PREVIEW_CHARS));
return lines.join('\n');
}
function safeToolResultFileStem(toolName: string, toolCallId: string): string {
const label = `${toolName}-${toolCallId}`
.replace(/[^a-zA-Z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 80);
return label || 'tool-result';
}

View file

@ -671,7 +671,12 @@ function normalizeToolResult(r: ExecutableToolResult): ExecutableToolResult {
output = textJoined.length > 0 ? textJoined : TOOL_OUTPUT_EMPTY;
}
}
return r.isError === true ? { output, isError: true } : { output };
if (r.isError === true) {
return r.truncated === true
? { output, isError: true, truncated: true }
: { output, isError: true };
}
return r.truncated === true ? { output, truncated: true } : { output };
}
function makeToolResult(

View file

@ -78,6 +78,12 @@ export interface ExecutableToolSuccessResult {
* this to the user.
*/
readonly message?: string | undefined;
/**
* True when the tool has already returned a partial result because it
* truncated, paged, or otherwise dropped original output. Later generic
* budgeting must not treat the visible output as complete source text.
*/
readonly truncated?: boolean | undefined;
}
export interface ExecutableToolErrorResult {
@ -87,6 +93,8 @@ export interface ExecutableToolErrorResult {
readonly message?: string | undefined;
/** See {@link ExecutableToolSuccessResult.stopTurn}. */
readonly stopTurn?: boolean | undefined;
/** See {@link ExecutableToolSuccessResult.truncated}. */
readonly truncated?: boolean | undefined;
}
export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult;

View file

@ -133,7 +133,7 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu
export function mcpResultToExecutableOutput(
result: MCPToolResult,
qualifiedToolName: string,
): { output: string | ContentPart[]; isError: boolean } {
): { output: string | ContentPart[]; isError: boolean; truncated?: true } {
const converted: ContentPart[] = [];
for (const block of result.content) {
const part = convertMCPContentBlock(block);
@ -144,8 +144,10 @@ export function mcpResultToExecutableOutput(
const wrapped = wrapMediaOnly(converted, qualifiedToolName);
const limited = applyOutputLimits(wrapped);
const output = collapseSingleText(limited);
return { output, isError: result.isError };
const output = collapseSingleText(limited.parts);
return limited.truncated
? { output, isError: result.isError, truncated: true }
: { output, isError: result.isError };
}
/**
@ -173,20 +175,26 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string)
* the last surviving text part this keeps the single-text-part collapse
* working when the entire (oversized) input is a single text block.
*/
function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] {
function applyOutputLimits(parts: readonly ContentPart[]): {
readonly parts: ContentPart[];
readonly truncated: boolean;
} {
let remaining = MCP_MAX_OUTPUT_CHARS;
let truncated = false;
let textTruncated = false;
const out: ContentPart[] = [];
for (const part of parts) {
if (part.type === 'text') {
if (remaining <= 0) {
truncated = true;
textTruncated = true;
continue;
}
if (part.text.length > remaining) {
out.push({ type: 'text', text: part.text.slice(0, remaining) });
remaining = 0;
truncated = true;
textTruncated = true;
} else {
out.push(part);
@ -198,12 +206,14 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] {
if (part.type === 'think') {
const size = part.think.length + (part.encrypted?.length ?? 0);
if (remaining <= 0) {
truncated = true;
textTruncated = true;
continue;
}
if (size > remaining) {
out.push({ type: 'think', think: part.think.slice(0, remaining) });
remaining = 0;
truncated = true;
textTruncated = true;
} else {
out.push(part);
@ -225,6 +235,7 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] {
const kind =
part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video';
out.push({ type: 'text', text: binaryPartTooLargeNotice(kind, url.length) });
truncated = true;
continue;
}
out.push(part);
@ -233,7 +244,7 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] {
if (textTruncated) {
appendTruncationNotice(out);
}
return out;
return { parts: out, truncated };
}
function appendTruncationNotice(out: ContentPart[]): void {

View file

@ -31,7 +31,10 @@ import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../l
import { renderPrompt } from '../../../utils/render-prompt';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
import {
type ExecutableToolResultBuilderResult,
ToolResultBuilder,
} from '../../support/result-builder';
import bashDescriptionTemplate from './bash.md?raw';
const MS_PER_SECOND = 1000;
@ -248,12 +251,18 @@ export class BashTool implements BuiltinTool<BashInput> {
closeProcessStdin(proc);
let collectForegroundOutput = !startsInBackground;
let foregroundOutputPersisted = false;
let foregroundTaskId: string | undefined;
const onProcessOutput = startsInBackground
? undefined
: (kind: 'stdout' | 'stderr', text: string): void => {
if (!collectForegroundOutput) return;
onUpdate?.({ kind, text });
builder.write(text);
if (!foregroundOutputPersisted && builder.truncated && foregroundTaskId !== undefined) {
this.backgroundManager.persistOutput(foregroundTaskId);
foregroundOutputPersisted = true;
}
};
let taskId: string;
@ -266,6 +275,7 @@ export class BashTool implements BuiltinTool<BashInput> {
signal: startsInBackground ? undefined : signal,
},
);
foregroundTaskId = startsInBackground ? undefined : taskId;
} catch (error) {
collectForegroundOutput = false;
await killSpawnedProcess(proc);
@ -299,7 +309,7 @@ export class BashTool implements BuiltinTool<BashInput> {
);
}
return this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs);
return await this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs);
} finally {
collectForegroundOutput = false;
}
@ -328,41 +338,56 @@ export class BashTool implements BuiltinTool<BashInput> {
return undefined;
}
private foregroundCompletionResult(
private async foregroundCompletionResult(
taskId: string,
proc: KaosProcess,
builder: ToolResultBuilder,
foregroundTimeoutMs: number,
): ExecutableToolResult {
): Promise<ExecutableToolResult> {
const current = this.backgroundManager.getTask(taskId);
const exitCode = current?.kind === 'process' ? current.exitCode : proc.exitCode;
let result: ExecutableToolResultBuilderResult;
if (current?.status === 'timed_out') {
const timeoutLabel = formatTimeoutLabel(foregroundTimeoutMs);
return builder.error(`Command killed by timeout (${timeoutLabel})`, {
result = builder.error(`Command killed by timeout (${timeoutLabel})`, {
brief: `Killed by timeout (${timeoutLabel})`,
});
}
if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) {
return builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON });
}
if (
} else if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) {
result = builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON });
} else if (
(current?.status === 'failed' || current?.status === 'killed') &&
current.stopReason !== undefined
) {
return builder.error(current.stopReason, { brief: current.stopReason });
result = builder.error(current.stopReason, { brief: current.stopReason });
} else if (exitCode === 0) {
result = builder.ok('Command executed successfully.');
} else {
if (builder.nChars === 0) builder.write(`Process exited with code ${String(exitCode)}`);
result = builder.error(`Command failed with exit code: ${String(exitCode)}.`, {
brief: `Failed with exit code: ${String(exitCode)}`,
});
}
return this.addForegroundOutputReference(taskId, result);
}
const isError = exitCode !== 0;
if (isError && builder.nChars === 0) {
builder.write(`Process exited with code ${String(exitCode)}`);
}
private async addForegroundOutputReference(
taskId: string,
result: ExecutableToolResultBuilderResult,
): Promise<ExecutableToolResult> {
if (!result.truncated) return result;
const output = await this.backgroundManager.getOutputSnapshot(taskId, 0);
if (!output.fullOutputAvailable || output.outputPath === undefined) return result;
if (!isError) {
return builder.ok('Command executed successfully.');
}
return builder.error(`Command failed with exit code: ${String(exitCode)}.`, {
brief: `Failed with exit code: ${String(exitCode)}`,
});
const taskOutputHint = this.allowBackground
? `, or TaskOutput(task_id="${taskId}", block=false)`
: '';
const reference =
`\n\n[Full output saved]\n` +
`task_id: ${taskId}\n` +
`output_path: ${output.outputPath}\n` +
`output_size_bytes: ${String(output.outputSizeBytes)}\n` +
`next_step: Use Read with output_path to page through the full log${taskOutputHint}.`;
return { ...result, output: `${result.output}${reference}` };
}
private backgroundStartedResult(

View file

@ -45,6 +45,10 @@ export class ToolResultBuilder {
return this.nCharsValue;
}
get truncated(): boolean {
return this.truncationHappened;
}
write(text: string): number {
if (this.nCharsValue >= this.maxChars) {
if (text.length > 0 && !this.truncationHappened) {

View file

@ -257,6 +257,8 @@ describe('BackgroundManager — notification delivery', () => {
const text = (content as Array<{ text: string }>)[0]!.text;
expect(text).toContain('Background agent completed');
expect(text).toContain('final subagent summary');
expect(text).toContain('<output-preview');
expect(text).not.toContain('<output-file');
});
it('steers completed process task notifications into the turn flow', async () => {
@ -280,6 +282,25 @@ describe('BackgroundManager — notification delivery', () => {
expect(text).toContain('shell task completed.');
});
it('uses a bounded output preview when no persisted task output exists', async () => {
const { agent, manager } = createBackgroundManager();
const output = `early-output-marker\n${'x'.repeat(4_000)}\nfinal subagent line`;
const taskId = manager.registerTask(agentTask(Promise.resolve({ result: output }), 'agent task'));
await manager.wait(taskId);
await vi.waitFor(() => {
expect(agent.turn.steer).toHaveBeenCalledTimes(1);
});
const [content] = agent.turn.steer.mock.calls[0]!;
const text = (content as Array<{ text: string }>)[0]!.text;
expect(text).toContain('<output-preview');
expect(text).toContain('truncated="true"');
expect(text).toContain('final subagent line');
expect(text).not.toContain('early-output-marker');
expect(text).not.toContain('<output-file');
});
it('steers stopped process task notifications into the turn flow', async () => {
const { agent, manager } = createBackgroundManager();
const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long shell task');
@ -325,7 +346,9 @@ describe('BackgroundManager — notification delivery', () => {
});
const text = (content as Array<{ text: string }>)[0]!.text;
expect(text).toContain('Background agent completed');
expect(text).toContain('restored subagent summary');
expect(text).not.toContain('restored subagent summary');
expect(text).toContain('<output-file');
expect(text).toContain(persistence.taskOutputFile('agent-done0000'));
} finally {
await rm(sessionDir, { recursive: true, force: true });
}
@ -355,13 +378,15 @@ describe('BackgroundManager — notification delivery', () => {
});
const text = (content as Array<{ text: string }>)[0]!.text;
expect(text).toContain('Background process completed');
expect(text).toContain('restored shell output');
expect(text).not.toContain('restored shell output');
expect(text).toContain('<output-file');
expect(text).toContain(persistence.taskOutputFile('bash-done0000'));
} finally {
await rm(sessionDir, { recursive: true, force: true });
}
});
it('reads only a bounded output tail for restored process task notifications', async () => {
it('references persisted output without reading a tail for restored process notifications', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-tail-'));
try {
const taskId = 'bash-large000';
@ -381,10 +406,12 @@ describe('BackgroundManager — notification delivery', () => {
});
expect(readOutputSpy).not.toHaveBeenCalled();
expect(snapshotSpy).toHaveBeenCalledWith(taskId, expect.any(Number));
expect(snapshotSpy.mock.calls[0]![1]).toBeLessThan(largeOutput.length);
expect(snapshotSpy.mock.calls[0]![1]).toBe(0);
const [content] = agent.context.appendUserMessage.mock.calls[0]!;
const text = (content as Array<{ text: string }>)[0]!.text;
expect(text).toContain('final output line');
expect(text).toContain('<output-file');
expect(text).toContain(persistence.taskOutputFile(taskId));
expect(text).not.toContain('final output line');
expect(text).not.toContain('early-output-marker');
} finally {
await rm(sessionDir, { recursive: true, force: true });

View file

@ -59,7 +59,9 @@ describe('background notification → main agent (real Agent instance)', () => {
expect(flatHistoryText).toContain('<notification');
expect(flatHistoryText).toContain('task.completed');
expect(flatHistoryText).toContain(taskId);
expect(flatHistoryText).toContain('idle-state repro completed');
expect(flatHistoryText).toContain('background agent finished its job');
expect(flatHistoryText).toContain('<output-preview');
});
it('BUSY: completed bg agent during an active turn is flushed before the next LLM call', async () => {
@ -121,7 +123,9 @@ describe('background notification → main agent (real Agent instance)', () => {
expect(flatContext).toContain('<notification');
expect(flatContext).toContain('task.completed');
expect(flatContext).toContain(taskId);
expect(flatContext).toContain('busy-state repro completed');
expect(flatContext).toContain('busy-state bg result');
expect(flatContext).toContain('<output-preview');
});
it('IDLE × N: a GROUP of bg agents completes — all notifications should reach the LLM', async () => {
@ -166,9 +170,13 @@ describe('background notification → main agent (real Agent instance)', () => {
for (const id of taskIds) {
expect(flatHistoryText).toContain(id);
}
expect(flatHistoryText).toContain('group-1 completed');
expect(flatHistoryText).toContain('group-2 completed');
expect(flatHistoryText).toContain('group-3 completed');
expect(flatHistoryText).toContain('bg #1 result');
expect(flatHistoryText).toContain('bg #2 result');
expect(flatHistoryText).toContain('bg #3 result');
expect(flatHistoryText).toContain('<output-preview');
});
it('RACE: bg completion fires AFTER LLM returns but BEFORE activeTurn is cleared', async () => {
@ -225,7 +233,9 @@ describe('background notification → main agent (real Agent instance)', () => {
const flatHistoryText = JSON.stringify(lastCall.history);
expect(flatHistoryText).toContain('<notification');
expect(flatHistoryText).toContain(taskId);
expect(flatHistoryText).toContain('race-after-turn completed');
expect(flatHistoryText).toContain('post-turn bg result');
expect(flatHistoryText).toContain('<output-preview');
});
it('RESUME: terminal bg tasks discovered on reconcile are SILENTLY injected (no auto-turn)', async () => {
@ -298,7 +308,9 @@ describe('background notification → main agent (real Agent instance)', () => {
// Both notifications are in context, waiting for the user.
const flatContext = JSON.stringify(ctx.agent.context.data());
expect(flatContext).toContain('previous bash output');
expect(flatContext).toContain('<output-file');
expect(flatContext).toContain(backgroundPersistence.taskOutputFile('bash-prev0000'));
expect(flatContext).not.toContain('previous bash output');
expect(flatContext).toMatch(/task\.completed/);
expect(flatContext).toMatch(/task\.lost/);
} finally {

View file

@ -804,9 +804,7 @@ describe('Agent context', () => {
});
describe('Agent context notification projection', () => {
it('renders task notifications with escaped attributes and a bounded output tail', () => {
const tail = Array.from({ length: 25 }, (_, index) => `line ${String(index + 1)}`).join('\n');
it('renders task notifications with escaped attributes and generic children', () => {
const text = renderNotificationXml({
id: 'n_"1&2',
category: 'task',
@ -816,17 +814,24 @@ describe('Agent context notification projection', () => {
title: 'Task finished',
severity: 'info',
body: 'The task completed.',
tail_output: tail,
children: [
[
'<output-file path="/tmp/logs/a&amp;b/output.log" bytes="1234">',
'Read the output file to retrieve the result: /tmp/logs/a&amp;b/output.log',
'</output-file>',
].join('\n'),
],
});
expect(text).toContain('id="n_&quot;1&amp;2"');
expect(text).toContain('source_id="bg&amp;1"');
expect(text).toContain('Title: Task finished');
expect(text).toContain('Severity: info');
expect(text).toContain('<task-notification>');
expect(text).not.toContain('line 5');
expect(text).toContain('line 6');
expect(text).toContain('line 25');
expect(text).toContain('<output-file path="/tmp/logs/a&amp;b/output.log" bytes="1234">');
expect(text).toContain(
'Read the output file to retrieve the result: /tmp/logs/a&amp;b/output.log',
);
expect(text).not.toContain('<task-notification>');
expect(text.trimEnd()).toMatch(/<\/notification>$/);
});
@ -870,13 +875,14 @@ describe('Agent context notification projection', () => {
const text = renderNotificationXml({
id: '',
source_kind: 'host',
tail_output: 'should stay out of the XML',
output_path: '/tmp/output.log',
});
expect(text).toContain('id="unknown"');
expect(text).toContain('category="unknown"');
expect(text).not.toContain('<task-notification>');
expect(text).not.toContain('should stay out of the XML');
expect(text).not.toContain('<output-file');
expect(text).not.toContain('/tmp/output.log');
});
it('does not merge a cron-fire envelope into an adjacent user message', () => {

View file

@ -1,6 +1,11 @@
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';
@ -372,6 +377,111 @@ describe('Agent tools', () => {
`);
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 {
@ -396,6 +506,13 @@ function agentCall(): ToolCall {
};
}
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 = '';",

View file

@ -285,6 +285,7 @@ describe('mcpResultToExecutableOutput', () => {
// The notice merges into the single text part so collapseSingleText still
// emits a plain string — the very common "single oversized text" case.
expect(out.output).toBe('x'.repeat(100_000) + MCP_OUTPUT_TRUNCATED_TEXT);
expect(out.truncated).toBe(true);
});
test('drops oversized binary parts in favor of a per-part notice without touching the text budget', () => {
@ -304,6 +305,7 @@ describe('mcpResultToExecutableOutput', () => {
// The text-budget marker must NOT appear — only the binary part was dropped.
const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
expect(joined).not.toContain('Output truncated');
expect(out.truncated).toBe(true);
});
test('binary part within the per-part cap survives intact alongside oversized text', () => {
@ -320,5 +322,6 @@ describe('mcpResultToExecutableOutput', () => {
{ type: 'text', text: 'A'.repeat(100_000) },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,' + 'B'.repeat(500_000) } },
]);
expect(out).not.toHaveProperty('truncated');
});
});

View file

@ -1,3 +1,6 @@
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';
@ -993,6 +996,33 @@ describe('BashTool', () => {
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(