mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 06:05:36 +00:00
fix(agent-core): surface user interruptions to the model instead of a neutral abort (#236)
When the user interrupts running tools or parallel subagents, the tool_result fed back to the model was a neutral `Tool "X" was aborted` or a weak "stopped by the user", so the model treated it as a system fault and speculated about capacity/concurrency limits instead of recognizing a deliberate stop. Carry a UserCancellationError as the AbortSignal reason from the cancel sites (Turn.cancel/abortTurn, SessionSubagentHost.cancelAll) through to the message sites (tool-call settle paths and the AgentTool catches), which now emit an explicit "deliberate user action, not a system error/timeout/capacity limit" message. Aborts propagated from another signal (e.g. a subagent's deadline via waitForCurrentTurn) carry their original reason, so a timeout is not mislabeled as a user interruption. The telemetry outcome classifier matches the new "manually interrupted" phrase to keep counting these as cancelled.
This commit is contained in:
parent
a24bfb1df3
commit
933cf6727e
10 changed files with 266 additions and 23 deletions
|
|
@ -32,7 +32,7 @@ import {
|
|||
} from '../../loop/index';
|
||||
import type { AgentEvent, TurnEndedEvent } from '../../rpc';
|
||||
import type { TelemetryPropertyValue } from '../../telemetry';
|
||||
import { abortable } from '../../utils/abort';
|
||||
import { abortable, userCancellationReason } from '../../utils/abort';
|
||||
import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context';
|
||||
import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks';
|
||||
import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args';
|
||||
|
|
@ -147,13 +147,18 @@ export class TurnFlow {
|
|||
this.activeTurn = 'resuming';
|
||||
}
|
||||
|
||||
cancel(turnId?: number): void {
|
||||
cancel(turnId?: number, reason?: unknown): void {
|
||||
this.agent.records.logRecord({ type: 'turn.cancel', turnId });
|
||||
if (turnId !== undefined && turnId !== this.currentId) {
|
||||
return; // Ignore cancel for non-active turn
|
||||
}
|
||||
this.abortTurn();
|
||||
this.agent.subagentHost?.cancelAll();
|
||||
// A direct cancel (RPC / replay) is the user pressing stop. When the cancel
|
||||
// is propagated from an aborting signal (e.g. a subagent's deadline via
|
||||
// waitForCurrentTurn), carry that original reason instead so a timeout is
|
||||
// not mislabeled to the model as a deliberate user interruption.
|
||||
const cancelReason = reason ?? userCancellationReason();
|
||||
this.abortTurn(cancelReason);
|
||||
this.agent.subagentHost?.cancelAll(cancelReason);
|
||||
}
|
||||
|
||||
get currentId() {
|
||||
|
|
@ -174,7 +179,7 @@ export class TurnFlow {
|
|||
|
||||
const turnId = this.currentId;
|
||||
const onAbort = (): void => {
|
||||
this.agent.turn.cancel(turnId);
|
||||
this.agent.turn.cancel(turnId, signal.reason);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
|
|
@ -183,9 +188,13 @@ export class TurnFlow {
|
|||
});
|
||||
}
|
||||
|
||||
private abortTurn() {
|
||||
private abortTurn(reason: unknown) {
|
||||
if (this.activeTurn !== 'resuming') {
|
||||
this.activeTurn?.controller.abort();
|
||||
// The reason (a user cancellation by default, or the originating signal's
|
||||
// reason when propagated) travels as signal.reason so tools settling on
|
||||
// this signal can report a deliberate user interruption distinctly from a
|
||||
// timeout/system abort. linkAbortSignal forwards it to linked subagents.
|
||||
this.activeTurn?.controller.abort(reason);
|
||||
}
|
||||
this.activeTurn = null;
|
||||
}
|
||||
|
|
@ -798,7 +807,11 @@ type ToolTelemetryResult = Extract<LoopEvent, { type: 'tool.result' }>['result']
|
|||
function telemetryToolOutcome(result: ToolTelemetryResult): 'success' | 'error' | 'cancelled' {
|
||||
if (result.isError !== true) return 'success';
|
||||
const text = toolResultText(result).toLowerCase();
|
||||
return text.includes('aborted') || text.includes('cancelled') ? 'cancelled' : 'error';
|
||||
return text.includes('aborted') ||
|
||||
text.includes('cancelled') ||
|
||||
text.includes('manually interrupted')
|
||||
? 'cancelled'
|
||||
: 'error';
|
||||
}
|
||||
|
||||
function telemetryToolErrorType(result: ToolTelemetryResult): string {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
} from '../tools/args-validator';
|
||||
import { PathSecurityError } from '../tools/policies/path-access';
|
||||
|
||||
import { isUserCancellation } from '../utils/abort';
|
||||
import { errorMessage, isAbortError } from './errors';
|
||||
import type { LoopEventDispatcher, LoopToolCallEvent } from './events';
|
||||
import type { LLM, LLMChatResponse } from './llm';
|
||||
|
|
@ -46,6 +47,19 @@ const TOOL_OUTPUT_NON_TEXT = 'Tool returned non-text content.';
|
|||
|
||||
const validators = new WeakMap<ExecutableTool, ToolArgsValidator>();
|
||||
|
||||
/**
|
||||
* Output for an aborted tool call. When the abort carries a user-cancellation
|
||||
* reason (the user pressed stop), say so explicitly so the model treats it as a
|
||||
* deliberate interruption instead of a system fault to theorise about or retry.
|
||||
* Any other abort keeps the neutral wording.
|
||||
*/
|
||||
function abortedToolOutput(toolName: string, signal: AbortSignal): string {
|
||||
if (isUserCancellation(signal.reason)) {
|
||||
return `The user manually interrupted "${toolName}" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.`;
|
||||
}
|
||||
return `Tool "${toolName}" was aborted`;
|
||||
}
|
||||
|
||||
export interface ToolCallStepContext {
|
||||
readonly tools?: readonly ExecutableTool[] | undefined;
|
||||
readonly hooks?: LoopHooks | undefined;
|
||||
|
|
@ -285,7 +299,7 @@ async function prepareToolCall(
|
|||
|
||||
const displayFields = toolCallDisplayFieldsFromExecution(execution);
|
||||
const settleAborted = (): Promise<PreparedToolCallTask> =>
|
||||
settleError(effectiveArgs, `Tool "${call.toolName}" was aborted`, displayFields);
|
||||
settleError(effectiveArgs, abortedToolOutput(call.toolName, step.signal), displayFields);
|
||||
|
||||
if (step.signal.aborted) return settleAborted();
|
||||
|
||||
|
|
@ -452,7 +466,7 @@ async function runRunnableToolCall(
|
|||
const { toolCall, toolName } = call;
|
||||
|
||||
if (signal.aborted) {
|
||||
return makeErrorToolResult(call, effectiveArgs, `Tool "${toolName}" was aborted`);
|
||||
return makeErrorToolResult(call, effectiveArgs, abortedToolOutput(toolName, signal));
|
||||
}
|
||||
|
||||
let toolResult: ExecutableToolResult;
|
||||
|
|
@ -469,7 +483,7 @@ async function runRunnableToolCall(
|
|||
});
|
||||
}
|
||||
const output = aborted
|
||||
? `Tool "${toolName}" was aborted`
|
||||
? abortedToolOutput(toolName, signal)
|
||||
: `Tool "${toolName}" failed: ${errorMessage(error)}`;
|
||||
return makeErrorToolResult(call, effectiveArgs, output);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
prepareSystemPromptContext,
|
||||
type ResolvedAgentProfile,
|
||||
} from '../profile';
|
||||
import { linkAbortSignal } from '../utils/abort';
|
||||
import { linkAbortSignal, userCancellationReason } from '../utils/abort';
|
||||
import { collectGitContext } from './git-context';
|
||||
import type { Session } from './index';
|
||||
import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md';
|
||||
|
|
@ -167,13 +167,15 @@ export class SessionSubagentHost {
|
|||
};
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
cancelAll(reason: unknown = userCancellationReason()): void {
|
||||
const foregroundChildren = Array.from(this.activeChildren).filter(
|
||||
([, child]) => !child.runInBackground,
|
||||
);
|
||||
for (const [childId, child] of foregroundChildren) {
|
||||
this.session.agents.get(childId)?.subagentHost?.cancelAll();
|
||||
child.controller.abort();
|
||||
this.session.agents.get(childId)?.subagentHost?.cancelAll(reason);
|
||||
// Abort with the cancel reason (a user interruption by default) so the
|
||||
// subagent's in-flight tools report the cause accurately to the model.
|
||||
child.controller.abort(reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ import { isAbortError } from '../../../loop/errors';
|
|||
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';
|
||||
import type { ResolvedAgentProfile } from '../../../profile';
|
||||
import type { SessionSubagentHost, SubagentHandle } from '../../../session/subagent-host';
|
||||
import { createDeadlineAbortSignal, type DeadlineAbortSignal } from '../../../utils/abort';
|
||||
import {
|
||||
createDeadlineAbortSignal,
|
||||
isUserCancellation,
|
||||
type DeadlineAbortSignal,
|
||||
} from '../../../utils/abort';
|
||||
import type { BackgroundProcessManager } from '../../background/manager';
|
||||
import { toInputJsonSchema } from '../../support/input-schema';
|
||||
import { matchesGlobRuleSubject } from '../../support/rule-match';
|
||||
|
|
@ -303,8 +307,11 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
|
|||
let message: string;
|
||||
if (foregroundDeadline?.timedOut() === true && args.timeout !== undefined) {
|
||||
message = `Agent timed out after ${args.timeout}s.`;
|
||||
} else if (isUserCancellation(signal.reason)) {
|
||||
message =
|
||||
'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.';
|
||||
} else if (isAbortError(error)) {
|
||||
message = 'The subagent was stopped by the user.';
|
||||
message = 'The subagent was stopped before it finished.';
|
||||
} else {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -321,8 +328,11 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
|
|||
let message: string;
|
||||
if (foregroundDeadline?.timedOut() === true && args.timeout !== undefined) {
|
||||
message = `Agent timed out after ${args.timeout}s.`;
|
||||
} else if (isUserCancellation(signal.reason)) {
|
||||
message =
|
||||
'The user manually interrupted this subagent (and any sibling agents launched alongside it). This was a deliberate user action, not a system error, a timeout, or a capacity/concurrency limit. Do not retry automatically or speculate about why it failed — wait for the user\'s next instruction.';
|
||||
} else if (isAbortError(error)) {
|
||||
message = 'The subagent was stopped by the user.';
|
||||
message = 'The subagent was stopped before it finished.';
|
||||
} else {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,34 @@ export function abortError(): Error {
|
|||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks an abort the user triggered deliberately (e.g. pressing ESC to
|
||||
* interrupt the agent), as distinct from a timeout, an internal error, or any
|
||||
* other programmatic abort. It travels as the AbortSignal's `reason`, so code
|
||||
* that settles an interrupted operation can tell a user interruption apart from
|
||||
* a failure and report it to the model accordingly instead of emitting a
|
||||
* neutral "was aborted" that the model mistakes for a system problem.
|
||||
*
|
||||
* `name` stays 'AbortError' so existing `isAbortError()` checks (and
|
||||
* `AbortSignal.throwIfAborted()`) keep treating it as an abort.
|
||||
*/
|
||||
export class UserCancellationError extends Error {
|
||||
readonly userCancelled = true;
|
||||
|
||||
constructor() {
|
||||
super('Aborted by the user');
|
||||
this.name = 'AbortError';
|
||||
}
|
||||
}
|
||||
|
||||
export function userCancellationReason(): UserCancellationError {
|
||||
return new UserCancellationError();
|
||||
}
|
||||
|
||||
export function isUserCancellation(value: unknown): value is UserCancellationError {
|
||||
return value instanceof UserCancellationError;
|
||||
}
|
||||
|
||||
export function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -1261,8 +1261,8 @@ describe('Agent turn flow', () => {
|
|||
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
|
||||
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
|
||||
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
|
||||
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted", "isError": true } }, "time": "<time>" }
|
||||
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted", "isError": true }
|
||||
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true } }, "time": "<time>" }
|
||||
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true }
|
||||
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
|
||||
`);
|
||||
|
|
@ -1390,8 +1390,8 @@ describe('Agent turn flow', () => {
|
|||
[wire] turn.cancel { "turnId": 0, "time": "<time>" }
|
||||
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
|
||||
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf should-not-run", "timeout": 60 }, "description": "Running: printf should-not-run", "display": { "kind": "command", "command": "printf should-not-run", "cwd": "<cwd>", "language": "bash" } }
|
||||
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "Tool \\"Bash\\" was aborted", "isError": true } }, "time": "<time>" }
|
||||
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "Tool \\"Bash\\" was aborted", "isError": true }
|
||||
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true } }, "time": "<time>" }
|
||||
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "The user manually interrupted \\"Bash\\" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.", "isError": true }
|
||||
[emit] turn.step.interrupted { "turnId": 0, "step": 1, "reason": "aborted" }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "cancelled" }
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { inputTotal } from '@moonshot-ai/kosong';
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { LLMChatResponse, LoopHooks } from '../../src/loop/index';
|
||||
import { userCancellationReason } from '../../src/utils/abort';
|
||||
import { makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
|
||||
import { runTurn } from './fixtures/helpers';
|
||||
import { EchoTool, GatedTool, markReadFileAccesses, SlowTool } from './fixtures/tools';
|
||||
|
|
@ -211,6 +212,36 @@ describe('runTurn — abort handling', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('tells the model a running tool was interrupted by the user, not by a system fault', async () => {
|
||||
// When the user presses stop, the tool_result fed back to the model must
|
||||
// convey "the user deliberately interrupted this" — not the neutral
|
||||
// `Tool "X" was aborted`, which the model mistakes for a system problem
|
||||
// (e.g. "too many parallel agents") and then theorises about / retries.
|
||||
const slow = new SlowTool();
|
||||
const controller = new AbortController();
|
||||
|
||||
const turnPromise = runTurn({
|
||||
tools: [slow],
|
||||
responses: [
|
||||
makeToolUseResponse([makeToolCall('slow', {}, 'tc-1')]),
|
||||
makeEndTurnResponse('unreachable'),
|
||||
],
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await slow.started.promise;
|
||||
controller.abort(userCancellationReason());
|
||||
const { result, sink } = await turnPromise;
|
||||
|
||||
expect(result.stopReason).toBe('aborted');
|
||||
const toolResult = sink.byType('tool.result').find((e) => e.toolCallId === 'tc-1');
|
||||
const output = toolResult?.result.output;
|
||||
expect(typeof output).toBe('string');
|
||||
expect(output).not.toBe('Tool "slow" was aborted');
|
||||
expect(output).toContain('not a system error');
|
||||
expect(output).toContain("wait for the user");
|
||||
});
|
||||
|
||||
it('every tool.call still has a matching tool.result when aborted mid-batch', async () => {
|
||||
// Transcript-balance contract: even when the turn is aborted while
|
||||
// multiple tool tasks are running, every dispatched tool.call must be
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import type { SDKSessionRPC } from '../../src/rpc';
|
|||
import { Session } from '../../src/session';
|
||||
import { collectGitContext } from '../../src/session/git-context';
|
||||
import { SessionSubagentHost } from '../../src/session/subagent-host';
|
||||
import { testAgent } from '../agent/harness/agent';
|
||||
import { abortError, userCancellationReason } from '../../src/utils/abort';
|
||||
import { testAgent, type AgentTestContext } from '../agent/harness/agent';
|
||||
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
|
||||
|
||||
// Git context collection is exercised in git-context.test.ts; here it is
|
||||
|
|
@ -360,6 +361,69 @@ describe('SessionSubagentHost', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("tells a cancelled subagent's in-flight tools the user interrupted them", async () => {
|
||||
const parent = testAgent();
|
||||
parent.configure();
|
||||
parent.newEvents();
|
||||
|
||||
const controller = new AbortController();
|
||||
const child = testAgent();
|
||||
child.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
|
||||
const session = fakeSession(parent.agent, child.agent);
|
||||
const host = new SessionSubagentHost(session, 'main');
|
||||
|
||||
const handle = await host.spawn('explore', {
|
||||
parentToolCallId: 'call_agent',
|
||||
prompt: 'Keep working',
|
||||
description: 'Long task',
|
||||
runInBackground: false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await child.untilApprovalRequest();
|
||||
// The parent turn signal aborts with a user-cancellation reason; linkAbortSignal
|
||||
// forwards it to the child exactly as Turn.cancel does on a real ESC.
|
||||
controller.abort(userCancellationReason());
|
||||
await expect(handle.completion).rejects.toThrow();
|
||||
await child.untilTurnEnd();
|
||||
|
||||
const output = childBashToolResultOutput(child);
|
||||
expect(output).toContain('manually interrupted');
|
||||
expect(output).toContain('not a system error');
|
||||
});
|
||||
|
||||
it('does not mislabel a non-user subagent abort (e.g. a deadline) as a user interruption', async () => {
|
||||
const parent = testAgent();
|
||||
parent.configure();
|
||||
parent.newEvents();
|
||||
|
||||
const controller = new AbortController();
|
||||
const child = testAgent();
|
||||
child.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall());
|
||||
const session = fakeSession(parent.agent, child.agent);
|
||||
const host = new SessionSubagentHost(session, 'main');
|
||||
|
||||
const handle = await host.spawn('explore', {
|
||||
parentToolCallId: 'call_agent',
|
||||
prompt: 'Keep working',
|
||||
description: 'Long task',
|
||||
runInBackground: false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await child.untilApprovalRequest();
|
||||
// A generic (non-user) abort — e.g. a foreground subagent's deadline timeout
|
||||
// propagating through waitForCurrentTurn — must NOT be reported to the
|
||||
// child's tools as a deliberate user interruption.
|
||||
controller.abort(abortError());
|
||||
await expect(handle.completion).rejects.toThrow();
|
||||
await child.untilTurnEnd();
|
||||
|
||||
const output = childBashToolResultOutput(child);
|
||||
expect(output).toBe('Tool "Bash" was aborted');
|
||||
expect(output).not.toContain('manually interrupted');
|
||||
});
|
||||
|
||||
it('cancelAll leaves background children running until their task signal aborts', async () => {
|
||||
const parent = testAgent();
|
||||
parent.configure();
|
||||
|
|
@ -1068,6 +1132,22 @@ async function writeWire(homedir: string, records: readonly Record<string, unkno
|
|||
await writeFile(join(homedir, 'wire.jsonl'), text.length === 0 ? '' : `${text}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
function childBashToolResultOutput(child: AgentTestContext): string | undefined {
|
||||
for (const entry of child.allEvents) {
|
||||
if (entry.type !== '[wire]' || entry.event !== 'context.append_loop_event') continue;
|
||||
const loopEvent = (
|
||||
entry.args as {
|
||||
event?: { type?: string; toolCallId?: string; result?: { output?: unknown } };
|
||||
}
|
||||
).event;
|
||||
if (loopEvent?.type === 'tool.result' && loopEvent.toolCallId === 'call_bash') {
|
||||
const output = loopEvent.result?.output;
|
||||
return typeof output === 'string' ? output : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bashCall(): ToolCall {
|
||||
return {
|
||||
type: 'function',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { ResolvedAgentProfile } from '../../src/profile';
|
|||
import type { SessionSubagentHost } from '../../src/session/subagent-host';
|
||||
import { BackgroundProcessManager } from '../../src/tools/background/manager';
|
||||
import { AgentTool, AgentToolInputSchema } from '../../src/tools/builtin/collaboration/agent';
|
||||
import { userCancellationReason } from '../../src/utils/abort';
|
||||
import { executeTool } from './fixtures/execute-tool';
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
|
@ -666,6 +667,47 @@ describe('AgentTool', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => {
|
||||
const controller = new AbortController();
|
||||
const host = mockSubagentHost({
|
||||
spawn: vi.fn((_profileName: string, options: { signal: AbortSignal }) =>
|
||||
Promise.resolve({
|
||||
agentId: 'agent-child',
|
||||
profileName: 'coder',
|
||||
resumed: false,
|
||||
completion: new Promise<{ result: string }>((_resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
reject(options.signal.reason);
|
||||
};
|
||||
if (options.signal.aborted) onAbort();
|
||||
else options.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const tool = new AgentTool(host);
|
||||
|
||||
const resultPromise = executeTool(tool, {
|
||||
turnId: '0',
|
||||
toolCallId: 'call_agent',
|
||||
args: { prompt: 'Investigate', description: 'Find cause' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Let spawn wire up and the tool reach `await handle.completion`.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
controller.abort(userCancellationReason());
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toContain('status: failed');
|
||||
// The old message ("The subagent was stopped by the user.") is too weak —
|
||||
// the model still blamed a "system limit". The new message rules that out.
|
||||
expect(result.output).not.toContain('was stopped by the user');
|
||||
expect(result.output).toContain('not a system error');
|
||||
expect(result.output).toContain('capacity');
|
||||
expect(result.output).toContain('wait for the user');
|
||||
});
|
||||
|
||||
it('returns the spawned agent id when a foreground subagent times out', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
try {
|
||||
|
|
|
|||
23
packages/agent-core/test/utils/abort.test.ts
Normal file
23
packages/agent-core/test/utils/abort.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isAbortError } from '../../src/loop/errors';
|
||||
import { abortError, isUserCancellation, userCancellationReason } from '../../src/utils/abort';
|
||||
|
||||
describe('userCancellationReason', () => {
|
||||
it('is recognised as a deliberate user cancellation', () => {
|
||||
expect(isUserCancellation(userCancellationReason())).toBe(true);
|
||||
});
|
||||
|
||||
it('stays an AbortError so abort detection keeps treating it as an abort', () => {
|
||||
expect(isAbortError(userCancellationReason())).toBe(true);
|
||||
});
|
||||
|
||||
it('is distinguishable from a generic abort, an ordinary error, and undefined', () => {
|
||||
// A generic abort (timeout, internal) must NOT read as a user cancellation —
|
||||
// that distinction is the whole point: the model needs to know a user
|
||||
// pressed stop, not that "something aborted".
|
||||
expect(isUserCancellation(abortError())).toBe(false);
|
||||
expect(isUserCancellation(new Error('boom'))).toBe(false);
|
||||
expect(isUserCancellation(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue