Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
38ba22d
feat(telemetry): Phase 2 — tool.blocked_on_user + hook spans
doudouOUC May 19, 2026
6767469
fix(telemetry): address #4321 review — Copilot inline + code-reviewer…
doudouOUC May 19, 2026
32f94d3
fix(telemetry): close autoApprove blocked-span leak + cover three new…
doudouOUC May 19, 2026
68dea8a
fix(telemetry): revert autoApprove catch finalizeBlockedSpan (#4321 c…
doudouOUC May 19, 2026
2b227b0
fix(telemetry): split tool.failure_kind labels + cover proceed_once d…
doudouOUC May 19, 2026
cc3f7fc
fix(telemetry): address #4321 wenshao Critical + bot summary nits
doudouOUC May 19, 2026
f9ff554
refactor(telemetry): extract withHookSpan helper + drop dead finalize…
doudouOUC May 19, 2026
eafe688
fix(telemetry): hook span error tracking + TTL cleanup safety + call_…
doudouOUC May 19, 2026
574f645
fix(telemetry): close hookError plumbing gaps from final pre-merge audit
doudouOUC May 19, 2026
48e78d6
test(telemetry): cover #4321 rethrow path + 2 of the new failure_kind…
doudouOUC May 19, 2026
9cbbdfc
fix(telemetry): adopt 4 wenshao Critical/Suggestion findings on PR #4321
doudouOUC May 20, 2026
31921a9
fix(telemetry): adopt 7 DeepSeek /review findings on PR #4321
doudouOUC May 20, 2026
fc509d5
fix(telemetry): adopt 3 wenshao /review findings on PR #4321
doudouOUC May 20, 2026
f0befac
fix(telemetry): adopt 7 wenshao /review round-3 findings on PR #4321
doudouOUC May 20, 2026
8716069
fix(telemetry): polish 2 wenshao /review round-4 nits on PR #4321
doudouOUC May 20, 2026
e7dd8aa
fix(telemetry): adopt 4 wenshao /review round-5 findings on PR #4321
doudouOUC May 20, 2026
51cb97c
fix(telemetry): adopt 1 wenshao /review round-6 finding on PR #4321
doudouOUC May 20, 2026
84851f2
fix(telemetry): close 4 silent-failure + test-gap findings from final…
doudouOUC May 21, 2026
2c268a8
fix(telemetry): adopt 3 wenshao /review round-8 findings on PR #4321
doudouOUC May 21, 2026
a1d1190
fix(telemetry): adopt 3 wenshao /review round-9 findings on PR #4321
doudouOUC May 21, 2026
ac7597e
test(telemetry): pin empty-string runner error sentinel behavior on P…
doudouOUC May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,708 changes: 1,708 additions & 0 deletions packages/core/src/core/coreToolScheduler.test.ts

Large diffs are not rendered by default.

767 changes: 712 additions & 55 deletions packages/core/src/core/coreToolScheduler.ts

Large diffs are not rendered by default.

104 changes: 95 additions & 9 deletions packages/core/src/core/toolHookTriggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('toolHookTriggers', () => {
expect(result).toEqual({ shouldProceed: true });
});

it('should return shouldProceed: true when hook execution fails', async () => {
it('should return shouldProceed: true with sentinel hookError when hook execution fails without an error message', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
Expand All @@ -72,7 +72,38 @@ describe('toolHookTriggers', () => {
'auto',
);

expect(result).toEqual({ shouldProceed: true });
// #4321 review-7 SF-H1: runner contract violation (success:false
// with no error.message) used to silently return allow with no
// telemetry. Now synthesizes a sentinel hookError so the span
// records `success: false` + the description of what went wrong.
Comment thread
doudouOUC marked this conversation as resolved.
expect(result.shouldProceed).toBe(true);
expect(result.hookError).toMatch(/success: false/);
});

it('synthesizes sentinel hookError when runner returns empty-string error message (#4321)', async () => {
// #4321 review-9: pin the `||` (not `??`) semantics. A future
// regression back to `??` would preserve `hookError: ""` here
// which downstream `r.hookError ? ...` truthiness then silently
// drops — same allow-without-telemetry pathology SF-H1 closed.
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
error: { message: '' },
});

const result = await firePreToolUseHook(
mockMessageBus,
'test-tool',
{},
'test-id',
'auto',
);

expect(result.shouldProceed).toBe(true);
expect(result.hookError).toMatch(/success: false/);
// Specifically NOT empty: an empty string would round-trip through
// a downstream truthiness check as missing.
expect(result.hookError).not.toBe('');
});

it('should return shouldProceed: true when hook output is empty', async () => {
Expand Down Expand Up @@ -215,7 +246,13 @@ describe('toolHookTriggers', () => {
'auto',
);

expect(result).toEqual({ shouldProceed: true });
// #4321 review: hookError surfaces the swallowed transport error so
// observers (telemetry spans, debug logs) can distinguish a failed
// hook from a successful "allow" decision.
expect(result).toEqual({
shouldProceed: true,
hookError: 'Network error',
});
});
});

Expand All @@ -233,7 +270,7 @@ describe('toolHookTriggers', () => {
expect(result).toEqual({ shouldStop: false });
});

it('should return shouldStop: false when hook execution fails', async () => {
it('should return shouldStop: false with sentinel hookError when hook execution fails without an error message', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
Expand All @@ -248,7 +285,31 @@ describe('toolHookTriggers', () => {
'auto',
);

expect(result).toEqual({ shouldStop: false });
// #4321 review-7 SF-H1 — see firePreToolUseHook counterpart.
expect(result.shouldStop).toBe(false);
expect(result.hookError).toMatch(/success: false/);
});

it('synthesizes sentinel hookError when runner returns empty-string error message (#4321)', async () => {
// #4321 review-9 — see firePreToolUseHook counterpart.
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
error: { message: '' },
});

const result = await firePostToolUseHook(
mockMessageBus,
'test-tool',
{},
{},
'test-id',
'auto',
);

expect(result.shouldStop).toBe(false);
expect(result.hookError).toMatch(/success: false/);
expect(result.hookError).not.toBe('');
});

it('should return shouldStop: false when hook output is empty', async () => {
Expand Down Expand Up @@ -338,7 +399,9 @@ describe('toolHookTriggers', () => {
'auto',
);

expect(result).toEqual({ shouldStop: false });
// #4321 review: hookError now surfaced to caller (see PreToolUse parallel test).
expect(result.shouldStop).toBe(false);
expect(result.hookError).toBeDefined();
});
});

Expand All @@ -355,7 +418,7 @@ describe('toolHookTriggers', () => {
expect(result).toEqual({});
});

it('should return empty object when hook execution fails', async () => {
it('should return sentinel hookError when hook execution fails without an error message', async () => {
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
Expand All @@ -369,7 +432,28 @@ describe('toolHookTriggers', () => {
'error message',
);

expect(result).toEqual({});
// #4321 review-7 SF-H1 — see firePreToolUseHook counterpart.
expect(result.hookError).toMatch(/success: false/);
});

it('synthesizes sentinel hookError when runner returns empty-string error message (#4321)', async () => {
// #4321 review-9 — see firePreToolUseHook counterpart.
const mockMessageBus = createMockMessageBus();
(mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
success: false,
error: { message: '' },
});

const result = await firePostToolUseFailureHook(
mockMessageBus,
'test-id',
'test-tool',
{},
'error message',
);

expect(result.hookError).toMatch(/success: false/);
expect(result.hookError).not.toBe('');
});

it('should return empty object when hook output is empty', async () => {
Expand Down Expand Up @@ -429,7 +513,9 @@ describe('toolHookTriggers', () => {
'error message',
);

expect(result).toEqual({});
// #4321 review: hookError now surfaced to caller.
expect(result.hookError).toBeDefined();
expect(result.additionalContext).toBeUndefined();
});
});

Expand Down
79 changes: 66 additions & 13 deletions packages/core/src/core/toolHookTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export interface PreToolUseHookResult {
blockType?: 'denied' | 'ask' | 'stop';
/** Additional context to add */
additionalContext?: string;
/**
* Set when the hook helper caught and absorbed a transport / dispatch
* error. The tool execution still proceeds (existing non-blocking
* contract), but observers (telemetry spans, debug logs) can detect
* that the hook itself failed instead of treating the safe-default
* response as a successful "allow" decision (#4321 review).
*/
hookError?: string;
}

/**
Expand All @@ -55,6 +63,8 @@ export interface PostToolUseHookResult {
stopReason?: string;
/** Additional context to append to tool response */
additionalContext?: string;
/** See PreToolUseHookResult.hookError. */
hookError?: string;
}

/**
Expand All @@ -63,6 +73,8 @@ export interface PostToolUseHookResult {
export interface PostToolUseFailureHookResult {
/** Additional context about the failure */
additionalContext?: string;
/** See PreToolUseHookResult.hookError. */
hookError?: string;
}

/**
Expand Down Expand Up @@ -107,7 +119,27 @@ export async function firePreToolUseHook(
);

if (!response.success || !response.output) {
return { shouldProceed: true };
// Hook runner reported failure (URL validation, fn exception,
// prompt-runner crash, ...). The `response.error` from the runner
// is the canonical cause — forward it so telemetry and operators
// see the actual failure instead of a fake "allow" success
// (#4321 review silent-failure-hunter HIGH).
//
// If runner returned `{ success: false }` (or missing output) with no
// `error.message`, synthesize a sentinel so the contract violation is
// still visible on the span instead of silently degrading to an allow
// with empty telemetry (#4321 review-7 silent-failure-hunter HIGH-1).
// `||` (revert from `??`): downstream consumers in
// coreToolScheduler.ts gate on `r.hookError ? ...`, so an
// empty-string message would be silently dropped — the previous
// `??` change defeated its own intent. Empty-string error
// messages carry no operator value; the sentinel is more
// actionable. (#4321 review-9 wenshao Suggestion refines
// review-8.)
const message =
response.error?.message ||
`hook runner returned ${response.success ? 'no output' : 'success: false'} without error detail`;
return { shouldProceed: true, hookError: message };
}

const preToolOutput = createHookOutput(
Expand Down Expand Up @@ -155,10 +187,9 @@ export async function firePreToolUseHook(
};
} catch (error) {
// Hook errors should not block tool execution
debugLogger.warn(
`PreToolUse hook error for ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
);
return { shouldProceed: true };
const message = error instanceof Error ? error.message : String(error);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Catch blocks construct hookError as error instanceof Error ? error.message : String(error) without the empty-string guard that the runner-contract-violation paths already apply. If a hook transport throws new Error(''), hookError is ''; downstream r.hookError ? ... (truthiness) silently drops it, and the span records success: true — the exact allow-without-telemetry pathology the || sentinel on the !response.success paths was designed to close.

Same issue at lines 277 (firePostToolUseHook catch) and 356 (firePostToolUseFailureHook catch).

Suggested change
const message = error instanceof Error ? error.message : String(error);
const message =
(error instanceof Error ? error.message : String(error)) ||
'hook threw with empty message';

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

debugLogger.warn(`PreToolUse hook error for ${toolName}: ${message}`);
return { shouldProceed: true, hookError: message };
}
}

Expand Down Expand Up @@ -207,7 +238,18 @@ export async function firePostToolUseHook(
);

if (!response.success || !response.output) {
return { shouldStop: false };
// See firePreToolUseHook for the rationale.
// `||` (revert from `??`): downstream consumers in
// coreToolScheduler.ts gate on `r.hookError ? ...`, so an
// empty-string message would be silently dropped — the previous
// `??` change defeated its own intent. Empty-string error
// messages carry no operator value; the sentinel is more
// actionable. (#4321 review-9 wenshao Suggestion refines
// review-8.)
const message =
response.error?.message ||
`hook runner returned ${response.success ? 'no output' : 'success: false'} without error detail`;
return { shouldStop: false, hookError: message };
}

const postToolOutput = createHookOutput(
Expand All @@ -232,10 +274,9 @@ export async function firePostToolUseHook(
};
} catch (error) {
// Hook errors should not affect tool result
debugLogger.warn(
`PostToolUse hook error for ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
);
return { shouldStop: false };
const message = error instanceof Error ? error.message : String(error);
debugLogger.warn(`PostToolUse hook error for ${toolName}: ${message}`);
return { shouldStop: false, hookError: message };
}
}

Expand Down Expand Up @@ -287,7 +328,18 @@ export async function firePostToolUseFailureHook(
);

if (!response.success || !response.output) {
return {};
// See firePreToolUseHook for the rationale.
// `||` (revert from `??`): downstream consumers in
// coreToolScheduler.ts gate on `r.hookError ? ...`, so an
// empty-string message would be silently dropped — the previous
// `??` change defeated its own intent. Empty-string error
// messages carry no operator value; the sentinel is more
// actionable. (#4321 review-9 wenshao Suggestion refines
// review-8.)
const message =
response.error?.message ||
`hook runner returned ${response.success ? 'no output' : 'success: false'} without error detail`;
return { hookError: message };
}

const failureOutput = createHookOutput(
Expand All @@ -301,10 +353,11 @@ export async function firePostToolUseFailureHook(
};
} catch (error) {
// Hook errors should not affect error handling
const message = error instanceof Error ? error.message : String(error);
debugLogger.warn(
`PostToolUseFailure hook error for ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
`PostToolUseFailure hook error for ${toolName}: ${message}`,
);
return {};
return { hookError: message };
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ export const SPAN_INTERACTION = 'qwen-code.interaction';
export const SPAN_LLM_REQUEST = 'qwen-code.llm_request';
export const SPAN_TOOL = 'qwen-code.tool';
export const SPAN_TOOL_EXECUTION = 'qwen-code.tool.execution';
/** Brackets the time a tool spends in `awaiting_approval` waiting on the user. */
export const SPAN_TOOL_BLOCKED_ON_USER = 'qwen-code.tool.blocked_on_user';
/** Wraps each pre/post-tool-use hook fire site for per-hook latency / decision tracking. */
export const SPAN_HOOK = 'qwen-code.hook';
10 changes: 10 additions & 0 deletions packages/core/src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,23 @@ export {
runInToolSpanContext,
startToolExecutionSpan,
endToolExecutionSpan,
startToolBlockedOnUserSpan,
endToolBlockedOnUserSpan,
startHookSpan,
endHookSpan,
getActiveInteractionSpan,
truncateSpanError,
} from './session-tracing.js';
export type {
StartInteractionOptions,
EndInteractionOptions,
LLMRequestMetadata,
ToolSpanMetadata,
ToolBlockedDecision,
ToolBlockedSource,
HookEvent,
StartHookSpanOptions,
HookSpanMetadata,
} from './session-tracing.js';
export {
addUserPromptAttributes,
Expand Down
Loading
Loading