mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
fix(telemetry): close autoApprove blocked-span leak + cover three new behaviors
Two follow-ups from the post-#6767469b2 review pass on PR #4321:
1. autoApproveCompatiblePendingTools error path was logging-only and
leaving the sibling tool's blocked_on_user span open until the 30-min
TTL fires. Symmetric with the success branch's
finalizeBlockedSpan('auto_approved', 'auto'), the catch now finalizes
with ('error', 'system') so the trace deterministically explains why
the sibling didn't auto-approve.
2. Three behaviors introduced by 6767469b2 had no test coverage:
- decision='error' from _schedule's outer catch when
getConfirmationDetails throws (asserts tool span ends, no blocked
span ever opens since the throw happens pre-awaiting_approval).
- source='ide' when getBlockedSource() honors getIdeMode (Cancel
path with getIdeMode: () => true).
- Explicit Cancel takes precedence over a concurrent signal.aborted
in the decision label — the bug the precedence flip was meant to
fix is now regression-tested.
Extracted a small `buildApprovalScheduler` helper for the two
awaiting_approval-flow tests; the throw-on-confirmation test reuses
StructuredErrorOnConfirmationTool.
🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
parent
6767469b23
commit
32f94d3485
2 changed files with 226 additions and 0 deletions
|
|
@ -4117,6 +4117,227 @@ describe('CoreToolScheduler telemetry spans', () => {
|
|||
expect(span.ended).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// #4321 follow-up review tests — three behaviors introduced by the
|
||||
// 6767469b2 follow-up that were not previously asserted.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a scheduler around a single MockEditTool that requires
|
||||
* approval. Used by the awaiting_approval-flow tests below.
|
||||
*/
|
||||
function buildApprovalScheduler(overrides: { getIdeMode?: () => boolean }): {
|
||||
scheduler: CoreToolScheduler;
|
||||
onToolCallsUpdate: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const mockEditTool = new MockEditTool();
|
||||
const mockToolRegistry = {
|
||||
getTool: () => mockEditTool,
|
||||
ensureTool: async () => mockEditTool,
|
||||
getFunctionDeclarations: () => [],
|
||||
tools: new Map(),
|
||||
discovery: {},
|
||||
registerTool: () => {},
|
||||
getToolByName: () => mockEditTool,
|
||||
getToolByDisplayName: () => mockEditTool,
|
||||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
getAllTools: () => [],
|
||||
getToolsByServer: () => [],
|
||||
} as unknown as ToolRegistry;
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||
getPermissionsAllow: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
terminalHeight: 30,
|
||||
}),
|
||||
storage: { getProjectTempDir: () => '/tmp' },
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null,
|
||||
isInteractive: () => true,
|
||||
getIdeMode: overrides.getIdeMode ?? (() => false),
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getChatRecordingService: () => undefined,
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getDisableAllHooks: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
onAllToolCallsComplete: vi.fn(),
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
return { scheduler, onToolCallsUpdate };
|
||||
}
|
||||
|
||||
it('blocked_on_user span ends with decision=error when getConfirmationDetails throws (#4321)', async () => {
|
||||
// Trigger _schedule's outer catch (line ~1711) by making
|
||||
// getConfirmationDetails throw. The blocked span hasn't been started
|
||||
// yet at the catch point — the span only opens AFTER setStatusInternal
|
||||
// 'awaiting_approval' which never runs in this path. So the outer
|
||||
// finalizeBlockedSpan('error', 'system') call is a no-op. Assert the
|
||||
// tool span still ends correctly.
|
||||
toolSpanRecords.length = 0;
|
||||
const declarativeTool = new StructuredErrorOnConfirmationTool(
|
||||
ToolErrorType.EDIT_REQUIRES_PRIOR_READ,
|
||||
);
|
||||
const mockToolRegistry = {
|
||||
getTool: () => declarativeTool,
|
||||
ensureTool: async () => declarativeTool,
|
||||
getFunctionDeclarations: () => [],
|
||||
tools: new Map(),
|
||||
discovery: {},
|
||||
registerTool: () => {},
|
||||
getToolByName: () => declarativeTool,
|
||||
getToolByDisplayName: () => declarativeTool,
|
||||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
getAllTools: () => [],
|
||||
getToolsByServer: () => [],
|
||||
} as unknown as ToolRegistry;
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||
getPermissionsAllow: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
terminalHeight: 30,
|
||||
}),
|
||||
storage: { getProjectTempDir: () => '/tmp' },
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null,
|
||||
getChatRecordingService: () => undefined,
|
||||
isInteractive: () => true,
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getDisableAllHooks: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
onAllToolCallsComplete: vi.fn(),
|
||||
onToolCallsUpdate: vi.fn(),
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
await scheduler.schedule(
|
||||
[
|
||||
{
|
||||
callId: 'err-1',
|
||||
name: 'structuredErrorOnConfirmationTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-err',
|
||||
},
|
||||
],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Tool span exists and ended; no blocked span ever opened (the throw
|
||||
// happens before setStatusInternal awaiting_approval).
|
||||
const toolSpans = toolSpanRecords.filter(
|
||||
(r) => r.name === 'tool.structuredErrorOnConfirmationTool',
|
||||
);
|
||||
expect(toolSpans).toHaveLength(1);
|
||||
expect(toolSpans[0].ended).toBe(true);
|
||||
expect(
|
||||
toolSpanRecords.filter((r) => r.name === 'tool.blocked_on_user'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('blocked_on_user span source=ide when getIdeMode returns true (#4321)', async () => {
|
||||
toolSpanRecords.length = 0;
|
||||
const { scheduler, onToolCallsUpdate } = buildApprovalScheduler({
|
||||
getIdeMode: () => true,
|
||||
});
|
||||
await scheduler.schedule(
|
||||
[
|
||||
{
|
||||
callId: 'ide-1',
|
||||
name: 'mockEditTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-ide',
|
||||
},
|
||||
],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const awaitingCall = (await waitForStatus(
|
||||
onToolCallsUpdate,
|
||||
'awaiting_approval',
|
||||
)) as WaitingToolCall;
|
||||
await awaitingCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
|
||||
const blockedSpan = toolSpanRecords.find(
|
||||
(r) => r.name === 'tool.blocked_on_user',
|
||||
);
|
||||
expect(blockedSpan?.blockedMetadata?.decision).toBe('cancel');
|
||||
// Key assertion: getBlockedSource() honored getIdeMode -> 'ide'.
|
||||
expect(blockedSpan?.blockedMetadata?.source).toBe('ide');
|
||||
});
|
||||
|
||||
it('explicit Cancel takes precedence over signal.aborted in decision label (#4321)', async () => {
|
||||
toolSpanRecords.length = 0;
|
||||
const abortController = new AbortController();
|
||||
const { scheduler, onToolCallsUpdate } = buildApprovalScheduler({});
|
||||
await scheduler.schedule(
|
||||
[
|
||||
{
|
||||
callId: 'cancel-1',
|
||||
name: 'mockEditTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-cancel',
|
||||
},
|
||||
],
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
const awaitingCall = (await waitForStatus(
|
||||
onToolCallsUpdate,
|
||||
'awaiting_approval',
|
||||
)) as WaitingToolCall;
|
||||
|
||||
// Abort the signal AND pass Cancel as outcome — both conditions true.
|
||||
abortController.abort();
|
||||
await awaitingCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
|
||||
const blockedSpan = toolSpanRecords.find(
|
||||
(r) => r.name === 'tool.blocked_on_user',
|
||||
);
|
||||
// Pre-fix this would have been 'aborted' / 'system'. The fix flips
|
||||
// precedence so an explicit user Cancel always wins.
|
||||
expect(blockedSpan?.blockedMetadata?.decision).toBe('cancel');
|
||||
expect(blockedSpan?.blockedMetadata?.source).toBe('cli');
|
||||
});
|
||||
});
|
||||
|
||||
// Integration tests for the fire* functions
|
||||
|
|
|
|||
|
|
@ -2826,6 +2826,11 @@ export class CoreToolScheduler {
|
|||
`Error checking confirmation for tool ${pendingTool.request.callId}:`,
|
||||
error,
|
||||
);
|
||||
// Symmetry with the success branch above: finalize this sibling's
|
||||
// blocked span with 'error' so the trace explains why it didn't
|
||||
// get auto-approved. Without this, the span lingers until the
|
||||
// 30-min TTL fires (#4321 review follow-up).
|
||||
this.finalizeBlockedSpan(pendingTool.request.callId, 'error', 'system');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue