-
Notifications
You must be signed in to change notification settings - Fork 1.6k
ToolCallingLoop: avoid duplicate start hooks #3558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
ToolCallingLoop: avoid duplicate start hooks #3558
Conversation
There was a problem hiding this 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 invokerunStartHooks()and remove duplicate “start” work paths. - Make
runStartHooks()idempotent via a per-instance promise latch; add regression tests preventing doubleSessionStart/SubagentStart. - Replace locale-dependent
toLocaleString()formatting in replay notebook output with deterministicIntl.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. |
| 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, |
Copilot
AI
Feb 8, 2026
There was a problem hiding this comment.
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.
| public async runStartHooks(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<void> { | ||
| this._startHooksPromise ??= this.doRunStartHooks(outputStream, token); |
Copilot
AI
Feb 8, 2026
There was a problem hiding this comment.
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.
| 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.'); | |
| } |
122b6ed to
36cb398
Compare
What
Fixes a foot gun where
runStartHooks()can be invoked by callers (e.g. to run SessionStart/SubagentStart before other hooks) and thenrun()would effectively run the same “start” work again.Why
This can lead to duplicate
SessionStart/SubagentStartexecutions (and transcript initialization/logging) for a singleToolCallingLoopinstance.Changes
ToolCallingLoop.run()always callrunStartHooks()up-front.runStartHooks()idempotent per loop instance via a promise latch, so multiple calls safely share the same work.SessionStart/SubagentStartaren’t executed twice whenrunStartHooks()is called beforerun().Also (separate commit)
100000→1,00,000onen-IN, which makes tests/output flaky).Verification
npm run test:unitnpm run test:extensionnpm run lint