fix(cli): keep full-turn tool cancellation out of the error path (#11154)

* fix(cli): keep full-turn tool cancellation out of the error path

The full-turn branch of useReactToolScheduler awaited resolveForModel() and
then treated every scheduler.schedule() rejection as a genuine failure. When
the shared scheduler is busy, an aborted queued request rejects with "Tool
call cancelled while in queue.", which is a cancellation, so the catch
synthesized status: 'error' / UNHANDLED_EXCEPTION calls, appended them to the
active tool list, and ran the completion handler. use-llm-stream commits that
group to history and records the tool result before its cancellation checks,
and allToolsCancelled only matches status === 'cancelled', so pressing Esc was
persisted to the chat recording and submitted to the model as a real
scheduling failure.

Complete an aborted rejection as 'cancelled' instead, mirroring the normal
branch's signal.aborted guard and core's pre-validation cancellation. Routing
it through the existing completion handler rather than returning early is what
releases toolBatchIdByCallIdRef, interactionOwnersByToolCallIdRef and
continuationOwnersByToolCallIdRef for the cancelled callIds, and what makes
allToolsCancelled fire so nothing is submitted as a tool error. Genuine
runtime-resolution and scheduling failures still fail closed with
UNHANDLED_EXCEPTION.

Fixes #11148

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmtpcl9s4mm

* fix(cli): scope full-turn cancellation branch to scheduler rejection

Give resolveForModel its own try so a fail-closed runtime-model
resolution always lands on the error path (and its log line) even when
the signal aborts in the same window, instead of being swept into the
cancellation branch and recorded as a user cancellation. Strengthen the
two cancellation tests: pin the onComplete call count, the exact
responseParts wire literal, and multi-call batch arity, and add a test
pinning the resolution-failure-under-abort boundary (red without the
source change).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu304qrn6z

* test(cli): restore the scheduler spy even when the cancellation test fails

The queued-cancellation test spies on CoreToolScheduler.prototype.schedule
with a mockImplementation that only settles when the abort signal fires, and
it restored that spy as the last statement of the test body. On a real
regression an assertion throws first, so the never-settling mock stays
installed on the prototype for the rest of the file and five unrelated tests
fail alongside the two genuine ones: one source regression reports as seven
failures, sending whoever triages it to tests whose cause is nowhere near
them.

Move the restore into a finally so it runs on the pass and the fail path.
Measured against the same mutation of the cancelled branch (the reason
constant replaced by the scheduling-failure message): 7 failed | 20 passed
before, 2 failed | 25 passed after, both remaining failures genuine.
Test file only, no production change.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu3jf6se7s

* fix(cli): require the exact queue rejection for full-turn cancellation

Two follow-ups from review of the full-turn cancellation path:

The cancellation branch keyed off `signal.aborted` alone, so once runtime
resolution succeeded any scheduler rejection that happened to race an
abort (e.g. `already running`) was completed as `cancelled` with no
`errorType`, erasing a real scheduling failure. It now also requires the
exact Core queue rejection (`Tool call cancelled while in queue.`), so
only a genuine queued cancellation takes that path.

The detached full-turn task had no terminal rejection handler, so a
rejecting completion callback escaped as an unhandled rejection. It now
ends in a `.catch` that logs the completion failure without retrying or
completing the batch a second time.

Adds collocated regressions for both, and pins that a fail-closed
resolution rejection never reaches the scheduler.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu3wa5g88b

* test(cli): pin the full-turn cancellation diagnostics and Core contract

Address the three review findings on the full-turn cancellation path:

- The cancellation classifier compared against a hand-copy of Core's
  queue-rejection text, and every test covering that branch stubbed
  `schedule()` with its own copy of it, so a Core-side reword would
  silently re-introduce #11148 with a fully green suite. Add a test that
  drives Core's real busy branch instead, so the rejection being
  classified is produced by Core's own code. Exporting the string from
  Core is the durable fix but is out of scope for this PR.
- The terminal `.catch` kept a rejected completion from surfacing as an
  unhandled rejection but logged only the message: no stack, no callIds.
  Log both, since reaching that handler means the batch never got
  submitted to the model and the ownership registered for its callIds
  was never released.
- The fail-closed test repeated the production comment's "(and its log
  line)" claim without asserting the log line. Assert it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu40kh0f8h

* test(cli): pin both real Core queue-rejection sites, trim a stale comment

The contract pin reached Core's busy branch by writing `private isScheduling`
through a cast and pre-aborting the signal, so it only exercised the
already-aborted reject site (coreToolScheduler.ts:2566) and never the
abort-while-queued site (:2576) that #11148 is about. Park a batch on a tool
that never settles instead, so `isRunning()` is genuinely true, then take both
reject sites and record what Core's real `schedule()` rejected with. The hook is
rendered with a stable `onEditorClose` here because the shared helper's inline
arrow is a `useMemo` dependency of the scheduler: without it, the display update
from the parked batch swaps in a fresh CoreToolScheduler and strands the batch
that is supposed to keep it busy.

Also drop the terminal handler's claim that reaching it means the batch was
never submitted and its callIds were never released. The only production caller
releases batch ownership in a `finally` (use-llm-stream.ts:1129-1144) and
deletes the interaction and continuation owners before the awaited submission
(:5062-5069), so a rejection can arrive after either happened.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu4ba9vq8w

* fix(cli): complete normal queued cancellations

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-09-18 10:11:50 +00:00
1 parent ff38a9c430
commit 3307b89cda
3 files changed
+695 -11

No files matched your search

@@ -154,10 +154,11 @@ describe('useReactToolScheduler', () => {
.mockRejectedValueOnce(new Error('Tool call cancelled while in queue.'));
const abortController = new AbortController();
abortController.abort();
const onComplete = vi.fn();
const { result } = renderHook(() =>
useReactToolScheduler(
vi.fn(),
onComplete,
{ getToolRegistry: () => ({}) } as unknown as Config,
() => undefined,
vi.fn(),
@@ -181,6 +182,18 @@ describe('useReactToolScheduler', () => {
});
expect(scheduleSpy).toHaveBeenCalledOnce();
expect(onComplete).toHaveBeenCalledOnce();
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'cancelled',
request: expect.objectContaining({ callId: 'queued-call' }),
response: expect.objectContaining({
error: undefined,
errorType: undefined,
executionStatus: 'not_started',
}),
}),
]);
scheduleSpy.mockRestore();
});
});
@@ -30,6 +30,7 @@ import {
isShellProgressData,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import type { ResolvedGeneratorForModel } from '@qwen-code/qwen-code-core/core/baseLlmClient.js';
import * as path from 'node:path';
import { useCallback, useState, useMemo } from 'react';
import type {
@@ -225,8 +226,49 @@ export function useReactToolScheduler(
signal: AbortSignal,
modelOverride?: string,
) => {
const requests = Array.isArray(request) ? request : [request];
const isQueuedCancellation = (error: unknown) =>
signal.aborted &&
error instanceof Error &&
error.message === 'Tool call cancelled while in queue.';
const completeAsCancelled = async () => {
const reason =
'[Operation Cancelled] Reason: Tool call cancelled before execution.';
const cancelledCalls: CompletedToolCall[] = requests.map(
(toolRequest) => ({
status: 'cancelled',
request: toolRequest,
response: {
callId: toolRequest.callId,
responseParts: convertToFunctionErrorResponse(
toolRequest.name,
toolRequest.callId,
reason,
reason,
),
resultDisplay: undefined,
error: undefined,
errorType: undefined,
executionStatus: 'not_started',
contentLength: reason.length,
},
durationMs: 0,
}),
);
await allToolCallsCompleteHandler(cancelledCalls);
};
if (!modelOverride?.endsWith('\0')) {
void scheduler.schedule(request, signal).catch((error: unknown) => {
if (isQueuedCancellation(error)) {
void completeAsCancelled().catch((completionError: unknown) => {
debugLogger.error(
'Tool cancellation completion failed:',
completionError,
);
});
return;
}
if (signal.aborted) return;
debugLogger.error(
`Tool scheduling failed: ${
@@ -236,15 +278,10 @@ export function useReactToolScheduler(
});
return;
}
// Declared outside the detached task so the terminal handler below can
// name the batch it failed to complete.
void (async () => {
try {
const runtimeView = await config
.getBaseLlmClient()
.resolveForModel(modelOverride.slice(0, -1), {
failClosed: true,
});
await scheduler.schedule(request, signal, runtimeView);
} catch (error) {
const completeAsSchedulingError = async (error: unknown) => {
debugLogger.error(
`Full-turn tool scheduling failed: ${
error instanceof Error ? error.message : String(error)
@@ -252,7 +289,6 @@ export function useReactToolScheduler(
);
const message =
'Full-turn tool scheduling failed. The tool was not executed.';
const requests = Array.isArray(request) ? request : [request];
const completedCalls: CompletedToolCall[] = requests.map(
(toolRequest) => {
const toolError = new Error(message);
@@ -279,9 +315,60 @@ export function useReactToolScheduler(
);
setToolCallsForDisplay((prev) => [...prev, ...completedCalls]);
await allToolCallsCompleteHandler(completedCalls);
};
// A fail-closed runtime-model resolution is a scheduling failure,
// never a user cancellation, so it gets its own `try`: even when the
// signal aborts in the same window it must still land on the error
// path (and its log line) rather than vanish into the cancellation
// branch below.
let runtimeView: ResolvedGeneratorForModel;
try {
runtimeView = await config
.getBaseLlmClient()
.resolveForModel(modelOverride.slice(0, -1), {
failClosed: true,
});
} catch (error) {
await completeAsSchedulingError(error);
return;
}
})();
try {
await scheduler.schedule(request, signal, runtimeView);
} catch (error) {
// The busy scheduler rejects a queued request once its signal
// aborts, which is a cancellation rather than a scheduling failure.
// Completing it as `cancelled` — instead of returning early — still
// runs the caller's completion path, the only place that releases
// the batch and continuation ownership registered for these
// callIds. An aborted signal alone is not enough: any other
// rejection is a real scheduling failure and must keep its
// `errorType`, so this matches the exact Core queue rejection
// (see `CoreToolScheduler.schedule`).
if (isQueuedCancellation(error)) {
await completeAsCancelled();
return;
}
await completeAsSchedulingError(error);
}
})().catch((error: unknown) => {
// Terminal handler for the detached task: the completion callbacks
// above are caller-supplied and may reject. Containing that here
// keeps a rejected completion from surfacing as an unhandled
// rejection, and deliberately does not retry or complete the batch a
// second time. Keep the stack and the callIds in the line: they are
// the only way to tie it back to a turn.
const detail =
error instanceof Error
? (error.stack ?? error.message)
: String(error);
debugLogger.error(
`Full-turn tool completion failed for callIds ${requests
.map((toolRequest) => toolRequest.callId)
.join(', ')}: ${detail}`,
);
});
},
[allToolCallsCompleteHandler, config, scheduler],
);
@@ -34,16 +34,39 @@ import {
CoreToolScheduler,
getRuntimeContentGenerator,
MockTool,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import { ToolCallStatus } from '../types.js';
// Mocks
const { debugLoggerErrors } = vi.hoisted(() => ({
debugLoggerErrors: [] as string[],
}));
vi.mock('@qwen-code/qwen-code-core', async () => {
const actual = await vi.importActual('@qwen-code/qwen-code-core');
const realCreateDebugLogger = (
actual as {
createDebugLogger: (tag?: string) => { error: (...a: unknown[]) => void };
}
).createDebugLogger;
return {
...actual,
ToolRegistry: vi.fn(),
Config: vi.fn(),
// Delegates to the real logger and records `error` lines, so tests can
// assert a contained failure was reported. The real logger is a no-op
// without an active debug session, which would make it unobservable.
createDebugLogger: (tag?: string) => {
const real = realCreateDebugLogger(tag);
return {
...real,
error: (...args: unknown[]) => {
debugLoggerErrors.push(args.map(String).join(' '));
real.error(...args);
},
};
},
};
});
@@ -274,6 +297,7 @@ describe('useReactToolScheduler', () => {
beforeEach(() => {
onComplete = vi.fn();
setPendingHistoryItem = vi.fn();
debugLoggerErrors.length = 0;
mockToolRegistry.getTool.mockClear();
mockToolRegistry.ensureTool.mockClear();
@@ -453,6 +477,7 @@ describe('useReactToolScheduler', () => {
message: expect.stringContaining('tool was not executed'),
}),
executionStatus: 'not_started',
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
}),
}),
]);
@@ -502,12 +527,571 @@ describe('useReactToolScheduler', () => {
message: expect.stringContaining('tool was not executed'),
}),
executionStatus: 'not_started',
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
}),
}),
]);
scheduleSpy.mockRestore();
});
it('keeps a full-turn cancellation when the signal aborts while the request is queued', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const runtimeView = {
contentGenerator: {},
contentGeneratorConfig: {
model: 'vision-agent',
authType: 'openai',
},
model: 'vision-agent',
};
(mockConfig.getBaseLlmClient as Mock).mockReturnValue({
resolveForModel: vi.fn().mockResolvedValue(runtimeView),
});
// Mirrors the busy-scheduler branch of CoreToolScheduler.schedule: the
// request parks in the queue and its abort listener rejects with the
// queue-cancellation error.
const scheduleSpy = vi
.spyOn(CoreToolScheduler.prototype, 'schedule')
.mockImplementation(
(_request, signal) =>
new Promise<void>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => reject(new Error('Tool call cancelled while in queue.')),
{ once: true },
);
}),
);
try {
const { result } = renderScheduler();
const request = {
callId: 'queued-full-turn-call',
name: 'mockTool',
args: {},
} as ToolCallRequestInfo;
const abortController = new AbortController();
act(() => {
result.current[1]([request], abortController.signal, 'vision-agent\0');
});
// Let resolveForModel settle so the request is queued before the abort.
await act(async () => {
await vi.runAllTimersAsync();
});
expect(scheduleSpy).toHaveBeenCalled();
act(() => {
abortController.abort();
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mockTool.execute).not.toHaveBeenCalled();
expect(result.current[0]).toEqual([]);
expect(
onComplete.mock.calls
.flat(Infinity)
.filter(
(toolCall: any) =>
toolCall?.status === 'error' ||
toolCall?.response?.errorType ===
ToolErrorType.UNHANDLED_EXCEPTION,
),
).toEqual([]);
// The cancelled call still travels the completion path so the caller
// releases the batch and continuation ownership it registered.
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'cancelled',
request,
response: expect.objectContaining({
callId: request.callId,
errorType: undefined,
executionStatus: 'not_started',
responseParts: [
{
functionResponse: {
id: request.callId,
name: 'mockTool',
response: {
error:
'[Operation Cancelled] Reason: Tool call cancelled before execution.',
},
},
},
],
}),
}),
]);
} finally {
scheduleSpy.mockRestore();
}
});
it('keeps a full-turn cancellation when the signal is already aborted before scheduling', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const runtimeView = {
contentGenerator: {},
contentGeneratorConfig: {
model: 'vision-agent',
authType: 'openai',
},
model: 'vision-agent',
};
(mockConfig.getBaseLlmClient as Mock).mockReturnValue({
resolveForModel: vi.fn().mockResolvedValue(runtimeView),
});
const scheduleSpy = vi
.spyOn(CoreToolScheduler.prototype, 'schedule')
.mockRejectedValueOnce(new Error('Tool call cancelled while in queue.'));
const { result } = renderScheduler();
// Two requests in one batch pin the arity of the completion: every
// queued callId must be completed exactly once.
const request = {
callId: 'pre-aborted-full-turn-call',
name: 'mockTool',
args: {},
} as ToolCallRequestInfo;
const secondRequest = {
callId: 'pre-aborted-full-turn-call-2',
name: 'mockTool',
args: {},
} as ToolCallRequestInfo;
const abortController = new AbortController();
abortController.abort();
act(() => {
result.current[1](
[request, secondRequest],
abortController.signal,
'vision-agent\0',
);
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mockTool.execute).not.toHaveBeenCalled();
expect(result.current[0]).toEqual([]);
expect(
onComplete.mock.calls
.flat(Infinity)
.filter(
(toolCall: any) =>
toolCall?.status === 'error' ||
toolCall?.response?.errorType === ToolErrorType.UNHANDLED_EXCEPTION,
),
).toEqual([]);
const cancelledPart = (toolRequest: ToolCallRequestInfo) => ({
functionResponse: {
id: toolRequest.callId,
name: 'mockTool',
response: {
error:
'[Operation Cancelled] Reason: Tool call cancelled before execution.',
},
},
});
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'cancelled',
request,
response: expect.objectContaining({
callId: request.callId,
errorType: undefined,
executionStatus: 'not_started',
responseParts: [cancelledPart(request)],
}),
}),
expect.objectContaining({
status: 'cancelled',
request: secondRequest,
response: expect.objectContaining({
callId: secondRequest.callId,
errorType: undefined,
executionStatus: 'not_started',
responseParts: [cancelledPart(secondRequest)],
}),
}),
]);
scheduleSpy.mockRestore();
});
it('reports a fail-closed resolution failure as an error even when the signal is aborted', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
(mockConfig.getBaseLlmClient as Mock).mockReturnValue({
resolveForModel: vi.fn().mockRejectedValue(new Error('missing route')),
});
const scheduleSpy = vi.spyOn(CoreToolScheduler.prototype, 'schedule');
try {
const { result } = renderScheduler();
const request = {
callId: 'aborted-resolution-full-turn-call',
name: 'mockTool',
args: {},
} as ToolCallRequestInfo;
const abortController = new AbortController();
abortController.abort();
act(() => {
result.current[1]([request], abortController.signal, 'vision-agent\0');
});
await act(async () => {
await vi.runAllTimersAsync();
});
// The signal aborted, but a fail-closed resolution rejection is a
// scheduling failure, not a cancellation: it must stay on the error
// path (and its log line) instead of vanishing into the cancellation
// branch, and the request must never reach the scheduler.
expect(mockTool.execute).not.toHaveBeenCalled();
expect(scheduleSpy).not.toHaveBeenCalled();
// The production comment promises the error path *and its log line*;
// the user-visible message alone would survive deleting the
// `debugLogger.error` call in `completeAsSchedulingError`, so pin it.
expect(
debugLoggerErrors.filter((line) =>
line.includes('Full-turn tool scheduling failed: missing route'),
),
).toHaveLength(1);
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'error',
request,
response: expect.objectContaining({
error: expect.objectContaining({
message: expect.stringContaining('tool was not executed'),
}),
executionStatus: 'not_started',
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
}),
}),
]);
} finally {
scheduleSpy.mockRestore();
}
});
it('reports a non-queue scheduler rejection as an error even when the signal is aborted', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const runtimeView = {
contentGenerator: {},
contentGeneratorConfig: {
model: 'vision-agent',
authType: 'openai',
},
model: 'vision-agent',
};
(mockConfig.getBaseLlmClient as Mock).mockReturnValue({
resolveForModel: vi.fn().mockResolvedValue(runtimeView),
});
// Runtime resolution succeeds, so the rejection comes from the scheduler
// itself — but it is *not* the Core queue rejection. An aborted signal
// alone must not reclassify it as a user cancellation.
const scheduleSpy = vi
.spyOn(CoreToolScheduler.prototype, 'schedule')
.mockRejectedValueOnce(new Error('already running'));
try {
const { result } = renderScheduler();
const request = {
callId: 'aborted-non-queue-full-turn-call',
name: 'mockTool',
args: {},
} as ToolCallRequestInfo;
const abortController = new AbortController();
abortController.abort();
act(() => {
result.current[1]([request], abortController.signal, 'vision-agent\0');
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mockTool.execute).not.toHaveBeenCalled();
expect(scheduleSpy).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
expect(
onComplete.mock.calls
.flat(Infinity)
.filter((toolCall: any) => toolCall?.status === 'cancelled'),
).toEqual([]);
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'error',
request,
response: expect.objectContaining({
error: expect.objectContaining({
message: expect.stringContaining('tool was not executed'),
}),
executionStatus: 'not_started',
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
}),
}),
]);
} finally {
scheduleSpy.mockRestore();
}
});
it('classifies the queue rejections real Core produces as cancellations', async () => {
// Contract pin for the cancellation classifier. Every other test here
// stubs `schedule()` with its own copy of Core's queue-rejection text, so
// all of them stay green if Core rewords that string and the hook's
// `error.message === …` comparison silently stops matching — which would
// send a user's Esc back down the error path (#11148). This one makes the
// real scheduler busy by parking a batch on a tool that never settles, so
// both reject sites in `CoreToolScheduler.schedule` are reached through
// public behaviour: one for a signal already aborted at schedule time,
// and one for an abort that lands while the request sits in the queue —
// the #11148 path.
let releaseParked: ((result: ToolResult) => void) | undefined;
const parkedTool = new MockTool({
name: 'mockTool',
displayName: 'Mock Tool',
execute: vi.fn(
() =>
new Promise<ToolResult>((resolve) => {
releaseParked = resolve;
}),
) as any,
});
mockToolRegistry.getTool.mockReturnValue(parkedTool);
const runtimeView = {
contentGenerator: {},
contentGeneratorConfig: {
model: 'vision-agent',
authType: 'openai',
},
model: 'vision-agent',
};
(mockConfig.getBaseLlmClient as Mock).mockReturnValue({
resolveForModel: vi.fn().mockResolvedValue(runtimeView),
});
const realSchedule = CoreToolScheduler.prototype.schedule;
// Record what Core's own `schedule()` rejected with, so the assertions
// below pin Core's half of the contract and not only the hook's.
const coreRejections: string[] = [];
const scheduleSpy = vi
.spyOn(CoreToolScheduler.prototype, 'schedule')
.mockImplementation(function (
this: CoreToolScheduler,
...args: Parameters<typeof realSchedule>
) {
return realSchedule.apply(this, args).catch((error: unknown) => {
coreRejections.push(
error instanceof Error ? error.message : String(error),
);
throw error;
});
});
try {
// Rendered directly rather than through `renderScheduler()`: that
// helper passes an inline arrow as `onEditorClose`, which is a
// `useMemo` dependency of the scheduler, so the display update from the
// parked batch below would swap in a fresh `CoreToolScheduler` and
// strand the batch that is supposed to keep it busy.
const onEditorClose = () => {};
const { result } = renderHook(() =>
useReactToolScheduler(
onComplete,
mockConfig as unknown as Config,
setPendingHistoryItem,
onEditorClose,
),
);
const flush = async () => {
await act(async () => {
await vi.runAllTimersAsync();
});
};
const scheduleFullTurn = (callId: string, signal: AbortSignal) => {
const request = {
callId,
name: 'mockTool',
args: {},
} as ToolCallRequestInfo;
act(() => {
result.current[1]([request], signal, 'vision-agent\0');
});
return request;
};
const cancelledCallsFor = (callId: string) =>
onComplete.mock.calls
.flat(Infinity)
.filter(
(toolCall: any) =>
toolCall?.request?.callId === callId &&
toolCall?.status === 'cancelled',
);
const expectCancelledOnce = (
callId: string,
request: ToolCallRequestInfo,
) => {
expect(cancelledCallsFor(callId)).toHaveLength(1);
// Core's own rejection was read as a user cancellation: no error
// history item, no UNHANDLED_EXCEPTION reported back to the model.
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'cancelled',
request,
response: expect.objectContaining({
callId,
error: undefined,
errorType: undefined,
executionStatus: 'not_started',
}),
}),
]);
};
// Busy for real: the parked batch reaches `execute` and stays there, so
// `isRunning()` holds without touching any private scheduler field.
act(() => {
result.current[1](
[
{
callId: 'parked-call',
name: 'mockTool',
args: {},
} as ToolCallRequestInfo,
],
new AbortController().signal,
);
});
await flush();
expect(releaseParked).toBeDefined();
expect(coreRejections).toHaveLength(0);
// Site 1: the signal is already aborted when Core is asked to schedule.
const preAbortedController = new AbortController();
preAbortedController.abort();
const preAbortedRequest = scheduleFullTurn(
'real-core-pre-aborted-call',
preAbortedController.signal,
);
await flush();
expect(coreRejections).toHaveLength(1);
expectCancelledOnce('real-core-pre-aborted-call', preAbortedRequest);
// Site 2: a live signal parks in `requestQueue` behind the busy batch
// and only the later abort rejects it. Site 1 rejects synchronously at
// schedule time, so a rejection that appears only after the abort
// cannot have come from it.
const queuedController = new AbortController();
const queuedRequest = scheduleFullTurn(
'real-core-queued-call',
queuedController.signal,
);
await flush();
expect(coreRejections).toHaveLength(1);
act(() => {
queuedController.abort();
});
await flush();
expect(coreRejections).toHaveLength(2);
expectCancelledOnce('real-core-queued-call', queuedRequest);
// Only the parked tool ever ran; neither full-turn batch executed, and
// neither was reported to the model as an unhandled failure.
expect(parkedTool.execute).toHaveBeenCalledTimes(1);
expect(
onComplete.mock.calls
.flat(Infinity)
.filter(
(toolCall: any) =>
toolCall?.response?.errorType ===
ToolErrorType.UNHANDLED_EXCEPTION,
),
).toEqual([]);
} finally {
scheduleSpy.mockRestore();
act(() => {
releaseParked?.({
llmContent: 'done',
returnDisplay: 'done',
} as ToolResult);
});
await act(async () => {
await vi.runAllTimersAsync();
});
}
});
it('contains a rejected full-turn cancellation completion without a second completion', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const runtimeView = {
contentGenerator: {},
contentGeneratorConfig: {
model: 'vision-agent',
authType: 'openai',
},
model: 'vision-agent',
};
(mockConfig.getBaseLlmClient as Mock).mockReturnValue({
resolveForModel: vi.fn().mockResolvedValue(runtimeView),
});
const scheduleSpy = vi
.spyOn(CoreToolScheduler.prototype, 'schedule')
.mockRejectedValueOnce(new Error('Tool call cancelled while in queue.'));
// The caller's completion callback rejects while releasing the batch.
onComplete.mockRejectedValue(new Error('cleanup failed'));
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
try {
const { result } = renderScheduler();
const request = {
callId: 'rejected-completion-full-turn-call',
name: 'mockTool',
args: {},
} as ToolCallRequestInfo;
const abortController = new AbortController();
abortController.abort();
act(() => {
result.current[1]([request], abortController.signal, 'vision-agent\0');
});
await act(async () => {
await vi.runAllTimersAsync();
});
// Give the detached task a real turn boundary so a rejection left
// unhandled would surface.
vi.useRealTimers();
await new Promise((resolve) => setTimeout(resolve, 0));
// The terminal handler logs the failure instead of letting it escape…
const completionFailures = debugLoggerErrors.filter((line) =>
line.includes('Full-turn tool completion failed'),
);
expect(completionFailures).toHaveLength(1);
// …naming the batch it failed to complete…
expect(completionFailures[0]).toContain(
'callIds rejected-completion-full-turn-call',
);
// …and keeping the stack, not just the message, so the throw site is
// recoverable from the line alone.
expect(completionFailures[0]).toContain('Error: cleanup failed');
expect(completionFailures[0]).toMatch(/\n\s+at /);
// …without retrying the completion a second time…
expect(onComplete).toHaveBeenCalledTimes(1);
// …and without an unhandled rejection.
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
scheduleSpy.mockRestore();
}
});
it('should handle tool not found', async () => {
mockToolRegistry.getTool.mockReturnValue(undefined);
const { result } = renderScheduler();