Skip to content

feat(core): add post tool batch hooks - #4454

Merged
tanzhenxin merged 19 commits into
QwenLM:mainfrom
qqqys:feat/post-tool-batch-hook
Jun 3, 2026
Merged

feat(core): add post tool batch hooks#4454
tanzhenxin merged 19 commits into
QwenLM:mainfrom
qqqys:feat/post-tool-batch-hook

Conversation

@qqqys

@qqqys qqqys commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Adds a PostToolBatch hook event that runs once after a resolved tool-call batch and before the next model request. The hook receives the resolved tool calls, can add one batch-level context note, and can request a stop using the existing hook output contract.
  • Why it changed: Batch-level hooks let integrations observe or annotate the whole tool result set without running duplicate logic for each individual tool.
  • Reviewer focus: Please check the batch timing, the no-hooks fast path, and how batch additional context is attached to the final tool response.

Validation

  • Commands run:
    cd packages/core && npx vitest run src/core/toolHookTriggers.test.ts src/hooks/hookPlanner.test.ts src/hooks/hookEventHandler.test.ts src/hooks/hookSystem.test.ts src/core/coreToolScheduler.test.ts
    cd packages/cli && npx vitest run src/ui/components/hooks/constants.test.ts
    npm run build --workspace=packages/core
    npm run build --workspace=packages/cli
    git diff --check
  • Prompts / inputs used: N/A
  • Expected result: PostToolBatch fires once for a completed batch, includes every resolved tool call, and can add batch-level context before completion is submitted.
  • Observed result: Core focused tests passed, CLI hook UI tests passed, core and CLI package builds passed, and whitespace check passed.
  • Quickest reviewer verification path: Run the focused core scheduler test for PostToolBatch plus the hook trigger tests.
  • Evidence: 468 core tests passed in the focused run; 27 CLI hook UI tests passed.

Scope / Risk

  • Main risk or tradeoff: Batch-level context is attached to the final tool response so it reaches the next model request once per batch.
  • Not covered / not validated: Full repository build was not used as final proof because this temporary worktree needed local dependency symlink repair for channel workspaces; the affected core and CLI packages were built directly.
  • Breaking changes / migration notes: None expected. Existing hook events are unchanged.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️
Docker N/A N/A N/A
Podman N/A N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • Validated locally on macOS with focused package builds and tests.

Linked Issues / Bugs

Related #4343

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR adds a PostToolBatch hook event that fires once after all tool calls in a batch have resolved and before the next model request. The implementation is well-structured, follows existing hook patterns, and includes comprehensive tests. The batch-level hook enables integrations to observe or annotate the entire tool result set without duplicate logic per tool.

🔍 General Feedback

  • Positive aspects:

    • Clean integration with existing hook infrastructure (HookSystem, HookEventHandler, HookPlanner)
    • Consistent error handling pattern with other hook types (non-blocking, hookError tracking)
    • Good test coverage across coreToolScheduler, toolHookTriggers, hookEventHandler, hookPlanner, and hookSystem
    • Proper telemetry span integration for observability
    • Settings schema and UI constants updated for CLI integration
  • Architectural decisions:

    • Batch context is attached to the final tool response (last call in batch) - efficient approach to ensure context reaches the model once
    • Stop decisions from the hook convert the last tool call to an error state - clear semantics
    • No matcher context for PostToolBatch (like UserPromptSubmit, Stop) - appropriate since batch is a system-level event
  • Code quality:

    • Follows existing patterns for hook events consistently
    • Type definitions are clear and well-documented
    • Good use of helper functions (toPostToolBatchToolCall, withPostToolBatchAdditionalContext, withPostToolBatchStop)

🎯 Specific Feedback

🔵 Low

  • File: packages/core/src/core/coreToolScheduler.ts:724-735 - The toPostToolBatchToolCall function calls serializeToolResponse which is not shown in the diff. Consider adding a comment or inline documentation explaining what fields are included in the serialized response to help hook authors understand what data they'll receive.

  • File: packages/core/src/core/coreToolScheduler.ts:3286-3293 - The hasHooksForEvent?.('PostToolBatch') ?? true pattern means if hasHooksForEvent is undefined, hooks will fire. This is safe but could be more explicit. Consider a comment explaining why true is the safe default (e.g., "Default to firing hooks if checker is unavailable to ensure hook integrations aren't silently skipped").

  • File: packages/core/src/core/toolHookTriggers.ts:417 - The firePostToolBatchHook function uses createHookOutput('PostToolBatch', response.output) but doesn't type-assert to a specific output type like other hook functions do. While this works because the generic hook output interface supports the methods used, adding a type assertion or comment would improve clarity.

  • File: packages/cli/src/ui/components/hooks/constants.ts:129 - The short description "After a batch of tool calls resolves" could be slightly more precise: "After all tool calls in a batch resolve" to emphasize it fires once per batch, not per tool.

✅ Highlights

  • Test coverage: Excellent test coverage across multiple layers:

    • coreToolScheduler.test.ts: Integration test verifying PostToolBatch fires once with all resolved tool calls and context attachment
    • toolHookTriggers.test.ts: Unit tests for the hook trigger function including stop decisions and error handling
    • hookEventHandler.test.ts: Tests verifying hook execution without matcher context and proper input structure
    • hookPlanner.test.ts: Simple but important test confirming no matcher target
    • hookSystem.test.ts: Tests for the system-level wrapper
  • Error handling: Consistent with the established pattern from feat(telemetry): Phase 2 — tool.blocked_on_user + hook spans (#3731) #4321 review - synthesizes sentinel hookError when runner returns success:false without error details, ensuring telemetry visibility

  • Telemetry integration: session-tracing.ts updated to include PostToolBatch in the HookEvent type, enabling proper span tracking

  • Documentation: Hook descriptions clearly explain the input format (JSON with tool_calls array containing tool_name, tool_input, tool_use_id, tool_response)

);

if (batchHookResult.shouldStop) {
completedCalls = withPostToolBatchStop(

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.

[Critical] withPostToolBatchStop silently discards additionalContext.

When the hook returns both additionalContext and shouldStop: true, the code first appends context to the last call's response (line 3326), then withPostToolBatchStop replaces that same response with a fresh createErrorResponse(), destroying the appended context. Hook authors who return both fields will find their additionalContext silently lost.

Suggested change
completedCalls = withPostToolBatchStop(
if (batchHookResult.shouldStop) {
completedCalls = withPostToolBatchStop(
completedCalls,
batchHookResult.stopReason ||
'Execution stopped by PostToolBatch hook',
);
// Re-apply additionalContext to the (now-error) last response
completedCalls = withPostToolBatchAdditionalContext(
completedCalls,
batchHookResult.additionalContext,
);
}

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

const response = part.functionResponse.response ?? {};
const output = response['output'];
const error = response['error'];
const key = typeof output === 'string' ? 'output' : '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.

[Critical] appendContextToResponsePart writes to 'error' field when output is non-string.

When output is an object (e.g., structured JSON from MCP tools), typeof output !== 'string', so key becomes 'error'. Then currentText falls through to JSON.stringify(response) (stringifying the entire response), creating a spurious error field on a success response. The model may interpret this as a tool failure.

Suggested change
const key = typeof output === 'string' ? 'output' : 'error';
const key =
typeof output === 'string' ? 'output'
: typeof error === 'string' ? 'error'
: 'output';

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

const batchHookResult = await this.withHookSpan(
{ hookEvent: 'PostToolBatch', toolName: 'batch' },
() =>
firePostToolBatchHook(

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.

[Critical] firePostToolBatchHook is called without an AbortSignal.

The function signature accepts signal?: AbortSignal (toolHookTriggers.ts:388) and the messageBus request forwards it, but no signal is passed here. A misbehaving or slow PostToolBatch hook (e.g., an HTTP hook with no timeout) can block the tool-completion path indefinitely with no way for the user to cancel.

Suggested change
firePostToolBatchHook(
() =>
firePostToolBatchHook(
messageBus,
completedCalls.map(toPostToolBatchToolCall),
this.currentSignal,
),

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

expect(lastResponse?.functionResponse?.response?.['output']).toContain(
'batch context',
);
});

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.

[Critical] Missing integration test for shouldStop: true path.

The existing test only covers the additionalContext scenario. The shouldStop path (lines 3332-3338 of coreToolScheduler.ts) — which converts the last completed call into an ErroredToolCall with EXECUTION_DENIED — has no test coverage. This is the feature's primary control-flow action. At minimum, add a test that:

  1. Has the mock messageBus return { continue: false, stopReason: 'halt' } for PostToolBatch
  2. Asserts the last completed call has status === 'error'
  3. Asserts response.errorType === ToolErrorType.EXECUTION_DENIED
  4. Asserts response.error.message contains 'halt'

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

const batchOutput = createHookOutput('PostToolBatch', response.output);

return {
shouldStop: batchOutput.shouldStopExecution(),

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] shouldStopExecution() is called twice. Cache the result for consistency with the existing firePostToolUseHook pattern (line 276) which uses early-return:

Suggested change
shouldStop: batchOutput.shouldStopExecution(),
const shouldStop = batchOutput.shouldStopExecution();
return {
shouldStop,
stopReason: shouldStop ? batchOutput.getEffectiveReason() : undefined,
additionalContext: batchOutput.getAdditionalContext(),
};

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

requiresRestart: false,
default: [],
description:
'Hooks that execute after every tool call in a batch resolves.',

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] Description is misleading — "after every tool call in a batch resolves" implies per-call semantics. The hook fires once after all tool calls resolve.

Suggested change
'Hooks that execute after every tool call in a batch resolves.',
'Hooks that execute once after all tool calls in a batch resolve.',

Also update the same string in packages/vscode-ide-companion/schemas/settings.schema.json (line 1607).

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


// PostToolBatch uses DefaultHookOutput intentionally: it only needs the
// common stop and additional-context helpers.
const batchOutput = createHookOutput('PostToolBatch', response.output);

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.

[Critical] Exit code 2 (standard "block" pattern) does not stop PostToolBatch execution.

firePostToolBatchHook calls createHookOutput('PostToolBatch', ...) which falls to the default case returning DefaultHookOutput. Its shouldStopExecution() only checks continue === false, not decision === 'deny'.

When a hook exits with code 2, convertPlainTextToHookOutput produces {decision: 'deny', reason: text} — which does NOT set continue. The batch continues silently.

Other hooks (PreToolUse, PostToolUse) check isDenied() which DOES inspect decision === 'deny', so the exit-code-2 pattern works correctly for them.

Suggested change
const batchOutput = createHookOutput('PostToolBatch', response.output);
case HookEventName.PostToolBatch:
return new PostToolBatchHookOutput(data);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

try {
const shouldFirePostToolBatch =
!this.config.getDisableAllHooks() &&
// Default to firing when the hook registry cannot answer. This keeps

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] hasHooksForEvent?.('PostToolBatch') ?? true defaults to true — should be false.

Config.hasHooksForEvent always exists and internally uses ?? false. If a test mock or future refactoring removes the method, the ?. returns undefined and ?? true fires PostToolBatch on every batch — the opposite of the intended optimization. This asymmetry means all other hooks silently break while PostToolBatch silently fires.

Suggested change
// Default to firing when the hook registry cannot answer. This keeps
(this.config.hasHooksForEvent('PostToolBatch'))

— DeepSeek/deepseek-v4-pro via Qwen Code /review

batchSignal,
),
(r) =>
r.hookError

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] completedCalls.map(toPostToolBatchToolCall) allocates serialized tool response objects even when no PostToolBatch hooks are configured.

Move the .map() call into the if (messageBus) block (after line ~3317) so it only runs when hooks are actually registered. This avoids unnecessary allocations for the majority of users who don't use PostToolBatch hooks.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

const response = part.functionResponse.response ?? {};
const output = response['output'];
const error = response['error'];
const hasOutput = Object.prototype.hasOwnProperty.call(response, 'output');

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] appendContextToResponsePartkey and currentText decision trees diverge when both output (non-string) and error (string) are present.

When response = { output: 42, error: "some error" }: key evaluates to 'error' (because hasOutput && typeof error !== 'string' is false), but currentText evaluates to JSON.stringify(output)"42" (because hasOutput is true). The stringified output overwrites the original error message.

While current code paths produce either { output } or { error } (never both), this utility function should handle the mixed case correctly. Derive both from a single boolean:

Suggested change
const hasOutput = Object.prototype.hasOwnProperty.call(response, 'output');
const useOutputKey =
typeof output === 'string' || (hasOutput && typeof error !== 'string');
const key = useOutputKey ? 'output' : 'error';
const currentText = useOutputKey
? typeof output === 'string'
? output
: JSON.stringify(output)
: typeof error === 'string'
? error
: JSON.stringify(response);

— glm-5.1 via Qwen Code /review

completedCalls,
batchHookResult.stopReason ||
'Execution stopped by PostToolBatch hook',
);

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] Order matters here but is undocumented — add a comment.

withPostToolBatchStop replaces the last call's entire response with createErrorResponse. withPostToolBatchAdditionalContext appends to whatever response exists. Stop MUST run first — if reordered, additionalContext is silently discarded (this was the exact bug fixed in this PR's prior commit). A future maintainer could easily reorder them during a refactor since "context then stop" sounds more natural.

Suggested change
);
// Order matters: stop MUST precede additionalContext.
// withPostToolBatchStop replaces the last call's response entirely,
// so context must be appended AFTER the stop decision is applied.
if (batchHookResult.shouldStop) {

— glm-5.1 via Qwen Code /review

.calls as unknown as Array<[ToolCall[]]>;
const completedCalls = completionCalls[0]?.[0];
const lastCompletedCall = completedCalls?.at(-1);
expect(lastCompletedCall?.status).toBe('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] Add an assertion that non-last calls are unaffected by the stop decision.

The test only asserts properties of completedCalls.at(-1). If a regression incorrectly marks all calls in the batch as errors (not just the last), this test would not catch it. withPostToolBatchStop is designed to modify only the last call — a defensive assertion would guard against scope creep:

Suggested change
expect(lastCompletedCall?.status).toBe('error');
const completedCalls = completionCalls[0]?.[0];
const lastCompletedCall = completedCalls?.at(-1);
expect(completedCalls[0]?.status).toBe('success');
expect(lastCompletedCall?.status).toBe('error');

— glm-5.1 via Qwen Code /review

| 'PreToolUse'
| 'PostToolUse'
| 'PostToolUseFailure'
| 'PostToolBatch';

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] Missing telemetry span test for PostToolBatch.

The HookEvent union was extended with 'PostToolBatch' and the scheduler creates PostToolBatch hook spans, but no test was added in session-tracing.test.ts. Every other hook event variant (PreToolUse, PostToolUse, PostToolUseFailure) has a dedicated telemetry test. PostToolBatch uses a synthetic toolName: 'batch' and carries should_stop + has_additional_context attributes — without a test, regressions in span attribute names would go undetected.

— glm-5.1 via Qwen Code /review

@wenshao wenshao left a comment

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.

[Critical] hookAggregator.ts missing PostToolBatch in mergeWithOrLogic and createSpecificHookOutput (file not in this PR's diff, so cannot be anchored as inline comment).

PostToolBatch falls through to mergeSimple in the mergeOutputs switch (hookAggregator.ts:102-118), which uses last-writer-wins { ...merged, ...output }. With two PostToolBatch hooks where hook 1 returns continue: false and hook 2 returns continue: true, the stop decision is silently overridden.

Similarly, createSpecificHookOutput (hookAggregator.ts:357-377) lacks a PostToolBatch case, so the merged result is wrapped in DefaultHookOutput instead of PostToolBatchHookOutput — the shouldStopExecution() override that checks isBlockingDecision() is not applied.

All other stop-capable events (PreToolUse, PostToolUse, Stop, SubagentStop, etc.) use mergeWithOrLogic.

// In mergeOutputs switch, add:
case HookEventName.PostToolBatch:
  merged = this.mergeWithOrLogic(outputs, eventName);
  break;

// In createSpecificHookOutput switch, add:
case HookEventName.PostToolBatch:
  return new PostToolBatchHookOutput(output);

— qwen3.7-max via Qwen Code /review

const completedCalls = [...this.toolCalls] as CompletedToolCall[];
let completedCalls = [...this.toolCalls] as CompletedToolCall[];
this.toolCalls = [];
const batchSignal = completedCalls

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.

[Critical] batchSignal is always undefined — abort signal forwarding is dead code.

releaseBatchListenerIfDrained deletes entries from this.callIdToBatch in finalizeToolSpan (called for each tool call as it reaches terminal state), which runs before checkAndNotifyCompletion reaches this line. By the time all calls are terminal and this code executes, every callIdToBatch entry for the batch has already been removed. The .map(...).find(...) always returns undefined, so firePostToolBatchHook never receives an AbortSignal.

Suggested fix: Capture the AbortSignal at batch-creation time (in _schedule, where BatchAbortState is constructed) and store it alongside the tool calls — for example, on each CompletedToolCall or as a scheduler field keyed by batch. Retrieve it directly here instead of depending on callIdToBatch map state that has already been cleaned up.

— qwen3.7-max via Qwen Code /review

/**
* PostToolBatch hook output
*/
export interface PostToolBatchOutput extends HookOutput {

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] PostToolBatchOutput interface is declared and exported but never imported anywhere in the codebase. Every other hook output interface (PostToolUseOutput, StopOutput, etc.) is consumed somewhere.

Either remove this interface or consume it where appropriate (e.g., as the return type annotation in hookSystem.firePostToolBatchEvent instead of the widened DefaultHookOutput).

— qwen3.7-max via Qwen Code /review

stopReason: shouldStop ? batchOutput.getEffectiveReason() : undefined,
additionalContext: batchOutput.getAdditionalContext(),
};
} catch (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] Missing test for messageBus.request rejection path.

The catch block at lines 428-431 returns { shouldStop: false, hookError: message } when messageBus.request throws, but no test exercises this path. Every other fire*Hook function in this file has a corresponding mockRejectedValue test:

  • firePreToolUseHook (test line 238)
  • firePostToolUseHook (test line 390)
  • firePostToolUseFailureHook (test line 602)

Add a test to follow the established pattern:

it('should return hookError when messageBus.request throws', async () => {
  const mockMessageBus = createMockMessageBus();
  (mockMessageBus.request as ReturnType<typeof vi.fn>).mockRejectedValue(
    new Error('bus timeout'),
  );
  const result = await firePostToolBatchHook(mockMessageBus, []);
  expect(result.shouldStop).toBe(false);
  expect(result.hookError).toContain('bus timeout');
});

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

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.

All Round 3 Critical/Suggestion findings are well-addressed in this push:

  • batchSignal dead code → New callIdToPostToolBatchSignal Map with explicit lifecycle and cleanup, documented with inline comment explaining why callIdToBatch cannot be reused.
  • hookAggregator missing PostToolBatch → Added to both mergeWithOrLogic and createSpecificHookOutput switches, with tests for stop and deny decisions across multiple hooks.
  • firePostToolBatchHook catch block → Test added for messageBus.request rejection path.
  • appendContextToResponsePart key/currentText divergence → Unified with useOutputKey variable.
  • Allocation on no-hook path.map(toPostToolBatchToolCall) moved inside if (messageBus) block.
  • shouldStop integration test → Added with non-last-call assertion.

Build passes, 263 focused tests pass (coreToolScheduler, toolHookTriggers, hookAggregator). Full review with 9 parallel agents + reverse audit found no new issues.

— qwen3.7-max via Qwen Code /review

// Keep the scheduling signal until the all-calls-complete hook fires.
// callIdToBatch is drained earlier when spans end, so it cannot be used
// to recover the PostToolBatch AbortSignal reliably.
private callIdToPostToolBatchSignal = new Map<string, AbortSignal>();

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] callIdToPostToolBatchSignal has a single cleanup path (line 3300 in checkAndNotifyCompletion), while the parallel map callIdToBatch is also cleaned up in releaseBatchListenerIfDrained (called from finalizeToolSpan). If a tool call gets stuck and never reaches terminal state (e.g., an awaiting_approval call that is never resolved), the AbortSignal reference persists for the scheduler's lifetime.

In a long-lived daemon session, orphaned entries hold references to AbortSignal objects and their listener chains. Consider adding a defensive cleanup in drainSpansForBatch (the abort path) for callIds whose tool call has already reached terminal state, or document the accepted risk.

— qwen3.7-max via Qwen Code /review

expect(lastResponse?.['error']).toContain('halt');
expect(lastResponse?.['error']).toContain('batch context');
}
});

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] Two PostToolBatch code paths lack scheduler-level integration tests:

(1) hookError path: When firePostToolBatchHook returns hookError (e.g., messageBus.request returns success: false), the withHookSpan callback maps it to { success: false, error, shouldStop: false }. No test verifies that tool calls pass through unmodified and onAllToolCallsComplete still fires.

(2) Map cleanup: The callIdToPostToolBatchSignal.delete() loop at line 3300 is untested. A post-condition assertion like (scheduler as any).callIdToPostToolBatchSignal.size === 0 in existing tests would cover the cleanup path.

— qwen3.7-max via Qwen Code /review

* Fired once after every tool call in a batch has resolved.
*/
export interface PostToolBatchInput extends HookInput {
tool_calls: PostToolBatchToolCall[];

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] PostToolBatchInput omits permission_mode, while PostToolUseInput (line 640) and PostToolUseFailureInput (line 665) both include it. Hook authors who write both PostToolUse and PostToolBatch hooks will find the batch input missing a field they rely on for permission-context-aware stop decisions (e.g., "only stop in default mode, not in yolo").

Suggested change
tool_calls: PostToolBatchToolCall[];
export interface PostToolBatchInput extends HookInput {
permission_mode: PermissionMode;
tool_calls: PostToolBatchToolCall[];
}

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

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] Test coverage gaps at the scheduler integration level: (1) callIdToPostToolBatchSignal cleanup is never verified across multiple sequential batches — stale signals could leak without detection; (2) no scheduler test verifies that tool calls pass through unchanged when firePostToolBatchHook returns hookError; (3) firePostToolBatchHook(undefined, ...) early-return guard is missing its standard test (every sibling hook trigger has one); (4) appendContextToResponsePart non-string output branch is untested (both integration tests use string outputs only).

[Suggestion] firePostToolBatchHook and PostToolBatchHookResult are not exported from the core package's public barrel (index.ts), while firePostToolUseHook and firePostToolUseFailureHook are. This means the ACP/daemon/VS Code integration path (Session.runToolCalls) cannot fire PostToolBatch hooks — the event works in CLI mode only.

);
}
if (messageBus) {
const batchToolCalls = completedCalls.map(toPostToolBatchToolCall);

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.

[Critical] isRunning() returns false during the PostToolBatch hook await window.

this.toolCalls = [] at line 3293 empties the array. isFinalizingToolCalls is only set to true at line 3362 — after the await this.withHookSpan(...) completes. During this await, isRunning() (line 1195: this.isFinalizingToolCalls || this.toolCalls.some(...)) returns false.

A concurrent schedule() call during this window bypasses the request queue (since isRunning() returns false) and starts execution while the previous batch's completion processing is still in flight. This can cause batch interleaving and corrupted state.

Suggested change
const batchToolCalls = completedCalls.map(toPostToolBatchToolCall);
this.toolCalls = [];
this.isFinalizingToolCalls = true;
try {
const batchSignal = completedCalls

Then wrap the rest of the completion logic in a finally { this.isFinalizingToolCalls = false; } block (replacing the existing set/unset around onAllToolCallsComplete).

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

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.

Round 7 review: permission_mode threading through the PostToolBatch hook chain is correct — consistent signatures, defaults, and test updates at every layer. The new hookError test and callIdToPostToolBatchSignal.size === 0 assertions are solid additions.

The R6 Critical (isRunning() returning false during the PostToolBatch hook await window — isFinalizingToolCalls set after the await at line 3320) remains unresolved and still blocks merge.

All prior open inline comments (4 Critical + 11 Suggestion) are still outstanding. tsc + eslint clean, 542/542 tests pass, CI green (8/8).

— qwen3.7-max via Qwen Code /review

}
this.notifyToolCallsUpdate();
// After completion, process the next item in the queue.
if (this.requestQueue.length > 0) {

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] Queue drain sits outside the try/finally — queued schedule() promises hang forever if the try block throws.

The try/finally correctly resets isFinalizingToolCalls on error, but the requestQueue draining at lines 3379-3384 is after the finally block. If recordToolResults, onAllToolCallsComplete, logToolCall, or notifyToolCallsUpdate throws:

  1. finally resets isFinalizingToolCalls = false
  2. The exception propagates out of checkAndNotifyCompletion
  3. Both call sites (lines 1157, 2186) are fire-and-forget — the error becomes an unhandled rejection
  4. Queue draining is never reached — any queued schedule() promises hang indefinitely (never resolve, never reject)

This commit expanded the at-risk surface: the entire PostToolBatch hook, logToolCall, and recordToolResults are now inside the try — more throw-points than before, same unprotected drain.

Suggested change
if (this.requestQueue.length > 0) {
} finally {
this.isFinalizingToolCalls = false;
// Always drain the queue — queued schedule() callers must not hang.
if (this.requestQueue.length > 0) {
const next = this.requestQueue.shift()!;
this._schedule(next.request, next.signal)
.then(next.resolve)
.catch(next.reject);
}
}

— qwen3.7-max via Qwen Code /review

).toBe(0);
});

it('queues new tool calls while a PostToolBatch hook is still running', async () => {

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] The new queueing test covers the happy path but not the error path the try/finally was designed for.

The test correctly verifies that schedule() during a pending PostToolBatch hook queues rather than re-entering _schedule(). However, no test exercises the scenario where the hook or completion callback throws while items are queued — which is exactly the failure mode that motivated the try/finally structure.

A test where onAllToolCallsComplete rejects (or the PostToolBatch hook returns success: false causing an internal throw) while a second schedule() is queued would verify that:

  • isFinalizingToolCalls is properly reset via finally
  • The queue is still drained after the error
  • The scheduler remains usable for subsequent batches

— qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes May 23, 2026

@wenshao wenshao left a comment

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.

R9 changes look good. Both R8 Suggestions are properly addressed: queue drain moved inside finally prevents hung promises on error paths, and the new test correctly validates the error recovery flow. The .catch() handlers on fire-and-forget checkAndNotifyCompletion() calls are a solid defensive improvement. 544/544 tests pass, tsc and eslint clean. LGTM! ✅ — qwen3.7-max via Qwen Code /review

});
});

describe('firePostToolBatchHook', () => {

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] Missing test branches for firePostToolBatchHook edge cases.

  1. No messageBus early-return: every other hook trigger function (firePreToolUseHook, firePostToolUseHook, firePostToolUseFailureHook, etc.) has a 'should return ... when no messageBus is provided' test. PostToolBatch is the only one missing it.

  2. success:true, output:undefined: the function at toolHookTriggers.ts:413 checks !response.success || !response.output and produces the message 'hook runner returned no output without error detail'. Only the success:false branch is tested (line 498). The distinct success:true, output:undefined path is never exercised.

// Suggested tests:
it('should return shouldStop: false when no messageBus is provided', async () => {
  const result = await firePostToolBatchHook(undefined, []);
  expect(result).toEqual({ shouldStop: false });
});

it('should return hookError when hook returns success without output', async () => {
  const mockMessageBus = createMockMessageBus();
  (mockMessageBus.request as ReturnType<typeof vi.fn>).mockResolvedValue({
    success: true,
    output: undefined,
  });
  const result = await firePostToolBatchHook(mockMessageBus, []);
  expect(result.shouldStop).toBe(false);
  expect(result.hookError).toMatch(/no output/);
});

— claude-opus-4-7 via Claude Code /qreview

contentLength: error.message.length,
});

function serializeToolResponse(

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] serializeToolResponse produces a tool_response schema that differs from PostToolUse's without documentation.

PostToolBatch: { response_parts, result_display, error, error_type, content_length }
PostToolUse: { llmContent, returnDisplay } (via toolHookTriggers.ts:247)

Hook authors working with both events will encounter different field names for similar data. The PostToolBatchToolCall.tool_response type is Record<string, unknown> with no JSDoc listing the fields.

Consider adding JSDoc on PostToolBatchToolCall.tool_response documenting the shape, or aligning field names where possible (e.g., include result_display alongside response_parts).

— claude-opus-4-7 via Claude Code /qreview

wenshao
wenshao previously approved these changes May 24, 2026

@wenshao wenshao left a comment

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.

R10 (14c1fd7): No issues found. LGTM! ✅

The R9→R10 delta adds well-targeted test coverage: two new tests for firePostToolBatchHook (no-messageBus early return, success-without-output hookError) and a PostToolBatch telemetry span test. All three new tests are correct and match the implementation. JSDoc and grammar improvements in types.ts are accurate.

9 review agents + 1 reverse audit round → 0 findings. tsc/eslint clean. 128/128 relevant tests pass. CI green 8/8.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Local Real-Test Verification Report

Re-validating after the round 5 sequence (fix(core): drain post-tool-batch queue on errors + test(core): cover post tool batch edge cases / telemetry) and your approval at 2026-05-24 08:45. PR HEAD 14c1fd777 re-merged onto main e8b79d772 via GitHub's test-merge 738cbddc0. The strategy: run the focused unit suite at exactly the commands the PR description claims, then write a real shell-command hook and drive it from a live qwen non-interactive run against qwen3.7-max to verify the contract end-to-end.

Test environment

  • PR HEAD: 14c1fd777 (test(core): cover post tool batch edge cases)
  • Test-merge HEAD: 738cbddc0 (= 14c1fd777 merged onto main e8b79d772)
  • Worktree: /private/tmp/pr4454-merged
  • Tmux session: pr4454 (5 windows: install / unit / e2e / errors / verify)
  • Node: v22.17.0, macOS 25.4.0
  • LLM: qwen3.7-max via idealab.alibaba-inc.com/api/openai/v1

Suite results

Step Result
npm ci (incl. postinstall build + bundle) ✅ exit 0; dist/cli.js (5.78 MB) produced
vitest packages/core — exact tests from PR description + the two new test files 590 / 590 pass, 19.7 s (PR description quoted 468 — actual is 590 after rounds 2–5 added edge-case + telemetry coverage)
vitest packages/cliui/components/hooks/constants.test.ts 27 / 27 pass, 7.9 s

Per-file breakdown:

File Tests Status
core/toolHookTriggers.test.ts 57
core/coreToolScheduler.test.ts 168
hooks/hookSystem.test.ts 75
hooks/hookEventHandler.test.ts 118
hooks/hookAggregator.test.ts 43
hooks/hookPlanner.test.ts 58
telemetry/session-tracing.test.ts 71
cli ui/components/hooks/constants.test.ts 27
total 617

The four PostToolBatch-specific scheduler tests reviewers should focus on:

  • fires PostToolBatch once after a resolved tool batch before completion callback (line 750)
  • queues new tool calls while a PostToolBatch hook is still running (line 893)
  • applies PostToolBatch stop decisions and preserves additional context (line 1097)
  • passes through completed calls when PostToolBatch returns hookError (line 1199)

All four green.

Live e2e — real shell-command hook against a real model

A real node /tmp/pr4454-test/hooks/log-batch.mjs shell-command hook was registered under hooks.PostToolBatch in .qwen/settings.json, then dist/cli.js -p '…' was run against qwen3.7-max in a fresh workspace /private/tmp/pr4454-test/workspace/{alpha,beta,gamma}.txt. Three sub-tests:

E1. Contract — payload shape

{
  "hook_event_name": "PostToolBatch",
  "permission_mode": "yolo",
  "has_session_id": true,
  "has_transcript_path": true,
  "has_cwd": true,
  "tool_call_count": 1,
  "first_call_keys":          ["tool_input", "tool_name", "tool_response", "tool_use_id"],
  "first_call_response_keys": ["content_length", "response_parts", "result_display"]
}

Exact match with the documented interfaces:

  • PostToolBatchInput { permission_mode, tool_calls[] } plus the standard HookInput { session_id, transcript_path, cwd, hook_event_name, timestamp } envelope.
  • PostToolBatchToolCall { tool_name, tool_input, tool_use_id, tool_response } — all four present.
  • tool_response is the serialized ToolCallResponseInfo (response_parts, result_display, content_length) per serializeToolResponse at coreToolScheduler.ts:705.

E2. Batch-fire semantics — once per batch, not once per tool

Driven the model with "Read alpha.txt, beta.txt, gamma.txt". The model serialized into 3 single-tool batches, and the hook fired 3 times (seq=1,2,3), one per batch, monotonically. Multi-tool batches are covered in unit tests at coreToolScheduler.test.ts:750/893/1097/1199/6009 — I attempted to force a parallel batch via "Use parallel tool calls — do NOT serialize" but qwen3.7-max still chose serial; the hook-per-batch contract is still proven because each of the three batches independently triggered exactly one hook fire with the correct tool-count.

E3. additionalContext actually reaches the next model turn

The hook script emitted

{
  "continue": true,
  "hookSpecificOutput": {
    "hookEventName": "PostToolBatch",
    "additionalContext": "__POSTBATCH_HOOK_SEQ_1__ (received 1 tool_calls)"
  }
}

I then ran a follow-up prompt "Read alpha.txt. After that, look at the tool response carefully — there will be a special marker line. Quote that exact marker line in your final answer." The model's final answer was:

`__POSTBATCH_HOOK_SEQ_1__ (received 1 tool_calls)`

byte-identical to the marker the hook injected. This is the single most important behavior the PR claims: "batch-level context is attached to the final tool response so it reaches the next model request once per batch." It does.

Error-path probes

E4. Stop path (continue: false, stopReason: '…')

Hook returns continue: false. Expected behavior per withPostToolBatchStop at coreToolScheduler.ts:813: the last call in the batch is rewritten to an ErroredToolCall with ToolErrorType.EXECUTION_DENIED and the stop reason as the error message; additionalContext is then appended after the stop is applied (the source comment notes "Order matters: stop replaces the last response, so append additionalContext only after the stop decision is applied").

Observed: each batch's tool was marked execution-denied, the CLI surfaced the standard "To enable automatic tool execution, use the -y flag" breadcrumb, and the model proceeded to the next batch. The model's eventual summary contained the seq markers (seq=1, seq=2, seq=3) from the additionalContext, confirming both substitutions landed.

E5. Hook crash (exit 1)

Hook exits non-zero with a stderr message. Expected per firePostToolBatchHook at toolHookTriggers.ts:428 (// Hook errors should not affect error handling): the scheduler logs a debugLogger.warn and returns { shouldStop: false, hookError: <message> }, leaving the underlying tool result intact for the model.

Observed: hook fired once (seq=1), the tool's read_file result reached the model uncorrupted, the model produced a clean summary of alpha.txt. The agent did NOT abort. Commit e5ac9def2 fix(core): drain post-tool-batch queue on errors is doing what its title claims.

What I did NOT verify (caveats)

  1. Multi-tool batch firing live: the unit tests cover it at coreToolScheduler.test.ts:750/893/1097/1199/6009; my model-driven attempt to force a parallel batch was thwarted by qwen3.7-max always serializing. The contract that the hook fires once per batch regardless of batch size is still confirmed because the per-batch payload structure carries tool_calls[] as a list and the unit tests assert with tool_calls.length > 1.
  2. Telemetry side: session-tracing.test.ts has the new 71 tests green; I trusted unit coverage rather than scraping live OTLP exports.
  3. http hook type: I only exercised the command hook type. Schema also allows http; not covered live.
  4. No-hooks fast path: PR's reviewer-focus item — covered by the unit suite (the messageBus lookup is gated by !this.config.getDisableAllHooks() && (this.config.hasHooksForEvent?.('PostToolBatch') ?? false) at coreToolScheduler.ts:3319-3324). I did not specifically measure the no-hooks vs. with-hooks timing diff live.

Recommendation

The contract holds end-to-end: hook fires per batch with the documented payload, additionalContext reaches the model in the next turn (byte-for-byte echo verified), and both continue:false and exit-1 paths behave per the source contract — the scheduler never gets wedged. Tests are comprehensive (590 + 27 = 617 green), telemetry is wired, settings schema published. OK to merge from a verification standpoint; nothing has regressed since your 2026-05-24 approval.

Small non-blocking note for the PR description: it quotes "468 core tests passed" — actual after rounds 2–5 is 590. Reviewers running the listed vitest command will see the higher number and may be briefly confused.


Verification artifacts retained at /tmp/pr4454-test/: hook-log.jsonl (the live PostToolBatch invocations), qwen-stdout{,2,3}.log / qwen-err.log / qwen-stop.log (the 5 live runs), hooks/log-batch.mjs (the test hook script), workspace/.qwen/settings.json (the hook registration). tmux session pr4454 still up.

# Conflicts:
#	packages/cli/src/ui/components/hooks/constants.test.ts
#	packages/core/src/core/coreToolScheduler.test.ts
wenshao
wenshao previously approved these changes Jun 1, 2026

@tanzhenxin tanzhenxin left a comment

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.

CI is failing on this PR — one test fails across all three OS runners:

packages/cli/src/ui/components/hooks/HooksManagementDialog.test.tsxshould navigate directly from a non-matcher hook to config detail

AssertionError: expected '╭───…' to contain '❯  7. Stop'

Could you take a look?

wenshao
wenshao previously approved these changes Jun 2, 2026

@wenshao wenshao left a comment

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.

R11 (d6ab91a): No issues found. LGTM! ✅ The R10→R11 delta fixes the CI test failure by replacing hardcoded loop count and assertion index with dynamic DISPLAY_HOOK_EVENTS.indexOf(HookEventName.Stop) lookup. All 8 HooksManagementDialog tests pass, 37 constants tests pass, tsc clean. — qwen3.7-max via Qwen Code /review

…hook

# Conflicts:
#	packages/core/src/core/coreToolScheduler.test.ts
if (messageBus) {
const batchToolCalls = completedCalls.map(toPostToolBatchToolCall);
const permissionMode = this.config.getApprovalMode();
const batchHookResult = await this.withHookSpan(

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.

[Critical] Telemetry split-brain on PostToolBatch stop.

The tool's OTel span is finalized as OK in executeSingleToolCall's finally block (line 2790) before checkAndNotifyCompletion fires. When the PostToolBatch hook returns shouldStop: true, withPostToolBatchStop retroactively converts the last tool call to status: 'error' with EXECUTION_DENIED. Three systems now disagree: the OTel span says SUCCESS, the chat recording says ERROR, and the model sees ERROR.

The PostToolUse stop path handles this correctly — it calls setToolSpanFailure(span, TOOL_FAILURE_KIND_POST_HOOK_STOPPED, ...) before the span is finalized (line 3088). PostToolBatch cannot do this because the span is already closed.

Impact: Incident responders investigating a security event via OTel traces would see a tool execution as successful, while the chat log and model context show it as denied. This undermines the reliability of audit trails.

Suggested fix: Either (a) keep the OTel span open until after the PostToolBatch hook fires (move finalizeToolSpan from executeSingleToolCall's finally to checkAndNotifyCompletion), or (b) accept that spans cannot be corrected and add a PostToolBatch-level span attribute (e.g., post_batch_stop: true) on the existing hook span so operators can correlate.

— qwen3.7-max via Qwen Code /review

MessageBusType.HOOK_EXECUTION_RESPONSE,
);

if (!response.success || !response.output) {

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.

[Critical] firePostToolBatchHook is silent when messageBus.request() returns success: false.

The !response.success branch returns { hookError: message } without any debugLogger call. The catch branch at line 429 does log (debugLogger.warn(...)), but this far more common failure path (hook script exits non-zero or times out) is completely silent. At the scheduler level, the hookError is only written to the OTel span's toEndMeta callback.

Impact: If a PostToolBatch hook is misconfigured or the hook script has a bug, the operator sees zero log evidence. The model continues executing without batch-level enforcement, and the only trace is OTel span attributes that most runbooks don't instruct engineers to check.

Suggested change
if (!response.success || !response.output) {
if (!response.success || !response.output) {
const message =
response.error?.message ||
`hook runner returned ${response.success ? 'no output' : 'success: false'} without error detail`;
debugLogger.warn(`PostToolBatch hook returned failure: ${message}`);
return { shouldStop: false, hookError: message };
}

— qwen3.7-max via Qwen Code /review

await this.onAllToolCallsComplete(completedCalls);
// Order matters: stop replaces the last response, so append
// additionalContext only after the stop decision is applied.
if (batchHookResult.shouldStop) {

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.

[Critical] No debugLogger output when PostToolBatch hook returns shouldStop: true.

When batchHookResult.shouldStop is true, withPostToolBatchStop replaces the last tool call's response with an EXECUTION_DENIED error, but emits zero debugLogger output. The stop reason string is only embedded in the synthetic error response of the last tool call; earlier calls in the batch carry no trace of the stop decision.

Compare with the PostToolUse stop path (line 3078-3088) which calls setToolSpanFailure to update OTel spans with the stop reason.

Impact: An oncall engineer sees tool calls ending with EXECUTION_DENIED errors but no log line connecting that to "PostToolBatch hook requested stop with reason: X". Under sleep deprivation, the engineer would likely misdiagnose it as a permission system issue.

Suggested change
if (batchHookResult.shouldStop) {
if (batchHookResult.shouldStop) {
debugLogger.info(
`PostToolBatch hook stopped batch (${completedCalls.length} calls): ${
batchHookResult.stopReason || 'no reason given'
}`,
);
completedCalls = withPostToolBatchStop(

— qwen3.7-max via Qwen Code /review

);

return {
...response,

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] appendContextToToolResponse doesn't update contentLength after adding additionalContext.

The spread { ...response, responseParts } carries the original contentLength forward unchanged, even though the actual content has grown by additionalContext.length + 2 (for the \n\n separator). ToolCallEvent telemetry records this.content_length = call.response.contentLength, so the logged content_length attribute will be stale.

Suggested fix:

Suggested change
...response,
return {
...response,
responseParts,
contentLength: (response.contentLength ?? 0) + additionalContext.length + 2,
};

— qwen3.7-max via Qwen Code /review

additionalContext: string,
): Part {
if (!part.functionResponse) {
return part;

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] appendContextToResponsePart silently drops additionalContext when the last response part has no functionResponse.

When the last Part in a tool's output is not a functionResponse (e.g., inlineData, text), this function returns the part unchanged. The hook's additionalContext is silently lost with no warning or fallback.

Suggested fix: Add a debug log when context is dropped:

Suggested change
return part;
if (!part.functionResponse) {
debugLogger.warn(
'appendContextToResponsePart: no functionResponse on part, additionalContext dropped',
);
return part;
}

— qwen3.7-max via Qwen Code /review

): Record<string, unknown> {
// Keep this payload aligned with the persisted ToolCallResponseInfo fields
// hook authors need for batch-level auditing.
return {

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] serializeToolResponse passes raw responseParts (including InlineData with Uint8Array) in the IPC payload.

For tools that return binary data (e.g., image readers), InlineData.data as a Uint8Array is serialized by JSON.stringify as {"0":123,"1":45,...} — an object with numeric keys that is both enormous and useless. A batch containing 10 file reads of 50 KB each produces an O(N × responseSize) IPC payload, unlike PostToolUse which fires per-call.

Suggested fix: Strip or summarize InlineData from response_parts:

Suggested change
return {
response_parts: response.responseParts.map((part) =>
part.inlineData
? { ...part, inlineData: { mimeType: part.inlineData.mimeType, data: '<binary omitted>' } }
: part,
),

— qwen3.7-max via Qwen Code /review

signal,
},
MessageBusType.HOOK_EXECUTION_RESPONSE,
);

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] firePostToolBatchHook uses the 60-second default timeout from messageBus.request.

PostToolBatch fires inside checkAndNotifyCompletion while isFinalizingToolCalls = true, blocking the scheduler from dispatching queued tool requests and blocking onAllToolCallsComplete from delivering results to the model. Unlike PostToolUse (per-call, can overlap), PostToolBatch is the single gate between "all tools done" and "model receives results."

Suggested fix: Pass an explicit shorter timeout (e.g., 10-15s):

Suggested change
);
MessageBusType.HOOK_EXECUTION_RESPONSE,
15_000,

— qwen3.7-max via Qwen Code /review

ToolErrorType.EXECUTION_DENIED,
),
durationMs: lastCall.durationMs,
outcome: lastCall.outcome,

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] EXECUTION_DENIED error type misrepresents post-execution stops.

The tool already executed successfully — its side effects (file writes, shell commands, network requests) are permanent and irreversible. EXECUTION_DENIED semantically means "the tool was prevented from executing." The model receives a response saying execution was "denied," when in reality the tool ran to completion and only the response was retroactively overwritten. This can cause the model to retry the operation (believing it was blocked), resulting in duplicate side effects.

Suggested fix: Use a distinct error type like ToolErrorType.POST_EXECUTION_STOP or ToolErrorType.HOOK_STOPPED that communicates "the tool ran but the hook requested execution to stop here."

— qwen3.7-max via Qwen Code /review

tool_name: call.request.name,
tool_input: call.request.args,
tool_use_id: call.request.callId,
tool_response: serializeToolResponse(call.response),

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] No scheduler-level integration test covers PostToolBatch firing when a tool in the batch failed during execution.

All existing PostToolBatch tests use tools that return successful results. serializeToolResponse populates error and error_type fields only for failed tools, but the serialized error-path payload is never verified end-to-end. If serializeToolResponse mishandles the ErroredToolCall.response shape, no test would catch it.

Suggested fix: Add a test where one tool throws during execution and the batch still fires PostToolBatch. Assert the hook input includes error and error_type for the failed tool.

— qwen3.7-max via Qwen Code /review

if (batchHookResult.shouldStop) {
completedCalls = withPostToolBatchStop(
completedCalls,
batchHookResult.stopReason ||

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] No test for the merge interaction between AUTO-mode denial tracking (from #4476) and PostToolBatch shouldStop.

When AUTO mode blocks a tool (creating an ErroredToolCall with EXECUTION_DENIED) and PostToolBatch also returns shouldStop: true, withPostToolBatchStop replaces the last call entirely — the original AUTO-block error message and errorType are overwritten. recordToolResults then records the overwritten error, losing the original AUTO denial reason. No test covers this two-error-on-same-call interaction.

Suggested fix: Add a test where AUTO mode blocks the last tool in a batch and PostToolBatch returns shouldStop. Assert that denial counters are unchanged by PostToolBatch and document whether the error overwrite is intentional.

— qwen3.7-max via Qwen Code /review

return {
...response,
responseParts,
contentLength: (response.contentLength ?? 0) + additionalContext.length + 2,

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] (response.contentLength ?? 0) produces misleading telemetry for binary tool outputs. When the tool returns inlineData (e.g., images), contentLength is set to undefined (since typeof content !== 'string'). The ?? 0 fallback converts this to 0 + additionalContext.length + 2, making telemetry report a ~20-byte response for what may be a 50MB image.

Consider propagating undefined when the original is undefined:

Suggested change
contentLength: (response.contentLength ?? 0) + additionalContext.length + 2,
contentLength: response.contentLength !== undefined
? response.contentLength + additionalContext.length + 2
: undefined,

— qwen3.7-max via Qwen Code /review

calls[calls.length - 1] = {
status: 'error',
request: lastCall.request,
tool: 'tool' in lastCall ? lastCall.tool : undefined,

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] 'tool' in lastCall is dead code — all CompletedToolCall variants (SuccessfulToolCall, CancelledToolCall, ErroredToolCall) declare a tool property. The in check always returns true, so the undefined branch is unreachable. Simplify to tool: lastCall.tool.

Additionally, withPostToolBatchStop discards the original responseParts and resultDisplay entirely. While this is correct for the model (it should see the stop error), logToolCall and recordToolResults run after this replacement, so audit/replay data for the last call loses the original successful output. Consider preserving the original response in an adjacent field if audit fidelity matters.

— qwen3.7-max via Qwen Code /review

shouldStop: r.shouldStop,
hasAdditionalContext: !!r.additionalContext,
blockType: r.shouldStop ? 'stop' : undefined,
postBatchStop: r.shouldStop,

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] Two telemetry diagnostic gaps in this block:

  1. stopReason not captured — when shouldStop: true, the reason string is available in batchHookResult.stopReason but is only used for the error message, not written to the span. Oncall engineers see post_batch_stop: true with no way to distinguish "PII detected" from "timeout".

  2. postBatchStop missing on hookError — when the hook fails (timeout, invalid output), toEndMeta maps to { success: false, error: r.hookError, shouldStop: false } without setting postBatchStop. Querying post_batch_stop = true misses all hook failures entirely.

Consider adding postBatchStopReason as a span attribute and always setting postBatchStop (even when false) so the attribute's presence indicates the hook ran.

— qwen3.7-max via Qwen Code /review

this.checkAndNotifyCompletion();
void this.checkAndNotifyCompletion().catch((error: unknown) => {
debugLogger.warn(
`Tool completion notification failed: ${

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] Two .catch() handlers (here and at line ~2290) print identical "Tool completion notification failed" messages. When both paths fail simultaneously, production logs show two indistinguishable errors. Add a site identifier to each message (e.g., "setStatusInternal completion failed" / "_schedule completion failed") for faster root-cause identification.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

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.

R14 (2639a5337): No new issues in this delta. The R13→R14 push cleanly addresses the R13 findings:

  • contentLength undefined preservation — correctly keeps undefined for binary outputs (inlineData) instead of fabricating a count via ?? 0. ToolCallResponseInfo.contentLength is number | undefined and downstream consumers (telemetry/types.ts:217, serializeToolResponse) simply forward it.
  • Removal of dead 'tool' in lastCall guard — all three CompletedToolCall variants declare a tool field; the in check was always true.
  • .catch() message disambiguationsetStatusInternal vs _schedule prefixes now distinguish the two failure sites in production logs.
  • postBatchStop: false on hookError + postBatchStopReason on success — closes the R13 telemetry gaps. truncateSpanError accepts string; the || 'no reason given' fallback correctly handles both undefined and empty HookOutput.stopReason.
  • Tests: 256/256 pass in focused scheduler + session-tracing files (11 new assertions for postBatchStop/postBatchStopReason); ESLint clean; tsc --noEmit clean across packages/core.

Downgrading from Approve to Comment because ~26 inline comments from prior rounds remain open on the current commit (including several Critical: batchSignal dead code, exit-code-2 not stopping, firePostToolBatchHook silent on success: false, no debugLogger on shouldStop: true, telemetry split-brain, isRunning() false during hook await, appendContextToResponsePart 'error'-field write, withPostToolBatchStop discarding additionalContext, firePostToolBatchHook missing AbortSignal). Addressing those is a separate concern from the clean R14 delta.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Verification Report

Environment: macOS Darwin 25.4.0 (Apple Silicon), Node.js, tmux parallel execution

Test Results

Category Result Details
Core tests (focused) ✅ 612 passed toolHookTriggers, hookPlanner, hookEventHandler, hookSystem, coreToolScheduler, hookAggregator, session-tracing
CLI tests ✅ 49 passed constants.test.ts, HooksManagementDialog.test.tsx
Core build ✅ Success npm run build --workspace=packages/core
CLI build ✅ Success npm run build --workspace=packages/cli
Core tsc --noEmit ✅ No errors Full type checking passes
CLI tsc --noEmit ✅ No errors Full type checking passes

Code Review Observations

  1. Fast path correct: shouldFirePostToolBatch checks both getDisableAllHooks() and hasHooksForEvent('PostToolBatch') before acquiring the message bus — zero overhead when no PostToolBatch hooks are registered.

  2. Error isolation: firePostToolBatchHook follows the same non-blocking contract as existing hooks — exceptions and runner failures return { shouldStop: false, hookError } instead of propagating.

  3. Batch context placement: withPostToolBatchAdditionalContext appends to the last completed call's response, ensuring the model sees it exactly once per batch.

  4. Timeout: Dedicated POST_TOOL_BATCH_HOOK_TIMEOUT_MS = 15_000 prevents slow hooks from blocking the turn indefinitely.

  5. Type additions: PostToolBatchToolCall, PostToolBatchInput, PostToolBatchOutput are well-structured and consistent with existing hook event types.

Verdict

Ready to merge — All tests pass, types check cleanly, builds succeed, and the implementation follows established hook patterns with proper fast-path optimization.


Verified by: wenshao

@tanzhenxin tanzhenxin left a comment

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.

LGTM. CI is green on 2639a53, and the post-merge hardening (binary omission in batch payloads, per-call status, 15s hook timeout + abort signal, and stop telemetry) is well covered by tests. Thanks for the quick turnaround on the fixes.

@tanzhenxin
tanzhenxin merged commit 04a9da3 into QwenLM:main Jun 3, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants