Skip to content

Conversation

@bharatvansh
Copy link
Contributor

@bharatvansh bharatvansh commented Feb 8, 2026

What

Fixes a foot gun where runStartHooks() can be invoked by callers (e.g. to run SessionStart/SubagentStart before other hooks) and then run() would effectively run the same “start” work again.

Why

This can lead to duplicate SessionStart / SubagentStart executions (and transcript initialization/logging) for a single ToolCallingLoop instance.

Changes

  • Make ToolCallingLoop.run() always call runStartHooks() up-front.
  • Make runStartHooks() idempotent per loop instance via a promise latch, so multiple calls safely share the same work.
  • Add regression tests ensuring SessionStart/SubagentStart aren’t executed twice when runStartHooks() is called before run().

Also (separate commit)

  • Stabilize chat replay notebook integer formatting by using a deterministic formatter (avoids locale-dependent output like 1000001,00,000 on en-IN, which makes tests/output flaky).

Verification

  • npm run test:unit
  • npm run test:extension
  • npm run lint

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR makes ToolCallingLoop start-hook execution (SessionStart / SubagentStart) safe to call multiple times by making it idempotent per loop instance, preventing duplicated hook execution and transcript side effects. It also stabilizes chat replay notebook output by using locale-independent integer formatting.

Changes:

  • Make ToolCallingLoop.run() always invoke runStartHooks() and remove duplicate “start” work paths.
  • Make runStartHooks() idempotent via a per-instance promise latch; add regression tests preventing double SessionStart/SubagentStart.
  • Replace locale-dependent toLocaleString() formatting in replay notebook output with deterministic Intl.NumberFormat('en-US').

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/extension/intents/node/toolCallingLoop.ts Adds a promise latch to make start hooks idempotent; ensures run() triggers start hooks exactly once.
src/extension/intents/test/node/toolCallingLoopHooks.spec.ts Adds regression tests for “runStartHooks called before run” to prevent duplicate start-hook execution.
src/extension/replay/vscode-node/chatReplayNotebookSerializer.ts Uses deterministic integer formatting to avoid locale-dependent notebook output.

Comment on lines +227 to +236
it('should NOT execute SessionStart hook twice when runStartHooks is called before run', async () => {
const conversation = createTestConversation(1);
const request = createMockChatRequest();

const loop = instantiationService.createInstance(
TestToolCallingLoopForRun,
{
conversation,
toolCallLimit: 10,
request,
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

These new tests build a ChatRequest via createMockChatRequest() that omits required fields like hasHooksEnabled (it’s required by the VS Code type). Because the helper casts the object to ChatRequest, this can mask behavior differences between hasHooksEnabled: true/false (e.g. transcript initialization) and makes the mock less representative. Consider setting a real boolean default in createMockChatRequest (or using the existing TestChatRequest helper) and overriding it explicitly per test.

Copilot uses AI. Check for mistakes.
Comment on lines 497 to +498
public async runStartHooks(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<void> {
this._startHooksPromise ??= this.doRunStartHooks(outputStream, token);
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

runStartHooks() is now latched on the first call’s outputStream/token. Any subsequent call with a different stream/token will silently reuse the original promise, which can lead to missing hook progress output or cancellation behavior that doesn’t match the caller’s token. Consider either (1) asserting/logging when subsequent calls pass different arguments, or (2) capturing only the hook execution promise and keeping progress reporting/cancellation independent of the first caller.

Suggested change
public async runStartHooks(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<void> {
this._startHooksPromise ??= this.doRunStartHooks(outputStream, token);
private _startHooksFirstOutputStream: ChatResponseStream | undefined;
private _startHooksFirstToken: CancellationToken | undefined;
public async runStartHooks(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<void> {
if (!this._startHooksPromise) {
this._startHooksFirstOutputStream = outputStream;
this._startHooksFirstToken = token;
this._startHooksPromise = this.doRunStartHooks(outputStream, token);
} else if (outputStream !== this._startHooksFirstOutputStream || token !== this._startHooksFirstToken) {
this._logService.warn('[ToolCallingLoop] runStartHooks called with different outputStream or token after hooks were already started; reusing the first call\'s arguments.');
}

Copilot uses AI. Check for mistakes.
@alexr00 alexr00 requested a review from connor4312 February 9, 2026 08:54
@connor4312 connor4312 requested review from pwang347 and roblourens and removed request for connor4312 February 10, 2026 02:22
@connor4312 connor4312 assigned roblourens and pwang347 and unassigned alexr00 Feb 10, 2026
@bharatvansh bharatvansh force-pushed the pr/subagentstart-start-hook-once branch from 122b6ed to 36cb398 Compare February 11, 2026 23:04
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.

4 participants