mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-09-07 16:57:36 +00:00
fix: report task stop reasons to the model (#1781)
* fix: report task stop reasons to the model * chore: add task stop reason changeset
This commit is contained in:
parent
ba36c6a563
commit
09e855401b
13 changed files with 110 additions and 31 deletions
5
.changeset/report-task-stop-reasons.md
Normal file
5
.changeset/report-task-stop-reasons.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@moonshot-ai/kimi-code": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Report when users stop tasks and preserve other stop reasons in model context.
|
||||||
|
|
@ -241,6 +241,10 @@ export class AgentRPCService implements IAgentRPCService {
|
||||||
}
|
}
|
||||||
|
|
||||||
stopTask(payload: StopTaskPayload): void {
|
stopTask(payload: StopTaskPayload): void {
|
||||||
|
if (payload.reason === undefined) {
|
||||||
|
void this.tasks.stopByUser(payload.taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void this.tasks.stop(payload.taskId, payload.reason);
|
void this.tasks.stop(payload.taskId, payload.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ export interface IAgentTaskService {
|
||||||
suppressTerminalNotification(taskId: string): Promise<void>;
|
suppressTerminalNotification(taskId: string): Promise<void>;
|
||||||
detach(taskId: string): AgentTaskInfo | undefined;
|
detach(taskId: string): AgentTaskInfo | undefined;
|
||||||
stop(taskId: string, reason?: string): Promise<AgentTaskInfo | undefined>;
|
stop(taskId: string, reason?: string): Promise<AgentTaskInfo | undefined>;
|
||||||
|
stopByUser(taskId: string): Promise<AgentTaskInfo | undefined>;
|
||||||
stopAll(reason?: string): Promise<readonly AgentTaskInfo[]>;
|
stopAll(reason?: string): Promise<readonly AgentTaskInfo[]>;
|
||||||
stopAllOnExit(reason: string): Promise<readonly AgentTaskInfo[]>;
|
stopAllOnExit(reason: string): Promise<readonly AgentTaskInfo[]>;
|
||||||
wait(
|
wait(
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||||
import type { ContentPart } from '#/app/llmProtocol/message';
|
import type { ContentPart } from '#/app/llmProtocol/message';
|
||||||
|
|
||||||
import { Disposable } from '#/_base/di/lifecycle';
|
import { Disposable } from '#/_base/di/lifecycle';
|
||||||
import { abortable } from '#/_base/utils/abort';
|
import {
|
||||||
|
abortable,
|
||||||
|
userCancellationReason,
|
||||||
|
} from '#/_base/utils/abort';
|
||||||
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
|
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
|
||||||
import { IEventBus } from '#/app/event/eventBus';
|
import { IEventBus } from '#/app/event/eventBus';
|
||||||
import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types';
|
import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types';
|
||||||
|
|
@ -167,7 +170,6 @@ function outputLimitReason(): string {
|
||||||
|
|
||||||
const SIGTERM_GRACE_MS = 5_000;
|
const SIGTERM_GRACE_MS = 5_000;
|
||||||
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
const USER_INTERRUPT_REASON = 'Interrupted by user';
|
|
||||||
const SESSION_CLOSED_REASON = 'Session closed';
|
const SESSION_CLOSED_REASON = 'Session closed';
|
||||||
const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
|
const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
|
||||||
const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status';
|
const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status';
|
||||||
|
|
@ -625,6 +627,17 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stopByUser(taskId: string): Promise<AgentTaskInfo | undefined> {
|
||||||
|
const entry = this.tasks.get(taskId);
|
||||||
|
if (entry === undefined) return undefined;
|
||||||
|
const reason = userCancellationReason();
|
||||||
|
return this.terminateWithGrace(entry, {
|
||||||
|
stopReason: reason.message,
|
||||||
|
abortReason: reason,
|
||||||
|
finalStatus: 'killed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async terminateWithGrace(
|
private async terminateWithGrace(
|
||||||
entry: ManagedTask,
|
entry: ManagedTask,
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -1098,8 +1111,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
||||||
|
|
||||||
const abortFromSignal = (): void => {
|
const abortFromSignal = (): void => {
|
||||||
if (this.isDetached(entry)) return;
|
if (this.isDetached(entry)) return;
|
||||||
|
const userReason = userCancellationReason();
|
||||||
void this.terminateWithGrace(entry, {
|
void this.terminateWithGrace(entry, {
|
||||||
stopReason: USER_INTERRUPT_REASON,
|
stopReason: userReason.message,
|
||||||
abortReason: signal.reason,
|
abortReason: signal.reason,
|
||||||
finalStatus: 'killed',
|
finalStatus: 'killed',
|
||||||
});
|
});
|
||||||
|
|
@ -1229,9 +1243,11 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {
|
||||||
const baseLine =
|
const baseLine =
|
||||||
info.status === 'timed_out'
|
info.status === 'timed_out'
|
||||||
? `${info.description} timed out.`
|
? `${info.description} timed out.`
|
||||||
: info.stopReason
|
: info.status === 'killed' && isSerializedUserCancellation(info.stopReason)
|
||||||
? `${info.description} ${info.status === 'killed' ? 'was killed' : info.status}: ${info.stopReason}.`
|
? `${info.description} was stopped by user.`
|
||||||
: `${info.description} ${info.status}.`;
|
: info.stopReason
|
||||||
|
? `${info.description} ${info.status === 'killed' ? 'was stopped' : info.status}. Reason: ${info.stopReason}`
|
||||||
|
: `${info.description} ${info.status}.`;
|
||||||
|
|
||||||
if (info.kind !== 'agent') return baseLine;
|
if (info.kind !== 'agent') return baseLine;
|
||||||
if (info.status === 'completed') return baseLine;
|
if (info.status === 'completed') return baseLine;
|
||||||
|
|
@ -1263,6 +1279,10 @@ function normalizeReason(reason: string | undefined): string | undefined {
|
||||||
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSerializedUserCancellation(reason: string | undefined): boolean {
|
||||||
|
return reason === userCancellationReason().message;
|
||||||
|
}
|
||||||
|
|
||||||
function createForegroundRelease(): ForegroundRelease {
|
function createForegroundRelease(): ForegroundRelease {
|
||||||
let resolve!: (reason: ForegroundTaskReleaseReason) => void;
|
let resolve!: (reason: ForegroundTaskReleaseReason) => void;
|
||||||
const promise = new Promise<ForegroundTaskReleaseReason>((done) => {
|
const promise = new Promise<ForegroundTaskReleaseReason>((done) => {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import { registerTool } from '#/agent/toolRegistry/toolContribution';
|
||||||
import { toInputJsonSchema } from '#/tool/input-schema';
|
import { toInputJsonSchema } from '#/tool/input-schema';
|
||||||
import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match';
|
import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match';
|
||||||
import { renderPrompt } from '#/_base/utils/render-prompt';
|
import { renderPrompt } from '#/_base/utils/render-prompt';
|
||||||
|
import { userCancellationReason } from '#/_base/utils/abort';
|
||||||
import bashDescriptionTemplate from './bash.md?raw';
|
import bashDescriptionTemplate from './bash.md?raw';
|
||||||
import { ProcessTask } from './process-task';
|
import { ProcessTask } from './process-task';
|
||||||
|
|
||||||
|
|
@ -58,7 +59,6 @@ const DEFAULT_TIMEOUT_S = 60;
|
||||||
const MAX_TIMEOUT_S = 5 * 60;
|
const MAX_TIMEOUT_S = 5 * 60;
|
||||||
const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60;
|
const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60;
|
||||||
const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60;
|
const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60;
|
||||||
const USER_INTERRUPT_REASON = 'Interrupted by user';
|
|
||||||
|
|
||||||
export const BashInputSchema = z
|
export const BashInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
@ -395,8 +395,11 @@ export class BashTool implements BuiltinTool<BashInput> {
|
||||||
result = builder.error(`Command killed by timeout (${timeoutLabel})`, {
|
result = builder.error(`Command killed by timeout (${timeoutLabel})`, {
|
||||||
brief: `Killed by timeout (${timeoutLabel})`,
|
brief: `Killed by timeout (${timeoutLabel})`,
|
||||||
});
|
});
|
||||||
} else if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) {
|
} else if (
|
||||||
result = builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON });
|
current?.status === 'killed' &&
|
||||||
|
current.stopReason === userCancellationReason().message
|
||||||
|
) {
|
||||||
|
result = builder.error('Interrupted by user', { brief: 'Interrupted by user' });
|
||||||
} else if (
|
} else if (
|
||||||
(current?.status === 'failed' || current?.status === 'killed') &&
|
(current?.status === 'failed' || current?.status === 'killed') &&
|
||||||
current.stopReason !== undefined
|
current.stopReason !== undefined
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,11 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { IAgentScopeHandle } from '#/_base/di/scope';
|
import type { IAgentScopeHandle } from '#/_base/di/scope';
|
||||||
import { isUserCancellation } from '#/_base/utils/abort';
|
import {
|
||||||
|
isAbortError,
|
||||||
|
isUserCancellation,
|
||||||
|
userCancellationReason,
|
||||||
|
} from '#/_base/utils/abort';
|
||||||
import { toInputJsonSchema } from '#/tool/input-schema';
|
import { toInputJsonSchema } from '#/tool/input-schema';
|
||||||
import { matchesGlobRuleSubject } from '#/tool/rule-match';
|
import { matchesGlobRuleSubject } from '#/tool/rule-match';
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,7 +33,6 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo
|
||||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||||
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
||||||
import { isAbortError } from '#/_base/utils/abort';
|
|
||||||
import {
|
import {
|
||||||
ToolAccesses,
|
ToolAccesses,
|
||||||
type BuiltinTool,
|
type BuiltinTool,
|
||||||
|
|
@ -127,7 +130,8 @@ const BACKGROUND_AGENT_UNAVAILABLE =
|
||||||
const RESUME_WITH_TYPE_UNAVAILABLE =
|
const RESUME_WITH_TYPE_UNAVAILABLE =
|
||||||
'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.';
|
'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.';
|
||||||
const USER_INTERRUPTED_SUBAGENT_MESSAGE =
|
const USER_INTERRUPTED_SUBAGENT_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.";
|
'The subagent was stopped before it finished by user.';
|
||||||
|
const SUBAGENT_STOPPED_MESSAGE = 'The subagent was stopped before it finished.';
|
||||||
|
|
||||||
|
|
||||||
export class AgentTool implements BuiltinTool<AgentToolInput> {
|
export class AgentTool implements BuiltinTool<AgentToolInput> {
|
||||||
|
|
@ -428,11 +432,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
|
||||||
const timedOut = info?.status === 'timed_out';
|
const timedOut = info?.status === 'timed_out';
|
||||||
const message = timedOut
|
const message = timedOut
|
||||||
? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.`
|
? `Agent timed out after ${formatSubagentTimeoutDescription(timeoutMs)}.`
|
||||||
: info?.stopReason === 'Interrupted by user'
|
: formatSubagentStoppedMessage(info?.stopReason);
|
||||||
? USER_INTERRUPTED_SUBAGENT_MESSAGE
|
|
||||||
: info?.stopReason !== undefined
|
|
||||||
? info.stopReason
|
|
||||||
: 'The subagent was stopped before it finished.';
|
|
||||||
return {
|
return {
|
||||||
output: formatForegroundAgentFailure(handle, message, timedOut),
|
output: formatForegroundAgentFailure(handle, message, timedOut),
|
||||||
isError: true,
|
isError: true,
|
||||||
|
|
@ -515,6 +515,19 @@ function formatForegroundAgentFailure(
|
||||||
|
|
||||||
function launchErrorMessage(error: unknown, signal: AbortSignal): string {
|
function launchErrorMessage(error: unknown, signal: AbortSignal): string {
|
||||||
if (isUserCancellation(signal.reason)) return USER_INTERRUPTED_SUBAGENT_MESSAGE;
|
if (isUserCancellation(signal.reason)) return USER_INTERRUPTED_SUBAGENT_MESSAGE;
|
||||||
if (isAbortError(error)) return 'The subagent was stopped before it finished.';
|
if (isAbortError(error)) return formatSubagentStoppedMessage(errorMessage(signal.reason));
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSubagentStoppedMessage(reason: string | undefined): string {
|
||||||
|
const normalized = reason?.trim();
|
||||||
|
if (normalized === userCancellationReason().message) return USER_INTERRUPTED_SUBAGENT_MESSAGE;
|
||||||
|
if (normalized === undefined || normalized.length === 0) return SUBAGENT_STOPPED_MESSAGE;
|
||||||
|
return `${SUBAGENT_STOPPED_MESSAGE} Reason: ${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string | undefined {
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -502,7 +502,7 @@ describe('AgentTaskService — notification delivery', () => {
|
||||||
const { agent, ctx, manager } = createAgentTaskService();
|
const { agent, ctx, manager } = createAgentTaskService();
|
||||||
const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long shell task');
|
const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long shell task');
|
||||||
|
|
||||||
await manager.stop(taskId);
|
await manager.stopByUser(taskId);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(notifiedCount(ctx)).toBe(1);
|
expect(notifiedCount(ctx)).toBe(1);
|
||||||
|
|
@ -516,9 +516,7 @@ describe('AgentTaskService — notification delivery', () => {
|
||||||
status: 'killed',
|
status: 'killed',
|
||||||
notificationId: `task:${taskId}:killed`,
|
notificationId: `task:${taskId}:killed`,
|
||||||
});
|
});
|
||||||
expect(message.content[0]!.text).toContain(
|
expect(message.content[0]!.text).toContain('long shell task was stopped by user.');
|
||||||
'Background process killed',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TaskStopTool suppresses the real terminal notification for model-requested stops', async () => {
|
it('TaskStopTool suppresses the real terminal notification for model-requested stops', async () => {
|
||||||
|
|
|
||||||
|
|
@ -477,7 +477,7 @@ describe('AgentTaskService', () => {
|
||||||
expect(killSpy).toHaveBeenCalledWith('SIGTERM');
|
expect(killSpy).toHaveBeenCalledWith('SIGTERM');
|
||||||
expect(manager.getTask(taskId)).toMatchObject({
|
expect(manager.getTask(taskId)).toMatchObject({
|
||||||
status: 'killed',
|
status: 'killed',
|
||||||
stopReason: 'Interrupted by user',
|
stopReason: 'Aborted by the user',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -507,7 +507,7 @@ describe('AgentTaskService', () => {
|
||||||
const info = await manager.wait(taskId);
|
const info = await manager.wait(taskId);
|
||||||
expect(info).toMatchObject({
|
expect(info).toMatchObject({
|
||||||
status: 'killed',
|
status: 'killed',
|
||||||
stopReason: 'Interrupted by user',
|
stopReason: 'Aborted by the user',
|
||||||
});
|
});
|
||||||
expect(isUserCancellation(subagentController.signal.reason)).toBe(true);
|
expect(isUserCancellation(subagentController.signal.reason)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,10 @@ class FakeTaskService implements IAgentTaskService {
|
||||||
return entry.info;
|
return entry.info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stopByUser(taskId: string): Promise<AgentTaskInfo | undefined> {
|
||||||
|
return this.stop(taskId, 'Aborted by the user');
|
||||||
|
}
|
||||||
|
|
||||||
async stopAll(reason?: string): Promise<readonly AgentTaskInfo[]> {
|
async stopAll(reason?: string): Promise<readonly AgentTaskInfo[]> {
|
||||||
const stopped = await Promise.all(
|
const stopped = await Promise.all(
|
||||||
Array.from(this.entries.keys()).map((taskId) => this.stop(taskId, reason)),
|
Array.from(this.entries.keys()).map((taskId) => this.stop(taskId, reason)),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
type RegisterAgentTaskOptions,
|
type RegisterAgentTaskOptions,
|
||||||
} from '#/agent/task/task';
|
} from '#/agent/task/task';
|
||||||
import type { AgentTaskSettlement } from '#/agent/task/types';
|
import type { AgentTaskSettlement } from '#/agent/task/types';
|
||||||
|
import { userCancellationReason } from '#/_base/utils/abort';
|
||||||
import type { IConfigService } from '#/app/config/config';
|
import type { IConfigService } from '#/app/config/config';
|
||||||
import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
|
import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
|
||||||
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||||
|
|
@ -313,7 +314,6 @@ const TERMINAL_STATUSES: ReadonlySet<AgentTaskStatus> = new Set([
|
||||||
'lost',
|
'lost',
|
||||||
]);
|
]);
|
||||||
const SIGTERM_GRACE_MS = 5_000;
|
const SIGTERM_GRACE_MS = 5_000;
|
||||||
const USER_INTERRUPT_REASON = 'Interrupted by user';
|
|
||||||
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
interface ForegroundRelease {
|
interface ForegroundRelease {
|
||||||
|
|
@ -542,7 +542,7 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
|
||||||
const signal = registerOptions.signal;
|
const signal = registerOptions.signal;
|
||||||
const abortFromSignal = (): void => {
|
const abortFromSignal = (): void => {
|
||||||
if (entry.foregroundRelease === undefined) return;
|
if (entry.foregroundRelease === undefined) return;
|
||||||
void stopEntry(entry, USER_INTERRUPT_REASON);
|
void stopEntry(entry, userCancellationReason().message);
|
||||||
};
|
};
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
abortFromSignal();
|
abortFromSignal();
|
||||||
|
|
@ -612,6 +612,10 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
|
||||||
return stopEntry(entry, reason);
|
return stopEntry(entry, reason);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async stopByUser(taskId: string): Promise<AgentTaskInfo | undefined> {
|
||||||
|
return service.stop(taskId, userCancellationReason().message);
|
||||||
|
},
|
||||||
|
|
||||||
async stopAll(reason?: string): Promise<readonly AgentTaskInfo[]> {
|
async stopAll(reason?: string): Promise<readonly AgentTaskInfo[]> {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
Array.from(tasks.keys()).map((taskId) => service.stop(taskId, reason)),
|
Array.from(tasks.keys()).map((taskId) => service.stop(taskId, reason)),
|
||||||
|
|
|
||||||
|
|
@ -1256,10 +1256,36 @@ describe('Agent tool execution contract', () => {
|
||||||
|
|
||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
expect(result.output).toContain('status: failed');
|
expect(result.output).toContain('status: failed');
|
||||||
expect(result.output).not.toContain('was stopped by the user');
|
expect(result.output).toContain('The subagent was stopped before it finished by user.');
|
||||||
expect(result.output).toContain('not a system error');
|
});
|
||||||
expect(result.output).toContain('capacity');
|
|
||||||
expect(result.output).toContain('wait for the user');
|
it('reports the reason when a foreground subagent is stopped for another cause', async () => {
|
||||||
|
const lifecycle = createAgentLifecycleStub({
|
||||||
|
createAgentIds: ['agent-child'],
|
||||||
|
runCompletion: (_agentId, _request, options) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
options.signal.addEventListener('abort', () => reject(options.signal.reason), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const context = createAgentToolContext(lifecycle);
|
||||||
|
|
||||||
|
const resultPromise = executeAgentTool(context, {
|
||||||
|
prompt: 'Investigate',
|
||||||
|
description: 'Find cause',
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(context.get(IAgentTaskService).list(false)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
const [task] = context.get(IAgentTaskService).list(false);
|
||||||
|
await context.get(IAgentTaskService).stop(task!.taskId, 'Session closed');
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.output).toContain(
|
||||||
|
'The subagent was stopped before it finished. Reason: Session closed',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the spawned agent id when a foreground subagent times out', async () => {
|
it('returns the spawned agent id when a foreground subagent times out', async () => {
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,7 @@ export function registerTasksRoutes(app: TasksRouteHost, core: Scope): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await resolved.tasks?.stop(task_id);
|
await resolved.tasks?.stopByUser(task_id);
|
||||||
requestLog(req)?.info({ session_id, task_id }, 'task cancelled');
|
requestLog(req)?.info({ session_id, task_id }, 'task cancelled');
|
||||||
reply.send(okEnvelope({ cancelled: true as const }, req.id));
|
reply.send(okEnvelope({ cancelled: true as const }, req.id));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => {
|
||||||
);
|
);
|
||||||
expect(cancelled.body.code).toBe(0);
|
expect(cancelled.body.code).toBe(0);
|
||||||
expect(cancelled.body.data).toEqual({ cancelled: true });
|
expect(cancelled.body.data).toEqual({ cancelled: true });
|
||||||
|
expect(tasks.getTask(taskId)?.stopReason).toBe('Aborted by the user');
|
||||||
|
|
||||||
// The task is now terminal (killed → cancelled); a second cancel is a
|
// The task is now terminal (killed → cancelled); a second cancel is a
|
||||||
// conflict with the idempotent envelope shape.
|
// conflict with the idempotent envelope shape.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue