Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-08 20:17:21 +08:00
commit 3bbb8b62f2
47 changed files with 2328 additions and 1013 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core-v2": patch
"@moonshot-ai/kap-server": patch
---
Fix the v2 AskUserQuestion flow: answers now come back keyed by question text with option labels as values, aborting a turn or stopping a background question dismisses the pending question instead of leaking it, and duplicate question texts or option labels are rejected before the question is shown. The pending-question wire shape no longer carries a synthetic expires_at field.

View file

@ -296,6 +296,7 @@ swarm --> systemReminder #34495E
mcp --> toolRegistry #34495E
mcp --> wire #34495E
mcp --> toolExecutor #34495E
mcp --> telemetry #34495E
fullCompaction --> contextMemory #34495E
fullCompaction --> contextSize #34495E
fullCompaction --> llmRequester #34495E

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Before After
Before After

View file

@ -37,13 +37,6 @@ import type { TokenUsage } from '#/app/llmProtocol/usage';
import type { ContextMessage } from './types';
// Status strings must match v1 / `loopService.ts` so folded tool results render
// byte-identically to a v2-native turn.
const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>';
const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.';
const TOOL_INTERRUPTED_ON_RESUME_OUTPUT =
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';
@ -175,9 +168,11 @@ export function foldLoopEvent(
}
case 'tool.result': {
if (!ctx.pending.has(event.toolCallId)) return state;
const output = event.result.output;
const toolMessage: ContextMessage = {
...createToolMessage(event.toolCallId, toolResultOutputForModel(event.result)),
...createToolMessage(event.toolCallId, typeof output === 'string' ? output : [...output]),
isError: event.result.isError,
note: event.result.note,
};
ctx.pending.delete(event.toolCallId);
return bind(flushDeferred([...state, toolMessage], ctx), ctx);
@ -242,45 +237,7 @@ function flushDeferred(state: readonly ContextMessage[], ctx: FoldCtx): readonly
function interruptedToolMessage(toolCallId: string): ContextMessage {
return {
...createToolMessage(
toolCallId,
toolResultOutputForModel({ output: TOOL_INTERRUPTED_ON_RESUME_OUTPUT, isError: true }),
),
...createToolMessage(toolCallId, TOOL_INTERRUPTED_ON_RESUME_OUTPUT),
isError: true,
};
}
/** Mirrors v1 / `loopService.ts` `toolResultOutputForModel`. */
function toolResultOutputForModel(result: {
readonly output: string | readonly ContentPart[];
readonly isError?: boolean;
readonly note?: string;
}): string | ContentPart[] {
const { output, isError, note } = result;
let base: string | ContentPart[];
if (typeof output === 'string') {
if (isError === true) {
if (output.length === 0) base = TOOL_EMPTY_ERROR_STATUS;
else if (output.trimStart().startsWith('<system>ERROR:')) base = output;
else base = `${TOOL_ERROR_STATUS}\n${output}`;
} else if (output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT) {
base = TOOL_EMPTY_STATUS;
} else {
base = output;
}
} else if (output.length === 0) {
base = [{ type: 'text', text: isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS }];
} else if (isError === true) {
base = [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output];
} else {
base = [...output];
}
if (note === undefined || note.length === 0) return base;
const notePart: ContentPart = { type: 'text', text: note };
if (typeof base === 'string') return `${base}\n${note}`;
const only = base[0];
if (base.length === 1 && only?.type === 'text') {
return [{ type: 'text', text: `${only.text}\n${note}` }];
}
return [...base, notePart];
}

View file

@ -0,0 +1,68 @@
/**
* `contextMemory` domain helper projects stored tool result facts into
* model-visible content.
*
* Tool messages keep the raw tool output plus structured status fields in
* context. The LLM projection is the only boundary that turns those facts into
* system status text or appends model-only notes.
*/
import type { ContentPart } from '#/app/llmProtocol/message';
const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>';
const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.';
export interface RenderableToolResult {
readonly output: string | readonly ContentPart[];
readonly note?: string;
readonly isError?: boolean;
}
export function renderToolResultForModel(result: RenderableToolResult): ContentPart[] {
const rendered = renderStatus(result);
if (result.note === undefined || result.note.length === 0) return rendered;
const only = rendered[0];
if (rendered.length === 1 && only?.type === 'text') {
return [textPart(only.text + '\n' + result.note)];
}
return [...rendered, textPart(result.note)];
}
function renderStatus(result: RenderableToolResult): ContentPart[] {
const output = result.output;
const single = typeof output === 'string' ? output : singleTextPart(output);
if (single !== undefined) {
if (result.isError === true) {
if (single.length === 0) return [textPart(TOOL_EMPTY_ERROR_STATUS)];
return [textPart(TOOL_ERROR_STATUS + '\n' + single)];
}
return isEmptyOutputText(single) ? [textPart(TOOL_EMPTY_STATUS)] : [textPart(single)];
}
const parts = output as readonly ContentPart[];
if (isEmptyEquivalentContentArray(parts)) {
return [textPart(result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS)];
}
if (result.isError === true) return [textPart(TOOL_ERROR_STATUS), ...parts];
return [...parts];
}
function singleTextPart(output: readonly ContentPart[]): string | undefined {
const first = output[0];
return output.length === 1 && first?.type === 'text' ? first.text : undefined;
}
function textPart(text: string): ContentPart {
return { type: 'text', text };
}
function isEmptyOutputText(output: string): boolean {
return output.trim().length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT;
}
function isEmptyEquivalentContentArray(output: readonly ContentPart[]): boolean {
return output.every((part) => part.type === 'text' && part.text.trim().length === 0);
}

View file

@ -83,6 +83,7 @@ export type ContextMessage = Message & {
readonly providerMessageId?: string;
readonly origin?: PromptOrigin | undefined;
readonly isError?: boolean;
readonly note?: string;
};
export interface UserMessageRecord {

View file

@ -1,6 +1,7 @@
import { IInstantiationService } from "#/_base/di/instantiation";
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { ErrorCodes, KimiError } from '#/errors';
import { IAgentMicroCompactionService } from '#/agent/microCompaction/microCompaction';
@ -139,7 +140,7 @@ function project(history: readonly ContextMessage[]): Message[] {
};
const emit = (source: ContextMessage): void => {
const content = cleanContent(source);
const content = projectedContent(source);
if (content.length === 0 && source.toolCalls.length === 0) return;
if (canMergeUserMessage(source)) {
@ -170,7 +171,7 @@ function project(history: readonly ContextMessage[]): Message[] {
const slot = openSlots.get(message.toolCallId);
if (slot === undefined) continue;
openSlots.delete(message.toolCallId);
out[slot] = toWireMessage(message, cleanContent(message));
out[slot] = toWireMessage(message, projectedContent(message));
continue;
}
emit(message);
@ -204,10 +205,22 @@ function appendMergeContent(group: MergeGroup, content: readonly ContentPart[]):
if (text.length > 0) group.texts.push(text);
}
function cleanContent(source: ContextMessage): ContentPart[] {
const content = source.content.some(isBlankText)
? source.content.filter((part) => !isBlankText(part))
: source.content;
function projectedContent(source: ContextMessage): ContentPart[] {
const content =
source.role === 'tool'
? renderToolResultForModel({
output: outputFromToolContent(source.content),
isError: source.isError,
note: source.note,
})
: source.content;
return cleanContent(source, content);
}
function cleanContent(source: ContextMessage, rawContent: readonly ContentPart[]): ContentPart[] {
const content = rawContent.some(isBlankText)
? rawContent.filter((part) => !isBlankText(part))
: rawContent;
if (source.role === 'tool' && content.length === 0) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
@ -215,7 +228,12 @@ function cleanContent(source: ContextMessage): ContentPart[] {
{ details: { toolCallId: source.toolCallId } },
);
}
return content;
return [...content];
}
function outputFromToolContent(content: readonly ContentPart[]): string | readonly ContentPart[] {
const only = content[0];
return content.length === 1 && only?.type === 'text' ? only.text : content;
}
const TOOL_INTERRUPTED_TEXT =

View file

@ -12,6 +12,7 @@ import type {
ToolListUpdatedEvent,
} from '@moonshot-ai/protocol';
import { IEventBus } from '#/app/event/eventBus';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { createMcpAuthTool } from '#/agent/mcp/tools/auth';
@ -56,6 +57,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
@IEventBus private readonly eventBus: IEventBus,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
@IAgentWireService private readonly wire: IWireService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {
super();
this.attachMcpTools();
@ -236,7 +238,10 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
seenInThisCall.set(qualified, tool.name);
const disposable = this._register(
this.registry.register(
createMcpTool(qualified, tool, client, { originalsDir: this.options.originalsDir }),
createMcpTool(qualified, tool, client, {
originalsDir: this.options.originalsDir,
telemetry: this.telemetry,
}),
{ source: 'mcp' },
),
);

View file

@ -7,16 +7,22 @@
*/
import type { Tool as KosongTool } from '#/app/llmProtocol/tool';
import type { ITelemetryService } from '#/app/telemetry/telemetry';
import type { ExecutableTool, ExecutableToolResult } from '#/agent/tool/toolContract';
import { mcpResultToExecutableOutput } from '#/agent/mcp/output';
import type { MCPClient } from '#/agent/mcp/types';
interface McpToolOptions {
readonly originalsDir?: string;
readonly telemetry?: ITelemetryService;
}
export function createMcpTool(
qualifiedName: string,
tool: KosongTool,
client: MCPClient,
options: { readonly originalsDir?: string } = {},
options: McpToolOptions = {},
): ExecutableTool {
return {
name: qualifiedName,
@ -33,6 +39,7 @@ export function createMcpTool(
return normalizeMcpToolResult(
await mcpResultToExecutableOutput(result, qualifiedName, {
originalsDir: options.originalsDir,
telemetry: options.telemetry,
}),
);
},

View file

@ -15,6 +15,8 @@ Overusing this tool interrupts the user's flow. Only use it when the user's inpu
- Use multi_select to allow multiple answers to be selected for a question
- Keep option labels concise (1-5 words), use descriptions for trade-offs and details
- Each question should have 2-4 meaningful, distinct options
- Question texts must be unique across the call, and option labels must be unique within each question
- You can ask 1-4 questions at a time; group related questions to minimize interruptions
- If you recommend a specific option, list it first and append "(Recommended)" to its label
- Set background=true when you can keep working without the answer. This starts a background question task and returns a task_id immediately. The answer arrives automatically in a later turn — you do not need to poll, sleep, or check on it. Continue with other work; never fabricate or predict the answer.
- The result is JSON with an `answers` object keyed by question text; each value is the chosen option's label (comma-separated labels for multi_select, or the user's own words if they picked "Other"); if `answers` is empty and a `note` says the user dismissed it, they declined to answer — proceed with your best judgment and do not re-ask the same question
- Set background=true when you can keep working without the answer. This starts a background question task and returns a task_id immediately. The answer arrives automatically in a later turn — you do not need to poll, sleep, or check on it. Continue with other work; never fabricate or predict the answer.

View file

@ -10,7 +10,10 @@
import { z } from 'zod';
import { CoreErrors } from '#/_base/errors/codes';
import { KimiError } from '#/_base/errors/errors';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { errorMessage, isAbortError } from '#/agent/loop/errors';
import { IAgentTaskService } from '#/agent/task/task';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type { TelemetryProperties } from '#/app/telemetry/telemetry';
@ -37,12 +40,13 @@ import { QuestionTask } from './question-task';
const QuestionOptionSchema = z.object({
label: z
.string()
.min(1)
.describe("Concise display text (1-5 words). If recommended, append '(Recommended)'."),
description: z.string().default('').describe('Brief explanation of trade-offs or implications.'),
});
const QuestionItemSchema = z.object({
question: z.string().describe("A specific, actionable question. End with '?'."),
question: z.string().min(1).describe("A specific, actionable question. End with '?'."),
header: z
.string()
.default('')
@ -70,6 +74,36 @@ export interface AskUserQuestionInput {
}>;
}
const QUESTION_UNIQUENESS_MESSAGE =
'Question texts must be unique across questions, and option labels must be unique within each question.';
/**
* Answers are keyed by question text with option labels as values, so both
* must be unambiguous: question texts unique across the call, option labels
* unique within their question. Runtime tool-arg validation is AJV against
* the JSON Schema (where zod refinements are unrepresentable), so the
* execution path re-runs this check itself.
*/
function questionUniquenessError(
questions: AskUserQuestionInput['questions'],
): string | null {
const texts = new Set<string>();
for (const q of questions) {
if (texts.has(q.question)) {
return `Invalid questions: duplicate question text ${JSON.stringify(q.question)}. ${QUESTION_UNIQUENESS_MESSAGE} Rephrase the duplicates and call the tool again.`;
}
texts.add(q.question);
const labels = new Set<string>();
for (const option of q.options) {
if (labels.has(option.label)) {
return `Invalid questions: duplicate option label ${JSON.stringify(option.label)} in question ${JSON.stringify(q.question)}. ${QUESTION_UNIQUENESS_MESSAGE} Rephrase the duplicates and call the tool again.`;
}
labels.add(option.label);
}
}
return null;
}
const AskUserQuestionInputBaseSchema = z.object({
questions: z
.array(QuestionItemSchema)
@ -83,15 +117,23 @@ const AskUserQuestionInputSchemaWithBackground = AskUserQuestionInputBaseSchema.
.boolean()
.default(false)
.describe(
'Set true to ask in the background and return immediately with a background task_id. Use TaskOutput to read the answer later.',
'Set true to ask in the background and return immediately with a background task_id; you are notified automatically when the user answers — do not poll with TaskOutput while the question is pending.',
),
}).refine((data) => questionUniquenessError(data.questions) === null, {
message: QUESTION_UNIQUENESS_MESSAGE,
});
export const AskUserQuestionInputSchema: z.ZodType<AskUserQuestionInput> =
AskUserQuestionInputSchemaWithBackground;
AskUserQuestionInputBaseSchema.refine(
(data) => questionUniquenessError(data.questions) === null,
{ message: QUESTION_UNIQUENESS_MESSAGE },
);
const QUESTION_DISMISSED_MESSAGE = 'User dismissed the question without answering.';
const QUESTION_UNSUPPORTED_FAILURE_MESSAGE =
'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.';
// ── Implementation ───────────────────────────────────────────────────
export class AskUserQuestionTool implements BuiltinTool<AskUserQuestionInput> {
@ -122,45 +164,72 @@ export class AskUserQuestionTool implements BuiltinTool<AskUserQuestionInput> {
args: AskUserQuestionInput,
{ toolCallId, signal, turnId }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
// AJV (the runtime arg validator) cannot express the uniqueness refine,
// so enforce it here before any UI interaction or task registration.
const uniquenessError = questionUniquenessError(args.questions);
if (uniquenessError !== null) {
return { isError: true, output: uniquenessError };
}
if (args.background === true) {
return this.executeInBackground(args, { toolCallId, turnId, signal });
}
return this.executeQuestion(args, { toolCallId, turnId });
return this.executeQuestion(args, { toolCallId, turnId, signal });
}
private async executeQuestion(
args: AskUserQuestionInput,
{ toolCallId, turnId }: Pick<ExecutableToolContext, 'toolCallId' | 'turnId'>,
): Promise<ExecutableToolResult> {
const result = await this.question.request({
turnId,
{
toolCallId,
questions: args.questions.map((q) => ({
question: q.question,
header: q.header.length > 0 ? q.header : undefined,
options: q.options.map((o) => ({
label: o.label,
description: o.description.length > 0 ? o.description : undefined,
})),
multiSelect: q.multi_select,
})),
});
signal,
turnId,
}: Pick<ExecutableToolContext, 'toolCallId' | 'signal' | 'turnId'>,
): Promise<ExecutableToolResult> {
try {
const result = await this.question.request(
{
turnId,
toolCallId,
questions: args.questions.map((q) => ({
question: q.question,
header: q.header,
options: q.options.map((o) => ({
label: o.label,
description: o.description,
})),
multiSelect: q.multi_select,
})),
},
{ signal },
);
const normalized = normalizeQuestionResult(result);
if (normalized === null || Object.keys(normalized.answers).length === 0) {
this.telemetry.track('question_dismissed');
return dismissedQuestionResult();
}
const properties: TelemetryProperties =
normalized.method !== undefined
? { answered: Object.keys(normalized.answers).length, method: normalized.method }
: { answered: Object.keys(normalized.answers).length };
this.telemetry.track('question_answered', properties);
return {
isError: false,
output: JSON.stringify({ answers: normalized.answers }),
};
} catch (error) {
if (isAbortError(error) || signal.aborted) throw error;
if (error instanceof KimiError && error.code === CoreErrors.codes.NOT_IMPLEMENTED) {
return {
isError: true,
output: QUESTION_UNSUPPORTED_FAILURE_MESSAGE,
};
}
const normalized = normalizeQuestionResult(result);
if (normalized === null || Object.keys(normalized.answers).length === 0) {
this.telemetry.track('question_dismissed');
return dismissedQuestionResult();
}
const properties: TelemetryProperties =
normalized.method !== undefined
? { answered: Object.keys(normalized.answers).length, method: normalized.method }
: { answered: Object.keys(normalized.answers).length };
this.telemetry.track('question_answered', properties);
return {
isError: false,
output: JSON.stringify({ answers: normalized.answers }),
};
}
private executeInBackground(
@ -180,7 +249,7 @@ export class AskUserQuestionTool implements BuiltinTool<AskUserQuestionInput> {
try {
taskId = this.tasks.registerTask(
new QuestionTask(
() => this.executeQuestion(args, { toolCallId, turnId }),
(taskSignal) => this.executeQuestion(args, { toolCallId, turnId, signal: taskSignal }),
description,
{
questionCount: args.questions.length,
@ -250,7 +319,3 @@ function isQuestionResponse(result: Exclude<QuestionResult, null>): result is Qu
const answers = (result as { readonly answers?: unknown }).answers;
return typeof answers === 'object' && answers !== null && !Array.isArray(answers);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -93,6 +93,10 @@ interface ManagedTask {
readonly outputChunks: string[];
outputSizeBytes: number;
retainedOutputBytes: number;
/**
* True once a command has crossed `MAX_TASK_OUTPUT_BYTES` and termination has
* been requested. One-shot guard so the ceiling fires exactly once.
*/
outputLimitTripped: boolean;
status: AgentTaskStatus;
options: RegisterAgentTaskOptions & { description?: string };
@ -116,8 +120,30 @@ interface ManagedTask {
handleSubscription?: { dispose(): void };
}
const MAX_OUTPUT_BYTES = 1024 * 1024;
const MAX_TASK_OUTPUT_BYTES = 16 * 1024 * 1024;
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB
/**
* Hard ceiling on the combined output a single shell command may stream before
* it is force-terminated (SIGTERM grace SIGKILL). It guards both the
* live-forward path and the on-disk `output.log` write chain from a runaway
* command (e.g. `b3sum --length <huge>`) whose output would otherwise grow
* without bound filling the disk, or retaining each pending-write chunk until
* Node aborts with an out-of-memory crash. Scoped to process tasks (foreground
* and background); subagent and user-question results are appended once and must
* always be persisted, so they are intentionally not capped here.
*/
const MAX_TASK_OUTPUT_BYTES = 16 * 1024 * 1024; // 16 MiB
/** Terminal `stopReason` recorded when a command trips the output ceiling. */
function outputLimitReason(): string {
const mib = Math.floor(MAX_TASK_OUTPUT_BYTES / (1024 * 1024));
return (
`Output limit exceeded: the command produced more than ${mib} MiB and was ` +
'terminated. Redirect large output to a file (e.g. `command > out.txt`) and ' +
'inspect it in slices instead.'
);
}
const SIGTERM_GRACE_MS = 5_000;
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
const USER_INTERRUPT_REASON = 'Interrupted by user';
@ -127,6 +153,24 @@ export function isAgentTaskTerminal(status: AgentTaskStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
/**
* A manager-driven deadline (`timeoutMs` / `detachTimeoutMs`) sets
* `entry.timedOut` before aborting. A process task that self-settles on that
* abort reports `killed` (its signal was aborted); rewrite it to `timed_out`
* so the terminal status always reflects the deadline, matching v1's
* `settlementForOutcome` where a timeout outcome is forced to `timed_out`
* regardless of how the worker responded to SIGTERM.
*/
function coerceTimeoutSettlement(
entry: ManagedTask,
settlement: AgentTaskSettlement,
): AgentTaskSettlement {
if (entry.timedOut && settlement.status === 'killed') {
return { ...settlement, status: 'timed_out' };
}
return settlement;
}
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'task.notified': AgentTaskNotificationContext;
@ -248,8 +292,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
if (timeoutMs !== undefined && timeoutMs > 0) {
entry.timeoutHandle = setTimeout(() => {
entry.abortController.abort('Timed out');
void this.settleTask(entry, { status: 'timed_out' });
void this.terminateWithGrace(entry, {
abortReason: 'Timed out',
finalStatus: 'timed_out',
});
}, timeoutMs);
entry.timeoutHandle.unref?.();
}
@ -261,11 +307,20 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
appendOutput: (chunk) => {
this.appendOutput(entry, chunk);
},
settle: (settlement) => this.settleTask(entry, settlement),
settle: (settlement) =>
this.settleTask(entry, coerceTimeoutSettlement(entry, settlement)),
}),
)
.catch(async (error: unknown) => {
const status = entry.abortController.signal.aborted ? 'killed' : 'failed';
const aborted = entry.abortController.signal.aborted;
let status: AgentTaskStatus;
if (entry.timedOut) {
status = 'timed_out';
} else if (aborted) {
status = 'killed';
} else {
status = 'failed';
}
await this.settleTask(entry, {
status,
stopReason: status === 'failed' ? errorMessage(error) : undefined,
@ -319,8 +374,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
if (timeoutMs !== undefined && timeoutMs > 0) {
entry.timeoutHandle = setTimeout(() => {
entry.timedOut = true;
handle.cancel();
void this.terminateWithGrace(entry, {
abortReason: 'Timed out',
finalStatus: 'timed_out',
});
}, timeoutMs);
entry.timeoutHandle.unref?.();
}
@ -511,13 +568,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
}
if (timeoutMs > 0) {
entry.timeoutHandle = setTimeout(() => {
entry.timedOut = true;
if (entry.handle) {
entry.handle.cancel();
} else {
entry.abortController.abort('Timed out');
void this.settleTask(entry, { status: 'timed_out' });
}
void this.terminateWithGrace(entry, {
abortReason: 'Timed out',
finalStatus: 'timed_out',
});
}, timeoutMs);
entry.timeoutHandle.unref?.();
}
@ -526,24 +580,52 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
async stop(taskId: string, reason?: string): Promise<AgentTaskInfo | undefined> {
const entry = this.tasks.get(taskId);
if (entry === undefined) return undefined;
return this.stopEntry(entry, normalizeReason(reason), normalizeReason(reason));
const normalized = normalizeReason(reason);
return this.terminateWithGrace(entry, {
stopReason: normalized,
abortReason: normalized,
finalStatus: 'killed',
});
}
private async stopEntry(
/**
* Manager-driven teardown shared by every termination path: explicit `stop`,
* the wall-clock `timeoutMs` deadline, and the post-detach `detachTimeoutMs`
* deadline. It sends SIGTERM (or `handle.cancel()`), gives the task up to
* `SIGTERM_GRACE_MS` to settle, escalates to `forceStop` (SIGKILL) when it is
* still alive, and records `finalStatus`.
*
* This mirrors v1's `settlementForOutcome`, where timeout and stop always
* shared the same grace + force-stop sequence. Routing the deadline paths
* through here is what keeps a runaway process that ignores SIGTERM from
* leaking when its deadline fires.
*/
private async terminateWithGrace(
entry: ManagedTask,
stopReason: string | undefined,
abortReason: unknown,
options: {
readonly stopReason?: string;
readonly abortReason: unknown;
readonly finalStatus: 'killed' | 'timed_out';
},
): Promise<AgentTaskInfo | undefined> {
if (TERMINAL_STATUSES.has(entry.status)) {
await entry.persistWriteQueue;
return this.toInfo(entry);
}
entry.stopReason = stopReason;
// Disarm a pending wall-clock deadline so it cannot re-enter teardown.
if (entry.timeoutHandle !== undefined) {
clearTimeout(entry.timeoutHandle);
entry.timeoutHandle = undefined;
}
if (options.finalStatus === 'timed_out') {
entry.timedOut = true;
}
entry.stopReason = options.stopReason;
if (entry.handle) {
entry.handle.cancel();
} else {
entry.abortController.abort(abortReason);
entry.abortController.abort(options.abortReason);
}
let graceTimer: ReturnType<typeof setTimeout> | undefined;
@ -582,7 +664,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
return this.toInfo(entry);
}
await this.settleTask(entry, { status: 'killed', stopReason });
await this.settleTask(entry, {
status: options.finalStatus,
stopReason: options.stopReason,
});
await entry.persistWriteQueue;
return this.toInfo(entry);
}
@ -715,6 +800,13 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
entry.outputSizeBytes += chunkBytes;
this.appendRetainedOutput(entry, chunk, chunkBytes);
// Output ceiling: a single shell command must not grow the (unbounded)
// live-forward buffer or the on-disk write chain until the process runs out
// of memory or fills the disk. Trip once, then request graceful termination
// through the shared stop path (SIGTERM → grace → SIGKILL). Scoped to
// process tasks (foreground and background): subagent and user-question tasks
// append their bounded result in one shot and must always persist it, so they
// are intentionally not capped here.
if (
!entry.outputLimitTripped &&
entry.task?.kind === 'process' &&
@ -724,6 +816,11 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
void this.stop(entry.taskId, outputLimitReason());
}
// Once the cap has tripped the task is being terminated: keep only the
// bounded in-memory ring buffer above and stop feeding the (unbounded) disk
// write chain. A producer that ignores SIGTERM could otherwise keep the
// chain — and the chunk strings each pending write retains — growing through
// the grace window until SIGKILL, re-introducing the OOM this cap prevents.
if (entry.outputLimitTripped) return;
if (!entry.outputPersistStarted) {
@ -943,7 +1040,11 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
const abortFromSignal = (): void => {
if (this.isDetached(entry)) return;
void this.stopEntry(entry, USER_INTERRUPT_REASON, signal.reason);
void this.terminateWithGrace(entry, {
stopReason: USER_INTERRUPT_REASON,
abortReason: signal.reason,
finalStatus: 'killed',
});
};
if (signal.aborted) {
abortFromSignal();
@ -982,15 +1083,6 @@ function emptyOutputSnapshot(): AgentTaskOutputSnapshot {
};
}
function outputLimitReason(): string {
const mib = Math.floor(MAX_TASK_OUTPUT_BYTES / (1024 * 1024));
return (
`Output limit exceeded: the command produced more than ${String(mib)} MiB and was ` +
'terminated. Redirect large output to a file (e.g. `command > out.txt`) and ' +
'inspect it in slices instead.'
);
}
function agentTaskNotificationChildren(
output: AgentTaskOutputSnapshot,
): readonly string[] | undefined {

View file

@ -27,6 +27,7 @@ export type ExecutableToolResultBuilderResult = (
readonly output: string;
readonly message: string;
readonly truncated: boolean;
readonly brief?: string;
};
export class ToolResultBuilder {
@ -103,7 +104,7 @@ export class ToolResultBuilder {
return charsWritten;
}
ok(message = ''): ExecutableToolResultBuilderResult {
ok(message = '', options: { readonly brief?: string } = {}): ExecutableToolResultBuilderResult {
let finalMessage = message;
if (finalMessage.length > 0 && !finalMessage.endsWith('.')) {
finalMessage += '.';
@ -127,10 +128,14 @@ export class ToolResultBuilder {
: output,
message: finalMessage,
truncated: this.truncationHappened,
brief: options.brief,
};
}
error(message: string): ExecutableToolResultBuilderResult {
error(
message: string,
options: { readonly brief?: string } = {},
): ExecutableToolResultBuilderResult {
const finalMessage = this.truncationHappened
? message.length === 0
? TRUNCATION_MESSAGE
@ -149,6 +154,7 @@ export class ToolResultBuilder {
: `${output}\n${finalMessage}`,
message: finalMessage,
truncated: this.truncationHappened,
brief: options.brief,
};
}
}

View file

@ -730,21 +730,21 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult {
output = textJoined.length > 0 ? textJoined : TOOL_OUTPUT_EMPTY;
}
}
const base: {
output: ToolResult['output'];
stopTurn?: boolean;
truncated?: true;
note?: string;
} = { output, stopTurn: result.stopTurn };
if (result.truncated === true) base.truncated = true;
if (typeof result.note === 'string' && result.note.length > 0) base.note = result.note;
if (result.isError === true) {
return {
output,
...base,
isError: true,
stopTurn: result.stopTurn,
truncated: result.truncated,
note: result.note,
};
}
return {
output,
stopTurn: result.stopTurn,
truncated: result.truncated,
note: result.note,
};
return base;
}
function toolTelemetryOutcome(result: ToolResult): 'success' | 'error' | 'cancelled' {

View file

@ -89,6 +89,7 @@ export class MoonshotWebSearchProvider implements WebSearchProvider {
snippet: r.snippet ?? '',
};
if (typeof r.date === 'string' && r.date.length > 0) out.date = r.date;
if (typeof r.site_name === 'string' && r.site_name.length > 0) out.siteName = r.site_name;
if (typeof r.content === 'string' && r.content.length > 0) out.content = r.content;
return out;
});

View file

@ -1,3 +1,5 @@
Search the web for information. Use this when you need up-to-date information from the internet.
Each result includes its title, URL, snippet, and—when available—a publication date. When `include_content` is enabled, the full page content—when available—is appended after the snippet.
Each result includes its title, its URL, and a snippet, plus its source site and publication date when available. Results are short summaries, not full pages — when a result looks relevant, call the FetchURL tool on its URL to read the full page content. Fetch only the few URLs you actually need. Prefer specific queries, and refine the query if the results don't contain what you need.
When you rely on a result in your answer, cite its source URL so the user can verify it.

View file

@ -35,6 +35,7 @@ export interface WebSearchResult {
url: string;
snippet: string;
date?: string;
siteName?: string;
content?: string;
}
@ -126,12 +127,20 @@ export class WebSearchTool implements BuiltinTool<WebSearchInput> {
first = false;
builder.write(`Title: ${result.title}\n`);
if (result.siteName) builder.write(`Site: ${result.siteName}\n`);
if (result.date) builder.write(`Date: ${result.date}\n`);
builder.write(`URL: ${result.url}\n`);
builder.write(`Snippet: ${result.snippet}\n\n`);
if (result.content) builder.write(`${result.content}\n\n`);
}
// Keep the citation reminder next to the data (not just in the static tool
// description), so it is present on every search. Cite the page actually
// relied on — after a FetchURL follow-up, that is the fetched page.
builder.write(
'When you rely on a result in your answer, cite it inline as a markdown link, e.g. [title](url).',
);
return builder.ok();
} catch (error) {
// Propagate in-flight cancellation so the executor can classify it

View file

@ -5,7 +5,7 @@
* Bound at App scope.
*/
import { open, readFile, readdir, stat, mkdir, rm, writeFile } from 'node:fs/promises';
import { appendFile, lstat, open, readFile, readdir, mkdir, rm, writeFile } from 'node:fs/promises';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
@ -52,6 +52,10 @@ export class HostFileSystem implements IHostFileSystem {
await writeFile(path, data, 'utf8');
}
async appendText(path: string, data: string): Promise<void> {
await appendFile(path, data, 'utf8');
}
async readBytes(path: string, n?: number): Promise<Uint8Array> {
if (n === undefined) {
const buf = await readFile(path);
@ -149,10 +153,15 @@ export class HostFileSystem implements IHostFileSystem {
}
async stat(path: string): Promise<HostFileStat> {
const s = await stat(path);
// Non-following `lstat` so a symbolic link is reported as itself
// (`isSymbolicLink: true`) rather than transparently resolved to its
// target. Callers that confine paths lexically rely on this to avoid
// escaping the workspace through a symlinked directory.
const s = await lstat(path);
return {
isFile: s.isFile(),
isDirectory: s.isDirectory(),
isSymbolicLink: s.isSymbolicLink(),
size: s.size,
mtimeMs: s.mtimeMs,
ino: s.ino,
@ -165,6 +174,7 @@ export class HostFileSystem implements IHostFileSystem {
name: d.name,
isFile: d.isFile(),
isDirectory: d.isDirectory(),
isSymbolicLink: d.isSymbolicLink(),
}));
}

View file

@ -38,7 +38,10 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner';
import { IAgentProfileService } from '#/agent/profile/profile';
import type { BuiltinTool, ExecutableToolResult, ToolExecution, ToolUpdate } from '#/agent/tool/toolContract';
import { ToolResultBuilder } from '#/agent/tool/result-builder';
import {
type ExecutableToolResultBuilderResult,
ToolResultBuilder,
} from '#/agent/tool/result-builder';
import { registerTool } from '#/agent/toolRegistry/toolContribution';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
@ -146,7 +149,7 @@ function renderBashDescription(shellName: string): string {
function withoutBackgroundDescription(description: string): string {
return description
.replace(
/\n\nIf `run_in_background=true`,[\s\S]*?point them to the `\/tasks` command, which opens an interactive panel; it has no subcommands\./,
/\r?\n\r?\nIf `run_in_background=true`,[\s\S]*?point them to the `\/tasks` command, which opens an interactive panel; it has no subcommands\./,
'\n\nBackground execution is disabled for this agent. Do not set `run_in_background=true`.',
)
.replace(
@ -154,7 +157,7 @@ function withoutBackgroundDescription(description: string): string {
` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s.`,
)
.replace(
/\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./,
/\r?\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./,
'\n- Do not set `run_in_background=true`; background task management tools are not available.',
);
}
@ -179,7 +182,11 @@ export class BashTool implements BuiltinTool<BashInput> {
}
private allowBackground(): boolean {
return this.profile.isToolActive('TaskOutput') && this.profile.isToolActive('TaskStop');
return (
this.profile.isToolActive('TaskList') &&
this.profile.isToolActive('TaskOutput') &&
this.profile.isToolActive('TaskStop')
);
}
get description(): string {
@ -286,6 +293,9 @@ export class BashTool implements BuiltinTool<BashInput> {
{
detached: startsInBackground,
timeoutMs,
// Detaching (ctrl+b) moves a foreground command to the background;
// give it the background timeout so it is not still bounded by the
// shorter foreground deadline.
detachTimeoutMs: DEFAULT_BACKGROUND_TIMEOUT_S * MS_PER_SECOND,
signal: startsInBackground ? undefined : signal,
},
@ -300,11 +310,14 @@ export class BashTool implements BuiltinTool<BashInput> {
};
}
// Foreground `!` shell commands surface their task id so the TUI can detach
// (ctrl+b) this exact task. Background runs are already detached.
if (!startsInBackground) onForegroundTaskStart?.(taskId);
if (startsInBackground) {
return this.detachedTaskResult(taskId, proc, description, {
title: 'Task started in background',
return this.backgroundStartedResult(taskId, proc, description, {
title: 'Background task started',
brief: `Started ${taskId}`,
});
}
@ -312,19 +325,20 @@ export class BashTool implements BuiltinTool<BashInput> {
const release = await this.tasks.waitForForegroundRelease(taskId);
if (release === 'detached') {
collectForegroundOutput = false;
return this.detachedTaskResult(
return this.backgroundStartedResult(
taskId,
proc,
description,
{
title: 'Task moved to background',
brief: `Backgrounded ${taskId}`,
},
builder,
'foreground_detached',
);
}
return this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs);
return await this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs);
} finally {
collectForegroundOutput = false;
}
@ -353,46 +367,65 @@ export class BashTool implements BuiltinTool<BashInput> {
return undefined;
}
private foregroundCompletionResult(
private async foregroundCompletionResult(
taskId: string,
proc: IProcess,
builder: ToolResultBuilder,
foregroundTimeoutMs: number,
): ExecutableToolResult {
): Promise<ExecutableToolResult> {
const current = this.tasks.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})`);
}
if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) {
return builder.error(USER_INTERRUPT_REASON);
}
if (
result = builder.error(`Command killed by timeout (${timeoutLabel})`, {
brief: `Killed by timeout (${timeoutLabel})`,
});
} 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);
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)}`,
});
}
const isError = exitCode !== 0;
if (isError && builder.nChars === 0) {
builder.write(`Process exited with code ${String(exitCode)}`);
}
if (!isError) {
return builder.ok('Command executed successfully.');
}
return builder.error(`Command failed with exit code: ${String(exitCode)}.`);
return this.addForegroundOutputReference(taskId, result);
}
private detachedTaskResult(
private async addForegroundOutputReference(
taskId: string,
result: ExecutableToolResultBuilderResult,
): Promise<ExecutableToolResult> {
if (!result.truncated) return result;
const output = await this.tasks.getOutputSnapshot(taskId, 0);
if (!output.fullOutputAvailable || output.outputPath === undefined) return result;
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(
taskId: string,
proc: IProcess,
description: string,
labels: { title: string },
labels: { title: string; brief: string },
builder = new ToolResultBuilder(),
scenario: 'detached_started' | 'foreground_detached' = 'detached_started',
scenario: 'background_started' | 'foreground_detached' = 'background_started',
): ExecutableToolResult {
const status = this.tasks.getTask(taskId)?.status ?? 'running';
const metadata =
@ -402,23 +435,31 @@ export class BashTool implements BuiltinTool<BashInput> {
`status: ${status}\n` +
`automatic_notification: true\n` +
this.nextStepLines(scenario) +
'human_shell_hint: Tell the human to run /tasks to open the interactive task panel.';
'human_shell_hint: Tell the human to run /tasks to open the interactive background-task panel.';
const foregroundResult = builder.ok('');
const foregroundOutput = foregroundResult.output.length > 0 ? foregroundResult.output : '';
const message = taskResultMessage(labels.title, foregroundResult.message);
return {
const message = backgroundResultMessage(labels.title, foregroundResult.message);
const result: ExecutableToolResult & {
readonly message: string;
readonly brief: string;
readonly truncated: boolean;
} = {
isError: false,
output:
foregroundOutput.length === 0
? metadata
: `${metadata}\n\nforeground_output:\n${foregroundOutput}`,
message,
brief: labels.brief,
truncated: foregroundResult.truncated,
};
return result;
}
private nextStepLines(scenario: 'detached_started' | 'foreground_detached'): string {
private nextStepLines(
scenario: 'background_started' | 'foreground_detached',
): string {
if (scenario === 'foreground_detached') {
// The user explicitly moved a foreground call to the background to avoid
// blocking the current turn. Steer the model away from waiting on it.
@ -431,7 +472,9 @@ export class BashTool implements BuiltinTool<BashInput> {
`when it completes — ${avoid}; continue with your current work.\n`
);
}
// detached_started: the model chose to launch in the background.
// background_started: the model chose to launch in the background. Same anti-wait
// stance — immediately waiting on a background task is just a blocked turn, so do
// not invite a TaskOutput peek here.
if (!this.allowBackground()) {
return 'next_step: You will be automatically notified when it completes.\n';
}
@ -445,7 +488,7 @@ export class BashTool implements BuiltinTool<BashInput> {
registerTool(BashTool);
function taskResultMessage(title: string, suffix: string): string {
function backgroundResultMessage(title: string, suffix: string): string {
const normalized = title.endsWith('.') ? title : `${title}.`;
if (suffix.length === 0) return normalized;
return suffix.endsWith('.') ? `${normalized} ${suffix}` : `${normalized} ${suffix}.`;

View file

@ -1,619 +0,0 @@
/**
* `fileTools` domain grep search execution (ripgrep + node fallback).
*
* Runs the actual content search behind {@link GrepTool}: resolves a working
* `rg`, streams `--json` output through the host `IHostProcessService`, and
* parses it into the protocol `FsGrepResponse`. When `rg` is unavailable it
* falls back to a pure-node walker (gitignore-aware, JS regex) so Grep still
* works on hosts without ripgrep matching the previous `sessionFs`
* `ISessionFsService.grep` behavior.
*
* Ported from `session/sessionFs/fsService` (`grep` / `grepWithRg` /
* `grepWithNode` / `parseRgJsonOutput`) onto the os domains: subprocesses go
* through `IHostProcessService` and the node walker through `IHostFileSystem`.
*/
import { join } from 'node:path';
import type {
FsGrepFileHit,
FsGrepMatch,
FsGrepRequest,
FsGrepResponse,
} from '@moonshot-ai/protocol';
import ignore, { type Ignore } from 'ignore';
import { ErrorCodes, KimiError } from '#/errors';
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess';
import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator';
const GREP_TIMEOUT_MS = 30_000;
const WALK_MAX_DEPTH = 64;
type InternalGrepRequest = FsGrepRequest & { readonly multiline?: boolean };
export interface GrepSearchDeps {
readonly processService: IHostProcessService;
readonly fs: IHostFileSystem;
/** Absolute working directory the search is confined to. */
readonly cwd: string;
}
/**
* Execute a grep search. Resolves `rg` and runs it when available, otherwise
* falls back to a pure-node walker. Times out after {@link GREP_TIMEOUT_MS};
* if the timeout fires before any match is found, throws a coded
* `FS_GREP_TIMEOUT` error (the tool maps it to a friendly message).
*/
export async function executeGrepSearch(
req: FsGrepRequest,
deps: GrepSearchDeps,
): Promise<FsGrepResponse> {
const startedAt = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GREP_TIMEOUT_MS);
timer.unref?.();
try {
const resolution = await resolveRg(deps.processService, deps.cwd, controller.signal);
if (resolution !== null) {
return await grepWithRg(req, controller.signal, startedAt, resolution.path, deps);
}
return await grepWithNode(req, controller.signal, startedAt, deps);
} finally {
clearTimeout(timer);
}
}
// ── ripgrep path ─────────────────────────────────────────────────────
async function grepWithRg(
req: FsGrepRequest,
signal: AbortSignal,
startedAt: number,
rgPath: string,
deps: GrepSearchDeps,
): Promise<FsGrepResponse> {
const args = ['--json'];
if (req.context_lines > 0) {
args.push('--context', String(req.context_lines));
}
if (multilineEnabled(req)) {
args.push('--multiline', '--multiline-dotall');
}
if (!req.case_sensitive) args.push('--ignore-case');
if (!req.regex) args.push('--fixed-strings');
if (req.follow_gitignore) {
args.push('--no-require-git');
} else {
args.push('--no-ignore');
}
if (req.include_globs) {
for (const g of req.include_globs) args.push('--glob', g);
}
if (req.exclude_globs) {
for (const g of req.exclude_globs) args.push('--glob', `!${g}`);
}
args.push('--max-count', String(req.max_matches_per_file));
args.push(req.pattern);
args.push('.');
const res = await runCommand(deps.processService, rgPath, args, {
cwd: deps.cwd,
signal,
});
return parseRgJsonOutput(res.stdout, req, signal.aborted, Date.now() - startedAt);
}
// ── node fallback path ───────────────────────────────────────────────
async function grepWithNode(
req: FsGrepRequest,
signal: AbortSignal,
startedAt: number,
deps: GrepSearchDeps,
): Promise<FsGrepResponse> {
const matcher = req.follow_gitignore ? await loadGitignoreMatcher(deps.fs, deps.cwd) : undefined;
const re = compileGrepPattern(req);
const files: FsGrepFileHit[] = [];
let filesScanned = 0;
let totalMatches = 0;
let truncated = false;
const filePaths: string[] = [];
await walk(deps.fs, deps.cwd, '', matcher, async (_abs, rel, _name, kind) => {
if (kind !== 'file') return;
if (req.include_globs && !matchesAnyGlob(rel, req.include_globs)) return;
if (req.exclude_globs && matchesAnyGlob(rel, req.exclude_globs)) return;
filePaths.push(rel);
});
for (const rel of filePaths) {
if (signal.aborted) {
if (totalMatches === 0 && filesScanned === 0) {
throw new KimiError(
ErrorCodes.FS_GREP_TIMEOUT,
`grep timed out after ${Date.now() - startedAt}ms`,
);
}
truncated = true;
break;
}
if (filesScanned >= req.max_files) {
truncated = true;
break;
}
filesScanned += 1;
let content: string;
try {
content = await deps.fs.readText(join(deps.cwd, rel));
} catch {
continue;
}
const remainingMatches = req.max_total_matches - totalMatches;
const collected = multilineEnabled(req)
? collectMultilineMatches(content, re, req, remainingMatches)
: collectLineMatches(content, re, req, remainingMatches);
const matches = collected.matches;
totalMatches += matches.length;
if (collected.truncated) truncated = true;
if (matches.length > 0) {
files.push({ path: rel, matches });
}
if (totalMatches >= req.max_total_matches) break;
}
return { files, files_scanned: filesScanned, truncated, elapsed_ms: Date.now() - startedAt };
}
interface MatchCollection {
readonly matches: FsGrepMatch[];
readonly truncated: boolean;
}
function collectLineMatches(
content: string,
re: RegExp,
req: FsGrepRequest,
remainingMatches: number,
): MatchCollection {
const lines = content.split(/\r?\n/);
const matches: FsGrepMatch[] = [];
let truncated = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
re.lastIndex = 0;
const m = re.exec(line);
if (m === null) continue;
if (matches.length >= remainingMatches) {
truncated = true;
break;
}
if (matches.length >= req.max_matches_per_file) break;
const before: string[] = [];
for (let k = Math.max(0, i - req.context_lines); k < i; k++) {
before.push(lines[k] ?? '');
}
const after: string[] = [];
for (let k = i + 1; k < Math.min(lines.length, i + 1 + req.context_lines); k++) {
after.push(lines[k] ?? '');
}
matches.push({ line: i + 1, col: m.index + 1, text: line, before, after });
if (matches.length >= remainingMatches) {
truncated = true;
break;
}
if (matches.length >= req.max_matches_per_file) break;
}
return { matches, truncated };
}
function collectMultilineMatches(
content: string,
re: RegExp,
req: FsGrepRequest,
remainingMatches: number,
): MatchCollection {
const lines = content.split(/\r?\n/);
const lineStarts = lineStartOffsets(content);
const matches: FsGrepMatch[] = [];
let truncated = false;
re.lastIndex = 0;
while (true) {
const m = re.exec(content);
if (m === null) break;
if (matches.length >= remainingMatches) {
truncated = true;
break;
}
if (matches.length >= req.max_matches_per_file) break;
const rawText = m[0] ?? '';
const start = lineAndColumnAtOffset(lineStarts, m.index);
const endOffset = Math.max(m.index, m.index + rawText.length - 1);
const end = lineAndColumnAtOffset(lineStarts, endOffset);
const text = lines.slice(start.lineIndex, end.lineIndex + 1).join('\n');
matches.push({
line: start.lineIndex + 1,
col: start.column,
text,
before: lines.slice(Math.max(0, start.lineIndex - req.context_lines), start.lineIndex),
after: lines.slice(end.lineIndex + 1, end.lineIndex + 1 + req.context_lines),
});
if (matches.length >= remainingMatches) {
truncated = true;
break;
}
if (matches.length >= req.max_matches_per_file) break;
if (rawText.length === 0) re.lastIndex += 1;
}
return { matches, truncated };
}
function lineStartOffsets(content: string): number[] {
const starts = [0];
const newline = /\r?\n/g;
let match: RegExpExecArray | null;
while ((match = newline.exec(content)) !== null) {
starts.push(match.index + match[0].length);
}
return starts;
}
function lineAndColumnAtOffset(
lineStarts: readonly number[],
offset: number,
): { readonly lineIndex: number; readonly column: number } {
let low = 0;
let high = lineStarts.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const start = lineStarts[mid] ?? 0;
const next = lineStarts[mid + 1] ?? Number.POSITIVE_INFINITY;
if (offset < start) {
high = mid - 1;
} else if (offset >= next) {
low = mid + 1;
} else {
return { lineIndex: mid, column: offset - start + 1 };
}
}
const last = lineStarts.length - 1;
return { lineIndex: last, column: offset - (lineStarts[last] ?? 0) + 1 };
}
async function walk(
fs: IHostFileSystem,
absRoot: string,
relRoot: string,
matcher: Ignore | undefined,
visit: (
absPath: string,
relPath: string,
name: string,
kind: 'file' | 'directory',
) => Promise<void>,
depth = 0,
): Promise<void> {
if (depth > WALK_MAX_DEPTH) return;
let entries: Awaited<ReturnType<IHostFileSystem['readdir']>>;
try {
entries = await fs.readdir(absRoot);
} catch {
return;
}
for (const entry of entries) {
if (entry.name === '.git') continue;
const childAbs = join(absRoot, entry.name);
const childRel = relRoot === '' ? entry.name : `${relRoot}/${entry.name}`;
const isDir = entry.isDirectory;
if (matcher) {
const probe = isDir ? `${childRel}/` : childRel;
if (matcher.ignores(probe)) continue;
}
const kind: 'file' | 'directory' = isDir ? 'directory' : 'file';
await visit(childAbs, childRel, entry.name, kind);
if (isDir) {
await walk(fs, childAbs, childRel, matcher, visit, depth + 1);
}
}
}
async function loadGitignoreMatcher(
fs: IHostFileSystem,
cwd: string,
): Promise<Ignore | undefined> {
const ig = ignore();
ig.add('.git/');
try {
const contents = await fs.readText(join(cwd, '.gitignore'));
ig.add(contents);
} catch {
// No .gitignore — keep the `.git/` default only.
}
return ig;
}
/**
* Resolve a usable `rg`. Probes `rg --version` through the host process
* service. Returns `null` when `rg` is unavailable so the caller can fall
* back to the pure-node walker. The cached-binary fallback is disabled here
* Grep's node fallback already covers the missing-`rg` case and keeping it
* off makes the fallback deterministic.
*/
async function resolveRg(
processService: IHostProcessService,
cwd: string,
signal: AbortSignal,
): Promise<RgResolution | null> {
const probe: RgProbe = {
exec: (args) => {
const [command, ...rest] = args;
if (command === undefined) return Promise.resolve({ exitCode: -1 });
return runCommand(processService, command, rest, { cwd, signal }).then((r) => ({
exitCode: r.exitCode,
}));
},
};
try {
return await ensureRgPath(probe, { signal });
} catch {
return null;
}
}
// ── subprocess helper ────────────────────────────────────────────────
interface RunResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
async function runCommand(
processService: IHostProcessService,
command: string,
args: readonly string[],
options: { cwd?: string; signal?: AbortSignal } = {},
): Promise<RunResult> {
const proc: IHostProcess = await processService.spawn(command, args, { cwd: options.cwd });
const signal = options.signal;
const onAbort = (): void => {
void proc.kill('SIGKILL');
};
if (signal !== undefined) {
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
}
try {
const [stdout, stderr, exitCode] = await Promise.all([
readStream(proc.stdout),
readStream(proc.stderr),
proc.wait().catch(() => -1),
]);
return { exitCode, stdout, stderr };
} finally {
if (signal !== undefined) signal.removeEventListener('abort', onAbort);
try {
proc.dispose();
} catch {
/* best-effort cleanup */
}
}
}
function readStream(stream: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
stream.setEncoding('utf-8');
stream.on('data', (chunk: string) => {
data += chunk;
});
stream.once('end', () => resolve(data));
stream.once('error', reject);
});
}
// ── pure helpers (ported from session/sessionFs/fsSearch) ──────────────
function compileGrepPattern(req: FsGrepRequest): RegExp {
const flags = `g${req.case_sensitive ? '' : 'i'}${multilineEnabled(req) ? 's' : ''}`;
const body = req.regex ? req.pattern : escapeRegExp(req.pattern);
return new RegExp(body, flags);
}
function multilineEnabled(req: FsGrepRequest): boolean {
return (req as InternalGrepRequest).multiline === true;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function matchesAnyGlob(rel: string, globs: readonly string[]): boolean {
for (const g of globs) {
if (globToRegExp(g).test(rel)) return true;
}
return false;
}
function globToRegExp(glob: string): RegExp {
let re = '^';
let i = 0;
while (i < glob.length) {
const ch = glob[i]!;
if (ch === '*' && glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i++;
} else if (ch === '*') {
re += '[^/]*';
i++;
} else if (ch === '?') {
re += '[^/]';
i++;
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
re += `\\${ch}`;
i++;
} else {
re += ch;
i++;
}
}
re += '$';
return new RegExp(re);
}
function stripTrailingNewline(s: string): string {
if (s.endsWith('\r\n')) return s.slice(0, -2);
if (s.endsWith('\n')) return s.slice(0, -1);
return s;
}
interface RgPathField {
text?: string;
bytes?: string;
}
interface RgLinesField {
text?: string;
bytes?: string;
}
interface RgJsonRecord {
type: 'begin' | 'end' | 'match' | 'context' | 'summary';
data?: {
path?: RgPathField;
lines?: RgLinesField;
line_number?: number;
submatches?: { start: number; end: number }[];
};
}
function rgPath(p: RgPathField | undefined): string | undefined {
if (p === undefined) return undefined;
let raw: string | undefined;
if (typeof p.text === 'string') {
raw = p.text;
} else if (typeof p.bytes === 'string') {
try {
raw = Buffer.from(p.bytes, 'base64').toString('utf-8');
} catch {
return undefined;
}
}
if (raw === undefined) return undefined;
if (raw.startsWith('./')) return raw.slice(2);
return raw;
}
function rgText(l: RgLinesField | undefined): string {
if (l === undefined) return '';
if (typeof l.text === 'string') return l.text;
if (typeof l.bytes === 'string') {
try {
return Buffer.from(l.bytes, 'base64').toString('utf-8');
} catch {
return '';
}
}
return '';
}
function parseRgJsonOutput(
stdout: string,
req: FsGrepRequest,
aborted: boolean,
elapsedMs: number,
): FsGrepResponse {
const fileBuf = new Map<
string,
{ matches: FsGrepMatch[]; pending: string[]; lastMatchLine: number }
>();
const files: FsGrepFileHit[] = [];
let totalMatches = 0;
let truncated = false;
let filesScanned = 0;
const finalize = (p: string): void => {
const buf = fileBuf.get(p);
if (buf === undefined) return;
if (buf.matches.length > 0 && buf.pending.length > 0) {
const last = buf.matches[buf.matches.length - 1]!;
last.after = buf.pending.slice(0, req.context_lines);
}
if (buf.matches.length > 0) {
files.push({ path: p, matches: buf.matches });
}
fileBuf.delete(p);
};
for (const line of stdout.split('\n')) {
if (line.length === 0) continue;
let rec: RgJsonRecord;
try {
rec = JSON.parse(line) as RgJsonRecord;
} catch {
continue;
}
const t = rec.type;
if (t === 'begin') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
if (filesScanned >= req.max_files) {
truncated = true;
continue;
}
fileBuf.set(p, { matches: [], pending: [], lastMatchLine: -1 });
filesScanned += 1;
} else if (t === 'context') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
const buf = fileBuf.get(p);
if (buf === undefined) continue;
buf.pending.push(stripTrailingNewline(rgText(rec.data?.lines)));
if (buf.pending.length > req.context_lines * 2) {
buf.pending.shift();
}
} else if (t === 'match') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
const buf = fileBuf.get(p);
if (buf === undefined) continue;
if (totalMatches >= req.max_total_matches) {
truncated = true;
continue;
}
if (buf.matches.length >= req.max_matches_per_file) continue;
const text = stripTrailingNewline(rgText(rec.data?.lines));
const lineNo = rec.data?.line_number ?? 0;
const col = (rec.data?.submatches?.[0]?.start ?? 0) + 1;
const before = buf.pending.slice(-req.context_lines);
buf.pending.length = 0;
buf.matches.push({ line: lineNo, col, text, before, after: [] });
buf.lastMatchLine = lineNo;
totalMatches += 1;
if (totalMatches >= req.max_total_matches) truncated = true;
} else if (t === 'end') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
finalize(p);
}
}
for (const p of Array.from(fileBuf.keys())) {
finalize(p);
}
if (aborted) {
if (totalMatches === 0 && filesScanned === 0) {
throw new KimiError(ErrorCodes.FS_GREP_TIMEOUT, `grep timed out after ${elapsedMs}ms`);
}
truncated = true;
}
return { files, files_scanned: filesScanned, truncated, elapsed_ms: elapsedMs };
}

View file

@ -145,6 +145,11 @@ function observeProcessStream(
const onData = (chunk: string): void => {
if (chunk.length === 0) return;
sink.appendOutput(chunk);
// Once the manager has begun terminating the task — an output-limit trip
// (see MAX_TASK_OUTPUT_BYTES), a user interrupt, or a timeout —
// `appendOutput` above may synchronously abort the signal. Stop forwarding
// live output from that point so the unbounded forward buffer cannot keep
// growing while the process is being killed.
if (sink.signal.aborted) return;
onOutput?.(kind, chunk);
};

View file

@ -6,9 +6,9 @@
* (mirroring `mkdir(parents=True, exist_ok=True)`). Path access policy is
* resolved before any filesystem I/O.
*
* `IHostFileSystem.writeText` has no mode flag: overwrite maps to a direct
* write, while append reads the existing content first (treating a missing
* file as empty) and writes the concatenation back.
* Append uses `IHostFileSystem.appendText` (a native `O_APPEND`-style append),
* so existing content is never read or rewritten keeping appends atomic with
* respect to concurrent writers and safe against mid-write crashes.
*
* Write access flows through the os `hostFs` domain (`IHostFileSystem`); path
* semantics (home expansion, path class) come from the `hostEnvironment`
@ -107,14 +107,7 @@ export class WriteTool implements BuiltinTool<WriteInput> {
try {
const mode = args.mode ?? 'overwrite';
if (mode === 'append') {
let existing = '';
try {
existing = await this.fs.readText(safePath);
} catch (error) {
const code = (error as { code?: unknown } | null)?.code;
if (code !== 'ENOENT') throw error;
}
await this.fs.writeText(safePath, existing + args.content);
await this.fs.appendText(safePath, args.content);
} else {
await this.fs.writeText(safePath, args.content);
}

View file

@ -13,6 +13,12 @@ import type { TextDecodeErrors } from '#/_base/execEnv/decodeText';
export interface HostFileStat {
readonly isFile: boolean;
readonly isDirectory: boolean;
/**
* `true` when the path itself is a symbolic link (reported via a
* non-following `lstat`). Lets callers surface `kind: 'symlink'` instead of
* silently following the link and reporting the target's type.
*/
readonly isSymbolicLink?: boolean;
readonly size: number;
/** Last-modified time in epoch milliseconds, when the backend exposes it. */
readonly mtimeMs?: number;
@ -24,6 +30,12 @@ export interface HostDirEntry {
readonly name: string;
readonly isFile: boolean;
readonly isDirectory: boolean;
/**
* `true` when the directory entry is a symbolic link (from `readdir`
* `withFileTypes`). Does not follow the link a symlink to a directory is
* reported with `isSymbolicLink: true` and `isDirectory: false`.
*/
readonly isSymbolicLink?: boolean;
}
export interface IHostFileSystem {
@ -34,6 +46,15 @@ export interface IHostFileSystem {
options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors },
): Promise<string>;
writeText(path: string, data: string): Promise<void>;
/**
* Append UTF-8 `data` to the end of `path`, creating the file if it does not
* exist. Maps to a native append (POSIX `O_APPEND` / `fs.appendFile`): it
* never reads or truncates existing content, so concurrent readers never see
* a partially-rewritten file and a crash mid-write can lose only the new
* bytes, never the prior contents. Prefer this over a read-then-rewrite for
* log-style appends.
*/
appendText(path: string, data: string): Promise<void>;
/**
* Read bytes from `path`. When `n` is given, reads at most the first `n`
* bytes (a ranged/prefix read); otherwise reads the whole file. The ranged

View file

@ -8,7 +8,7 @@
*
* The model is the **in-process** representation (camelCase, options carry no
* ids). The protocol wire shape (snake_case, synthesized item/option ids,
* `expires_at`, 5-kind answer union) is produced at the edge see the
* 5-kind answer union) is produced at the edge see the
* `server-v2` questions route, which is the single protocolin-process
* adapter for this domain. Session-scoped one instance per session.
*/
@ -32,7 +32,11 @@ export interface QuestionItem {
export type QuestionAnswerMethod = 'enter' | 'space' | 'number_key';
/** Flattened answers keyed by item id — `true` marks a selected flag-style option. */
/**
* Flattened answers keyed by question text; values are the chosen option
* label(s) (comma-joined for multi-select) or free-form "Other" text.
* `true` marks a question as answered without echoing a concrete value.
*/
export type QuestionAnswers = Record<string, string | true>;
export interface QuestionResponse {
@ -54,7 +58,13 @@ export interface QuestionRequest {
export interface ISessionQuestionService {
readonly _serviceBrand: undefined;
request(req: QuestionRequest): Promise<QuestionResult>;
/**
* Post a question and block on the answer. When `options.signal` aborts
* while the question is parked (or was already aborted), the pending entry
* is dismissed and the promise resolves with `null` the same dismissed
* result as an explicit dismiss (v1 broker semantics).
*/
request(req: QuestionRequest, options?: { signal?: AbortSignal }): Promise<QuestionResult>;
/**
* Post a question without blocking on the answer. Returns the request with
* its resolved `id`; the answer is delivered through the interaction

View file

@ -20,13 +20,35 @@ export class SessionQuestionService implements ISessionQuestionService {
constructor(@ISessionInteractionService private readonly interaction: ISessionInteractionService) {}
request(req: QuestionRequest): Promise<QuestionResult> {
return this.interaction.request<QuestionRequest, QuestionResult>({
id: requestId(req),
request(req: QuestionRequest, options?: { signal?: AbortSignal }): Promise<QuestionResult> {
const id = requestId(req);
const pending = this.interaction.request<QuestionRequest, QuestionResult>({
id,
kind: 'question',
payload: req,
origin: { turnId: req.turnId },
});
// Mirrors the v1 broker: when the caller aborts (turn interrupted,
// background task killed) — or was aborted before parking — the entry is
// dismissed so listPending()/session status don't stay stuck in
// awaiting_question, and the caller receives the same `null` (dismissed)
// result as an explicit dismiss.
const signal = options?.signal;
if (signal !== undefined) {
if (signal.aborted) {
this.dismiss(id);
} else {
const onAbort = (): void => {
this.dismiss(id);
};
signal.addEventListener('abort', onAbort, { once: true });
void pending.finally(() => {
signal.removeEventListener('abort', onAbort);
});
}
}
return pending;
}
enqueue(req: QuestionRequest): QuestionRequest & { readonly id: string } {

View file

@ -52,7 +52,7 @@ export async function runCommand(
return { exitCode, stdout, stderr };
}
function readStream(stream: Readable): Promise<string> {
export function readStream(stream: Readable): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
stream.setEncoding('utf-8');

View file

@ -49,12 +49,12 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError } from '#/errors';
import { IGitService } from '#/app/git/git';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem';
import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs';
import { runCommand } from './fsProcess';
import { readStream, runCommand } from './fsProcess';
import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator';
import {
compileGrepPattern,
@ -491,12 +491,60 @@ export class SessionFsService implements ISessionFsService {
args.push(req.pattern);
args.push('.');
const res = await runCommand(this.runner, [rgPath, ...args], {
cwd: this.workspace.workDir,
signal,
});
const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workspace.workDir });
return parseRgJsonOutput(res.stdout, req, signal.aborted, Date.now() - startedAt);
// Stream `--json` records as they arrive so we can stop `rg` the moment a
// cap (`max_total_matches` / `max_files`) is hit, instead of buffering the
// whole output and letting rg scan the entire tree. The accumulator drops
// any records that were already buffered before the kill landed.
const acc = new RgJsonAccumulator(req);
let killed = false;
const kill = (): void => {
if (killed) return;
killed = true;
void proc.kill('SIGKILL');
};
const onAbort = (): void => kill();
if (signal.aborted) kill();
else signal.addEventListener('abort', onAbort, { once: true });
let stdoutBuf = '';
const drainStdout = async (): Promise<void> => {
proc.stdout.setEncoding('utf-8');
try {
for await (const chunk of proc.stdout) {
stdoutBuf += chunk as string;
let nl = stdoutBuf.indexOf('\n');
while (nl >= 0) {
const line = stdoutBuf.slice(0, nl);
stdoutBuf = stdoutBuf.slice(nl + 1);
if (line.length > 0) {
acc.feed(line);
if (acc.capped) kill();
}
nl = stdoutBuf.indexOf('\n');
}
}
if (stdoutBuf.length > 0) acc.feed(stdoutBuf);
} catch (error) {
// Once we kill rg (cap reached / abort / timeout) the pipe can close
// mid-read; that is the intended early stop, not a search failure.
if (!(killed && isPrematureCloseError(error))) throw error;
}
};
try {
await Promise.all([drainStdout(), readStream(proc.stderr), proc.wait().catch(() => -1)]);
} finally {
signal.removeEventListener('abort', onAbort);
try {
proc.dispose();
} catch {
/* best-effort cleanup */
}
}
return acc.finish(signal.aborted, Date.now() - startedAt);
}
private async grepWithNode(
@ -582,23 +630,29 @@ export class SessionFsService implements ISessionFsService {
depth = 0,
): Promise<void> {
if (depth > WALK_MAX_DEPTH) return;
let names: readonly string[];
let entries: readonly HostDirEntry[];
try {
names = (await this.hostFs.readdir(this.absOf(rootRel))).map((e) => e.name);
entries = await this.hostFs.readdir(this.absOf(rootRel));
} catch {
return;
}
for (const name of names) {
for (const entry of entries) {
const { name } = entry;
if (name === '.git') continue;
const childRel = rootRel === '' ? name : `${rootRel}/${name}`;
const st = await this.hostFs.stat(this.absOf(childRel)).catch(() => undefined);
if (st === undefined) continue;
const isDir = st.isDirectory;
// Symlinks are reported as themselves and never descended into — a
// symlinked directory must not be treated as a traversable directory,
// otherwise search could escape the workspace through the link target.
const isDir = entry.isDirectory && entry.isSymbolicLink !== true;
if (matcher) {
const probe = isDir ? `${childRel}/` : childRel;
if (matcher.ignores(probe)) continue;
}
const kind: 'file' | 'directory' | 'symlink' = isDir ? 'directory' : 'file';
const kind: 'file' | 'directory' | 'symlink' = entry.isSymbolicLink
? 'symlink'
: isDir
? 'directory'
: 'file';
await visit(childRel, name, kind);
if (isDir) {
await this.walk(childRel, matcher, visit, depth + 1);
@ -678,99 +732,115 @@ export class SessionFsService implements ISessionFsService {
}
}
function parseRgJsonOutput(
stdout: string,
req: FsGrepRequest,
aborted: boolean,
elapsedMs: number,
): FsGrepResponse {
const fileBuf = new Map<
/**
* Incremental accumulator for ripgrep `--json` output. Fed one record per line
* by `grepWithRg` so the caller can kill `rg` as soon as `max_total_matches`
* or `max_files` is reached (see {@link RgJsonAccumulator.capped}). Records
* buffered in the pipe after the cap are dropped by the same `>=` guards that
* bound the live counts.
*/
class RgJsonAccumulator {
private readonly fileBuf = new Map<
string,
{ matches: FsGrepMatch[]; pending: string[]; lastMatchLine: number }
>();
const files: FsGrepFileHit[] = [];
let totalMatches = 0;
let truncated = false;
let filesScanned = 0;
private readonly files: FsGrepFileHit[] = [];
private totalMatches = 0;
private filesScanned = 0;
private truncated = false;
const finalize = (p: string): void => {
const buf = fileBuf.get(p);
if (buf === undefined) return;
if (buf.matches.length > 0 && buf.pending.length > 0) {
const last = buf.matches[buf.matches.length - 1]!;
last.after = buf.pending.slice(0, req.context_lines);
}
if (buf.matches.length > 0) {
files.push({ path: p, matches: buf.matches });
}
fileBuf.delete(p);
};
constructor(private readonly req: FsGrepRequest) {}
for (const line of stdout.split('\n')) {
if (line.length === 0) continue;
/** `true` once either output cap has been reached and `rg` should be stopped. */
get capped(): boolean {
return (
this.totalMatches >= this.req.max_total_matches || this.filesScanned >= this.req.max_files
);
}
feed(line: string): void {
let rec: RgJsonRecord;
try {
rec = JSON.parse(line) as RgJsonRecord;
} catch {
continue;
return;
}
const t = rec.type;
if (t === 'begin') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
if (filesScanned >= req.max_files) {
truncated = true;
continue;
if (p === undefined) return;
if (this.filesScanned >= this.req.max_files) {
this.truncated = true;
return;
}
fileBuf.set(p, { matches: [], pending: [], lastMatchLine: -1 });
filesScanned += 1;
this.fileBuf.set(p, { matches: [], pending: [], lastMatchLine: -1 });
this.filesScanned += 1;
} else if (t === 'context') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
const buf = fileBuf.get(p);
if (buf === undefined) continue;
if (p === undefined) return;
const buf = this.fileBuf.get(p);
if (buf === undefined) return;
buf.pending.push(stripTrailingNewline(rgText(rec.data?.lines)));
if (buf.pending.length > req.context_lines * 2) {
if (buf.pending.length > this.req.context_lines * 2) {
buf.pending.shift();
}
} else if (t === 'match') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
const buf = fileBuf.get(p);
if (buf === undefined) continue;
if (totalMatches >= req.max_total_matches) {
truncated = true;
continue;
if (p === undefined) return;
const buf = this.fileBuf.get(p);
if (buf === undefined) return;
if (this.totalMatches >= this.req.max_total_matches) {
this.truncated = true;
return;
}
if (buf.matches.length >= req.max_matches_per_file) continue;
if (buf.matches.length >= this.req.max_matches_per_file) return;
const text = stripTrailingNewline(rgText(rec.data?.lines));
const lineNo = rec.data?.line_number ?? 0;
const col = (rec.data?.submatches?.[0]?.start ?? 0) + 1;
const before = buf.pending.slice(-req.context_lines);
const before = buf.pending.slice(-this.req.context_lines);
buf.pending.length = 0;
buf.matches.push({ line: lineNo, col, text, before, after: [] });
buf.lastMatchLine = lineNo;
totalMatches += 1;
if (totalMatches >= req.max_total_matches) truncated = true;
this.totalMatches += 1;
if (this.totalMatches >= this.req.max_total_matches) this.truncated = true;
} else if (t === 'end') {
const p = rgPath(rec.data?.path);
if (p === undefined) continue;
finalize(p);
if (p === undefined) return;
this.finalize(p);
}
}
for (const p of fileBuf.keys()) {
finalize(p);
}
if (aborted) {
if (totalMatches === 0 && filesScanned === 0) {
throw new KimiError(ErrorCodes.FS_GREP_TIMEOUT, `grep timed out after ${elapsedMs}ms`);
finish(aborted: boolean, elapsedMs: number): FsGrepResponse {
for (const p of this.fileBuf.keys()) {
this.finalize(p);
}
truncated = true;
let truncated = this.truncated;
if (aborted) {
if (this.totalMatches === 0 && this.filesScanned === 0) {
throw new KimiError(ErrorCodes.FS_GREP_TIMEOUT, `grep timed out after ${elapsedMs}ms`);
}
truncated = true;
}
return {
files: this.files,
files_scanned: this.filesScanned,
truncated,
elapsed_ms: elapsedMs,
};
}
return { files, files_scanned: filesScanned, truncated, elapsed_ms: elapsedMs };
private finalize(p: string): void {
const buf = this.fileBuf.get(p);
if (buf === undefined) return;
if (buf.matches.length > 0 && buf.pending.length > 0) {
const last = buf.matches[buf.matches.length - 1]!;
last.after = buf.pending.slice(0, this.req.context_lines);
}
if (buf.matches.length > 0) {
this.files.push({ path: p, matches: buf.matches });
}
this.fileBuf.delete(p);
}
}
// ---------------------------------------------------------------------------
@ -782,6 +852,13 @@ function isHidden(name: string): boolean {
return HIDDEN_NAME_RE.test(name) || MACOS_NOISE.has(name);
}
function isPrematureCloseError(error: unknown): boolean {
return (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === 'ERR_STREAM_PREMATURE_CLOSE'
);
}
function sortChildren(
children: { name: string; stat: HostFileStat }[],
sort: FsListRequest['sort'],
@ -814,7 +891,11 @@ function buildFsEntry(
st: HostFileStat,
withMime: boolean,
): FsEntry {
const kind: FsEntry['kind'] = st.isDirectory ? 'directory' : 'file';
const kind: FsEntry['kind'] = st.isSymbolicLink
? 'symlink'
: st.isDirectory
? 'directory'
: 'file';
const entry: FsEntry = {
path: relPath,
name,

View file

@ -21,7 +21,7 @@ export interface ISessionTodoService {
setTodos(todos: readonly TodoItem[]): void;
/** Clear the list (equivalent to `setTodos([])`). */
clear(): void;
/** Fires after every `setTodos` with the new list. */
/** Fires when the materialized list changes (after a `todo.set` is applied); carries the sanitized list. */
readonly onDidChange: Event<readonly TodoItem[]>;
}

View file

@ -1,15 +1,23 @@
/**
* `todo` domain (L4) `ISessionTodoService` implementation.
*
* Holds the session's shared in-memory todo list. Every mutation dispatches a
* `todo.set` Op to the main agent's wire (the single source of truth and
* replayable timeline); on resume the main agent's `wire.replay` rebuilds the
* `TodoModel` and the `wire.onRestored` handler copies it back into the
* in-memory list. Binds the `TodoListTool` and the stale-todo reminder into
* every agent (`onDidCreate`), and the restore handler into the main agent
* (`onDidCreateMain`), borrowing each agent's services through its
* `IAgentScopeHandle.accessor`. Per-agent bindings are disposed when the agent
* is disposed. Bound at Session scope.
* Holds the session's shared todo list as a stateless facade over the main
* agent's `TodoModel`: `getTodos` reads `wire.getModel(TodoModel)` live, and
* every mutation only dispatches a `todo.set` Op to the main agent's wire (the
* single source of truth and replayable timeline); `onDidChange` is bridged
* from `wire.subscribe(TodoModel)`. The service keeps no list copy of its own,
* so the live view and the post-replay view can never drift. Binds the
* `TodoListTool` and the stale-todo reminder into every agent (`onDidCreate`),
* and the model subscription into the main agent (`onDidCreateMain`),
* borrowing each agent's services through its `IAgentScopeHandle.accessor`.
* Per-agent bindings are disposed when the agent is disposed. Bound at Session
* scope.
*
* Debt: the session's todo list is still persisted on the MAIN agent's wire (a
* Session Agent edge), so it follows the main agent's lifetime. Once
* `ISessionWireService` is wired up with its own log + replay, move `TodoModel`
* there swap `@IAgentWireService` for `@ISessionWireService` and drop the
* main-agent subscription. The stateless facade makes that a one-line change.
*/
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
@ -41,11 +49,10 @@ const MAIN_AGENT_ID = 'main';
export class SessionTodoService extends Disposable implements ISessionTodoService {
declare readonly _serviceBrand: undefined;
private todos: readonly TodoItem[] = [];
private readonly onDidChangeEmitter = this._register(new Emitter<readonly TodoItem[]>());
readonly onDidChange = this.onDidChangeEmitter.event;
/** Per-agent bindings (tool + reminder, plus the resume resumer for main). */
/** Per-agent bindings (reminder per agent, plus the model subscription for main). */
private readonly agentBindings = new Map<string, IDisposable[]>();
constructor(
@ -72,13 +79,14 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
for (const agentId of Array.from(this.agentBindings.keys())) {
this.disposeAgentBindings(agentId);
}
this.todos = [];
}),
);
}
getTodos(): readonly TodoItem[] {
return this.todos;
const main = this.agentLifecycle.getHandle(MAIN_AGENT_ID);
if (main === undefined) return [];
return main.accessor.get(IAgentWireService).getModel(TodoModel);
}
setTodos(todos: readonly TodoItem[]): void {
@ -86,9 +94,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
title: todo.title,
status: todo.status,
}));
this.todos = next;
this.dispatchTodoSet(next);
this.onDidChangeEmitter.fire(next);
}
clear(): void {
@ -105,10 +111,11 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
private bindMainWire(handle: IAgentScopeHandle): void {
const wire = handle.accessor.get(IAgentWireService);
// Registered on the main agent's wire by `onDidCreateMain`, which fires in
// `ensureMainAgent` strictly before that wire's `replay`, so this handler
// runs at the end of the main agent's restore and copies the rebuilt list.
const disposable = wire.onRestored(() => {
this.todos = wire.getModel(TodoModel);
// `ensureMainAgent` strictly before that wire's `replay`. Bridge model
// changes to `onDidChange`: replay applies silently (no notification), so
// this fires only for live `todo.set` writes, carrying the sanitized model.
const disposable = wire.subscribe(TodoModel, (state) => {
this.onDidChangeEmitter.fire(state);
});
this.trackAgentBinding(handle.id, disposable);
}
@ -127,7 +134,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
return todoListStaleReminder({
active: profile.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'),
history: memory.get(),
todos: this.todos,
todos: this.getTodos(),
});
}

View file

@ -3,10 +3,12 @@
* (`todoSet`) for the session's shared todo list.
*
* Declares the todo list as `readonly TodoItem[]` (initial `[]`) plus the single
* `todo.set` Op whose `apply` is a pure replace the whole list is carried in
* the payload returning the same reference when the payload is already the
* current list so the wire's reference-equality gate stays quiet. The Op type
* (`todo.set`) matches the legacy record type, so `wire.replay` rebuilds the
* `todo.set` Op whose `apply` replaces the whole list with the payload after
* sanitizing it through `readTodoItems`. Replayed / hand-written records may
* carry malformed items, and `apply` is the single logmodel boundary that
* keeps the model clean so every consumer (`getTodos`, the tool render, the
* stale reminder, the compaction summary) can trust it without re-validating.
* The Op type (`todo.set`) matches the legacy record type, so `wire.replay`
* Model from the existing shared append log. Consumed cross-scope by the
* Session-scope `SessionTodoService`: it dispatches `todo.set` to the MAIN
* agent's wire (the single source of truth and replayable timeline) and, on
@ -18,7 +20,7 @@
import { defineModel } from '#/wire/model';
import { defineOp } from '#/wire/op';
import type { TodoItem } from './todoItem';
import { readTodoItems, type TodoItem } from './todoItem';
export type TodoModelState = readonly TodoItem[];
@ -29,5 +31,5 @@ export interface TodoSetPayload {
}
export const todoSet = defineOp(TodoModel, 'todo.set', {
apply: (s, p: TodoSetPayload): TodoModelState => (p.todos === s ? s : p.todos),
apply: (_s, p: TodoSetPayload): TodoModelState => readTodoItems(p.todos),
});

View file

@ -25,6 +25,7 @@ describe('loop-event fold parity', () => {
toolCalls: m.toolCalls,
toolCallId: m.toolCallId,
isError: m.isError,
note: m.note,
}));
}
@ -79,7 +80,7 @@ describe('loop-event fold parity', () => {
},
{
role: 'tool',
content: [{ type: 'text', text: '<system>ERROR: Tool execution failed.</system>\nboom' }],
content: [{ type: 'text', text: 'boom' }],
toolCalls: [],
toolCallId: 'c2',
isError: true,
@ -107,7 +108,7 @@ describe('loop-event fold parity', () => {
expect(folded).toEqual(baseline);
});
it('folds a tool-result note as trailing model text without splitting text-only output', () => {
it('folds a tool-result note as structured model-only metadata', () => {
context.append(
{
role: 'assistant',
@ -116,10 +117,11 @@ describe('loop-event fold parity', () => {
},
{
role: 'tool',
content: [{ type: 'text', text: 'result text\n<system>Image compressed.</system>' }],
content: [{ type: 'text', text: 'result text' }],
toolCalls: [],
toolCallId: 'c3',
isError: false,
note: '<system>Image compressed.</system>',
},
);
const baseline = comparable(context.get());

View file

@ -7,6 +7,7 @@ import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector';
import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService';
import { IAgentMicroCompactionService } from '#/agent/microCompaction/microCompaction';
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
import type { Message } from '#/app/llmProtocol/message';
// Tests for how the projector normalizes tool exchanges: results are pulled up
@ -214,6 +215,59 @@ describe('projector tool-exchange normalization', () => {
expect(project([message])).toHaveLength(1);
});
it('renders structured tool-result notes only for the model projection', () => {
const note = '<system>Image compressed.</system>';
const result: ContextMessage = {
role: 'tool',
content: [{ type: 'text', text: 'image result' }],
toolCalls: [],
toolCallId: 'call_image',
note,
};
const history = [assistant('', ['call_image']), result];
expect(project(history)[1]?.content).toEqual([
{ type: 'text', text: `image result\n${note}` },
]);
expect(result.content).toEqual([{ type: 'text', text: 'image result' }]);
const protocol = toProtocolMessage('session_1', 0, result, 0);
expect(protocol.content).toEqual([
{ type: 'tool_result', tool_call_id: 'call_image', output: 'image result' },
]);
});
it('renders v1 tool-result status at the model projection boundary', () => {
const history = [
assistant('', ['call_error', 'call_empty']),
{
role: 'tool',
content: [{ type: 'text', text: '<system>ERROR: remote failed</system>' }],
toolCalls: [],
toolCallId: 'call_error',
isError: true,
},
{
role: 'tool',
content: [{ type: 'text', text: ' ' }],
toolCalls: [],
toolCallId: 'call_empty',
},
] satisfies ContextMessage[];
expect(project(history)[1]?.content).toEqual([
{
type: 'text',
text:
'<system>ERROR: Tool execution failed.</system>\n' +
'<system>ERROR: remote failed</system>',
},
]);
expect(project(history)[2]?.content).toEqual([
{ type: 'text', text: '<system>Tool output is empty.</system>' },
]);
});
it('strict mode dedupes duplicate assistant tool call ids', () => {
const history = [
user('go'),

View file

@ -6,10 +6,9 @@
* minimal fake `IHostFileSystem` inline so the tool can be exercised without
* the composition root.
*
* The v1 append path used `kaos.writeText(path, data, { mode: 'a' })`. v2's
* `IHostFileSystem.writeText` has no mode flag, so append is implemented as
* `readText` (treating a missing file as empty) followed by `writeText` of the
* concatenation. The append-call assertions below reflect that mechanic.
* Append is routed through `IHostFileSystem.appendText` (a native append), so
* the tool no longer reads the existing file. The append-call assertions below
* reflect that single-call mechanic.
*/
import { describe, expect, it, vi } from 'vitest';
@ -51,6 +50,8 @@ interface WriteFsOptions {
readText?: (path: string) => Promise<string>;
/** Override writeText. Default no-op. */
writeText?: (path: string, data: string) => Promise<void>;
/** Override appendText. Default no-op. */
appendText?: (path: string, data: string) => Promise<void>;
/** Override stat. Default reports an existing directory. */
stat?: (path: string) => Promise<HostFileStat>;
/** Override mkdir. Default no-op. */
@ -72,12 +73,13 @@ function createWriteFs(options: WriteFsOptions = {}) {
}),
);
const writeText = vi.fn(options.writeText ?? (async () => {}));
const appendText = vi.fn(options.appendText ?? (async () => {}));
const stat = vi.fn(
options.stat ?? (async () => ({ isFile: false, isDirectory: true, size: 0 })),
);
const mkdir = vi.fn(options.mkdir ?? (async () => {}));
const fs = { cwd: '/', readText, writeText, stat, mkdir } as unknown as IHostFileSystem;
return { fs, readText, writeText, stat, mkdir };
const fs = { cwd: '/', readText, writeText, appendText, stat, mkdir } as unknown as IHostFileSystem;
return { fs, readText, writeText, appendText, stat, mkdir };
}
function makeTool(options: WriteFsOptions = {}, workspace = PERMISSIVE_WORKSPACE) {
@ -218,8 +220,8 @@ describe('WriteTool', () => {
expect(result.output).toContain('Wrote 5 bytes');
});
it('appends content by reading existing bytes then writing the concatenation', async () => {
const { tool, readText, writeText } = makeTool({ readText: async () => 'old' });
it('appends content through appendText without reading existing bytes', async () => {
const { tool, readText, writeText, appendText } = makeTool();
const result = await execute(tool, {
path: '/tmp/existing.txt',
@ -227,8 +229,9 @@ describe('WriteTool', () => {
mode: 'append',
});
expect(readText).toHaveBeenCalledWith('/tmp/existing.txt');
expect(writeText).toHaveBeenCalledWith('/tmp/existing.txt', 'old\nhello');
expect(appendText).toHaveBeenCalledWith('/tmp/existing.txt', '\nhello');
expect(readText).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
expect(result.output).toContain('Appended 6 bytes');
});
@ -272,11 +275,11 @@ describe('WriteTool', () => {
const expectedBytes = Buffer.byteLength(content, 'utf8');
expect(expectedBytes).toBe(5);
const { tool, writeText } = makeTool({ readText: async () => 'prefix' });
const { tool, appendText } = makeTool();
const result = await execute(tool, { path: '/tmp/menu.txt', content, mode: 'append' });
expect(writeText).toHaveBeenCalledWith('/tmp/menu.txt', 'prefixcafé');
expect(appendText).toHaveBeenCalledWith('/tmp/menu.txt', 'café');
expect(result.output).toContain('Appended 5 bytes');
});
@ -414,9 +417,9 @@ describe('WriteTool', () => {
});
it('appending to a nonexistent file creates it with just the appended bytes', async () => {
// Append mode on a missing path returns success and creates the file:
// readText rejects with ENOENT, so existing content is treated as empty.
const { tool, readText, writeText } = makeTool();
// Native append (fs.appendFile) creates the file when it is missing, so
// append mode on a new path succeeds and writes exactly the appended bytes.
const { tool, readText, appendText } = makeTool();
const result = await execute(tool, {
path: '/tmp/new-append.txt',
@ -426,8 +429,8 @@ describe('WriteTool', () => {
expect(result.isError).toBeFalsy();
expect(toolContentString(result).toLowerCase()).toContain('appended');
expect(readText).toHaveBeenCalledWith('/tmp/new-append.txt');
expect(writeText).toHaveBeenCalledWith('/tmp/new-append.txt', 'New content');
expect(appendText).toHaveBeenCalledWith('/tmp/new-append.txt', 'New content');
expect(readText).not.toHaveBeenCalled();
});
it('allows absolute writes to a sibling dir that merely shares the work-dir prefix', async () => {

View file

@ -432,6 +432,7 @@ function isFullHostFs(input: unknown): boolean {
const keys: readonly (keyof IHostFileSystem)[] = [
'readText',
'writeText',
'appendText',
'readBytes',
'writeBytes',
'readLines',

View file

@ -1,11 +1,13 @@
import type { ContentPart } from '#/app/llmProtocol/message';
import type { Tool as KosongTool } from '#/app/llmProtocol/tool';
import { Jimp } from 'jimp';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type { McpConnectionManager, McpServerEntry } from '#/agent/mcp/connection-manager';
import { IAgentMcpService } from '#/agent/mcp/mcp';
import { AgentMcpService } from '#/agent/mcp/mcpService';
@ -24,6 +26,7 @@ import { IAgentTurnService } from '#/agent/turn/turn';
import { IAgentProfileService } from '#/agent/profile/profile';
import { createTestAgent, mcpServices, type TestAgentContext } from '../harness';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import { stubTurnWithHooks } from '../turn/stubs';
import { discoverTools, executeTool, fakeMcpClient } from './stubs';
@ -147,18 +150,21 @@ describe('AgentMcpService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let events: DomainEvent[];
let telemetryEvents: TelemetryRecord[];
let wire: WireService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
events = [];
telemetryEvents = [];
ix.stub(IEventBus, {
publish: (event) => {
events.push(event);
},
subscribe: () => toDisposable(() => {}),
});
ix.stub(ITelemetryService, recordingTelemetry(telemetryEvents));
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
ix.set(IAgentToolExecutorService, new SyncDescriptor(AgentToolExecutorService));
ix.stub(IAgentTurnService, stubTurnWithHooks());
@ -417,6 +423,57 @@ describe('AgentMcpService', () => {
]);
});
it('reports MCP image compression telemetry through the wrapped tool path', async () => {
const manager = new FakeMcpManager();
const image = Buffer.from(
await new Jimp({ width: 2600, height: 2600, color: 0x3366ccff }).getBuffer('image/png'),
).toString('base64');
const client: MCPClient = {
async listTools() {
return [
{
name: 'shot',
description: 'Returns a large image',
inputSchema: { type: 'object', properties: {} },
},
];
},
async callTool() {
return {
content: [{ type: 'image', data: image, mimeType: 'image/png' }],
isError: false,
};
},
};
manager.setResolved('s', client, await discoverTools(client));
createService(manager);
manager.connect('s');
const shot = ix.get(IAgentToolRegistryService).resolve('mcp__s__shot');
const result = await executeTool(shot!, {
turnId: 1,
toolCallId: 'tc-large-image',
args: {},
signal: new AbortController().signal,
});
expect(result.isError).toBeUndefined();
const imageCompressEvents = telemetryEvents.filter((record) => record.event === 'image_compress');
expect(imageCompressEvents).toHaveLength(1);
const properties = imageCompressEvents[0]!.properties;
expect(properties).toEqual(
expect.objectContaining({
source: 'mcp_tool_result',
outcome: 'compressed',
input_mime: 'image/png',
original_width: 2600,
original_height: 2600,
}),
);
expect(properties?.['final_width']).toBeLessThanOrEqual(2000);
expect(properties?.['final_height']).toBeLessThanOrEqual(2000);
});
it('forwards the execution AbortSignal through the wrapped MCP tool', async () => {
const manager = new FakeMcpManager();
let receivedSignal: AbortSignal | undefined;

View file

@ -97,6 +97,52 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker
});
});
it('request with a pre-aborted signal resolves null and parks nothing', async () => {
const questions = session.accessor.get(ISessionQuestionService);
const controller = new AbortController();
controller.abort();
await expect(
questions.request(makeRequest('q1'), { signal: controller.signal }),
).resolves.toBeNull();
expect(questions.listPending()).toEqual([]);
});
it('aborting a parked request dismisses it and resolves the caller with null', async () => {
const interaction = session.accessor.get(ISessionInteractionService);
const questions = session.accessor.get(ISessionQuestionService);
const resolved: { id: string; response: unknown }[] = [];
disposables.add(interaction.onDidResolve((r) => resolved.push(r)));
const controller = new AbortController();
const pending = questions.request(makeRequest('q1'), { signal: controller.signal });
expect(questions.listPending().map((r) => r.id)).toEqual(['q1']);
controller.abort();
// v1 broker semantics: the abort settles the entry as a dismissal, so the
// caller sees the same `null` result (→ `event.question.dismissed`) as an
// explicit dismiss instead of a rejection.
await expect(pending).resolves.toBeNull();
expect(questions.listPending()).toEqual([]);
expect(resolved).toEqual([{ id: 'q1', response: null }]);
expect(interaction.isRecentlyResolved('q1')).toBe(true);
});
it('an answer that arrives before the abort still wins', async () => {
const questions = session.accessor.get(ISessionQuestionService);
const controller = new AbortController();
const pending = questions.request(makeRequest('q1'), { signal: controller.signal });
questions.answer('q1', { answers: { q_0: 'Yes' } });
await expect(pending).resolves.toEqual({ answers: { q_0: 'Yes' } });
// A late abort is a no-op: the entry is already settled.
controller.abort();
expect(questions.listPending()).toEqual([]);
});
it('Session scope isolates brokers: a question parked in A is invisible to B', () => {
const sessionB = host.child(LifecycleScope.Session, 'session-b');
const questionsA = session.accessor.get(ISessionQuestionService);

View file

@ -0,0 +1,574 @@
/**
* AskUserQuestionTool unit tests ported from v1
* `packages/agent-core/test/tools/ask-user.test.ts` and adapted to the v2 DI
* constructor (`ISessionQuestionService` / `IAgentTaskService` /
* `ITelemetryService` stubs instead of a fake `Agent`).
*/
import { describe, expect, it, vi } from 'vitest';
import { CoreErrors } from '#/_base/errors/codes';
import { KimiError } from '#/_base/errors/errors';
import { IAgentTaskService } from '#/agent/task/task';
import type { AgentTask, AgentTaskInfoBase, AgentTaskSink, AgentTaskStatus } from '#/agent/task/types';
import {
AskUserQuestionInputSchema,
AskUserQuestionTool,
type AskUserQuestionInput,
} from '#/agent/questionTools/tools/ask-user';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type {
ISessionQuestionService,
QuestionRequest,
QuestionResult,
} from '#/session/question/question';
import { executeTool } from '../tools/fixtures/execute-tool';
const signal = new AbortController().signal;
function input(
overrides: Partial<AskUserQuestionInput['questions'][number]> = {},
): AskUserQuestionInput {
return {
questions: [
{
question: 'Which database?',
header: 'Storage',
options: [
{ label: 'Postgres', description: 'Relational storage' },
{ label: 'SQLite', description: 'Embedded storage' },
],
multi_select: false,
...overrides,
},
],
};
}
interface FakeTaskEntry {
readonly task: AgentTask;
readonly controller: AbortController;
output: string;
status: AgentTaskStatus;
stopReason?: string;
readonly settled: Promise<void>;
}
/**
* Minimal in-memory stand-in for `IAgentTaskService`: runs a registered task
* immediately with a real sink so the QuestionTask wiring (including the
* task-signal passthrough) is exercised end to end.
*/
function createFakeTaskService(): {
readonly tasks: IAgentTaskService;
readonly registerTask: ReturnType<typeof vi.fn>;
entry(taskId: string): FakeTaskEntry;
kill(taskId: string): void;
wait(taskId: string): Promise<void>;
} {
const entries = new Map<string, FakeTaskEntry>();
let counter = 0;
const registerTask = vi.fn((task: AgentTask): string => {
counter += 1;
const taskId = `${task.idPrefix}-${String(counter).padStart(8, '0')}`;
const controller = new AbortController();
let settleResolve!: () => void;
const settled = new Promise<void>((resolve) => {
settleResolve = resolve;
});
const entry: FakeTaskEntry = { task, controller, output: '', status: 'running', settled };
const sink: AgentTaskSink = {
signal: controller.signal,
appendOutput: (chunk) => {
entry.output += chunk;
},
settle: (settlement) => {
entry.status = settlement.status;
entry.stopReason = settlement.stopReason;
settleResolve();
return Promise.resolve(true);
},
};
entries.set(taskId, entry);
void Promise.resolve(task.start(sink)).catch(() => {});
return taskId;
});
const tasks = {
registerTask,
getTask: (taskId: string) => {
const entry = entries.get(taskId);
if (entry === undefined) return undefined;
const base: AgentTaskInfoBase = {
taskId,
description: entry.task.description,
status: entry.status,
startedAt: 0,
endedAt: null,
stopReason: entry.stopReason,
};
return entry.task.toInfo(base);
},
} as unknown as IAgentTaskService;
return {
tasks,
registerTask,
entry: (taskId) => {
const entry = entries.get(taskId);
if (entry === undefined) throw new Error(`unknown task ${taskId}`);
return entry;
},
kill: (taskId) => {
entries.get(taskId)?.controller.abort();
},
wait: async (taskId) => entries.get(taskId)?.settled,
};
}
function makeTool(
options: {
readonly request?: (
req: QuestionRequest,
requestOptions?: { readonly signal?: AbortSignal },
) => Promise<QuestionResult>;
} = {},
): {
readonly tool: AskUserQuestionTool;
readonly request: ReturnType<typeof vi.fn>;
readonly telemetryTrack: ReturnType<typeof vi.fn>;
readonly taskService: ReturnType<typeof createFakeTaskService>;
} {
const request = vi.fn(options.request ?? (async () => ({ Postgres: true }) as QuestionResult));
const telemetryTrack = vi.fn();
const taskService = createFakeTaskService();
const question = { request } as unknown as ISessionQuestionService;
const telemetry = { track: telemetryTrack } as unknown as ITelemetryService;
const tool = new AskUserQuestionTool(question, taskService.tasks, telemetry);
return { tool, request, telemetryTrack, taskService };
}
describe('AskUserQuestionTool', () => {
it('exposes current metadata and schema', () => {
const { tool } = makeTool();
expect(tool.name).toBe('AskUserQuestion');
expect(tool.description).toContain('structured options');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: { questions: { type: 'array' } },
});
expect(AskUserQuestionInputSchema.safeParse(input()).success).toBe(true);
expect(AskUserQuestionInputSchema.safeParse({ questions: [] }).success).toBe(false);
expect(
AskUserQuestionInputSchema.safeParse(
input({
options: [{ label: 'Only one', description: 'Not enough choices' }],
}),
).success,
).toBe(false);
});
it('documents the answers shape and the uniqueness requirement to the model', () => {
const { tool } = makeTool();
expect(tool.description).toContain('must be unique across the call');
expect(tool.description).toContain('keyed by question text');
});
it('tells the model not to poll a pending background question', () => {
const { tool } = makeTool();
const paramsJson = JSON.stringify(tool.parameters);
expect(paramsJson).toContain('do not poll with TaskOutput');
expect(paramsJson).not.toContain('Use TaskOutput to read the answer later');
});
it('rejects empty question text and empty option labels at the schema layer', () => {
expect(
AskUserQuestionInputSchema.safeParse(input({ question: '' })).success,
).toBe(false);
expect(
AskUserQuestionInputSchema.safeParse(
input({
options: [
{ label: '', description: 'Empty label' },
{ label: 'B', description: '' },
],
}),
).success,
).toBe(false);
});
it('rejects duplicate question texts across questions (schema + execution)', async () => {
const duplicated: AskUserQuestionInput = {
questions: [input().questions[0]!, input().questions[0]!],
};
expect(AskUserQuestionInputSchema.safeParse(duplicated).success).toBe(false);
const { tool, request } = makeTool();
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_dup_question',
args: duplicated,
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('unique');
expect(request).not.toHaveBeenCalled();
});
it('rejects duplicate option labels within one question (schema + execution)', async () => {
const duplicated = input({
options: [
{ label: 'Postgres', description: 'Relational storage' },
{ label: 'Postgres', description: 'Same label again' },
],
});
expect(AskUserQuestionInputSchema.safeParse(duplicated).success).toBe(false);
const { tool, request } = makeTool();
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_dup_label',
args: duplicated,
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('unique');
expect(request).not.toHaveBeenCalled();
});
it('allows the same option label to appear in different questions', async () => {
const args: AskUserQuestionInput = {
questions: [
input().questions[0]!,
input({ question: 'Which cache?' }).questions[0]!,
],
};
expect(AskUserQuestionInputSchema.safeParse(args).success).toBe(true);
const { tool, request } = makeTool();
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_cross_label',
args,
signal,
});
expect(result.isError).toBe(false);
expect(request).toHaveBeenCalledOnce();
});
it('rejects duplicate questions on the background path before starting a task', async () => {
const { tool, request, taskService } = makeTool();
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_bg_dup',
args: {
questions: [input().questions[0]!, input().questions[0]!],
background: true,
},
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('unique');
expect(result.output).not.toContain('task_id:');
expect(request).not.toHaveBeenCalled();
expect(taskService.registerTask).not.toHaveBeenCalled();
});
it('describes the no-Other rule on options and the Recommended hint on label', () => {
const { tool } = makeTool();
const params = tool.parameters as {
properties: {
questions: {
items: {
properties: {
options: {
description?: string;
items: { properties: { label: { description?: string } } };
};
};
};
};
};
};
const optionsSchema = params.properties.questions.items.properties.options;
expect(optionsSchema.description).toContain("Do NOT include an 'Other' option");
expect(optionsSchema.description).toContain('the system adds one automatically');
const labelSchema = optionsSchema.items.properties.label;
expect(labelSchema.description).toContain("append '(Recommended)'");
});
it('always builds the background-question schema', () => {
const { tool } = makeTool();
expect(tool.description).toContain('Set background=true');
expect(JSON.stringify(tool.parameters)).toContain('background');
});
it('dispatches questions through the session question service', async () => {
const { tool, request, telemetryTrack } = makeTool();
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_question',
args: input({ multi_select: true }),
signal,
});
expect(result.isError).toBe(false);
expect(result.output).toBe(JSON.stringify({ answers: { Postgres: true } }));
expect(request).toHaveBeenCalledWith(
{
turnId: 0,
toolCallId: 'call_question',
questions: [
{
question: 'Which database?',
header: 'Storage',
options: [
{ label: 'Postgres', description: 'Relational storage' },
{ label: 'SQLite', description: 'Embedded storage' },
],
multiSelect: true,
},
],
},
{ signal },
);
expect(telemetryTrack).toHaveBeenCalledWith('question_answered', {
answered: 1,
});
});
it('passes empty headers and option descriptions through verbatim (v1 wire parity)', async () => {
const { tool, request } = makeTool();
await executeTool(tool, {
turnId: 0,
toolCallId: 'call_empty_fields',
args: input({
header: '',
options: [
{ label: 'Postgres', description: '' },
{ label: 'SQLite', description: '' },
],
}),
signal,
});
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
questions: [
expect.objectContaining({
header: '',
options: [
{ label: 'Postgres', description: '' },
{ label: 'SQLite', description: '' },
],
}),
],
}),
{ signal },
);
});
it('tracks the structured question answer method without leaking it into output', async () => {
const { tool, telemetryTrack } = makeTool({
request: async () => ({
answers: { 'Which database?': 'SQLite' },
method: 'number_key',
}),
});
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_question',
args: input(),
signal,
});
expect(result).toMatchObject({ isError: false });
expect(result.output).toBe(JSON.stringify({ answers: { 'Which database?': 'SQLite' } }));
expect(telemetryTrack).toHaveBeenCalledWith('question_answered', {
answered: 1,
method: 'number_key',
});
});
it('starts a background question task and stores the eventual answer in task output', async () => {
let resolveQuestion!: (result: QuestionResult) => void;
const questionResult = new Promise<QuestionResult>((resolve) => {
resolveQuestion = resolve;
});
const { tool, taskService, telemetryTrack } = makeTool({
request: async () => questionResult,
});
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_background_question',
args: { ...input(), background: true },
signal,
});
expect(result.isError).toBe(false);
expect(result.output).toContain('task_id: question-');
const outputText = typeof result.output === 'string' ? result.output : '';
const taskId = /task_id: (?<taskId>question-[0-9a-z]{8})/.exec(outputText)?.groups?.['taskId'];
expect(taskId).toBeDefined();
expect(taskService.tasks.getTask(taskId!)).toMatchObject({
kind: 'question',
status: 'running',
questionCount: 1,
toolCallId: 'call_background_question',
});
resolveQuestion({ answers: { 'Which database?': 'SQLite' }, method: 'enter' });
await taskService.wait(taskId!);
expect(taskService.tasks.getTask(taskId!)).toMatchObject({ status: 'completed' });
expect(taskService.entry(taskId!).output).toBe(
JSON.stringify({ answers: { 'Which database?': 'SQLite' } }),
);
expect(telemetryTrack).toHaveBeenCalledWith('question_answered', {
answered: 1,
method: 'enter',
});
});
it('dismisses the underlying question when a background task is killed (v1 broker semantics)', async () => {
const { tool, request, taskService, telemetryTrack } = makeTool({
// Emulates the real question service: an abort dismisses the parked
// entry and resolves with `null` instead of rejecting.
request: async (_req, requestOptions) =>
new Promise<QuestionResult>((resolve) => {
requestOptions?.signal?.addEventListener('abort', () => resolve(null), {
once: true,
});
}),
});
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_bg_kill',
args: { ...input(), background: true },
signal,
});
const outputText = typeof result.output === 'string' ? result.output : '';
const taskId = /task_id: (?<taskId>question-[0-9a-z]{8})/.exec(outputText)?.groups?.['taskId'];
expect(taskId).toBeDefined();
// The task signal must reach the question service so a TaskStop actually
// cancels the pending question instead of leaking it.
const requestOptions = request.mock.calls[0]?.[1] as { signal?: AbortSignal } | undefined;
expect(requestOptions?.signal).toBeDefined();
taskService.kill(taskId!);
await taskService.wait(taskId!);
// The dismissal resolves the question thunk with the dismissed answers
// payload, so the task itself completes — matching the v1 runtime.
expect(taskService.entry(taskId!).status).toBe('completed');
expect(taskService.entry(taskId!).output).toBe(
JSON.stringify({ answers: {}, note: 'User dismissed the question without answering.' }),
);
expect(telemetryTrack).toHaveBeenCalledWith('question_dismissed');
});
it('returns a dismissed message when every question is dismissed', async () => {
const { tool, telemetryTrack } = makeTool({ request: async () => null });
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_question',
args: {
questions: [input().questions[0]!, input({ question: 'Which cache?' }).questions[0]!],
},
signal,
});
expect(result).toMatchObject({ isError: false });
expect(result.output).toContain('dismissed');
expect(result.output).toContain('answers');
expect(telemetryTrack).toHaveBeenCalledWith('question_dismissed');
});
it('resolves question service error responses as dismissed answers', async () => {
const { tool } = makeTool({
request: async () => {
throw new KimiError(CoreErrors.codes.INTERNAL, 'question broker error');
},
});
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'call_question',
args: input(),
signal,
});
expect(result).toMatchObject({ isError: false });
expect(result.output).toContain('dismissed');
expect(typeof result.output).toBe('string');
const output = typeof result.output === 'string' ? result.output : '';
expect(JSON.parse(output)).toEqual({
answers: {},
note: 'User dismissed the question without answering.',
});
expect(result.output).not.toContain('Do NOT call this tool again');
});
it('propagates aborts while waiting for the question service', async () => {
const controller = new AbortController();
const { tool } = makeTool({
request: async (_req, requestOptions) =>
new Promise<QuestionResult>((_resolve, reject) => {
requestOptions?.signal?.addEventListener(
'abort',
() => {
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
},
{ once: true },
);
}),
});
const result = executeTool(tool, {
turnId: 0,
toolCallId: 'call_question',
args: input(),
signal: controller.signal,
});
controller.abort();
await expect(result).rejects.toHaveProperty('name', 'AbortError');
});
it('returns a distinct hard error when the host signals unsupported', async () => {
const { tool } = makeTool({
request: async () => {
throw new KimiError(
CoreErrors.codes.NOT_IMPLEMENTED,
'Client does not support questions',
);
},
});
const result = await executeTool(tool, {
turnId: 0,
toolCallId: 'tc-ask-unsupported',
args: input(),
signal,
});
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('connected client');
expect(result.output).toContain('does not support interactive questions');
expect(result.output).toContain('Do NOT call this tool again');
expect(result.output).toContain('Ask the user directly in your text response instead');
});
});

View file

@ -39,18 +39,25 @@ function stubWorkspace(): ISessionWorkspaceContext {
};
}
function fakeFs(files: Record<string, string>): IHostFileSystem {
function fakeFs(files: Record<string, string>, symlinks: readonly string[] = []): IHostFileSystem {
// Keys are stored as absolute paths; fsService now resolves workspace-relative
// paths to absolute (`join(WORK_DIR, rel)`) before calling into `IHostFileSystem`.
const fileMap = new Map<string, string>();
const dirSet = new Set<string>([WORK_DIR]);
for (const [rel, content] of Object.entries(files)) {
const abs = join(WORK_DIR, rel);
fileMap.set(abs, content);
const addAncestors = (rel: string): void => {
const parts = rel.split('/');
for (let i = 1; i < parts.length; i++) {
dirSet.add(join(WORK_DIR, parts.slice(0, i).join('/')));
}
};
for (const [rel, content] of Object.entries(files)) {
fileMap.set(join(WORK_DIR, rel), content);
addAncestors(rel);
}
const symlinkSet = new Set<string>();
for (const rel of symlinks) {
symlinkSet.add(join(WORK_DIR, rel));
addAncestors(rel);
}
const isDir = (p: string): boolean => p === WORK_DIR || dirSet.has(p);
const enoent = (p: string): NodeJS.ErrnoException => {
@ -66,6 +73,7 @@ function fakeFs(files: Record<string, string>): IHostFileSystem {
return c;
},
writeText: async () => {},
appendText: async () => {},
readBytes: async (p, n) => {
const c = fileMap.get(p);
if (c === undefined) throw enoent(p);
@ -87,6 +95,9 @@ function fakeFs(files: Record<string, string>): IHostFileSystem {
ino: 1,
};
}
if (symlinkSet.has(p)) {
return { isFile: false, isDirectory: false, isSymbolicLink: true, size: 0, mtimeMs: 1000, ino: 1 };
}
if (isDir(p)) {
return { isFile: false, isDirectory: true, size: 0, mtimeMs: 1000, ino: 1 };
}
@ -106,17 +117,24 @@ function fakeFs(files: Record<string, string>): IHostFileSystem {
children.set(name, { name, isFile: true, isDirectory: false });
}
};
const visit = (key: string, isFile: boolean): void => {
const addSymlink = (name: string): void => {
if (!children.has(name)) {
children.set(name, { name, isFile: false, isDirectory: false, isSymbolicLink: true });
}
};
const visit = (key: string, kind: 'file' | 'dir' | 'symlink'): void => {
if (key === p || !key.startsWith(prefix)) return;
const rest = key.slice(prefix.length);
const first = rest.split('/')[0];
if (first === undefined || first.length === 0) return;
if (rest.includes('/')) addDir(first);
else if (isFile) addFile(first);
else if (kind === 'symlink') addSymlink(first);
else if (kind === 'file') addFile(first);
else addDir(first);
};
for (const d of dirSet) visit(d, false);
for (const f of fileMap.keys()) visit(f, true);
for (const d of dirSet) visit(d, 'dir');
for (const f of fileMap.keys()) visit(f, 'file');
for (const s of symlinkSet) visit(s, 'symlink');
return [...children.values()];
},
mkdir: async (p, options) => {
@ -181,6 +199,48 @@ function fakeRunner(handler: RunHandler): ISessionProcessRunner {
};
}
/**
* A process whose stdout yields the given `--json` lines one chunk at a time,
* stopping when `kill()` is called. Used to assert that `fs.grep` terminates
* `rg` early once an output cap is reached instead of draining everything.
*/
function makeStreamingProcess(lines: readonly string[]): {
proc: IProcess;
wasKilled: () => boolean;
yieldedLines: () => number;
} {
let killed = false;
let yielded = 0;
let resolveWait: (code: number) => void = () => {};
const waitP = new Promise<number>((res) => {
resolveWait = res;
});
async function* gen(): AsyncGenerator<string> {
for (const line of lines) {
if (killed) break;
yielded += 1;
yield `${line}\n`;
// Hand control back so the consumer can kill between chunks.
await new Promise((r) => setImmediate(r));
}
resolveWait(0);
}
const proc: IProcess = {
stdin: new Writable({ write(_c, _e, cb) { cb(); } }),
stdout: Readable.from(gen()),
stderr: Readable.from(['']),
pid: 1,
exitCode: null,
wait: () => waitP,
kill: async () => {
killed = true;
resolveWait(0);
},
dispose: () => undefined,
};
return { proc, wasKilled: () => killed, yieldedLines: () => yielded };
}
function telemetryStub(events: Array<{ event: string; properties: Record<string, unknown> }>): ITelemetryService {
return {
_serviceBrand: undefined,
@ -237,12 +297,14 @@ function makeSession(
handler: RunHandler,
events: Array<{ event: string; properties: Record<string, unknown> }> = [],
git: IGitService = defaultGitStub(),
symlinks: readonly string[] = [],
runner?: ISessionProcessRunner,
): ISessionFsService {
host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(ISessionWorkspaceContext, stubWorkspace()),
stubPair(IHostFileSystem, fakeFs(files)),
stubPair(ISessionProcessRunner, fakeRunner(handler)),
stubPair(IHostFileSystem, fakeFs(files, symlinks)),
stubPair(ISessionProcessRunner, runner ?? fakeRunner(handler)),
stubPair(ITelemetryService, telemetryStub(events)),
stubPair(IGitService, git),
]);
@ -341,6 +403,22 @@ describe('SessionFsService.search', () => {
expect(paths).toContain('src/foo.ts');
expect(paths).not.toContain('src/bar.ts');
});
it('reports symlinks as kind symlink and does not recurse into them', async () => {
const fs = makeSession(
{ 'src/real.ts': '', 'src/target/inside.ts': '' },
emptyHandler,
[],
defaultGitStub(),
['src/link'],
);
const result = await fs.search({ query: 'link', limit: 50, follow_gitignore: false });
const paths = result.items.map((i) => i.path);
expect(paths).toContain('src/link');
expect(result.items.find((i) => i.path === 'src/link')?.kind).toBe('symlink');
// The symlink is never descended into, so nothing under `src/link/` appears.
expect(paths.some((p) => p.startsWith('src/link/'))).toBe(false);
});
});
describe('SessionFsService.grep', () => {
@ -407,6 +485,56 @@ describe('SessionFsService.grep', () => {
expect(result.files).toHaveLength(1);
expect(result.files[0]?.matches[0]?.text).toBe('hello world');
});
it('stops rg as soon as max_total_matches is reached', async () => {
const TOTAL = 200;
const CAP = 5;
const lines: string[] = [
JSON.stringify({ type: 'begin', data: { path: { text: 'big.ts' } } }),
];
for (let i = 0; i < TOTAL; i++) {
lines.push(
JSON.stringify({
type: 'match',
data: {
path: { text: 'big.ts' },
lines: { text: `hit ${i}\n` },
line_number: i + 1,
submatches: [{ start: 0, end: 3 }],
},
}),
);
}
lines.push(JSON.stringify({ type: 'end', data: { path: { text: 'big.ts' } } }));
let streaming: ReturnType<typeof makeStreamingProcess> | undefined;
const runner: ISessionProcessRunner = {
_serviceBrand: undefined,
exec: async (args) => {
if (args[0] === 'rg' && args[1] === '--version') {
return fakeProcess('ripgrep 14.1.0', '', 0);
}
streaming = makeStreamingProcess(lines);
return streaming.proc;
},
};
const fs = makeSession({}, emptyHandler, [], defaultGitStub(), [], runner);
const result = await fs.grep({
pattern: 'hit',
regex: false,
case_sensitive: true,
follow_gitignore: true,
max_files: 200,
max_matches_per_file: 50,
max_total_matches: CAP,
context_lines: 0,
});
expect(result.truncated).toBe(true);
expect(result.files[0]?.matches).toHaveLength(CAP);
expect(streaming?.wasKilled()).toBe(true);
// The consumer stopped reading long before all 200 matches were produced.
expect(streaming?.yieldedLines()).toBeLessThan(TOTAL);
});
});
describe('SessionFsService.list', () => {

View file

@ -10,9 +10,6 @@
* semantics match production.
*
* Deviations from v1:
* - The `brief` result field does not exist on v2's `ExecutableToolResult`,
* so v1 assertions on `result.brief` are dropped; the `output` / `message`
* assertions are kept.
* - v1's `execWithEnv(args, env)` is now `runner.exec(args, { env })`, so
* spawn-call assertions read `options.env` from the second argument.
*/
@ -364,8 +361,10 @@ function errorMessage(error: unknown): string {
function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
readonly service: IAgentTaskService;
readonly tasks: Map<string, ManagedEntry>;
readonly persisted: Set<string>;
} {
const tasks = new Map<string, ManagedEntry>();
const persisted = new Set<string>();
let counter = 0;
const nextId = (prefix: string): string => {
@ -541,18 +540,20 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
return result;
},
persistOutput(): void {
/* no-op in the fake */
persistOutput(taskId: string): void {
persisted.add(taskId);
},
async getOutputSnapshot(taskId: string): Promise<AgentTaskOutputSnapshot> {
const entry = tasks.get(taskId);
const preview = entry === undefined ? '' : entry.outputChunks.join('');
const fullOutputAvailable = persisted.has(taskId);
return {
outputPath: fullOutputAvailable ? `/fake/tasks/${taskId}/output.log` : undefined,
outputSizeBytes: preview.length,
previewBytes: preview.length,
truncated: false,
fullOutputAvailable: false,
fullOutputAvailable,
preview,
};
},
@ -655,7 +656,7 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
},
};
return { service, tasks };
return { service, tasks, persisted };
}
// ── Test execution helper ────────────────────────────────────────────
@ -860,6 +861,7 @@ describe('BashTool', () => {
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.');
@ -889,6 +891,7 @@ describe('BashTool', () => {
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.');
@ -911,6 +914,7 @@ describe('BashTool', () => {
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');
@ -992,7 +996,7 @@ describe('BashTool', () => {
await vi.advanceTimersByTimeAsync(250);
const result = await running;
expect(result).toMatchObject({ isError: true });
expect(result).toMatchObject({ isError: true, brief: 'Killed by timeout (1s)' });
expect(result.output).toContain('Command killed by timeout (1s)');
} finally {
vi.useRealTimers();
@ -1012,7 +1016,7 @@ describe('BashTool', () => {
const result = await running;
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(result).toMatchObject({ isError: true });
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 {
@ -1111,6 +1115,40 @@ describe('BashTool', () => {
expect(output).toContain('Output is truncated');
});
it('saves full foreground output when the inline result is truncated', async () => {
const fullOutput = `${'short line\n'.repeat(6_000)}tail survives\n`;
const { runner } = createTestRunner(processWithOutput({ stdout: fullOutput }));
const { service, persisted } = createFakeTaskService();
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service);
const result = await executeTool(tool, context({ command: 'flood', timeout: 60 }));
const output = result.output as string;
const taskId = /^task_id: (bash-[0-9a-z]{8})$/m.exec(output)?.[1];
expect(output).toContain('[...truncated]');
expect(output).toContain('[Full output saved]');
expect(taskId).toBeTruthy();
// The inline truncation must have started early persistence of the full log.
expect(persisted.has(taskId!)).toBe(true);
expect(output).toContain(`output_path: /fake/tasks/${taskId}/output.log`);
expect(output).toContain('Use Read with output_path');
expect(output).toContain(`TaskOutput(task_id="${taskId}", block=false)`);
});
it('omits the TaskOutput hint from the saved-output reference when background tools are disabled', async () => {
const fullOutput = 'short line\n'.repeat(6_000);
const { runner } = createTestRunner(processWithOutput({ stdout: fullOutput }));
const { service } = createFakeTaskService();
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service, stubProfile(() => false));
const result = await executeTool(tool, context({ command: 'flood', timeout: 60 }));
const output = result.output as string;
expect(output).toContain('[Full output saved]');
expect(output).toContain('Use Read with output_path');
expect(output).not.toContain('TaskOutput');
});
it('rejects empty-string commands at the schema layer', () => {
expect(BashInputSchema.safeParse({ command: '' }).success).toBe(false);
});
@ -1163,6 +1201,33 @@ describe('BashTool', () => {
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');
});
it('disables background execution when TaskList is inactive even if TaskOutput/TaskStop are active', async () => {
const { runner, exec } = createTestRunner(processWithOutput());
const tool = bashTool(
runner,
createTestEnv(),
createTestCtx(),
createFakeTaskService().service,
stubProfile((name) => name !== 'TaskList'),
);
// Background management needs TaskList, TaskOutput, and TaskStop; without
// TaskList the description must fall back to the disabled variant.
expect(tool.description).toContain('Background execution is disabled for this agent');
const result = await executeTool(
tool,
context({ command: 'sleep 10', run_in_background: true, description: 'watch' }),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('Background execution is not available');
expect(exec).not.toHaveBeenCalled();
});
});
@ -1199,6 +1264,8 @@ describe('BashTool background mode', () => {
expect(result.output).toContain(`task_id: ${task.taskId}`);
expect(result.output).toContain('automatic_notification: true');
expect(result.output).toContain('do NOT wait, poll, or call TaskOutput');
expect(result).toMatchObject({ message: 'Task moved to background.' });
expect((result as { brief?: string }).brief).toBe(`Backgrounded ${task.taskId}`);
expect(service.getTask(task.taskId)).toMatchObject({ detached: true });
await vi.waitFor(async () => {
await expect(service.readOutput(task.taskId)).resolves.toContain('after detach\n');
@ -1367,6 +1434,11 @@ describe('BashTool background mode', () => {
expect(result.output).toMatch(/task_id: bash-[0-9a-z]{8}/);
expect(result.output).toContain('automatic_notification: true');
expect(result).toMatchObject({ message: 'Background task started.' });
expect((result as { brief?: string }).brief).toMatch(/^Started bash-[0-9a-z]{8}$/);
// 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(service.list(false)).toHaveLength(1);
});

View file

@ -937,6 +937,101 @@ describe('AgentTaskService', () => {
expect(killSpy).not.toHaveBeenCalledWith('SIGKILL');
});
/**
* Build a process that only reaps on SIGKILL and whose stdout never ends on
* its own, so the task lifecycle cannot settle before the manager's deadline
* and grace window drive teardown. Exercises the v1-aligned timeout path:
* deadline -> SIGTERM -> SIGTERM_GRACE_MS -> forceStop (SIGKILL).
*/
function sigtermOnlyKillProcess(pid: number): {
proc: IProcess;
killSpy: ReturnType<typeof vi.fn>;
} {
const stdout = new PassThrough();
let currentExitCode: number | null = null;
let resolveWait: (code: number) => void = () => {};
const waitPromise = new Promise<number>((resolve) => {
resolveWait = resolve;
});
const killSpy = vi.fn(async (signal: NodeJS.Signals) => {
if (currentExitCode !== null) return;
if (signal !== 'SIGKILL') return;
currentExitCode = 137;
stdout.destroy();
resolveWait(137);
});
const proc: IProcess = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout,
stderr: Readable.from([]),
pid,
get exitCode(): number | null {
return currentExitCode;
},
wait: () => waitPromise,
kill: killSpy as unknown as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
return { proc, killSpy };
}
it('escalates a wall-clock timeout to SIGKILL when the process ignores SIGTERM', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const { manager } = createAgentTaskService();
const { proc, killSpy } = sigtermOnlyKillProcess(54327);
const taskId = manager.registerTask(new ProcessTask(proc, 'runaway', 'timeout sigkill'), {
timeoutMs: 1,
});
const terminal = manager.wait(taskId);
await vi.advanceTimersByTimeAsync(1); // deadline -> abort -> SIGTERM (ignored)
expect(killSpy).toHaveBeenCalledWith('SIGTERM');
expect(killSpy).not.toHaveBeenCalledWith('SIGKILL');
await vi.advanceTimersByTimeAsync(5_000); // grace elapses -> forceStop SIGKILL
const info = await terminal;
expect(info?.status).toBe('timed_out');
expect(killSpy).toHaveBeenCalledWith('SIGKILL');
});
it('reports timed_out when a timed-out process exits to SIGTERM within the grace window', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const { manager } = createAgentTaskService();
const { proc, killSpy } = pendingProcess(); // SIGTERM reaps with 143
const taskId = manager.registerTask(new ProcessTask(proc, 'sleep 60', 'timeout graceful'), {
timeoutMs: 1,
});
const terminal = manager.wait(taskId);
await vi.advanceTimersByTimeAsync(1); // deadline -> SIGTERM reaps within grace
const info = await terminal;
expect(info?.status).toBe('timed_out');
expect(killSpy).toHaveBeenCalledWith('SIGTERM');
expect(killSpy).not.toHaveBeenCalledWith('SIGKILL');
});
it('applies the SIGTERM grace + SIGKILL escalation to a detachTimeout deadline', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const { manager } = createAgentTaskService();
const { proc, killSpy } = sigtermOnlyKillProcess(54328);
const taskId = manager.registerTask(new ProcessTask(proc, 'runaway', 'detach timeout'), {
detached: false,
detachTimeoutMs: 1,
});
manager.detach(taskId);
const terminal = manager.wait(taskId);
await vi.advanceTimersByTimeAsync(1); // detach deadline -> SIGTERM (ignored)
await vi.advanceTimersByTimeAsync(5_000); // grace -> SIGKILL
const info = await terminal;
expect(info?.status).toBe('timed_out');
expect(killSpy).toHaveBeenCalledWith('SIGTERM');
expect(killSpy).toHaveBeenCalledWith('SIGKILL');
});
it('persists graceful process shutdown as killed when stop was requested', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-race-'));
try {

View file

@ -1,11 +1,19 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Readable, type Writable } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentTaskService, type AgentTask } from '#/agent/task/task';
import {
IAgentTaskService,
type AgentTask,
type AgentTaskInfo,
} from '#/agent/task/task';
import { renderNotificationXml } from '#/agent/task/notificationXml';
import { AgentTaskService } from '#/agent/task/taskService';
import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import type { IProcess } from '#/session/process/processRunner';
import { IConfigRegistry, IConfigService } from '#/app/config/config';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentPromptService } from '#/agent/prompt/prompt';
@ -119,6 +127,254 @@ describe('AgentTaskService', () => {
expect(await svc.readOutput(id)).toBe('');
await svc.stop(id);
});
// ── Output ceiling for shell (process) tasks ─────────────────────────
//
// A single shell command that streams more output than the per-command limit
// must be force-terminated instead of growing the (unbounded) live-forward
// buffer or the on-disk write chain until the process runs out of memory or
// fills the disk. The ceiling applies to process tasks, foreground and
// background alike. Subagent and user-question tasks append their bounded
// result in one shot and must always be persisted, so they are not capped.
const MiB = 1024 * 1024;
const LIMIT_BYTES = 16 * MiB;
/**
* A process that streams `chunks` of stdout, then exits 0 on its own unless
* it is killed first, in which case `wait()` resolves with the signal's exit
* code and the stream is destroyed (simulating the child dying on SIGTERM).
*/
function streamingProcess(chunks: string[]): {
proc: IProcess;
kill: ReturnType<typeof vi.fn>;
} {
const stdout = Readable.from(chunks);
const stderr = Readable.from([]);
let resolveWait!: (code: number) => void;
const waitP = new Promise<number>((resolve) => {
resolveWait = resolve;
});
stdout.on('end', () => {
resolveWait(0);
});
const kill = vi.fn(async (signal: string) => {
stdout.destroy();
resolveWait(signal === 'SIGKILL' ? 137 : 143);
});
const proc = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 4242,
exitCode: null,
wait: () => waitP,
kill,
dispose: vi.fn().mockResolvedValue(undefined),
} as unknown as IProcess;
return { proc, kill };
}
/**
* A process that keeps streaming all of `chunks` regardless of SIGTERM (only
* SIGKILL stops it) simulating a producer that ignores the graceful stop
* and keeps writing through the SIGTERM grace window.
*/
function sigtermIgnoringProcess(chunks: string[]): {
proc: IProcess;
kill: ReturnType<typeof vi.fn>;
} {
const stdout = Readable.from(chunks);
const stderr = Readable.from([]);
let resolveWait!: (code: number) => void;
const waitP = new Promise<number>((resolve) => {
resolveWait = resolve;
});
stdout.on('end', () => {
resolveWait(0);
});
const kill = vi.fn(async (signal: string) => {
if (signal === 'SIGKILL') {
stdout.destroy();
resolveWait(137);
}
// SIGTERM is intentionally ignored.
});
const proc = {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 4243,
exitCode: null,
wait: () => waitP,
kill,
dispose: vi.fn().mockResolvedValue(undefined),
} as unknown as IProcess;
return { proc, kill };
}
/** One-shot non-process task appending its full result at once, like a subagent. */
function agentLikeTask(result: string, description: string): AgentTask {
return {
idPrefix: 'agent',
kind: 'agent',
description,
start: async (sink) => {
sink.appendOutput(result);
await sink.settle({ status: 'completed' });
},
toInfo: (base) => ({ ...base, kind: 'agent' }),
};
}
async function waitForTerminal(
svc: IAgentTaskService,
taskId: string,
timeoutMs = 30_000,
): Promise<AgentTaskInfo | undefined> {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
const info = await svc.wait(taskId, 5);
if (
info?.status === 'completed' ||
info?.status === 'failed' ||
info?.status === 'timed_out' ||
info?.status === 'killed' ||
info?.status === 'lost'
) {
return info;
}
await new Promise((resolve) => setTimeout(resolve, 1));
}
return svc.getTask(taskId);
}
/** Re-stub the byte store so `output.log` appends are counted, then build the service. */
function serviceWithAppendCounter(): {
svc: IAgentTaskService;
persistedChars: () => number;
} {
let persistedChars = 0;
ix.stub(IFileSystemStorageService, {
read: async () => undefined,
readStream: async function* () {},
write: async () => {},
append: async (_scope: string, _key: string, chunk: Uint8Array) => {
persistedChars += chunk.byteLength;
},
list: async () => [],
delete: async () => {},
flush: async () => {},
close: async () => {},
});
return { svc: ix.get(IAgentTaskService), persistedChars: () => persistedChars };
}
it('terminates a foreground command that exceeds the output limit and stops forwarding', async () => {
const svc = ix.get(IAgentTaskService);
// 20 MiB total, well past the 16 MiB ceiling.
const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB));
const { proc, kill } = streamingProcess(chunks);
let forwardedChars = 0;
const onOutput = vi.fn((_kind: 'stdout' | 'stderr', text: string) => {
forwardedChars += text.length;
});
const taskId = svc.registerTask(
new ProcessTask(proc, 'b3sum --length 18446744073709551615', 'hash', onOutput),
{ detached: false, signal: new AbortController().signal, timeoutMs: 60_000 },
);
const info = await waitForTerminal(svc, taskId);
expect(info?.status).toBe('killed');
expect(info?.stopReason ?? '').toMatch(/output limit/i);
expect(kill).toHaveBeenCalledWith('SIGTERM');
// The live-forward path is capped at the ceiling rather than draining the
// full 20 MiB into the (unbounded) transcript/stderr buffer.
expect(forwardedChars).toBeLessThanOrEqual(LIMIT_BYTES);
});
it('also terminates a detached (background) task that exceeds the output limit', async () => {
const svc = ix.get(IAgentTaskService);
const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB));
const { proc, kill } = streamingProcess(chunks);
const taskId = svc.registerTask(new ProcessTask(proc, 'producer', 'bg'), {
detached: true,
timeoutMs: 60_000,
});
const info = await waitForTerminal(svc, taskId);
expect(info?.status).toBe('killed');
expect(info?.stopReason ?? '').toMatch(/output limit/i);
expect(kill).toHaveBeenCalledWith('SIGTERM');
});
it('stops enqueuing output to disk once the foreground cap trips', async () => {
const { svc, persistedChars } = serviceWithAppendCounter();
// 20 MiB, and the producer ignores SIGTERM so it keeps writing through
// the whole grace window.
const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB));
const { proc } = sigtermIgnoringProcess(chunks);
const taskId = svc.registerTask(new ProcessTask(proc, 'runaway', 'hash', () => {}), {
detached: false,
signal: new AbortController().signal,
timeoutMs: 60_000,
});
const info = await waitForTerminal(svc, taskId);
expect(info?.status).toBe('killed');
// Before the fix every chunk of the 20 MiB is enqueued into the disk
// write chain (retaining each string until its write drains); afterwards
// enqueuing stops at the ceiling so the chain cannot grow unbounded.
expect(persistedChars()).toBeLessThanOrEqual(17 * MiB);
});
it('stops enqueuing output to disk once the cap trips for a background task', async () => {
const { svc, persistedChars } = serviceWithAppendCounter();
// 20 MiB, and the producer ignores SIGTERM so it keeps writing through
// the whole grace window. Background tasks share the same ceiling.
const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB));
const { proc } = sigtermIgnoringProcess(chunks);
const taskId = svc.registerTask(new ProcessTask(proc, 'runaway', 'bg', () => {}), {
detached: true,
timeoutMs: 60_000,
});
const info = await waitForTerminal(svc, taskId);
expect(info?.status).toBe('killed');
// Same guarantee as the foreground case: once the cap trips, subsequent
// chunks are dropped before they reach the disk write chain.
expect(persistedChars()).toBeLessThanOrEqual(17 * MiB);
});
it('does not cap or drop a detached subagent result larger than the limit', async () => {
const { svc, persistedChars } = serviceWithAppendCounter();
// 20 MiB result — well past the 16 MiB ceiling — delivered in one shot,
// exactly how a subagent appends its completed result.
const bigResult = 'y'.repeat(20 * MiB);
const taskId = svc.registerTask(agentLikeTask(bigResult, 'big subagent result'), {
detached: true,
timeoutMs: 60_000,
});
const info = await waitForTerminal(svc, taskId);
// Non-process tasks must complete normally and have their full result
// persisted; the shell-output ceiling must not drop it.
expect(info?.status).toBe('completed');
expect(persistedChars()).toBeGreaterThanOrEqual(bigResult.length);
});
});
describe('Agent task notification XML', () => {

View file

@ -16,10 +16,10 @@ import {
} from '#/session/agentLifecycle/agentLifecycle';
import { ISessionTodoService } from '#/session/todo/sessionTodo';
import { SessionTodoService } from '#/session/todo/sessionTodoService';
import { type TodoItem } from '#/session/todo/todoItem';
import { readTodoItems, type TodoItem } from '#/session/todo/todoItem';
import { TODO_LIST_REMINDER_VARIANT } from '#/session/todo/todoListReminder';
import { IAgentWireService } from '#/wire/tokens';
import type { IWireService } from '#/wire/wireService';
import type { IWireService, PersistedRecord } from '#/wire/wireService';
interface RecordedTodoSet {
readonly todos: readonly TodoItem[];
@ -30,14 +30,20 @@ interface FakeAgent {
readonly registeredTools: string[];
readonly registeredVariants: string[];
readonly appended: RecordedTodoSet[];
readonly resumers: Array<(record: RecordedTodoSet) => void>;
readonly subscribed: () => number;
readonly replay: (records: readonly PersistedRecord[]) => Promise<void>;
}
function makeFakeAgent(agentId: string): FakeAgent {
const registeredTools: string[] = [];
const registeredVariants: string[] = [];
const appended: RecordedTodoSet[] = [];
const resumers: Array<(record: RecordedTodoSet) => void> = [];
let todoState: readonly TodoItem[] = [];
type Subscriber = (state: readonly TodoItem[], prev: readonly TodoItem[]) => void;
const subscribers: Subscriber[] = [];
const restoredHandlers: Array<() => void> = [];
let subscribedCount = 0;
const registryStub = {
_serviceBrand: undefined,
@ -72,36 +78,50 @@ function makeFakeAgent(agentId: string): FakeAgent {
isToolActive: () => false,
};
let todoState: readonly TodoItem[] = [];
const wireStub: IWireService = {
_serviceBrand: undefined,
dispatch: (...ops: unknown[]) => {
for (const raw of ops) {
const op = raw as { type: string; payload: unknown };
const payload = op.payload;
if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)) {
const record = payload as Record<string, unknown>;
if (Array.isArray(record['todos'])) {
todoState = record['todos'] as readonly TodoItem[];
const record =
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: { payload };
appended.push({ type: op.type, ...record } as unknown as RecordedTodoSet);
if (op.type === 'todo.set') {
const prev = todoState;
todoState = readTodoItems(record['todos']);
if (prev !== todoState) {
for (const h of [...subscribers]) h(todoState, prev);
}
appended.push({ type: op.type, ...record } as unknown as RecordedTodoSet);
} else {
appended.push({ type: op.type, payload } as unknown as RecordedTodoSet);
}
}
},
replay: async () => {},
replay: async (...records: PersistedRecord[]) => {
for (const record of records) {
if (record.type === 'todo.set') {
todoState = readTodoItems(record['todos']);
}
}
// Replay is silent: subscribers are NOT notified. onRestored fires after.
for (const h of restoredHandlers) h();
},
signal: () => {},
flush: async () => {},
attach: () => toDisposable(() => {}),
getModel: () => todoState,
subscribe: () => toDisposable(() => {}),
subscribe: (_model: unknown, handler: unknown) => {
subscribedCount += 1;
subscribers.push(handler as Subscriber);
return toDisposable(() => {
const i = subscribers.indexOf(handler as Subscriber);
if (i >= 0) subscribers.splice(i, 1);
});
},
onEmission: () => toDisposable(() => {}),
onRestored: (handler: () => void) => {
resumers.push((record: RecordedTodoSet) => {
todoState = record.todos;
handler();
});
restoredHandlers.push(handler);
return toDisposable(() => {});
},
} as unknown as IWireService;
@ -125,7 +145,14 @@ function makeFakeAgent(agentId: string): FakeAgent {
dispose: () => {},
};
return { handle, registeredTools, registeredVariants, appended, resumers };
return {
handle,
registeredTools,
registeredVariants,
appended,
subscribed: () => subscribedCount,
replay: (records) => wireStub.replay(...records),
};
}
interface LifecycleStub {
@ -184,8 +211,9 @@ function makeLifecycleStub(handles: readonly IAgentScopeHandle[] = []): Lifecycl
}
describe('SessionTodoService', () => {
it('starts empty and updates in-memory list on setTodos', () => {
const lifecycle = makeLifecycleStub();
it('starts empty and updates the list on setTodos', () => {
const main = makeFakeAgent('main');
const lifecycle = makeLifecycleStub([main.handle]);
const service = new SessionTodoService(lifecycle.service);
expect(service.getTodos()).toEqual([]);
@ -202,7 +230,8 @@ describe('SessionTodoService', () => {
});
it('fires onDidChange after each setTodos', () => {
const lifecycle = makeLifecycleStub();
const main = makeFakeAgent('main');
const lifecycle = makeLifecycleStub([main.handle]);
const service = new SessionTodoService(lifecycle.service);
const seen: Array<readonly TodoItem[]> = [];
@ -232,9 +261,10 @@ describe('SessionTodoService', () => {
it('does not append to the wire when the main agent is absent', () => {
const lifecycle = makeLifecycleStub();
const service = new SessionTodoService(lifecycle.service);
// Should not throw even without a main agent.
// Should not throw even without a main agent. With no main wire there is
// no source of truth to read from, so the list stays empty.
expect(() => service.setTodos([{ title: 'x', status: 'pending' }])).not.toThrow();
expect(service.getTodos()).toEqual([{ title: 'x', status: 'pending' }]);
expect(service.getTodos()).toEqual([]);
});
it('binds the stale-todo reminder into every created agent', () => {
@ -254,25 +284,23 @@ describe('SessionTodoService', () => {
expect(sub.registeredVariants).toContain(TODO_LIST_REMINDER_VARIANT);
});
it('registers the todo.set resume resumer only on the main agent', () => {
it('subscribes to TodoModel only on the main agent', () => {
const main = makeFakeAgent('main');
const sub = makeFakeAgent('agent-1');
const lifecycle = makeLifecycleStub([main.handle, sub.handle]);
const service = new SessionTodoService(lifecycle.service);
void service;
expect(main.resumers).toHaveLength(1);
expect(sub.resumers).toHaveLength(0);
expect(main.subscribed()).toBe(1);
expect(sub.subscribed()).toBe(0);
});
it('rebuilds the in-memory list when a todo.set record is resumed', () => {
it('rebuilds the list when a todo.set record is replayed', async () => {
const main = makeFakeAgent('main');
const lifecycle = makeLifecycleStub([main.handle]);
const service = new SessionTodoService(lifecycle.service);
const resumer = main.resumers[0];
expect(resumer).toBeDefined();
resumer!({ todos: [{ title: 'restored', status: 'done' }] });
await main.replay([{ type: 'todo.set', todos: [{ title: 'restored', status: 'done' }] }]);
expect(service.getTodos()).toEqual([{ title: 'restored', status: 'done' }]);
});
@ -297,4 +325,37 @@ describe('SessionTodoService', () => {
expect(typeof service.clear).toBe('function');
expect(typeof service.onDidChange).toBe('function');
});
it('cleans malformed items from a replayed todo.set record', async () => {
const main = makeFakeAgent('main');
const lifecycle = makeLifecycleStub([main.handle]);
const service = new SessionTodoService(lifecycle.service);
await main.replay([
{
type: 'todo.set',
todos: [
{ title: 'valid', status: 'done' },
{ title: 'missing status' },
{ title: 123, status: 'pending' },
'garbage',
{ title: 'bad status', status: 'wip' },
],
} as unknown as PersistedRecord,
]);
expect(service.getTodos()).toEqual([{ title: 'valid', status: 'done' }]);
});
it('treats a non-array todo.set payload as an empty list on replay', async () => {
const main = makeFakeAgent('main');
const lifecycle = makeLifecycleStub([main.handle]);
const service = new SessionTodoService(lifecycle.service);
await main.replay([
{ type: 'todo.set', todos: 'not-an-array' } as unknown as PersistedRecord,
]);
expect(service.getTodos()).toEqual([]);
});
});

View file

@ -127,6 +127,23 @@ describe('AgentToolExecutorService', () => {
expect(protocolResult as unknown as Record<string, unknown>).not.toHaveProperty('note');
});
it('drops malformed notes and non-true truncated flags from internal results', async () => {
const tool = new TestTool('malformed-meta', {
result: {
output: 'image sent',
note: 123,
truncated: false,
} as unknown as ExecutableToolResult,
});
registry.register(tool);
const results = await execute([toolCall('call_malformed_meta', 'malformed-meta', {})]);
expect(results[0]).toMatchObject({ output: 'image sent' });
expect(results[0] as unknown as Record<string, unknown>).not.toHaveProperty('note');
expect(results[0] as unknown as Record<string, unknown>).not.toHaveProperty('truncated');
});
it('records an error tool.result when the tool name is unknown', async () => {
const results = await execute([toolCall('call_missing', 'missing', { text: 'hi' })]);

View file

@ -20,6 +20,7 @@ export function createFakeHostFs(overrides: Partial<IHostFileSystem> = {}): IHos
_serviceBrand: undefined,
readText: () => notImplemented('FakeHostFs.readText'),
writeText: () => notImplemented('FakeHostFs.writeText'),
appendText: () => notImplemented('FakeHostFs.appendText'),
readBytes: () => notImplemented('FakeHostFs.readBytes'),
writeBytes: () => notImplemented('FakeHostFs.writeBytes'),
readLines: () => notImplemented('FakeHostFs.readLines'),

View file

@ -76,6 +76,10 @@ class MemoryHostFs implements IHostFileSystem {
this.files.set(path, data);
}
async appendText(path: string, data: string): Promise<void> {
this.files.set(path, (this.files.get(path) ?? '') + data);
}
pauseNextWrite(): { readonly started: Promise<void>; readonly release: () => void } {
let started!: () => void;
let release!: () => void;

View file

@ -35,8 +35,10 @@
* **Anti-corruption**: this is the single protocolin-process adapter for
* questions. The v2 domain stores the in-process `QuestionRequest` (camelCase,
* options without ids); the wire shape (snake_case, synthesized item/option
* ids, `expires_at`, 5-kind answer union) is derived here. No `agent-core`
* (v1) imports.
* ids, 5-kind answer union) is derived here. On resolve, wire
* ids are translated back to question text / option labels (reading the
* pending request before it settles) so the flattened record the model sees
* is self-explanatory. No `agent-core` (v1) imports.
*/
import {
@ -100,13 +102,6 @@ const tailParamsSchema = z.object({
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
/**
* Wire `expires_at` horizon: v2 interactions never expire, so we emit the v1
* broker's default timeout (60s) as a stable derived value, because the wire
* schema requires an `expires_at`.
*/
const QUESTION_EXPIRY_MS = 60_000;
export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): void {
const listRoute = defineRoute(
{
@ -182,9 +177,11 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
}
const interaction = handle.accessor.get(ISessionInteractionService);
const isPending = interaction.listPending('question').some((i) => i.id === questionId);
const pendingInteraction = interaction
.listPending('question')
.find((i) => i.id === questionId);
if (!isPending) {
if (pendingInteraction === undefined) {
if (interaction.isRecentlyResolved(questionId)) {
reply.send({
code: ErrorCode.APPROVAL_ALREADY_RESOLVED, // 40902 — shared "already_resolved"
@ -238,7 +235,13 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo
return;
}
const result = toInProcessResponse(bodyParse.data);
// The pending request must be projected BEFORE answer() settles (and
// thereby drops) the kernel entry — its synthesized wire ids are the
// lookup table for the id → text translation below.
const result = toInProcessResponse(
bodyParse.data,
toWireQuestion(pendingInteraction, session_id),
);
questions.answer(questionId, result);
reply.send(
okEnvelope({ resolved: true as const, resolved_at: new Date().toISOString() }, req.id),
@ -286,15 +289,14 @@ function buildItem(item: QuestionItem, itemIdx: number): ProtocolQuestionItem {
export function toWireQuestion(
interaction: Interaction,
sessionId: string,
): ProtocolQuestionRequest & { expires_at: string } {
): ProtocolQuestionRequest {
const req = interaction.payload as QuestionRequest;
const createdAt = new Date(interaction.createdAt).toISOString();
const out: ProtocolQuestionRequest & { expires_at: string } = {
const out: ProtocolQuestionRequest = {
question_id: interaction.id,
session_id: sessionId,
questions: req.questions.map((q, i) => buildItem(q, i)),
created_at: createdAt,
expires_at: new Date(interaction.createdAt + QUESTION_EXPIRY_MS).toISOString(),
};
if (req.turnId !== undefined) out.turn_id = req.turnId;
if (req.toolCallId !== undefined) out.tool_call_id = req.toolCallId;
@ -302,29 +304,60 @@ export function toWireQuestion(
}
/**
* Protocol REST response body in-process `QuestionResponse`. Normalization
* rules (SCHEMAS §6.4):
* - single option_id
* - multi option_ids.join(',')
* Protocol REST response body in-process `QuestionResponse`.
*
* The wire keeps synthesized ids (`q_<idx>` / `opt_<q>_<o>`) so clients can
* answer unambiguously, but the flattened record is what the ask-user tool
* feeds back to the model so ids are translated back to text here using
* the pending wire `request` (ported from v1's `toAgentCoreResponse`):
* - key the question's text (falls back to the raw qid
* when the request is unavailable or the qid is
* unknown stale client, defensive)
* - single option label
* - multi labels.join(', ')
* - other text
* - multi_with_other [...option_ids, other_text].join(',')
* - multi_with_other [...labels, other_text].join(', ')
* - skipped OMIT entry
*
* Multi-select joins use `', '` to match what the TUI reverse-RPC path
* already emits, so the model sees one format regardless of which client
* answered.
*
* Unknown qids and option ids including ids that belong to a DIFFERENT
* question than the one being answered are kept verbatim rather than
* resolved or dropped: translating a cross-question id would hand the model
* a plausible-looking label that was never offered for that question, while
* the raw id stays diagnosable.
*/
function toInProcessResponse(resp: ProtocolQuestionResponse): QuestionResult {
function toInProcessResponse(
resp: ProtocolQuestionResponse,
request?: ProtocolQuestionRequest,
): QuestionResult {
const itemsById = new Map<string, ProtocolQuestionItem>();
for (const item of request?.questions ?? []) {
itemsById.set(item.id, item);
}
const flattened: QuestionAnswers = {};
for (const [qid, ans] of Object.entries(resp.answers)) {
const item = itemsById.get(qid);
const key = item?.question ?? qid;
// Resolve option ids only within the answered question's own options
// (at most 4, so a linear scan is fine).
const optionText = (id: string): string =>
item?.options.find((o) => o.id === id)?.label ?? id;
switch (ans.kind) {
case 'single':
flattened[qid] = ans.option_id;
flattened[key] = optionText(ans.option_id);
break;
case 'multi':
flattened[qid] = ans.option_ids.join(',');
flattened[key] = ans.option_ids.map(optionText).join(', ');
break;
case 'other':
flattened[qid] = ans.text;
flattened[key] = ans.text;
break;
case 'multi_with_other':
flattened[qid] = [...ans.option_ids, ans.other_text].join(',');
flattened[key] = [...ans.option_ids.map(optionText), ans.other_text].join(', ');
break;
case 'skipped':
// Omitted from the record — matches SCHEMAS §6.4.

View file

@ -46,7 +46,6 @@ interface QuestionWire {
tool_call_id?: string;
questions: QuestionItemWire[];
created_at: string;
expires_at: string;
}
interface ListWire {
@ -163,7 +162,8 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => {
},
]);
expect(Number.isNaN(Date.parse(item.created_at))).toBe(false);
expect(Number.isNaN(Date.parse(item.expires_at))).toBe(false);
// v1 parity: the question wire shape carries no synthetic expiry.
expect(item).not.toHaveProperty('expires_at');
});
it('resolves a pending question', async () => {
@ -193,11 +193,107 @@ describe('server-v2 /api/v1/sessions/{sid}/questions', () => {
method: 'click', // protocol-only method; dropped on the in-process side
});
// Wire ids are translated back to question text / option labels so the
// record the model sees is self-explanatory (v1 parity: multi joins with
// ', ' to match the TUI reverse-RPC path).
await expect(resultPromise).resolves.toEqual({
answers: { q_0: 'opt_0_0,opt_0_1' },
answers: { 'Pick one': 'Yes, No' },
});
});
function makeTwoQuestionRequest(id: string): QuestionRequest {
return {
id,
toolCallId: `tc-${id}`,
questions: [
{
question: 'Which animal?',
options: [{ label: 'Cat' }, { label: 'Dog' }],
},
{
question: 'Which colors?',
options: [{ label: 'Red' }, { label: 'Green' }, { label: 'Blue' }],
multiSelect: true,
},
],
};
}
it('translates ids to text across single / other / multi_with_other kinds', async () => {
const sid = await createSession();
const single: Promise<QuestionResult> = questionService(sid).request(
makeTwoQuestionRequest('q-t1'),
);
await postJson<ResolveWire>(`/api/v1/sessions/${sid}/questions/q-t1`, {
answers: {
q_0: { kind: 'single', option_id: 'opt_0_1' },
q_1: {
kind: 'multi_with_other',
option_ids: ['opt_1_0', 'opt_1_1'],
other_text: 'Custom',
},
},
});
await expect(single).resolves.toEqual({
answers: { 'Which animal?': 'Dog', 'Which colors?': 'Red, Green, Custom' },
});
const other: Promise<QuestionResult> = questionService(sid).request(
makeTwoQuestionRequest('q-t2'),
);
await postJson<ResolveWire>(`/api/v1/sessions/${sid}/questions/q-t2`, {
answers: {
q_0: { kind: 'other', text: 'Hippopotamus' },
q_1: { kind: 'skipped' },
},
});
await expect(other).resolves.toEqual({
answers: { 'Which animal?': 'Hippopotamus' },
});
});
it('keeps unknown and cross-question option ids verbatim (stale client)', async () => {
const sid = await createSession();
const resultPromise: Promise<QuestionResult> = questionService(sid).request(
makeTwoQuestionRequest('q-t3'),
);
await postJson<ResolveWire>(`/api/v1/sessions/${sid}/questions/q-t3`, {
answers: {
// opt_0_9 does not exist; q_9 is an unknown question id.
q_0: { kind: 'single', option_id: 'opt_0_9' },
q_9: { kind: 'single', option_id: 'opt_9_0' },
// opt_0_0 belongs to question 0 — never offered for question 1, so it
// must NOT be resolved to 'Cat'.
q_1: { kind: 'multi', option_ids: ['opt_1_0', 'opt_0_0'] },
},
});
await expect(resultPromise).resolves.toEqual({
answers: {
'Which animal?': 'opt_0_9',
q_9: 'opt_9_0',
'Which colors?': 'Red, opt_0_0',
},
});
});
it('produces an empty answers record when all questions are skipped (not a dismissal)', async () => {
const sid = await createSession();
const resultPromise: Promise<QuestionResult> = questionService(sid).request(
makeTwoQuestionRequest('q-t4'),
);
await postJson<ResolveWire>(`/api/v1/sessions/${sid}/questions/q-t4`, {
answers: {
q_0: { kind: 'skipped' },
q_1: { kind: 'skipped' },
},
});
await expect(resultPromise).resolves.toEqual({ answers: {} });
});
it('dismisses a pending question', async () => {
const sid = await createSession();
const resultPromise: Promise<QuestionResult> = questionService(sid).request(makeRequest('q-4'));