From 8c96629661cc78355c146c62fd0f6f17b251b1e3 Mon Sep 17 00:00:00 2001 From: edelauna <54631123+edelauna@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:45:46 +0000 Subject: [PATCH 01/21] fix(delegation): read task-local mode in getEnvironmentDetails and validateToolUse (#1625) * fix(delegation): read task-local mode in getEnvironmentDetails and validateToolUse * test(delegation): cover custom-tool execute context and state fallback --- package.json | 2 +- scripts/check-delegated-mode-readers.ts | 170 ++++++++++++++++++ ...resentAssistantMessage-custom-tool.spec.ts | 46 +++++ .../presentAssistantMessage-images.spec.ts | 1 + ...tantMessage-tool-usage-attribution.spec.ts | 79 +++++++- ...esentAssistantMessage-unknown-tool.spec.ts | 1 + .../presentAssistantMessage.ts | 9 +- .../__tests__/getEnvironmentDetails.spec.ts | 26 +++ src/core/environment/getEnvironmentDetails.ts | 5 +- 9 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 scripts/check-delegated-mode-readers.ts diff --git a/package.json b/package.json index 1fd9ddc8fe..df3410bbc1 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && tsx scripts/check-delegated-mode-readers.ts", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs", "mcp:integration-check": "tsx scripts/check-mcp-oauth-integration.ts", diff --git a/scripts/check-delegated-mode-readers.ts b/scripts/check-delegated-mode-readers.ts new file mode 100644 index 0000000000..bd77de29c1 --- /dev/null +++ b/scripts/check-delegated-mode-readers.ts @@ -0,0 +1,170 @@ +// check-delegated-mode-readers.ts +// +// Refinement check for the delegated-child mode-reader invariant (issue #1623). +// +// check-provider-handoff-scheduler.ts verifies the write side: that +// selectHandoffExecutionContext stores the task-local mode correctly. +// This script verifies the read side: that the mode observable by +// tool-validation readers is the task-local mode, not the shared provider mode. +// +// The VS Code-dependent readers (getEnvironmentDetails, +// presentAssistantMessage) are covered by their vitest regression tests. +// This script covers the pure-TS parts of the invariant chain and proves +// that the two sources of mode are observably different, so any reader +// that uses the wrong source silently produces wrong behavior. +// +// Invariant: for any delegated child task C with taskMode = M, +// toolAllowedForMode(tool, M) ≠ toolAllowedForMode(tool, providerMode) +// whenever M ≠ providerMode and the two modes differ on the tool's group. + +import assert from "node:assert/strict" + +import { DEFAULT_MODES } from "../packages/types/src/mode" + +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../src/shared/tools" +import { selectHandoffExecutionContext, type TaskExecutionContext } from "../src/core/task/providerHandoff" + +// --------------------------------------------------------------------------- +// Minimal inline mode-allows-tool check. +// Avoids importing src/shared/modes.ts, which pulls in VS Code. +// Only covers built-in modes (no custom modes, no file-regex options). +// That is enough to prove the behavioral divergence this check needs. +// --------------------------------------------------------------------------- + +type ModeConfig = (typeof DEFAULT_MODES)[number] +type GroupEntry = ModeConfig["groups"][number] + +function groupName(entry: GroupEntry): string { + return Array.isArray(entry) ? entry[0] : (entry as string) +} + +function toolAllowedForMode(tool: string, modeSlug: string): boolean { + const resolvedTool = (TOOL_ALIASES as Record)[tool] ?? tool + if ((ALWAYS_AVAILABLE_TOOLS as readonly string[]).includes(resolvedTool)) return true + const mode = DEFAULT_MODES.find((m) => m.slug === modeSlug) + if (!mode) return false + for (const entry of mode.groups) { + const groupTools = (TOOL_GROUPS as Record)[groupName(entry)]?.tools ?? [] + if (groupTools.includes(resolvedTool)) return true + } + return false +} + +// --------------------------------------------------------------------------- +// Scenario: parent in "orchestrator" mode delegates child to "code". +// Regression behavior: both readers used providerMode ("orchestrator"). +// Correct behavior: readers use taskMode ("code"). +// +// orchestrator groups: [] → apply_diff blocked +// code groups: [...edit] → apply_diff allowed +// --------------------------------------------------------------------------- + +const parentCtx: TaskExecutionContext = { + mode: "orchestrator", + apiConfigName: undefined, + apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 3 }, +} + +// 1. Handoff stores the task-local mode, not the parent mode. +const childCtx = selectHandoffExecutionContext(parentCtx, "code", parentCtx.mode, false, undefined) +assert.equal(childCtx.mode, "code", "handoff must store the requested task-local mode") +assert.notEqual(childCtx.mode, parentCtx.mode, "test scenario requires divergent provider and task modes") + +// 2. The two modes produce observably different tool-validation outcomes. +assert.equal(toolAllowedForMode("apply_diff", "orchestrator"), false, "orchestrator has no edit group") +assert.equal(toolAllowedForMode("apply_diff", "code"), true, "code has the edit group") + +// 3. Regression claim: a reader that consumes providerMode rejects apply_diff; +// a reader that consumes taskMode correctly allows it. +const viaProviderMode = toolAllowedForMode("apply_diff", parentCtx.mode) // "orchestrator" — wrong source +const viaTaskMode = toolAllowedForMode("apply_diff", childCtx.mode) // "code" — correct source +assert.equal(viaProviderMode, false, "stale provider mode rejects apply_diff (regression behavior)") +assert.equal(viaTaskMode, true, "task-local mode allows apply_diff (correct behavior)") + +// 4. Additional mode pairs that show the same divergence. +const DIVERGENT_PAIRS: Array<{ + label: string + providerMode: string + taskMode: string + probe: string + blockedInProvider: boolean + allowedInTask: boolean +}> = [ + // orchestrator → code: edit tools blocked at provider level, allowed at task level + { + label: "orchestrator→code apply_diff", + providerMode: "orchestrator", + taskMode: "code", + probe: "apply_diff", + blockedInProvider: true, + allowedInTask: true, + }, + // orchestrator → code: command tools blocked at provider level, allowed at task level + { + label: "orchestrator→code execute_command", + providerMode: "orchestrator", + taskMode: "code", + probe: "execute_command", + blockedInProvider: true, + allowedInTask: true, + }, + // code → ask: edit tools allowed at provider level, blocked at task level + { + label: "code→ask apply_diff", + providerMode: "code", + taskMode: "ask", + probe: "apply_diff", + blockedInProvider: false, + allowedInTask: false, + }, + // ask → code: edit tools blocked at provider level, allowed at task level + { + label: "ask→code write_to_file", + providerMode: "ask", + taskMode: "code", + probe: "write_to_file", + blockedInProvider: true, + allowedInTask: true, + }, +] + +for (const pair of DIVERGENT_PAIRS) { + const ctx = selectHandoffExecutionContext( + { ...parentCtx, mode: pair.providerMode }, + pair.taskMode, + pair.providerMode, + false, + undefined, + ) + assert.equal(ctx.mode, pair.taskMode, `${pair.label}: handoff must store task-local mode`) + assert.equal( + toolAllowedForMode(pair.probe, pair.providerMode), + !pair.blockedInProvider, + `${pair.label}: wrong provider-mode result`, + ) + assert.equal( + toolAllowedForMode(pair.probe, pair.taskMode), + pair.allowedInTask, + `${pair.label}: wrong task-mode result`, + ) + // The two sources disagree, so using the wrong one is always observable. + assert.notEqual( + toolAllowedForMode(pair.probe, pair.providerMode), + toolAllowedForMode(pair.probe, pair.taskMode), + `${pair.label}: provider and task mode must differ on this probe tool`, + ) +} + +// 5. For every built-in mode as a delegation target: selectHandoffExecutionContext +// always stores the requested mode, regardless of parent mode. +for (const mode of DEFAULT_MODES) { + const ctx = selectHandoffExecutionContext(parentCtx, mode.slug, parentCtx.mode, false, undefined) + assert.equal(ctx.mode, mode.slug, `handoff must store ${mode.slug}, not parent mode ${parentCtx.mode}`) +} + +console.log( + `Delegated mode reader check passed: ` + + `regression scenario verified, ` + + `${DIVERGENT_PAIRS.length} divergent-mode pairs checked, ` + + `${DEFAULT_MODES.length}/${DEFAULT_MODES.length} built-in modes verified`, +) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 1ef25e852b..e7f4465441 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -77,6 +77,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { }), }), }, + getTaskMode: vi.fn().mockResolvedValue("code"), say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } @@ -122,6 +123,51 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { }) }) + describe("Custom tool mode delegation regression", () => { + // Regression for issue #1623. + // Before the fix, customTool.execute received the shared provider mode + // instead of the task-local mode. A child delegated to "architect" would + // have its custom tool called with "orchestrator". + it("passes the task-local mode to customTool.execute, not the provider mode", async () => { + // Provider says "orchestrator"; task was delegated to "architect". + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "orchestrator", + customModes: [], + experiments: { customTools: true }, + }), + }), + } + mockTask.getTaskMode = vi.fn().mockResolvedValue("architect") + + const executeMock = vi.fn().mockResolvedValue("result") + vi.mocked(customToolRegistry.has).mockReturnValue(true) + vi.mocked(customToolRegistry.get).mockReturnValue({ + name: "my_custom_tool", + description: "A custom tool", + execute: executeMock, + }) + + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_delegation", + name: "my_custom_tool", + params: { value: "test" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask) + + expect(executeMock).toHaveBeenCalledOnce() + const context = executeMock.mock.calls[0][1] + expect(context.mode).toBe("architect") + expect(context.task).toBe(mockTask) + }) + }) + describe("Custom tool error recording", () => { it("should record custom tool error as 'custom_tool'", async () => { const toolCallId = "tool_call_custom_error_123" diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts index fcf778b8f8..7cb4c427d8 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts @@ -57,6 +57,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () = }), }), }, + getTaskMode: vi.fn().mockResolvedValue("code"), say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index c75eb6ee18..9becd11bbe 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -65,14 +65,17 @@ interface MockTask { recordToolError: ReturnType toolRepetitionDetector: { check: ReturnType } providerRef: { - deref: () => { - getState: ReturnType - getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } - } + deref: () => + | { + getState: ReturnType + getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + } + | undefined } say: ReturnType ask: ReturnType pushToolResultToUserContent: ReturnType + getTaskMode: ReturnType } describe("presentAssistantMessage - tool usage attribution", () => { @@ -115,6 +118,7 @@ describe("presentAssistantMessage - tool usage attribution", () => { say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), pushToolResultToUserContent: vi.fn(), + getTaskMode: vi.fn().mockResolvedValue("code"), } mockTask.pushToolResultToUserContent = vi @@ -316,4 +320,71 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(TelemetryService.instance.captureToolUsage).not.toHaveBeenCalled() }) }) + + describe("undefined provider state", () => { + // Covers the `state ?? {}` fallback branch (line 347 of presentAssistantMessage.ts). + // When providerRef.deref() returns undefined, state is undefined and the + // destructure falls back to {}, so customModes / experiments / disabledTools + // are all undefined. Tool validation must still use the task-local mode. + it("falls back to empty state when provider is unavailable", async () => { + mockTask.providerRef = { deref: () => undefined } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_no_state", + name: "read_file", + params: { path: "test.ts" }, + nativeArgs: { path: "test.ts" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + // validateToolUse must still be called with the task-local mode. + const calls = vi.mocked(validateToolUse).mock.calls + expect(calls.length).toBeGreaterThan(0) + expect(calls[0][1]).toBe("code") + // customModes falls back to [] (from the ?? {} path). + expect(calls[0][2]).toEqual([]) + }) + }) + + describe("mode delegation regression", () => { + // Regression for issue #1623. + // Before the fix, validateToolUse received the shared provider mode instead + // of the task-local mode, so a child delegated to "architect" mode would have + // its tools validated against "orchestrator". + it("passes the task-local mode to validateToolUse, not the provider mode", async () => { + // Provider says "orchestrator"; task was delegated to "architect". + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "orchestrator", + customModes: [], + }), + }), + } + mockTask.getTaskMode = vi.fn().mockResolvedValue("architect") + + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_delegation", + name: "read_file", + params: { path: "test.ts" }, + nativeArgs: { path: "test.ts" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + // The key assertion: task-local mode "architect" was passed, not "orchestrator". + const calls = vi.mocked(validateToolUse).mock.calls + expect(calls.length).toBeGreaterThan(0) + expect(calls[0][0]).toBe("read_file") + expect(calls[0][1]).toBe("architect") + }) + }) }) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 78a4a19e91..78af9653c7 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -60,6 +60,7 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { }), }), }, + getTaskMode: vi.fn().mockResolvedValue("code"), say: vi.fn().mockResolvedValue(undefined), ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), } diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7b25db4e66..b5a83882be 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -344,7 +344,10 @@ export async function presentAssistantMessage(cline: Task) { // Fetch state early so it's available for toolDescription and validation const state = await cline.providerRef.deref()?.getState() - const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {} + const { customModes, experiments: stateExperiments, disabledTools } = state ?? {} + // Read the task-local mode, not the shared provider mode. + // A delegated child task may run in a different mode than its parent. + const taskMode = await cline.getTaskMode() const toolDescription = (): string => { switch (block.name) { @@ -617,7 +620,7 @@ export async function presentAssistantMessage(cline: Task) { validateToolUse( block.name as ToolName, - mode ?? defaultModeSlug, + taskMode, customModes ?? [], toolRequirements, block.params, @@ -924,7 +927,7 @@ export async function presentAssistantMessage(cline: Task) { } const result = await customTool.execute(customToolArgs, { - mode: mode ?? defaultModeSlug, + mode: taskMode, task: cline, }) diff --git a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts index df47e83c21..0b4d63fbac 100644 --- a/src/core/environment/__tests__/getEnvironmentDetails.spec.ts +++ b/src/core/environment/__tests__/getEnvironmentDetails.spec.ts @@ -117,6 +117,7 @@ describe("getEnvironmentDetails", () => { deref: vi.fn().mockReturnValue(mockProvider), [Symbol.toStringTag]: "WeakRef", } as unknown as WeakRef, + getTaskMode: vi.fn().mockResolvedValue("code"), } // Mock other dependencies. @@ -464,4 +465,29 @@ describe("getEnvironmentDetails", () => { const result = await getEnvironmentDetails(mockCline as Task, true) expect(result).toContain("File listing unavailable: unexpected string rejection") }) + + // Regression for issue #1623. + // Before the fix, the Current Mode block read the shared provider mode. + // A child delegated to "architect" mode would report "orchestrator" instead. + it("uses the task-local mode in the Current Mode block, not the provider mode", async () => { + // Provider mode stays "code"; task was delegated to "architect". + mockState.mode = "code" + ;(mockCline.getTaskMode as Mock).mockResolvedValue("architect") + ;(getFullModeDetails as Mock).mockResolvedValue({ + name: "🏗️ Architect", + roleDefinition: "You design software.", + customInstructions: "", + }) + + const result = await getEnvironmentDetails(mockCline as Task) + + expect(result).toContain("architect") + expect(result).not.toContain("code") + expect(getFullModeDetails).toHaveBeenCalledWith( + "architect", + [], + undefined, + expect.objectContaining({ cwd: mockCwd }), + ) + }) }) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index 0e7d18a57a..773870c304 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -205,7 +205,6 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo // Add current mode and any mode-specific warnings. const { - mode, customModes, customModePrompts, experiments = {} as Record, @@ -213,7 +212,9 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo language, } = state ?? {} - const currentMode = mode ?? defaultModeSlug + // Read the task-local mode, not the shared provider mode. + // A delegated child task may run in a different mode than its parent. + const currentMode = await cline.getTaskMode() const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, { cwd: cline.cwd, From fdef10685ea30cd2a76f9cea3d61601a05209394 Mon Sep 17 00:00:00 2001 From: "zoomote[bot]" <305051434+zoomote[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:50:53 +0000 Subject: [PATCH 02/21] fix(ci): union extension coverage before upload (#1650) Co-authored-by: Roomote --- .github/workflows/code-qa.yml | 19 +-- src/package.json | 1 + src/scripts/__tests__/merge-lcov.spec.mjs | 55 +++++++++ src/scripts/merge-lcov.mjs | 135 ++++++++++++++++++++++ 4 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 src/scripts/__tests__/merge-lcov.spec.mjs create mode 100644 src/scripts/merge-lcov.mjs diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index abb344dbd5..9f4a52ba80 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -173,6 +173,11 @@ jobs: node src/scripts/verify-lcov.mjs src/coverage/services/lcov.info node src/scripts/verify-lcov.mjs src/coverage/misc/lcov.info node src/scripts/verify-lcov.mjs src/coverage/tree-sitter/lcov.info + - name: Merge extension coverage reports + run: | + mkdir -p src/coverage/merged + pnpm --dir src run merge:coverage + node src/scripts/verify-lcov.mjs src/coverage/merged/lcov.info - name: Save Turbo cache if: steps.turbo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -184,21 +189,16 @@ jobs: # there mostly adds Codecov overhead without changing pass/fail # behavior. # Coverage is uploaded in separate steps so each LCOV gets the - # correct flag set. Codecov double-counts overlapping lines when a - # single upload carries multiple flags whose paths overlap, so the - # core lanes and webview lane must be uploaded individually with - # their own flag. + # correct flag set. Extension lanes instrument the same sources, so + # union them before upload; a line is covered when any lane executes + # it. Core and webview reports retain their independent flags. # See https://docs.codecov.com/docs/flags - name: Upload non-core coverage to Codecov if: matrix.upload-coverage uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: >- - src/coverage/api/lcov.info, - src/coverage/core/lcov.info, - src/coverage/services/lcov.info, - src/coverage/misc/lcov.info, - src/coverage/tree-sitter/lcov.info, + src/coverage/merged/lcov.info, packages/cloud/coverage/lcov.info, packages/telemetry/coverage/lcov.info, apps/cli/coverage/lcov.info @@ -240,6 +240,7 @@ jobs: src/coverage/services/lcov.info src/coverage/misc/lcov.info src/coverage/tree-sitter/lcov.info + src/coverage/merged/lcov.info webview-ui/coverage/lcov.info packages/cloud/coverage/lcov.info packages/telemetry/coverage/lcov.info diff --git a/src/package.json b/src/package.json index 0f26ad0a6c..ede058efd9 100644 --- a/src/package.json +++ b/src/package.json @@ -443,6 +443,7 @@ "check-types": "tsc --noEmit", "test": "vitest run", "verify:coverage-contract": "node scripts/verify-coverage-contract.mjs", + "merge:coverage": "node scripts/merge-lcov.mjs coverage/merged/lcov.info coverage/api/lcov.info coverage/core/lcov.info coverage/services/lcov.info coverage/misc/lcov.info coverage/tree-sitter/lcov.info", "test:unit": "vitest run --config vitest.unit.config.ts", "test:dist": "vitest run --config vitest.dist.config.ts", "test:coverage": "vitest run --coverage", diff --git a/src/scripts/__tests__/merge-lcov.spec.mjs b/src/scripts/__tests__/merge-lcov.spec.mjs new file mode 100644 index 0000000000..64706eaf02 --- /dev/null +++ b/src/scripts/__tests__/merge-lcov.spec.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { mergeLcov } from "../merge-lcov.mjs" + +const report = (coveredLines) => `SF:src/example.ts +FN:1,example +FNDA:${coveredLines.has(1) ? 1 : 0},example +FNF:1 +FNH:${coveredLines.has(1) ? 1 : 0} +BRDA:2,0,0,${coveredLines.has(2) ? 1 : "-"} +BRF:1 +BRH:${coveredLines.has(2) ? 1 : 0} +DA:1,${coveredLines.has(1) ? 1 : 0} +DA:2,${coveredLines.has(2) ? 1 : 0} +DA:3,${coveredLines.has(3) ? 1 : 0} +LF:3 +LH:${coveredLines.size} +end_of_record +` + +describe("mergeLcov", () => { + it("counts a line as covered when any coverage lane executes it", () => { + const merged = mergeLcov([ + ["api", report(new Set([1]))], + ["core", report(new Set([2]))], + ]) + + expect(merged).toContain("FNDA:1,example") + expect(merged).toContain("BRDA:2,0,0,1") + expect(merged).toContain("DA:1,1") + expect(merged).toContain("DA:2,1") + expect(merged).toContain("LH:2") + }) + + it("keeps lines uncovered when no coverage lane executes them", () => { + const merged = mergeLcov([ + ["api", report(new Set([1]))], + ["core", report(new Set([2]))], + ]) + + expect(merged).toContain("DA:3,0") + expect(merged).not.toContain("DA:3,1") + }) + + it("merges disjoint source records without changing their paths", () => { + const merged = mergeLcov([ + ["api", report(new Set([1])).replaceAll("src/example.ts", "src/api.ts")], + ["core", report(new Set([2])).replaceAll("src/example.ts", "src/core.ts")], + ]) + + expect(merged.match(/^SF:/gm)).toHaveLength(2) + expect(merged).toContain("SF:src/api.ts") + expect(merged).toContain("SF:src/core.ts") + }) +}) diff --git a/src/scripts/merge-lcov.mjs b/src/scripts/merge-lcov.mjs new file mode 100644 index 0000000000..cfcbd46cb6 --- /dev/null +++ b/src/scripts/merge-lcov.mjs @@ -0,0 +1,135 @@ +import { readFileSync, writeFileSync } from "node:fs" +import process from "node:process" + +const parseCount = (value, description) => { + const count = Number(value) + if (!Number.isSafeInteger(count) || count < 0) throw new Error(`Invalid ${description}: ${value}`) + return count +} + +const mergeCount = (records, key, count) => records.set(key, Math.max(records.get(key) ?? 0, count)) + +const parseLcov = (lcov, label) => { + const sources = new Map() + let record + + for (const line of lcov.split(/\r?\n/)) { + if (!line || line.startsWith("TN:")) continue + if (line.startsWith("SF:")) { + if (record) throw new Error(`${label} contains an unfinished source record: ${record.source}`) + const source = line.slice(3) + if (!source) throw new Error(`${label} contains an empty source path`) + record = { + source, + functions: new Map(), + functionCounts: new Map(), + branches: new Map(), + lines: new Map(), + } + } else if (line === "end_of_record") { + if (!record) throw new Error(`${label} contains a record terminator outside a source record`) + if (sources.has(record.source)) + throw new Error(`${label} contains duplicate source record: ${record.source}`) + sources.set(record.source, record) + record = undefined + } else if (record && line.startsWith("FN:")) { + const separator = line.indexOf(",") + if (separator < 4) throw new Error(`${label} contains invalid FN for ${record.source}`) + const name = line.slice(separator + 1) + const location = line.slice(3, separator) + const existing = record.functions.get(name) + if (existing && existing !== location) + throw new Error(`${label} contains conflicting FN for ${record.source}:${name}`) + record.functions.set(name, location) + } else if (record && line.startsWith("FNDA:")) { + const [count, ...name] = line.slice(5).split(",") + if (name.length === 0) throw new Error(`${label} contains invalid FNDA for ${record.source}`) + mergeCount(record.functionCounts, name.join(","), parseCount(count, `FNDA for ${record.source}`)) + } else if (record && line.startsWith("BRDA:")) { + const [lineNumber, block, branch, taken] = line.slice(5).split(",") + const key = `${lineNumber},${block},${branch}` + const count = taken === "-" ? 0 : parseCount(taken, `BRDA for ${record.source}`) + mergeCount(record.branches, key, count) + } else if (record && line.startsWith("DA:")) { + const [lineNumber, count, checksum] = line.slice(3).split(",") + const key = parseCount(lineNumber, `DA line for ${record.source}`) + if (key < 1) throw new Error(`${label} contains invalid DA line for ${record.source}`) + const existing = record.lines.get(key) + if (existing?.checksum && checksum && existing.checksum !== checksum) + throw new Error(`${label} contains conflicting DA checksum for ${record.source}:${key}`) + record.lines.set(key, { + count: Math.max(existing?.count ?? 0, parseCount(count, `DA count for ${record.source}`)), + checksum: existing?.checksum ?? checksum, + }) + } else if (record && !/^(?:FNF|FNH|BRF|BRH|LF|LH):/.test(line)) { + throw new Error(`${label} contains unsupported LCOV data for ${record.source}: ${line}`) + } else if (!record) { + throw new Error(`${label} contains data outside a source record: ${line}`) + } + } + + if (record) throw new Error(`${label} contains an unfinished source record: ${record.source}`) + return sources +} + +export const mergeLcov = (reports) => { + const merged = new Map() + for (const [label, lcov] of reports) { + for (const [source, incoming] of parseLcov(lcov, label)) { + const record = merged.get(source) ?? { + source, + functions: new Map(), + functionCounts: new Map(), + branches: new Map(), + lines: new Map(), + } + for (const [name, location] of incoming.functions) { + const existing = record.functions.get(name) + if (existing && existing !== location) throw new Error(`Conflicting FN for ${source}:${name}`) + record.functions.set(name, location) + } + for (const [name, count] of incoming.functionCounts) mergeCount(record.functionCounts, name, count) + for (const [key, count] of incoming.branches) mergeCount(record.branches, key, count) + for (const [line, value] of incoming.lines) { + const existing = record.lines.get(line) + if (existing?.checksum && value.checksum && existing.checksum !== value.checksum) + throw new Error(`Conflicting DA checksum for ${source}:${line}`) + record.lines.set(line, { + count: Math.max(existing?.count ?? 0, value.count), + checksum: existing?.checksum ?? value.checksum, + }) + } + merged.set(source, record) + } + } + + return [...merged.values()] + .sort((a, b) => a.source.localeCompare(b.source)) + .flatMap((record) => { + const functions = [...record.functions].sort(([a], [b]) => a.localeCompare(b)) + const functionCounts = [...record.functionCounts].sort(([a], [b]) => a.localeCompare(b)) + const branches = [...record.branches].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) + const lines = [...record.lines].sort(([a], [b]) => a - b) + return [ + `SF:${record.source}`, + ...functions.map(([name, location]) => `FN:${location},${name}`), + ...functionCounts.map(([name, count]) => `FNDA:${count},${name}`), + `FNF:${functions.length}`, + `FNH:${functionCounts.filter(([, count]) => count > 0).length}`, + ...branches.map(([key, count]) => `BRDA:${key},${count || "-"}`), + `BRF:${branches.length}`, + `BRH:${branches.filter(([, count]) => count > 0).length}`, + ...lines.map(([line, { count, checksum }]) => `DA:${line},${count}${checksum ? `,${checksum}` : ""}`), + `LF:${lines.length}`, + `LH:${lines.filter(([, { count }]) => count > 0).length}`, + "end_of_record", + ] + }) + .join("\n") +} + +if (process.argv[1] === import.meta.filename) { + const [output, ...inputs] = process.argv.slice(2) + if (!output || inputs.length < 1) throw new Error("Usage: merge-lcov.mjs ") + writeFileSync(output, `${mergeLcov(inputs.map((input) => [input, readFileSync(input, "utf8")]))}\n`) +} From 216450810ef1263596e2303fb715123bf7d6f331 Mon Sep 17 00:00:00 2001 From: Alexei Gubin <36731953+WebMad@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:14:40 +0000 Subject: [PATCH 03/21] refactor(code-index): extract manager registry (#1622) * refactor(code-index): extract manager registry * test(task): mock code index registry in task suite * test(code-index): remove redundant context casts * refactor(code-index): apply registry review feedback * fix(code-index): dispose registry on deactivate; drop dead registerCommands call * fix(coderabbit): limit neighbouring review scope creep --------- Co-authored-by: Elliott de Launay Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> --- .coderabbit.yaml | 24 ++-- src/__tests__/extension.spec.ts | 12 +- .../__tests__/registerCommands.spec.ts | 6 +- src/activate/registerCommands.ts | 2 - src/core/prompts/system.ts | 4 +- src/core/task/__tests__/Task.spec.ts | 9 ++ src/core/task/build-tools.ts | 4 +- src/core/tools/CodebaseSearchTool.ts | 4 +- src/core/webview/ClineProvider.ts | 5 +- .../webview/__tests__/ClineProvider.spec.ts | 6 +- src/core/webview/webviewMessageHandler.ts | 4 +- src/eslint-suppressions.json | 2 +- src/extension.ts | 9 +- .../code-index-manager-registry.spec.ts | 127 ++++++++++++++++++ .../code-index/__tests__/manager.spec.ts | 15 ++- .../code-index/code-index-manager-registry.ts | 53 ++++++++ src/services/code-index/manager.ts | 56 +------- 17 files changed, 241 insertions(+), 101 deletions(-) create mode 100644 src/services/code-index/__tests__/code-index-manager-registry.spec.ts create mode 100644 src/services/code-index/code-index-manager-registry.ts diff --git a/.coderabbit.yaml b/.coderabbit.yaml index c0562fd3f8..48161fa057 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -48,17 +48,19 @@ reviews: instructions: >- Act as an adversarial second-opinion reviewer. Verify PR claims against implementation and contracts. Trace changed inputs through normal, boundary, error, cancellation, retry, and - default paths and their consumers. Seek plausible counterexamples and regressions from removed - safeguards. Identify assumptions in changed code that depend on facts outside the diff. First - verify repository conventions, tests, and related implementations. When a potential finding - depends on external behavior, use web search and prefer official documentation, specifications, - or upstream repositories. Report only concrete, actionable conflicts or failure modes, citing - the relevant repository location or external source. Prioritize correctness, security, data loss, - lifecycle, and test gaps. Do not report generic best practices, unsupported concerns, speculative - style comments, or unrelated refactors. When changed code introduces a local implementation of a - cross-cutting concern, check whether it bypasses or duplicates an established repository abstraction - or nearby convention. Report only a concrete inconsistency with behavioral or maintenance impact, - and allow intentional deviations. + default paths and their direct test counterparts. Do not flag defects in files not changed by + this PR unless the defect is directly triggered by changed code and cannot be detected in the + changed file alone. Seek plausible counterexamples and regressions from removed safeguards. + Identify assumptions in changed code that depend on facts outside the diff. First verify + repository conventions, tests, and related implementations. When a potential finding depends on + external behavior, use web search and prefer official documentation, specifications, or upstream + repositories. Report only concrete, actionable conflicts or failure modes, citing the relevant + repository location or external source. Prioritize correctness, security, data loss, lifecycle, + and test gaps. Do not report generic best practices, unsupported concerns, speculative style + comments, or unrelated refactors. When changed code introduces a local implementation of a + cross-cutting concern, check whether it bypasses or duplicates an established repository + abstraction or nearby convention. Report only a concrete inconsistency with behavioral or + maintenance impact, and allow intentional deviations. - path: "**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}" instructions: >- diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index bb72d567dd..56ccd52588 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -139,9 +139,10 @@ vi.mock("../services/mcp/McpServerManager", () => ({ }, })) -vi.mock("../services/code-index/manager", () => ({ - CodeIndexManager: { - getInstance: vi.fn().mockReturnValue(null), +vi.mock("../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { + getOrCreate: vi.fn().mockReturnValue(null), + disposeAll: vi.fn(), }, })) @@ -463,6 +464,7 @@ describe("extension.ts", () => { const { TelemetryService } = await import("@roo-code/telemetry") const { Terminal } = await import("../integrations/terminal/Terminal") const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry") + const { CodeIndexManagerRegistry } = await import("../services/code-index/code-index-manager-registry") vi.mocked(TelemetryService.instance.shutdown).mockRejectedValue(new Error("shutdown failed")) const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile") @@ -474,6 +476,7 @@ describe("extension.ts", () => { expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined) expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1) + expect(CodeIndexManagerRegistry.disposeAll).toHaveBeenCalledTimes(1) setTerminalProfileSpy.mockRestore() }) @@ -486,6 +489,7 @@ describe("extension.ts", () => { const { TelemetryService } = await import("@roo-code/telemetry") const { Terminal } = await import("../integrations/terminal/Terminal") const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry") + const { CodeIndexManagerRegistry } = await import("../services/code-index/code-index-manager-registry") const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile") @@ -509,9 +513,9 @@ describe("extension.ts", () => { expect(mockTelemetryServiceInstance.shutdown).not.toHaveBeenCalled() expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined) expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1) + expect(CodeIndexManagerRegistry.disposeAll).toHaveBeenCalledTimes(1) instanceGetterSpy.mockRestore() - setTerminalProfileSpy.mockRestore() }) }) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 67a2b935ec..7088560700 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -67,9 +67,9 @@ vi.mock("../../core/config/importExport", () => ({ importSettingsWithFeedback: vi.fn(), })) -vi.mock("../../services/code-index/manager", () => ({ - CodeIndexManager: { - getInstance: vi.fn(), +vi.mock("../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { + getOrCreate: vi.fn(), }, })) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 692aabfd68..da98be291b 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -10,7 +10,6 @@ import { ClineProvider } from "../core/webview/ClineProvider" import { ContextProxy } from "../core/config/ContextProxy" import { focusPanel } from "../utils/focusPanel" import { handleNewTask } from "./handleTask" -import { CodeIndexManager } from "../services/code-index/manager" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" @@ -227,7 +226,6 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit ({ default: vi.fn().mockImplementation(async () => Promise.resolve()), })) +// Task tests do not exercise indexing; keep workspace resolution and its cache out of this suite. +vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { + getOrCreate: vi.fn().mockReturnValue(undefined), + getAllInstances: vi.fn().mockReturnValue([]), + disposeAll: vi.fn(), + }, +})) + vi.mock("vscode", () => { const mockDisposable = { dispose: vi.fn() } const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ebbdc050dc..ce7d058af6 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -96,8 +96,8 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO const mcpHub = provider.getMcpHub() // Get CodeIndexManager for feature checking. - const { CodeIndexManager } = await import("../../services/code-index/manager") - const codeIndexManager = CodeIndexManager.getInstance(provider.context, cwd) + const { CodeIndexManagerRegistry } = await import("../../services/code-index/code-index-manager-registry") + const codeIndexManager = CodeIndexManagerRegistry.getOrCreate(provider.context, cwd) // Build settings object for tool filtering. const filterSettings = { diff --git a/src/core/tools/CodebaseSearchTool.ts b/src/core/tools/CodebaseSearchTool.ts index f0d906fabd..ba1eb9bf75 100644 --- a/src/core/tools/CodebaseSearchTool.ts +++ b/src/core/tools/CodebaseSearchTool.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode" import path from "path" import { Task } from "../task/Task" -import { CodeIndexManager } from "../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" import { getWorkspacePath } from "../../utils/path" import { formatResponse } from "../prompts/responses" import { VectorStoreSearchResult } from "../../services/code-index/interfaces" @@ -57,7 +57,7 @@ export class CodebaseSearchTool extends BaseTool<"codebase_search"> { throw new Error("Extension context is not available.") } - const manager = CodeIndexManager.getInstance(context) + const manager = CodeIndexManagerRegistry.getOrCreate(context) if (!manager) { throw new Error("CodeIndexManager is not available.") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 495fe454b7..86ce5d8e67 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -90,7 +90,8 @@ import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" -import { CodeIndexManager } from "../../services/code-index/manager" +import type { CodeIndexManager } from "../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" @@ -3307,7 +3308,7 @@ export class ClineProvider * @returns CodeIndexManager instance for the current workspace or the default one */ public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { - return CodeIndexManager.getInstance(this.context) + return CodeIndexManagerRegistry.getOrCreate(this.context) } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bfd4706dcc..97c4dd877e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3225,7 +3225,7 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { }) it("catches auto-enabled indexing failures and posts the resulting status", async () => { - const { CodeIndexManager } = await import("../../../services/code-index/manager") + const { CodeIndexManagerRegistry } = await import("../../../services/code-index/code-index-manager-registry") let workspaceEnabled = false const manager = createIndexManager({ setAutoEnableDefault: vi.fn().mockImplementation(async () => { @@ -3235,8 +3235,8 @@ describe("webviewMessageHandler no-floating-promises coverage", () => { }) Object.defineProperty(manager, "isWorkspaceEnabled", { get: () => workspaceEnabled }) const getAllInstances = vi - .spyOn(CodeIndexManager, "getAllInstances") - .mockReturnValue([manager] as unknown as ReturnType) + .spyOn(CodeIndexManagerRegistry, "getAllInstances") + .mockReturnValue([manager] as unknown as ReturnType) const provider = createProvider({ getCurrentWorkspaceCodeIndexManager: vi.fn().mockReturnValue(manager), }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..34a35ea3ca 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -62,7 +62,7 @@ import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" -import { CodeIndexManager } from "../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" import { checkExistKey } from "../../shared/checkExistApiConfig" import { getRouterRemovalMessage, getRouterUnavailableSignInMessage } from "../config/routerRemoval" import { experimentDefault } from "../../shared/experiments" @@ -3311,7 +3311,7 @@ export const webviewMessageHandler = async ( return } // Capture prior state for every manager before persisting the global change - const allManagers = CodeIndexManager.getAllInstances() + const allManagers = CodeIndexManagerRegistry.getAllInstances() const priorStates = new Map(allManagers.map((m) => [m, m.isWorkspaceEnabled])) await manager.setAutoEnableDefault(message.bool ?? true) // Apply stop/start to every affected manager diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d90272962b..0e5207046c 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1301,7 +1301,7 @@ }, "services/code-index/__tests__/manager.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 89 + "count": 87 } }, "services/code-index/__tests__/orchestrator.spec.ts": { diff --git a/src/extension.ts b/src/extension.ts index 0a78cd32ba..8706de765b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,7 +34,7 @@ import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" import { kimiCodeOAuthManager } from "./integrations/kimi-code/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" -import { CodeIndexManager } from "./services/code-index/manager" +import { CodeIndexManagerRegistry } from "./services/code-index/code-index-manager-registry" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" @@ -196,15 +196,11 @@ export async function activate(context: vscode.ExtensionContext) { ) // Initialize code index managers for all workspace folders. - const codeIndexManagers: CodeIndexManager[] = [] - if (vscode.workspace.workspaceFolders) { for (const folder of vscode.workspace.workspaceFolders) { - const manager = CodeIndexManager.getInstance(context, folder.uri.fsPath) + const manager = CodeIndexManagerRegistry.getOrCreate(context, folder.uri.fsPath) if (manager) { - codeIndexManagers.push(manager) - // Initialize in background; do not block extension activation void manager.initialize(contextProxy).catch((error) => { const message = error instanceof Error ? error.message : String(error) @@ -412,4 +408,5 @@ export async function deactivate() { Terminal.setTerminalProfile(undefined) TerminalRegistry.cleanup() + CodeIndexManagerRegistry.disposeAll() } diff --git a/src/services/code-index/__tests__/code-index-manager-registry.spec.ts b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts new file mode 100644 index 0000000000..9879ff8ea9 --- /dev/null +++ b/src/services/code-index/__tests__/code-index-manager-registry.spec.ts @@ -0,0 +1,127 @@ +import * as vscode from "vscode" +import { makeExtensionContext, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import { CodeIndexManager } from "../manager" +import { CodeIndexManagerRegistry } from "../code-index-manager-registry" + +vi.mock("vscode", () => ({ + workspace: { workspaceFolders: undefined, getWorkspaceFolder: vi.fn() }, + window: { activeTextEditor: undefined }, + Uri: { file: vi.fn() }, +})) + +vi.mock("../manager", () => ({ + CodeIndexManager: vi.fn().mockImplementation(function () { + return { dispose: vi.fn() } + }), +})) + +describe("CodeIndexManagerRegistry", () => { + let context: vscode.ExtensionContext + let first: vscode.WorkspaceFolder + let second: vscode.WorkspaceFolder + + beforeEach(() => { + vi.clearAllMocks() + context = makeExtensionContext() + first = { uri: makeUri("/first"), name: "first", index: 0 } + second = { uri: makeUri("/second"), name: "second", index: 1 } + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: [first, second] }) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: undefined }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(undefined) + vi.mocked(vscode.Uri.file).mockImplementation((value) => makeUri(value)) + }) + + afterEach(() => { + CodeIndexManagerRegistry.disposeAll() + vi.restoreAllMocks() + }) + + it.each([{ folders: undefined }, { folders: [] }])("returns no manager with folders=$folders", ({ folders }) => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: folders }) + expect(CodeIndexManagerRegistry.getOrCreate(context)).toBeUndefined() + expect(CodeIndexManager).not.toHaveBeenCalled() + }) + + it("uses the first workspace when there is no active editor", () => { + CodeIndexManagerRegistry.getOrCreate(context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + }) + + it("prefers the active editor's workspace", () => { + const editor = makeTextEditor({ document: makeTextDocument({ uri: makeUri("/second/file.ts") }) }) + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: editor }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(second) + expect(CodeIndexManagerRegistry.getOrCreate(context)).toBeDefined() + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + }) + + it("falls back to the first workspace for an editor outside all folders", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) + CodeIndexManagerRegistry.getOrCreate(context) + expect(CodeIndexManager).toHaveBeenCalledWith("/first", first.uri, context) + }) + + it("gives an explicit path priority over the active editor", () => { + Object.defineProperty(vscode.window, "activeTextEditor", { configurable: true, value: makeTextEditor() }) + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValue(first) + expect(CodeIndexManagerRegistry.getOrCreate(context, "/second")).toBeDefined() + expect(CodeIndexManager).toHaveBeenCalledWith("/second", second.uri, context) + }) + + it("preserves the actual remote workspace URI", () => { + const uri = makeUri("/remote", { scheme: "vscode-remote", authority: "ssh-remote+host" }) + Object.defineProperty(vscode.workspace, "workspaceFolders", { + configurable: true, + value: [{ uri, name: "remote", index: 0 }], + }) + CodeIndexManagerRegistry.getOrCreate(context, "/remote") + expect(CodeIndexManager).toHaveBeenCalledWith("/remote", uri, context) + expect(vi.mocked(CodeIndexManager).mock.calls[0][1]).toBe(uri) + expect(vscode.Uri.file).not.toHaveBeenCalled() + }) + + it("constructs a file URI for an explicit path without open workspaces", () => { + Object.defineProperty(vscode.workspace, "workspaceFolders", { configurable: true, value: undefined }) + const uri = makeUri("/outside folder/#name") + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + CodeIndexManagerRegistry.getOrCreate(context, uri.fsPath) + expect(vscode.Uri.file).toHaveBeenCalledWith(uri.fsPath) + expect(CodeIndexManager).toHaveBeenCalledWith(uri.fsPath, uri, context) + }) + + it("constructs a file URI for an explicit path not matching any open workspace folder", () => { + // workspaceFolders contains /first and /second, but /outside/project matches neither + const uri = makeUri("/outside/project") + vi.mocked(vscode.Uri.file).mockReturnValue(uri) + CodeIndexManagerRegistry.getOrCreate(context, "/outside/project") + expect(vscode.Uri.file).toHaveBeenCalledWith("/outside/project") + expect(CodeIndexManager).toHaveBeenCalledWith("/outside/project", uri, context) + }) + + it("reuses the same path and keeps different paths isolated", () => { + const a = CodeIndexManagerRegistry.getOrCreate(context, "/first") + expect(CodeIndexManagerRegistry.getOrCreate(makeExtensionContext(), "/first")).toBe(a) + const b = CodeIndexManagerRegistry.getOrCreate(context, "/second") + expect(b).not.toBe(a) + expect(CodeIndexManager).toHaveBeenCalledTimes(2) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([a, b]) + }) + + it("returns a snapshot that cannot mutate the cache", () => { + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + const manager = CodeIndexManagerRegistry.getOrCreate(context) + CodeIndexManagerRegistry.getAllInstances().pop() + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([manager]) + }) + + it("disposes every manager, supports repeated cleanup and recreates instances", () => { + const a = CodeIndexManagerRegistry.getOrCreate(context, "/first")! + const b = CodeIndexManagerRegistry.getOrCreate(context, "/second")! + CodeIndexManagerRegistry.disposeAll() + CodeIndexManagerRegistry.disposeAll() + expect(a.dispose).toHaveBeenCalledTimes(1) + expect(b.dispose).toHaveBeenCalledTimes(1) + expect(CodeIndexManagerRegistry.getAllInstances()).toEqual([]) + expect(CodeIndexManagerRegistry.getOrCreate(context, "/first")).not.toBe(a) + }) +}) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index ce52593ed5..9faf06627e 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -1,4 +1,5 @@ import { CodeIndexManager } from "../manager" +import { CodeIndexManagerRegistry } from "../code-index-manager-registry" import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" @@ -126,7 +127,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { beforeEach(() => { // Clear all instances before each test - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() const workspaceStateStore: Record = {} const globalStateStore: Record = {} @@ -160,11 +161,11 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { languageModelAccessInformation: {} as any, } - manager = CodeIndexManager.getInstance(mockContext)! + manager = CodeIndexManagerRegistry.getOrCreate(mockContext)! }) afterEach(() => { - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() }) describe("handleSettingsChange", () => { @@ -733,7 +734,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) it("should store enablement per folder URI, not per window", async () => { - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() const vscode = await import("vscode") @@ -764,8 +765,8 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { { uri: folderBUri, name: "folderB", index: 1 }, ] - const managerA = CodeIndexManager.getInstance(sharedContext as any, folderAPath)! - const managerB = CodeIndexManager.getInstance(sharedContext as any, folderBPath)! + const managerA = CodeIndexManagerRegistry.getOrCreate(sharedContext, folderAPath)! + const managerB = CodeIndexManagerRegistry.getOrCreate(sharedContext, folderBPath)! // Both start disabled (autoEnableDefault is false via globalState mock) expect(managerA.isWorkspaceEnabled).toBe(false) @@ -784,7 +785,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { expect(managerA.isWorkspaceEnabled).toBe(false) expect(managerB.isWorkspaceEnabled).toBe(true) - CodeIndexManager.disposeAll() + CodeIndexManagerRegistry.disposeAll() }) }) diff --git a/src/services/code-index/code-index-manager-registry.ts b/src/services/code-index/code-index-manager-registry.ts new file mode 100644 index 0000000000..635ec62647 --- /dev/null +++ b/src/services/code-index/code-index-manager-registry.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode" +import { CodeIndexManager } from "./manager" + +/** Resolves workspaces and owns their cached CodeIndexManager instances. */ +export class CodeIndexManagerRegistry { + private static instances = new Map() + + public static getOrCreate(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { + const folder = this.resolveWorkspaceFolder(workspacePath) + const resolvedPath = workspacePath || folder?.uri.fsPath + if (!resolvedPath) { + return undefined + } + + const existing = this.instances.get(resolvedPath) + if (existing) { + return existing + } + + // Preserve real workspace URIs, including remote schemes and authorities. + const folderUri = folder?.uri ?? vscode.Uri.file(resolvedPath) + const manager = new CodeIndexManager(resolvedPath, folderUri, context) + this.instances.set(resolvedPath, manager) + return manager + } + + public static getAllInstances(): CodeIndexManager[] { + return Array.from(this.instances.values()) + } + + public static disposeAll(): void { + for (const instance of this.instances.values()) { + instance.dispose() + } + this.instances.clear() + } + + private static resolveWorkspaceFolder(workspacePath?: string): vscode.WorkspaceFolder | undefined { + if (workspacePath) { + return vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === workspacePath) + } + + const activeEditor = vscode.window.activeTextEditor + if (activeEditor) { + const folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) + if (folder) { + return folder + } + } + + return vscode.workspace.workspaceFolders?.[0] + } +} diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd36a32d88..fd3e6b0553 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -18,9 +18,6 @@ import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" export class CodeIndexManager { - // --- Singleton Implementation --- - private static instances = new Map() // Map workspace path to instance - // Specialized class instances private _configManager: CodeIndexConfigManager | undefined private readonly _stateManager: CodeIndexStateManager @@ -33,61 +30,12 @@ export class CodeIndexManager { // Flag to prevent race conditions during error recovery private _isRecoveringFromError = false - public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { - // Resolve the workspace folder to get both fsPath and the real URI - let folder: vscode.WorkspaceFolder | undefined - - if (workspacePath) { - folder = vscode.workspace.workspaceFolders?.find((f) => f.uri.fsPath === workspacePath) - } else { - const activeEditor = vscode.window.activeTextEditor - if (activeEditor) { - folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) - } - if (!folder) { - const workspaceFolders = vscode.workspace.workspaceFolders - if (!workspaceFolders || workspaceFolders.length === 0) { - return undefined - } - folder = workspaceFolders[0] - } - workspacePath = folder.uri.fsPath - } - - if (!CodeIndexManager.instances.has(workspacePath)) { - // folder may be undefined when workspacePath was provided but doesn't match - // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. - const folderUri = - folder?.uri ?? - ({ - fsPath: workspacePath, - scheme: "file", - authority: "", - path: workspacePath, - toString: () => `file://${workspacePath}`, - } as unknown as vscode.Uri) - CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, folderUri, context)) - } - return CodeIndexManager.instances.get(workspacePath)! - } - - public static getAllInstances(): CodeIndexManager[] { - return Array.from(CodeIndexManager.instances.values()) - } - - public static disposeAll(): void { - for (const instance of CodeIndexManager.instances.values()) { - instance.dispose() - } - CodeIndexManager.instances.clear() - } - private readonly workspacePath: string private readonly _folderUri: vscode.Uri private readonly context: vscode.ExtensionContext - // Private constructor for singleton pattern - private constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { + /** @internal — construct only via {@link CodeIndexManagerRegistry} */ + public constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { this.workspacePath = workspacePath this._folderUri = folderUri this.context = context From 99736300f9a2dfdb8277124da1f6f13827deaa46 Mon Sep 17 00:00:00 2001 From: "zoomote[bot]" <305051434+zoomote[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:43:09 +0000 Subject: [PATCH 04/21] [Fix] Preserve coverage caches for verifier-only changes (#1649) * fix(ci): preserve coverage cache for verifier changes * fix(ci): isolate coverage input mutation check * fix(ci): avoid self-mutating coverage verifier * fix(ci): isolate coverage hash probes * fix(ci): validate cache before publication --------- Co-authored-by: Roomote --- .github/workflows/code-qa.yml | 3 + src/package.json | 1 + src/scripts/verify-coverage-cache-inputs.mjs | 132 +++++++++++++++++++ src/turbo.json | 16 +++ 4 files changed, 152 insertions(+) create mode 100644 src/scripts/verify-coverage-cache-inputs.mjs diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 9f4a52ba80..f1ba2a10cb 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -178,6 +178,9 @@ jobs: mkdir -p src/coverage/merged pnpm --dir src run merge:coverage node src/scripts/verify-lcov.mjs src/coverage/merged/lcov.info + # Validate cache boundaries before publishing any new Turbo entries. + - name: Verify coverage cache inputs + run: pnpm --dir src run verify:coverage-cache-inputs - name: Save Turbo cache if: steps.turbo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/src/package.json b/src/package.json index ede058efd9..e047f661d8 100644 --- a/src/package.json +++ b/src/package.json @@ -443,6 +443,7 @@ "check-types": "tsc --noEmit", "test": "vitest run", "verify:coverage-contract": "node scripts/verify-coverage-contract.mjs", + "verify:coverage-cache-inputs": "node --test scripts/verify-coverage-cache-inputs.mjs", "merge:coverage": "node scripts/merge-lcov.mjs coverage/merged/lcov.info coverage/api/lcov.info coverage/core/lcov.info coverage/services/lcov.info coverage/misc/lcov.info coverage/tree-sitter/lcov.info", "test:unit": "vitest run --config vitest.unit.config.ts", "test:dist": "vitest run --config vitest.dist.config.ts", diff --git a/src/scripts/verify-coverage-cache-inputs.mjs b/src/scripts/verify-coverage-cache-inputs.mjs new file mode 100644 index 0000000000..1337bddef9 --- /dev/null +++ b/src/scripts/verify-coverage-cache-inputs.mjs @@ -0,0 +1,132 @@ +import { spawnSync } from "node:child_process" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { resolve } from "node:path" +import process from "node:process" +import { test } from "node:test" + +const root = resolve(import.meta.dirname, "../..") +const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm" +if (!pnpm) throw new Error("pnpm executable path is unavailable") +const command = process.platform === "win32" ? process.execPath : pnpm +const args = process.platform === "win32" ? [pnpm] : [] +const lanes = ["api", "core", "services", "misc", "tree-sitter"] +let probeRoot + +const git = (gitArgs) => { + const result = spawnSync("git", gitArgs, { cwd: root, encoding: "utf8" }) + if (result.status !== 0) { + const details = [result.error?.message, result.signal, result.stderr, result.stdout].filter(Boolean).join("\n") + throw new Error(details || `git exited with status ${result.status ?? "unknown"}`) + } +} + +const coverageTasks = () => { + const result = spawnSync( + command, + [ + ...args, + "turbo", + "--cwd", + probeRoot, + "run", + ...lanes.map((lane) => `test:coverage:${lane}`), + "--filter=zoo-code", + "--dry=json", + "--no-daemon", + ], + { cwd: root, encoding: "utf8" }, + ) + if (result.status !== 0) { + const details = [result.error?.message, result.signal, result.stderr, result.stdout].filter(Boolean).join("\n") + throw new Error(details || `pnpm exited with status ${result.status ?? "unknown"}`) + } + const graph = JSON.parse(result.stdout) + return lanes.map((lane) => { + const task = graph.tasks.find(({ taskId }) => taskId === `zoo-code#test:coverage:${lane}`) + if (!task) throw new Error(`Coverage lane missing from Turbo graph: ${lane}`) + return task + }) +} + +const hashes = () => Object.fromEntries(coverageTasks().map((task) => [task.task.split(":").at(-1), task.hash])) + +const withChangedFiles = (paths, run) => { + const originals = paths.map((path) => [path, readFileSync(resolve(probeRoot, path), "utf8")]) + try { + for (const [path, contents] of originals) + writeFileSync(resolve(probeRoot, path), `${contents}\n// cache-input-test\n`) + return run() + } finally { + for (const [path, contents] of originals) writeFileSync(resolve(probeRoot, path), contents) + } +} + +const changedLanes = (before, after) => lanes.filter((lane) => before[lane] !== after[lane]) + +test("coverage cache input contract", async (context) => { + probeRoot = mkdtempSync(resolve(tmpdir(), "zoo-code-coverage-cache-inputs-")) + let worktreeAdded = false + let cleaned = false + const cleanup = () => { + if (cleaned) return + cleaned = true + try { + if (worktreeAdded) git(["worktree", "remove", "--force", probeRoot]) + } finally { + rmSync(probeRoot, { recursive: true, force: true }) + } + } + const terminate = (signal) => { + cleanup() + process.kill(process.pid, signal) + } + const onSigint = () => terminate("SIGINT") + const onSigterm = () => terminate("SIGTERM") + process.once("SIGINT", onSigint) + process.once("SIGTERM", onSigterm) + + try { + git(["worktree", "add", "--detach", probeRoot, "HEAD"]) + worktreeAdded = true + + await context.test("coverage lane hashes ignore post-coverage verifier implementation", () => { + const before = hashes() + const self = "scripts/verify-coverage-cache-inputs.mjs" + for (const task of coverageTasks()) { + if (Object.hasOwn(task.inputs, self)) throw new Error(`${self} is an input of ${task.taskId}`) + } + for (const path of [ + "src/scripts/coverage-contract.mjs", + "src/scripts/verify-coverage-contract.mjs", + "src/scripts/verify-lcov.mjs", + ]) { + const after = withChangedFiles([path], hashes) + const changed = changedLanes(before, after) + if (changed.length !== 0) throw new Error(`${path} invalidated coverage lanes: ${changed.join(", ")}`) + } + }) + + await context.test("shared production changes invalidate every coverage lane that can import them", () => { + const before = hashes() + const after = withChangedFiles(["src/utils/path.ts"], hashes) + const changed = changedLanes(before, after) + + if (changed.join(",") !== lanes.join(",")) + throw new Error(`Shared production change invalidated ${changed.join(", ") || "no lanes"}`) + }) + + await context.test("lane-owned tests invalidate only their general coverage lane", () => { + const before = hashes() + const after = withChangedFiles(["src/api/providers/__tests__/anthropic.spec.ts"], hashes) + const changed = changedLanes(before, after) + + if (changed.join(",") !== "api") + throw new Error(`API test change invalidated ${changed.join(", ") || "no lanes"}`) + }) + } finally { + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + cleanup() + } +}) diff --git a/src/turbo.json b/src/turbo.json index 0d023b5598..eac6f30f3c 100644 --- a/src/turbo.json +++ b/src/turbo.json @@ -24,6 +24,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!core/**/*.{test,spec}.{ts,tsx}", "!services/**/*.{test,spec}.{ts,tsx}", "!__tests__/**/*.{test,spec}.{ts,tsx}", @@ -42,6 +46,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!api/**/*.{test,spec}.{ts,tsx}", "!services/**/*.{test,spec}.{ts,tsx}", "!__tests__/**/*.{test,spec}.{ts,tsx}", @@ -60,6 +68,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!api/**/*.{test,spec}.{ts,tsx}", "!core/**/*.{test,spec}.{ts,tsx}", "!services/tree-sitter/**/*.{test,spec}.{ts,tsx}", @@ -79,6 +91,10 @@ "dependsOn": ["^build"], "inputs": [ "$TURBO_DEFAULT$", + "!scripts/verify-coverage-cache-inputs.mjs", + "!scripts/coverage-contract.mjs", + "!scripts/verify-coverage-contract.mjs", + "!scripts/verify-lcov.mjs", "!api/**/*.{test,spec}.{ts,tsx}", "!core/**/*.{test,spec}.{ts,tsx}", "!services/**/*.{test,spec}.{ts,tsx}", From 500152b7845d791cf762fa06d12bc2de51fef685 Mon Sep 17 00:00:00 2001 From: "zoomote[bot]" <305051434+zoomote[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:23:01 +0000 Subject: [PATCH 05/21] [Fix] DeepSeek Flash cannot read attached images (#1618) * fix(deepseek): enable images for current Flash models * test(deepseek): cover vision alias defaults --------- Co-authored-by: Roomote Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> --- .../src/__tests__/deepseek-v4-pro.test.ts | 20 +++-- packages/types/src/providers/deepseek.ts | 41 +++++---- src/api/providers/__tests__/deepseek.spec.ts | 83 ++++++++++++------- src/api/providers/deepseek.ts | 8 +- .../fetchers/__tests__/deepseek.spec.ts | 13 +++ 5 files changed, 113 insertions(+), 52 deletions(-) diff --git a/packages/types/src/__tests__/deepseek-v4-pro.test.ts b/packages/types/src/__tests__/deepseek-v4-pro.test.ts index 78a4befd6b..6fa40d28eb 100644 --- a/packages/types/src/__tests__/deepseek-v4-pro.test.ts +++ b/packages/types/src/__tests__/deepseek-v4-pro.test.ts @@ -10,12 +10,18 @@ describe("DeepSeek V4 Pro 0813 provider catalogs", () => { expect(model?.contextWindow).toBeGreaterThanOrEqual(1_000_000) }) - it("uses peak first-party pricing and unchanged OpenCode Go pricing", () => { + it("uses current peak first-party pricing and unchanged OpenCode Go pricing", () => { + expect(deepSeekModels["deepseek-flash"]).toMatchObject({ + supportsImages: true, + outputPrice: 1.2, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.006, + }) expect(deepSeekModels["deepseek-v4-flash"]).toMatchObject({ - supportsImages: false, - outputPrice: 1.32, - cacheWritesPrice: 0.44, - cacheReadsPrice: 0.014, + supportsImages: true, + outputPrice: 1.2, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.006, }) expect(deepSeekModels["deepseek-v4-pro"].supportsImages).toBe(false) expect(deepSeekModels["deepseek-v4-pro"]).toMatchObject({ @@ -42,6 +48,10 @@ describe("DeepSeek V4 Pro 0813 provider catalogs", () => { expect(model.supportsPromptCache).toBe(true) expect(model.contextWindow).toBeGreaterThanOrEqual(1_000_000) expect(model.supportsReasoningEffort).toEqual(["disable", "low", "high", "max"]) + expect(model).toMatchObject({ outputPrice: 1.2, cacheWritesPrice: 0.3, cacheReadsPrice: 0.006 }) + expect(model.description).toContain("Legacy model name") + expect(model).not.toHaveProperty("supportsTemperature") + expect(model).not.toHaveProperty("defaultTemperature") }) // Self-hosted providers retain separate IDs for the preview weights and 0813 checkpoint. diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index 3e42bbfeec..5cd2e0f21d 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -6,23 +6,38 @@ import type { ModelInfo } from "../model.js" // continuation within the same turn. See: https://api-docs.deepseek.com/guides/thinking_mode export type DeepSeekModelId = keyof typeof deepSeekModels -export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-v4-flash" +export const deepSeekDefaultModelId: DeepSeekModelId = "deepseek-flash" export const deepSeekModels = { + "deepseek-flash": { + maxTokens: 384_000, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "high", "max"], // Updated 2026-09-10 + preserveReasoning: true, + reasoningEffort: "high", + inputPrice: 0, // the inputs are priced as cache read/write, so `inputPrice` should be 0 + // Static estimates use peak rates; off-peak rates are 50% lower. Effective 2026-09-10. + outputPrice: 1.2, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.006, + description: `DeepSeek-V4.1-Flash is DeepSeek's fast multimodal model with image understanding. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`, + }, "deepseek-v4-flash": { maxTokens: 384_000, contextWindow: 1_000_000, - supportsImages: false, + supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["disable", "low", "high", "max"], // Updated 2026-08-13 preserveReasoning: true, reasoningEffort: "high", inputPrice: 0, // the inputs are priced as cache read/write, so `inputPrice` should be 0 - // Static estimates use peak rates; off-peak rates are 50% lower. Effective 2026-08-16. - outputPrice: 1.32, - cacheWritesPrice: 0.44, - cacheReadsPrice: 0.014, - description: `DeepSeek-V4-Flash is DeepSeek's fast, cost-efficient V4 model. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`, + // This retired ID is billed as the current Flash model. + outputPrice: 1.2, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.006, + description: `Legacy model name routed to the latest DeepSeek Flash model, which supports image input. Use deepseek-flash for new configurations.`, }, "deepseek-v4-pro": { displayName: "DeepSeek V4 Pro 0813", @@ -49,14 +64,12 @@ export const deepSeekModels = { supportsReasoningEffort: ["disable", "low", "high", "max"], // Updated 2026-08-13 preserveReasoning: true, reasoningEffort: "high", - supportsTemperature: true, - defaultTemperature: 1.0, inputPrice: 0, // the inputs are priced as cache read/write, so `inputPrice` should be 0 - // Static estimates use peak rates; off-peak rates are 50% lower. - outputPrice: 1.32, - cacheWritesPrice: 0.44, - cacheReadsPrice: 0.014, - description: `DeepSeek-V4-Flash-Vision-Exp is DeepSeek's experimental multimodal V4 Flash model with image understanding. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and image input through Chat Completions, Responses, and Anthropic-compatible APIs.`, + // This retired ID is billed as the current Flash model. + outputPrice: 1.2, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.006, + description: `Legacy model name routed to the latest DeepSeek Flash model, which supports image input. Use deepseek-flash for new configurations.`, }, } as const satisfies Record diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 2f344d8405..4ab247b131 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -240,22 +240,22 @@ describe("DeepSeekHandler", () => { expect(model.info).toBeDefined() expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) - expect(model.info.supportsImages).toBe(false) + expect(model.info.supportsImages).toBe(true) expect(model.info.supportsPromptCache).toBe(true) // Should be true now expect((model.info as ModelInfo).preserveReasoning).toBe(true) }) - it("should use deepseek-v4-flash as the default model ID for new configs", () => { + it("should use deepseek-flash as the default model ID for new configs", () => { const handlerWithoutModel = new DeepSeekHandler({ ...mockOptions, apiModelId: undefined, }) const model = handlerWithoutModel.getModel() expect(model.id).toBe(deepSeekDefaultModelId) - expect(model.id).toBe("deepseek-v4-flash") + expect(model.id).toBe("deepseek-flash") expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) - expect(model.info.supportsImages).toBe(false) + expect(model.info.supportsImages).toBe(true) expect((model.info as ModelInfo).supportsReasoningEffort).toContain("max") }) @@ -290,7 +290,6 @@ describe("DeepSeekHandler", () => { supportsPromptCache: true, preserveReasoning: true, reasoningEffort: "high", - defaultTemperature: 1.0, }) }) @@ -369,41 +368,61 @@ describe("DeepSeekHandler", () => { expect(textChunks[0].text).toBe("Test response") }) - it("should send images and V4 thinking controls to deepseek-v4-flash-vision-exp", async () => { + it.each(["deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"] as const)( + "should send images and thinking controls to %s", + async (modelId) => { + const visionHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: modelId, + }) + const visionMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image." }, + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "image-data" }, + }, + ], + }, + ] + + await collectStream(visionHandler.createMessage(systemPrompt, visionMessages)) + + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs).toMatchObject({ + model: modelId, + thinking: { type: "enabled" }, + reasoning_effort: "high", + max_completion_tokens: 200_000, + }) + expect(callArgs.temperature).toBeUndefined() + expect(callArgs.messages).toContainEqual({ + role: "user", + content: expect.arrayContaining([ + { type: "text", text: expect.stringContaining("Describe this image.") }, + { type: "image_url", image_url: { url: "data:image/png;base64,image-data" } }, + ]), + }) + }, + ) + + it("should use the provider default temperature when reasoning is disabled for the vision alias", async () => { const visionHandler = new DeepSeekHandler({ ...mockOptions, apiModelId: "deepseek-v4-flash-vision-exp", + enableReasoningEffort: false, }) - const visionMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { type: "text", text: "Describe this image." }, - { - type: "image", - source: { type: "base64", media_type: "image/png", data: "image-data" }, - }, - ], - }, - ] - await collectStream(visionHandler.createMessage(systemPrompt, visionMessages)) + await collectStream(visionHandler.createMessage(systemPrompt, messages)) - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs).toMatchObject({ + expect(mockCreate.mock.calls[0][0]).toMatchObject({ model: "deepseek-v4-flash-vision-exp", - thinking: { type: "enabled" }, - reasoning_effort: "high", - max_completion_tokens: 200_000, - }) - expect(callArgs.temperature).toBeUndefined() - expect(callArgs.messages).toContainEqual({ - role: "user", - content: expect.arrayContaining([ - { type: "text", text: expect.stringContaining("Describe this image.") }, - { type: "image_url", image_url: { url: "data:image/png;base64,image-data" } }, - ]), + thinking: { type: "disabled" }, + temperature: 0, }) + expect(mockCreate.mock.calls[0][0].reasoning_effort).toBeUndefined() }) it("should include usage information", async () => { diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index c423c2b55c..149adb186b 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -28,7 +28,12 @@ type DeepSeekChatCompletionParams = Omit deepSeekV4ThinkingModels.has(modelId) // Only known V4 models and the legacy reasoner alias support DeepSeek's @@ -49,6 +54,7 @@ export const normalizeDeepSeekReasoningEffort = ( ): "low" | "high" | "max" | undefined => { // still check the modelId so non-supported models won't produce reasoning efforts switch (modelId) { + case "deepseek-flash": case "deepseek-v4-flash": case "deepseek-v4-pro": case "deepseek-v4-flash-vision-exp": diff --git a/src/api/providers/fetchers/__tests__/deepseek.spec.ts b/src/api/providers/fetchers/__tests__/deepseek.spec.ts index e44190a141..7856874329 100644 --- a/src/api/providers/fetchers/__tests__/deepseek.spec.ts +++ b/src/api/providers/fetchers/__tests__/deepseek.spec.ts @@ -29,11 +29,24 @@ describe("getDeepSeekModels", () => { const models = await getDeepSeekModels("http://127.0.0.1:43123/v1", "mock-key") expect(globalThis.fetch).toHaveBeenCalledWith("http://127.0.0.1:43123/models", expect.any(Object)) + expect(models["deepseek-flash"]).toEqual(deepSeekModels["deepseek-flash"]) expect(models["deepseek-v4-flash"]).toEqual(deepSeekModels["deepseek-v4-flash"]) expect(models["deepseek-v4-pro"]).toEqual(deepSeekModels["deepseek-v4-pro"]) expect(models["deepseek-v4-flash-vision-exp"]).toEqual(deepSeekModels["deepseek-v4-flash-vision-exp"]) }) + it("applies vision metadata to the canonical Flash model returned by DeepSeek", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [{ id: "deepseek-flash" }] }), + }) as unknown as typeof fetch + + const models = await getDeepSeekModels(undefined, "test-key") + + expect(models["deepseek-flash"]).toEqual(deepSeekModels["deepseek-flash"]) + expect(models["deepseek-flash"].supportsImages).toBe(true) + }) + it("throws for 404 responses when fallback flag is not enabled", async () => { delete process.env.E2E_MOCK_MODEL_LIST_FALLBACK From 10b45abf7281d7b08c3ee65d625f8a5772ad2b5a Mon Sep 17 00:00:00 2001 From: "zoomote[bot]" <305051434+zoomote[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:36:15 +0000 Subject: [PATCH 06/21] fix(ci): scope mutation diff to merge result base (#1655) Co-authored-by: Roomote --- .github/workflows/mutation-testing.yml | 12 ++----- scripts/stryker-diff.test.mjs | 45 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 9e0987fbd5..2da0c5c5b4 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -36,13 +36,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Fetch pull request base - if: github.event_name == 'pull_request' - env: - BASE_REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}.git - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: git fetch --no-tags "$BASE_REPOSITORY_URL" "$BASE_SHA" - - name: Setup Node.js and pnpm if: github.event_name == 'pull_request' uses: ./.github/actions/setup-node-pnpm @@ -56,9 +49,10 @@ jobs: - name: Enforce executable-line scope and run advisory mutation testing if: github.event_name == 'pull_request' env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.sha }} - run: node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" + run: | + BASE_SHA="$(git rev-parse "$HEAD_SHA^1")" + node scripts/stryker-diff.mjs ci --base "$BASE_SHA" --head "$HEAD_SHA" - name: Upload mutation reports id: mutation_report diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 403cf62d89..deaba510a9 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -57,6 +57,8 @@ describe("mutation testing workflow", () => { assert.ok(!workflow.includes("ref: ${{ github.event.pull_request.head.sha }}")) assert.ok(workflow.includes("HEAD_SHA: ${{ github.sha }}")) assert.ok(!workflow.includes("HEAD_SHA: ${{ github.event.pull_request.head.sha }}")) + assert.ok(workflow.includes('BASE_SHA="$(git rev-parse "$HEAD_SHA^1")"')) + assert.ok(!workflow.includes("github.event.pull_request.base.sha")) assert.ok(workflow.includes("steps.mutation_report.outputs.artifact-url")) assert.ok(workflow.includes("open the package's mutation.html file")) assert.ok(workflow.includes("Enforce executable-line scope and run advisory mutation testing")) @@ -444,6 +446,49 @@ describe("selectFromGit", () => { fs.rmSync(repo, { recursive: true, force: true }) } }) + + it("does not charge intervening base-branch changes to the pull request", () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-stale-base-")) + const runGit = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim() + + try { + runGit("init", "--initial-branch=main") + runGit("config", "user.name", "Mutation Test") + runGit("config", "user.email", "mutation@example.com") + fs.mkdirSync(path.join(repo, "packages/core/src"), { recursive: true }) + fs.writeFileSync(path.join(repo, "packages/core/src/pr.ts"), "export const pr = false\n") + fs.writeFileSync(path.join(repo, "packages/core/src/base.ts"), "export const base = false\n") + runGit("add", ".") + runGit("commit", "-m", "initial") + const staleBaseSha = runGit("rev-parse", "HEAD") + + runGit("checkout", "-b", "feature") + fs.writeFileSync(path.join(repo, "packages/core/src/pr.ts"), "export const pr = true\n") + runGit("commit", "-am", "change pull request") + + runGit("checkout", "main") + fs.writeFileSync(path.join(repo, "packages/core/src/base.ts"), "export const base = true\n") + runGit("commit", "-am", "advance base branch") + const currentBaseSha = runGit("rev-parse", "HEAD") + runGit("merge", "--no-ff", "feature", "-m", "synthetic pull request merge") + const mergeSha = runGit("rev-parse", "HEAD") + const mergeResultBaseSha = runGit("rev-parse", `${mergeSha}^1`) + assert.equal(mergeResultBaseSha, currentBaseSha) + + assert.deepEqual( + selectFromGit(repo, staleBaseSha, mergeSha).packages[0].files.map(({ path: filePath }) => filePath), + ["packages/core/src/base.ts", "packages/core/src/pr.ts"], + ) + assert.deepEqual( + selectFromGit(repo, mergeResultBaseSha, mergeSha).packages[0].files.map( + ({ path: filePath }) => filePath, + ), + ["packages/core/src/pr.ts"], + ) + } finally { + fs.rmSync(repo, { recursive: true, force: true }) + } + }) }) describe("mutation exclusions", () => { From 77e422faf56afe32e10236e4aaf129a8ea2cff53 Mon Sep 17 00:00:00 2001 From: BambinoSK Date: Thu, 17 Sep 2026 02:18:01 +0000 Subject: [PATCH 07/21] fix: _isGrokXAI() false-positive substring match breaks token usage for domains containing "x.ai" (#1484) * fix: _isGrokXAI false-positive substring match breaks token usage for domains containing 'x.ai' Fixes #1483 The _isGrokXAI() method used urlHost.includes('x.ai') which matches any domain containing 'x.ai' as a substring (e.g. box.ai, fox.ai, max.ai). This false-positive causes stream_options:{include_usage:true} to be omitted, so the API never returns usage data and the token bar shows 0. Fix: Use exact host match (api.x.ai) or subdomain match (*.x.ai) instead of substring includes. Added tests for false-positive scenarios and valid x.ai domain detection. AI-assisted: developed with Zoo Code/GLM-5.2, reviewed and verified by the contributor. * Address CodeRabbit review: use URL.hostname, bracket notation, remove changeset * test: add O3+Grok stream_options coverage for handleO3FamilyMessage --------- Co-authored-by: Elliott de Launay --- src/api/providers/__tests__/openai.spec.ts | 105 +++++++++++++++++++++ src/api/providers/openai.ts | 4 +- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index a3dcbcc0d5..754d57a6cd 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1252,6 +1252,92 @@ describe("OpenAiHandler", () => { }) }) + describe("Grok xAI false-positive prevention", () => { + it("should NOT detect as Grok xAI when host contains 'x.ai' as a substring but is not x.ai (e.g. box.ai)", () => { + const nonGrokOptions = { + ...mockOptions, + openAiBaseUrl: "https://box.ai/v1", + openAiModelId: "gpt-4o", + } + const handler = new OpenAiHandler(nonGrokOptions) + expect(handler["_isGrokXAI"](nonGrokOptions.openAiBaseUrl)).toBe(false) + }) + + it("should NOT detect as Grok xAI for other domains containing 'x.ai' substring (e.g. fox.ai, max.ai)", () => { + const handler = new OpenAiHandler({ ...mockOptions, openAiBaseUrl: "https://fox.ai/v1" }) + expect(handler["_isGrokXAI"]("https://fox.ai/v1")).toBe(false) + expect(handler["_isGrokXAI"]("https://max.ai/v1")).toBe(false) + }) + + it("should detect as Grok xAI for api.x.ai", () => { + const handler = new OpenAiHandler({ ...mockOptions, openAiBaseUrl: "https://api.x.ai/v1" }) + expect(handler["_isGrokXAI"]("https://api.x.ai/v1")).toBe(true) + }) + + it("should detect as Grok xAI for subdomains of x.ai (e.g. custom.x.ai)", () => { + const handler = new OpenAiHandler({ ...mockOptions, openAiBaseUrl: "https://custom.x.ai/v1" }) + expect(handler["_isGrokXAI"]("https://custom.x.ai/v1")).toBe(true) + }) + + it("should detect as Grok xAI when api.x.ai uses a non-default port", () => { + const handler = new OpenAiHandler({ ...mockOptions, openAiBaseUrl: "https://api.x.ai:8443/v1" }) + expect(handler["_isGrokXAI"]("https://api.x.ai:8443/v1")).toBe(true) + }) + + it("should exclude stream_options when streaming with api.x.ai on a non-default port", async () => { + const portOptions = { + ...mockOptions, + openAiBaseUrl: "https://api.x.ai:8443/v1", + openAiModelId: "grok-1", + } + const handler = new OpenAiHandler(portOptions) + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] + + const stream = handler.createMessage(systemPrompt, messages) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: portOptions.openAiModelId, + stream: true, + }), + {}, + ) + + const mockCalls = mockCreate.mock.calls + const lastCall = mockCalls[mockCalls.length - 1] + expect(lastCall[0]).not.toHaveProperty("stream_options") + }) + + it("should include stream_options when using a non-Grok provider whose URL contains 'x.ai' substring", async () => { + const nonGrokOptions = { + ...mockOptions, + openAiBaseUrl: "https://box.ai/v1", + openAiModelId: "gpt-4o", + } + const handler = new OpenAiHandler(nonGrokOptions) + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] + + const stream = handler.createMessage(systemPrompt, messages) + await stream.next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: nonGrokOptions.openAiModelId, + stream: true, + }), + {}, + ) + + const mockCalls = mockCreate.mock.calls + const lastCall = mockCalls[mockCalls.length - 1] + expect(lastCall[0]).toHaveProperty("stream_options") + expect(lastCall[0].stream_options).toEqual({ include_usage: true }) + }) + }) + describe("O3 Family Models", () => { const o3Options = { ...mockOptions, @@ -1630,6 +1716,25 @@ describe("OpenAiHandler", () => { { path: "/models/chat/completions" }, ) }) + + it("should exclude stream_options when O3 model uses Grok xAI base URL", async () => { + const handler = new OpenAiHandler({ ...o3Options, openAiBaseUrl: "https://api.x.ai/v1" }) + const stream = handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello!" }]) + await stream.next() + + const lastCall = mockCreate.mock.calls[mockCreate.mock.calls.length - 1] + expect(lastCall[0]).not.toHaveProperty("stream_options") + }) + + it("should include stream_options when O3 model uses non-Grok URL containing 'x.ai' substring", async () => { + const handler = new OpenAiHandler({ ...o3Options, openAiBaseUrl: "https://box.ai/v1" }) + const stream = handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello!" }]) + await stream.next() + + const lastCall = mockCreate.mock.calls[mockCreate.mock.calls.length - 1] + expect(lastCall[0]).toHaveProperty("stream_options") + expect(lastCall[0].stream_options).toEqual({ include_usage: true }) + }) }) }) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 04b12f233d..619d05d28a 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -522,7 +522,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl protected _getUrlHost(baseUrl?: string): string { try { - return new URL(baseUrl ?? "").host + return new URL(baseUrl ?? "").hostname } catch (error) { return "" } @@ -530,7 +530,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl private _isGrokXAI(baseUrl?: string): boolean { const urlHost = this._getUrlHost(baseUrl) - return urlHost.includes("x.ai") + return urlHost === "api.x.ai" || urlHost.endsWith(".x.ai") } protected _isAzureAiInference(baseUrl?: string): boolean { From a0f2e0355cc0de494a008d5178609fccdd31d79a Mon Sep 17 00:00:00 2001 From: Franz Daubner Date: Fri, 18 Sep 2026 12:58:20 +0000 Subject: [PATCH 08/21] [Fix] Prevent unavailable tools from appearing in system prompts (#1505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial fix for issues #1240 and #505 * 1st round of fixes * fixed comments * increase test coverage * revert: remove Windows shell invocation from stryker-diff * fix: address CodeRabbit review on tool-policy prompt unification * fix(test): correct apiModelId in generateSystemPrompt state mock * drop use_mcp_tool from policy when no MCP tool is permitted * code hardening * bound model fetch with timeout, typed provider state test doubles * cover preview model fetch timeout path with tests * pin completion-time history save ordering with unit tests * poll history length in restart e2e to tolerate atomic write window * share one model-info snapshot per request between prompt and tools * resolve provider state once before the MCP wait getSystemPrompt read provider state twice: once for the MCP gate and again after the hub wait. When the caller threaded no state, the two reads could observe different snapshots. Hoist the fallback resolution to the top of the call so the prompt and the tool guidance share one snapshot on the unthreaded path. Type the test harness getSystemPrompt signature with ProviderState and ModelInfo instead of unknown, and align the affected test title and comments with the single-read behavior. * cover the undefined provider state path in the system prompt tests The provider state read can resolve to nothing even while the provider reference stays alive. Add a test for that case so the system prompt call keeps receiving undefined disabledTools instead of failing. * reuse one model-info snapshot per request and honor cancellation Request construction re-read model metadata twice after the streaming turn's bounded fetch; thread the captured snapshot through attemptApiRequest so prompt assembly, context sizing, and tool arrays agree on a single view, resolving the fallback only when no snapshot was supplied. A cancellation that lands during a request's waits now stops the request before any tool array, abort controller, or provider call is issued for it. * pin the retry count the request seam receives The empty-response retry test now asserts that the retry iteration reaches attemptApiRequest with its own incremented attempt count (second call, retryAttempt 1), instead of only checking the resulting conversation history. * refactor(task): require callers to thread provider state into system prompt build getSystemPrompt no longer falls back to re-reading provider state; the provider-state snapshot parameter is now required. An explicit undefined declares that the caller's own read came back empty because the provider was already gone, and the prompt then resolves from defaults. The prompt and the request's runtime tool array now resolve from a single snapshot by construction rather than by caller convention. Behavior is unchanged on all reachable paths. Task.spec.ts grows from 128 to 129 tests to cover the required-parameter contract. * fix(api): cancel abandoned model-metadata waits via AbortSignal The bounded metadata waits in Task.safeEnsureModelFetched and the system prompt preview cleared their timer but left the handler-side promise waiting on the model-catalog fetch. The ApiHandler contract now threads an optional AbortSignal through ensureModelFetched(): RouterProvider settles the waiter with a rejection when the signal aborts, so an abandoned or cancelled caller detaches instead of parking a promise on the shared fetch (which keeps running for other waiters and still populates the cache, by design). The task aborts its waiter both when the 5s bound expires and when cancelCurrentRequest runs (cancel and dispose paths); the preview aborts at its bound and on completion. The task-lifecycle doc's table padding was also reconciled with the PR base: the remaining diff there is now only prettier's column re-padding, which the repo's own pre-commit formatter enforces. * test(api): cover abort-signal detach paths and thread request model snapshot Mutation-diff gate kills (PR #1505): - zoo-gateway: signal-aware ensureModelFetched tests for the fetch-wins and fetch-rejects branches (block/CallExpression NoCoverage), an addEventListener spy pinning the { once: true } options, and paired add/remove listener assertions pinning the abort event name on both detach sites (StringLiteral mutants). - Task: ownership-guard tests for metadataFetchAbortController (clear on own completion, leave a replaced controller in place). - generateSystemPrompt: signal-capture tests pinning the timeout-bound and finally-block controller.abort() detaches (CallExpression mutants). CodeRabbit: thread the request model-info snapshot into buildCleanConversationHistory so preserveReasoning resolves from the same per-request snapshot as the prompt and tool arrays, plus regression tests. No Stryker-disable directives were needed; all 14 mutants are killed behaviorally. * Apply disabled and excluded tool policy to dynamic MCP declarations Gate dynamic MCP tool declarations through the shared effective-tool-policy predicate (alias-resolved disabled/excluded settings). Add filter-layer and builder-layer tests covering disabled, enabled, alias, and Gemini allowlist cases. Addresses maintainer review feedback. * Forward request options through API retry recursion Recursive attemptApiRequest retries dropped the options argument, losing caller-provided model info on retried attempts. Forward it at all three retry sites with regression tests. * Forward derived model snapshot through API retry recursion when the caller omitted requestModelInfo, each retry hop re-derived the model snapshot; the first hop's snapshot is now threaded into the recursive calls (caller-supplied values keep reference identity, no caller mutation), with a regression test pinning single derivation and snapshot arrival. * Tighten build-tools test assertions and provider double assert the MCP tool name is retained in Gemini-declared tool lists; replace double type assertions in the provider test double with a precisely-typed local shape. * Use the request model snapshot for context-window recovery math After a context-window overflow the recovery handler re-fetched model metadata, so truncation could run against a newer snapshot than the retry it feeds — history could be over-truncated. The pinned request snapshot is now passed into the handler and the stale re-fetch removed, with a regression test pinning one derivation per request. * Stop manual condensation when the task is cancelled condenseContext awaited the best-effort model metadata fetch and then continued even when the task had already been cancelled or abandoned, so a summarization request could still be issued for a task that was going away. Check for cancellation after the fetch and return early. Add regression tests for the cancelled and abandoned cases. * Recheck cancellation before summarizing and rewriting history condenseContext could still issue a summarization request, and rewrite the persisted conversation history, when the task was cancelled while the system prompt was being built or while summarization was in flight. Check for cancellation after each of those awaits and return early. Add regression tests that cancel at both points and assert that neither summarizeConversation nor overwriteApiConversationHistory runs. * Make the first cancellation checkpoint observable to tests The second cancellation check in condenseContext also skips summarization, so falsifying the first one left every test passing. The mutation gate caught this: two mutants on the first check survived because nothing observed the work between the two checks. Assert that a task cancelled at the first checkpoint never builds the system prompt, which is the behavior that check exists to guarantee. * Correct a rationale comment in the cancellation tests The comment claimed that skipping summarization is also achieved by the checks placed after the prompt and summarize awaits. Only the check after the prompt await can hide a missing first check: the later one runs once summarization has already been called. * Narrow the change set to the tool-policy work and its regression tests Remove the task-lifecycle and history-persistence work from this branch: the metadata-fetch timeout bound, the waiter-detach signal plumbing, and the post-summarization cancellation guard revert to main; that work is preserved outside the branch for a follow-up. What remains is the prompt/tool-policy change for #1240 and #505, plus two fixes the review asked for. A new builder-layer test pins that modelInfo.excludedTools excluding use_mcp_tool removes the dynamic mcp--* declarations from the sent tools, like a user-level disable. And a disabled or excluded attempt_completion now honors the tool allowlist end to end: it leaves the effective policy set and the callable allowlist, and execution rejects the call with the standard validation-error tool_result instead of completing the task. * Remove dead export, untriggerable timer guard, and duplicated prompt-spec coverage Unexport hasAnyMcpResources (no external callers), make the skills section policy parameter required (the sole caller always passes one), and make the model-metadata timeout clear unconditional (the handle is always assigned). Inline the single-use SystemPromptRequest alias and drop stale comment narration. Delete prompt-spec tests that duplicated sections.spec coverage, moving the two assertions that carried unique mutation kills (empty edit-restriction description branch, terminal-output fallback tail) into the surviving sections.spec tests. * fix(prompts): enforce effective tool policy guidance * fix(task): restore caller-layer cancellation for model-metadata fetches Model-metadata fetches (ensureModelFetched) could outlive the request that started them: a canceled task or a timed-out prompt preview left the fetch awaited, with no signal to abort it and no check before its result was persisted. This restores cancellation handling at the caller layer: - The bounded preview timeout now aborts the metadata fetch it races, instead of leaving the fetcher's promise dangling after the timeout. - Condense paths now check abort/abandoned state before starting and before persisting summarized history, with an added guard before summarization so a canceled task cannot write summarize output. - cancelCurrentRequest aborts the in-flight metadata fetch and detaches waiters, so stale promises no longer retain task state. - Adds a standalone edit-tool coverage test for prompt-section rendering (coverage gap: the tool was only exercised via combined fixtures). Related to #505, #1240. * fix(task): set disposal state before cancelling metadata waits Task disposal now marks the task as aborted before it cancels the prompts that in-flight metadata fetches are waiting on. Marking the disposal synchronously means any model request that could start after cleanup begins already observes an aborted task, so no request starts after disposal. Adds a regression test for disposal racing a metadata wait, and an assertion that getModels is not called when the signal is already aborted. * chore(ci): bump coverage-contract baseline for branch-added policy module Coverage source population moved from 469 records / 30229 lines to 470 records / 30324 lines. The delta is attributable to src/core/prompts/tools/effective-tool-policy.ts, a production module added by this change; the remaining line growth comes from branch modifications to existing instrumented sources. No source files were removed; verified by regenerating all coverage lanes locally. * test: mock CodeIndexManagerRegistry in build-tools.spec (upstream #1622 merge parity) * fix: describe codebase_search as semantic search; anchor read_file in build-tools allowlist test Address CodeRabbit review findings on the capabilities prompt and the build-tools test suite: - The codebase_search capability clause said "view source code definitions", wording inherited from the removed list_code_definition_names tool; it now reads "semantically search the codebase", matching the tool contract, and the generateSystemPrompt.spec.ts assertions quoting the old phrase are re-pointed. - The disabled-tools test asserted only tool absence, so an empty allowlist would pass; it now anchors on read_file being present, mirroring the sibling test. --------- Co-authored-by: Roomote --- src/api/index.ts | 9 +- .../providers/__tests__/zoo-gateway.spec.ts | 71 + src/api/providers/router-provider.ts | 31 +- ...resentAssistantMessage-custom-tool.spec.ts | 172 ++ .../presentAssistantMessage.ts | 16 +- .../architect-mode-prompt.snap | 19 +- .../ask-mode-prompt.snap | 20 +- .../no-mcp-servers.snap | 19 +- .../consistent-system-prompt.snap | 19 +- .../system-prompt/with-mcp-hub-provided.snap | 21 +- .../system-prompt/with-undefined-mcp-hub.snap | 19 +- src/core/prompts/__tests__/sections.spec.ts | 481 ++++- .../prompts/__tests__/system-prompt.spec.ts | 143 +- .../sections/__tests__/objective.spec.ts | 52 +- .../prompts/sections/__tests__/skills.spec.ts | 32 +- .../sections/__tests__/system-info.spec.ts | 44 +- .../__tests__/tool-use-guidelines.spec.ts | 43 +- src/core/prompts/sections/capabilities.ts | 104 +- src/core/prompts/sections/objective.ts | 24 +- src/core/prompts/sections/rules.ts | 149 +- src/core/prompts/sections/skills.ts | 6 + src/core/prompts/sections/system-info.ts | 22 +- .../prompts/sections/tool-use-guidelines.ts | 18 +- src/core/prompts/system.ts | 70 +- .../__tests__/effective-tool-policy.spec.ts | 723 +++++++ .../__tests__/filter-tools-for-mode.spec.ts | 228 ++- .../prompts/tools/effective-tool-policy.ts | 361 ++++ .../prompts/tools/filter-tools-for-mode.ts | 402 +--- src/core/task/Task.ts | 230 ++- src/core/task/__tests__/Task.spec.ts | 1813 ++++++++++++++++- src/core/task/__tests__/build-tools.spec.ts | 286 +++ src/core/task/build-tools.ts | 12 +- .../__tests__/generateSystemPrompt.spec.ts | 762 +++++++ src/core/webview/generateSystemPrompt.ts | 50 +- src/eslint-suppressions.json | 2 +- 35 files changed, 5763 insertions(+), 710 deletions(-) create mode 100644 src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts create mode 100644 src/core/prompts/tools/effective-tool-policy.ts create mode 100644 src/core/task/__tests__/build-tools.spec.ts create mode 100644 src/core/webview/__tests__/generateSystemPrompt.spec.ts diff --git a/src/api/index.ts b/src/api/index.ts index 98c3c5dc7b..e662c78386 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -130,8 +130,15 @@ export interface ApiHandler { * Ensures model metadata has been fetched from the remote API so that getModel() * returns accurate info (context window, pricing, etc.) instead of hardcoded defaults. * Only router providers that discover models over the network implement this. + * + * `signal` bounds the caller's wait: when it aborts (e.g. the caller's bounded + * metadata wait expired or the owning task was cancelled), the returned promise + * settles with a rejection so no handler-side waiter outlives its caller. + * Fetchers that observe the signal may also stop their network request; the + * shared, de-duplicated catalog fetch may still complete and populate the model + * cache, which is by design for concurrent waiters. */ - ensureModelFetched?(): Promise + ensureModelFetched?(signal?: AbortSignal): Promise /** * Optional context window for context-management / auto-condense when it must differ from diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index c6f4c15c1e..fe088bb5c9 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -724,6 +724,77 @@ describe("ZooGatewayHandler", () => { expect(refreshModels).not.toHaveBeenCalled() }) + it("settles the waiter with a rejection when the signal aborts mid-fetch", async () => { + // A caller that gives up must not leave a handler-side waiter pending + // on the (shared) catalog fetch: with an observing signal, the + // ensureModelFetched promise rejects at abort time, while the + // underlying fetch continues untouched for any other waiter. + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockImplementationOnce(() => new Promise(() => {})) + + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + + const wait = handler.ensureModelFetched(controller.signal) + // Let the waiter attach its abort listener before cancelling. + await Promise.resolve() + controller.abort() + + await expect(wait).rejects.toThrow() + }) + + it("settles the waiter when the fetch wins against a live signal and detaches the listener", async () => { + // Fetch-wins branch: resolve() must settle the await (a dropped + // resolve or a detached .then handler hangs this test), the abort + // listener must be registered with the real { once: true } options + // object, and the detach must target the *same* event name/handler + // pair that was registered — a mutated event name detaches nothing. + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + + await handler.ensureModelFetched(controller.signal) + + expect(addEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function), { once: true }) + const registered = addEventListenerSpy.mock.calls.find(([event]) => event === "abort") + expect(registered).toBeDefined() + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registered?.[1]) + }) + + it("rejects a signal-observing waiter with the fetch error and detaches the listener", async () => { + // Rejection-branch twin of the fetch-wins test: reject(error) must + // propagate the catalog failure to the waiter (a dropped reject hangs + // this test) and the listener must be detached under the right event + // name. The no-signal reject path cannot attach a listener, so this is the only + // coverage of the reject-side detach. + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockRejectedValueOnce(new Error("network down")) + + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + + await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow("network down") + const registered = addEventListenerSpy.mock.calls.find(([event]) => event === "abort") + expect(registered).toBeDefined() + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registered?.[1]) + }) + + it("never starts a wait when the signal is already aborted", async () => { + const { getModels } = await import("../fetchers/modelCache") + const handler = new ZooGatewayHandler(mockOptions) + const controller = new AbortController() + controller.abort() + + await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow() + // Without the spy, a guard relocated after fetchModel() starts would + // still reject here and settle identically; zero getModels calls pins + // that the check runs before the fetch starts. + expect(vitest.mocked(getModels)).not.toHaveBeenCalled() + }) + it("skips the fetch when models are already populated", async () => { const handler = new ZooGatewayHandler(mockOptions) const { getModels, refreshModels } = await import("../fetchers/modelCache") diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 7292824da8..5c457a71e7 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -108,8 +108,35 @@ export abstract class RouterProvider extends BaseProvider { return this.modelFetchPromise } - async ensureModelFetched(): Promise { - await this.fetchModel() + async ensureModelFetched(signal?: AbortSignal): Promise { + // A caller that already gave up must not start (or keep) a wait on the + // shared catalog fetch. + if (signal?.aborted) { + throw signal.reason + } + + const fetch = this.fetchModel() + if (!signal) { + await fetch + return + } + + // Detach this waiter as soon as the signal aborts; the shared in-flight + // fetch continues for any other waiter and still populates the cache. + await new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason) + signal.addEventListener("abort", onAbort, { once: true }) + fetch.then( + () => { + signal.removeEventListener("abort", onAbort) + resolve() + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort) + reject(error) + }, + ) + }) } override getModel(): { id: string; info: ModelInfo } { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index e7f4465441..b0b2aa25e0 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -23,6 +23,15 @@ vi.mock("@roo-code/core", () => ({ }, })) +// Mock the tool handlers so the tests only exercise validation (toolRequirements) +// and never the real tool execution logic. +vi.mock("../../tools/AttemptCompletionTool", () => ({ + attemptCompletionTool: { handle: vi.fn().mockResolvedValue(undefined) }, +})) +vi.mock("../../tools/AskFollowupQuestionTool", () => ({ + askFollowupQuestionTool: { handle: vi.fn().mockResolvedValue(undefined) }, +})) + // presentAssistantMessage records tool usage through TelemetryService.instance. vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { @@ -379,6 +388,169 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { edit: false, }) }) + + it("marks a disabled attempt_completion as blocked and answers it with an error tool_result", async () => { + // An explicit disabledTools entry outranks the always-available class, + // so a disabled attempt_completion reaches the validator like any + // other tool; its rejection must surface as the standard validation- + // error tool_result instead of completing the task. + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_protocol_123", + name: "attempt_completion", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: false, + }, + disabledTools: ["attempt_completion"], + }), + }), + } + + // Mirror the real validator's rejection for a requirement that maps + // to false (validateToolUse.spec pins the predicate itself). + vi.mocked(validateToolUse).mockImplementationOnce(() => { + throw new Error('Tool "attempt_completion" is not allowed in code mode.') + }) + + await presentAssistantMessage(mockTask) + + const validateToolUseMock = vi.mocked(validateToolUse) + expect(validateToolUseMock).toHaveBeenCalled() + const toolRequirements = validateToolUseMock.mock.calls[0][3] + expect(toolRequirements).toMatchObject({ attempt_completion: false }) + + const errorToolResults = mockTask.userMessageContent.filter((block: unknown) => { + const b = block as { type?: string; is_error?: boolean } + return b.type === "tool_result" && b.is_error + }) + expect(errorToolResults).toHaveLength(1) + expect(mockTask.consecutiveMistakeCount).toBe(1) + + // The completion handler must not run for the rejected call. + const { attemptCompletionTool } = await import("../../tools/AttemptCompletionTool") + expect(attemptCompletionTool.handle).not.toHaveBeenCalled() + }) + + it("treats a model-excluded attempt_completion as blocked and answers it with an error tool_result", async () => { + // A model excludedTools entry suppresses the protocol tool in the + // effective policy, so the execution gate must see the same + // restriction with disabledTools unset. + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_protocol_excluded_123", + name: "attempt_completion", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + mockTask.api.getModel = () => ({ id: "test-model", info: { excludedTools: ["attempt_completion"] } }) + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: false, + }, + }), + }), + } + + // Mirror the real validator's rejection for a requirement that maps + // to false (validateToolUse.spec pins the predicate itself). + vi.mocked(validateToolUse).mockImplementationOnce(() => { + throw new Error('Tool "attempt_completion" is not allowed in code mode.') + }) + + await presentAssistantMessage(mockTask) + + const validateToolUseMock = vi.mocked(validateToolUse) + expect(validateToolUseMock).toHaveBeenCalled() + const toolRequirements = validateToolUseMock.mock.calls[0][3] + expect(toolRequirements).toMatchObject({ attempt_completion: false }) + + const errorToolResults = mockTask.userMessageContent.filter((block: unknown) => { + const b = block as { type?: string; is_error?: boolean } + return b.type === "tool_result" && b.is_error + }) + expect(errorToolResults).toHaveLength(1) + expect(mockTask.consecutiveMistakeCount).toBe(1) + + // The completion handler must not run for the rejected call. + const { attemptCompletionTool } = await import("../../tools/AttemptCompletionTool") + expect(attemptCompletionTool.handle).not.toHaveBeenCalled() + + // Absent model metadata must not derail the requirements build: the + // protocol-tool leg simply sees no exclusions, and the call validates + // normally instead of erroring out. + mockTask.api.getModel = () => undefined + mockTask.currentStreamingContentIndex = 0 + mockTask.userMessageContent = [] + mockTask.consecutiveMistakeCount = 0 + mockTask.didAlreadyUseTool = false + mockTask.didCompleteReadingStream = false + + await presentAssistantMessage(mockTask) + + expect(validateToolUseMock).toHaveBeenCalledTimes(2) + expect(validateToolUseMock.mock.calls[1][3]).toEqual({}) + expect(mockTask.consecutiveMistakeCount).toBe(0) + const phase2Errors = mockTask.userMessageContent.filter((block: { type?: string; is_error?: boolean }) => { + return block.type === "tool_result" && block.is_error + }) + expect(phase2Errors).toHaveLength(0) + }) + + it("still marks ordinary tools (ask_followup_question) as blocked", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "tool_call_ordinary_123", + name: "ask_followup_question", + params: { question: "Which option?" }, + nativeArgs: { question: "Which option?" }, + partial: false, + }, + ] + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + experiments: { + customTools: false, + }, + disabledTools: ["ask_followup_question"], + }), + }), + } + + await presentAssistantMessage(mockTask) + + const validateToolUseMock = vi.mocked(validateToolUse) + expect(validateToolUseMock).toHaveBeenCalled() + const toolRequirements = validateToolUseMock.mock.calls[0][3] + expect(toolRequirements).toMatchObject({ + ask_followup_question: false, + }) + }) }) describe("Partial blocks", () => { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index b5a83882be..9fbd0a3e3b 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -36,6 +36,7 @@ import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" +import { buildToolRequirements } from "../prompts/tools/effective-tool-policy" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" @@ -607,16 +608,11 @@ export async function presentAssistantMessage(cline: Task) { const isCustomTool = Boolean(stateExperiments?.customTools && customToolRegistry.has(block.name)) try { - const toolRequirements = - disabledTools?.reduce( - (acc: Record, tool: string) => { - acc[tool] = false - const resolvedToolName = resolveToolAlias(tool) - acc[resolvedToolName] = false - return acc - }, - {} as Record, - ) ?? {} + // Build requirements through the shared policy module so every suppressed + // entry — disabled tools, and an excluded or disabled protocol tool — reaches + // the validator, which checks them before the always-available class. See + // `buildToolRequirements` in effective-tool-policy.ts. + const toolRequirements = buildToolRequirements(disabledTools, modelInfo?.info) validateToolUse( block.name as ToolName, diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap index d6fd17ba2f..8dacd14a25 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/architect-mode-prompt.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES -- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. -- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. -- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you accomplish tasks. +- If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. - When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. - At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. -- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. ==== @@ -79,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. -3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +3. Remember, use the tools provided to you in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap index 86d5b27f08..d383ad346e 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/ask-mode-prompt.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES -- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. -- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. -- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. +You have access to tools that let you list files, regex search, read files. +- These tools help you accomplish tasks. +- If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. ==== @@ -39,25 +39,19 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. -- Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. - When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. - At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. -- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc. ==== @@ -69,7 +63,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. ==== @@ -79,7 +73,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. -3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +3. Remember, use the tools provided to you in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. diff --git a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap index d6fd17ba2f..8dacd14a25 100644 --- a/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap +++ b/src/core/prompts/__tests__/__snapshots__/add-custom-instructions/no-mcp-servers.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES -- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. -- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. -- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you accomplish tasks. +- If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. - When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. - At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. -- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. ==== @@ -79,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. -3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +3. Remember, use the tools provided to you in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap index 2a1533bfef..8144b1fbd0 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/consistent-system-prompt.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES -- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. -- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. -- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you accomplish tasks. +- If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. - When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. - At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. -- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. ==== @@ -79,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. -3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +3. Remember, use the tools provided to you in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap index 5660cd4def..f470918698 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-mcp-hub-provided.snap @@ -24,11 +24,10 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES -- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. -- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. -- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. -- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. - +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you accomplish tasks. +- If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. +- You have access to MCP servers that may provide additional tools and/or resources actually available to this mode. Each server may provide different capabilities that you can use to accomplish tasks more effectively. ==== @@ -41,24 +40,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. - When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. - At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. - MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc. @@ -71,7 +66,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. ==== @@ -81,7 +76,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. -3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +3. Remember, use the tools provided to you in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. diff --git a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap index 2a1533bfef..8144b1fbd0 100644 --- a/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap +++ b/src/core/prompts/__tests__/__snapshots__/system-prompt/with-undefined-mcp-hub.snap @@ -24,9 +24,9 @@ By carefully considering the user's response after tool executions, you can reac CAPABILITIES -- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. -- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. -- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance. +You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\.md$' can be edited — Markdown files only) +- These tools help you accomplish tasks. +- If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. ==== @@ -39,25 +39,20 @@ MODES RULES - The project base directory is: /test/path -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- You cannot `cd` into a different directory to complete a task. You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. +- All file paths must be relative to this directory. +- You are stuck operating from '/test/path', so be sure to pass in the correct 'path' parameter when using tools that require a path. - Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '/test/path', and if so prepend with `cd`'ing into that directory && then executing the command (as one command since you are stuck operating from '/test/path'). For example, if you needed to run `npm install` in a project outside of '/test/path', you would need to prepend with a `cd` i.e. pseudocode for this would be `cd (path to project) && (command, in this case npm install)`. - Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. - Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\.md$" - When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. - Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. - You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. - You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. - When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. - At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. -- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc. ==== @@ -69,7 +64,7 @@ Default Shell: /bin/zsh Home Directory: /home/user Current Workspace Directory: /test/path -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. ==== @@ -79,7 +74,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. -3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. +3. Remember, use the tools provided to you in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. 4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. diff --git a/src/core/prompts/__tests__/sections.spec.ts b/src/core/prompts/__tests__/sections.spec.ts index 79d4fad4ca..c6633f14c0 100644 --- a/src/core/prompts/__tests__/sections.spec.ts +++ b/src/core/prompts/__tests__/sections.spec.ts @@ -1,9 +1,77 @@ import { addCustomInstructions } from "../sections/custom-instructions" import { getCapabilitiesSection } from "../sections/capabilities" import { getRulesSection, getCommandChainOperator } from "../sections/rules" -import { McpHub } from "../../../services/mcp/McpHub" +import { getSystemInfoSection } from "../sections/system-info" +import { getObjectiveSection } from "../sections/objective" +import { getToolUseGuidelinesSection } from "../sections/tool-use-guidelines" +import { getSkillsSection } from "../sections/skills" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" +import { resolveEffectiveToolPolicy } from "../tools/effective-tool-policy" +import type { GroupEntry, ModelInfo } from "@roo-code/types" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import type { SkillsManager } from "../../../services/skills/SkillsManager" +import type { SkillMetadata } from "../../../shared/skills" import * as shellUtils from "../../../utils/shell" +// Mock os-name so getSystemInfoSection never spawns PowerShell on Windows (cold +// launches can exceed the CI test timeout). Matches the form used in +// sections/__tests__/system-info.spec.ts, but returns a constant since no test +// here asserts on the OS string itself. +vi.mock("os-name", () => ({ + default: vi.fn(() => "MockOS"), +})) + +/** + * Build an {@link EffectiveToolPolicy} for arbitrary mode groups. `mode` is the + * custom-mode slug so the resolver derives everything from `groups` (never from + * built-in names), which keeps assertions mode-neutral. + */ +function policyFor( + groups: GroupEntry[], + extra: Partial<{ + mcpHub: ReturnType + disabledTools: string[] + modelInfo: ModelInfo + experiments: Record + todoListEnabled: boolean + codeIndexManager: CodeIndexManager + allowedMcpServers: string[] + }> = {}, +): EffectiveToolPolicy { + return resolveEffectiveToolPolicy({ + mode: "p", + customModes: [{ slug: "p", name: "Policy Under Test", roleDefinition: "", groups }], + ...extra, + }) +} + +/** Minimal McpHub stub. `tools`/`resources` mirror the McpServer shape the resolver reads. */ +function makeMcpHub( + servers: Array<{ + name: string + tools?: Array<{ name: string; description?: string; enabledForPrompt?: boolean }> + resources?: Array<{ uri: string; name?: string }> + }>, +) { + return { getServers: () => servers } +} + +/** Minimal SkillsManager stub returning a fixed skill list. */ +function makeSkillsManager(n: number): Pick { + return { + getSkillsForMode: () => + Array.from( + { length: n }, + (_, i): SkillMetadata => ({ + name: `skill-${i}`, + description: `Skill ${i}`, + path: `./skills/${i}`, + source: "global", + }), + ), + } +} + describe("addCustomInstructions", () => { it("adds vscode language to custom instructions", async () => { const result = await addCustomInstructions( @@ -32,69 +100,172 @@ describe("addCustomInstructions", () => { }) describe("getCapabilitiesSection", () => { - const cwd = "/test/path" - - it("includes standard capabilities", () => { - const result = getCapabilitiesSection(cwd) + it("includes standard clauses for a full-tool mode", () => { + const result = getCapabilitiesSection(policyFor(["read", "edit", "command"])) expect(result).toContain("CAPABILITIES") - expect(result).toContain("execute CLI commands") + expect(result).toContain("execute CLI commands on the user's computer") expect(result).toContain("list files") - expect(result).toContain("read and write files") + expect(result).toContain("read files") + expect(result).toContain("write and edit files") + // the task tail is a plain sentence — assert no over-claiming enumeration + expect(result).not.toContain("such as writing code") + }) + + it("uses the fallback sentence when zero per-tool clauses exist", () => { + // control-tools-only mode: only switch_mode/new_task remain (no read/edit/command clauses) + const result = getCapabilitiesSection(policyFor(["modes"])) + + expect(result).toContain("You have access to a limited set of tools for this mode") + expect(result).not.toContain("You have access to tools that let you") }) - const createMockMcpHub = (serverNames: string[]): McpHub => - ({ - getServers: () => serverNames.map((name) => ({ name })), - }) as unknown as McpHub + it("emits the edit-restriction suffix when the mode declares a fileRegex", () => { + const result = getCapabilitiesSection( + policyFor(["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }]]), + ) - it("includes MCP reference when mcpHub exposes at least one server", () => { - const mockMcpHub = createMockMcpHub(["test-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub) + expect(result).toContain("only files matching") + expect(result).toContain("\\.md$") + expect(result).toContain("Markdown files only") + // The suffix binds to the capability sentence, not the last emitted bullet. + expect(result).toContain( + "You have access to tools that let you list files, regex search, read files, write and edit files. (in this mode only files matching '\\.md$' can be edited — Markdown files only)", + ) + }) + + it("keeps the edit-restriction suffix off the MCP bullet when MCP is active", () => { + // With the mcp group + an enabled MCP server the MCP bullet is the last + // bullet; the restriction suffix must stay on the capability sentence. + const result = getCapabilitiesSection( + policyFor(["read", ["edit", { fileRegex: "\\.md$" }], "mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]), + }), + ) expect(result).toContain("MCP servers") + expect(result).not.toContain("accomplish tasks more effectively. (in this mode") + expect(result).toContain("write and edit files. (in this mode only files matching") + // This is the only fixture whose restriction carries no description, so the + // empty description-suffix branch must render nothing. "Stryker was here" + // (no trailing !) covers both the StringLiteral and ArrayDeclaration + // sentinel replacements Stryker injects. + expect(result).not.toContain("Stryker was here") }) - it("excludes MCP reference when mcpHub is undefined", () => { - const result = getCapabilitiesSection(cwd, undefined) + it("omits the edit-restriction suffix without a fileRegex", () => { + const result = getCapabilitiesSection(policyFor(["read", "edit"])) + expect(result).not.toContain("only files matching") + }) - expect(result).not.toContain("MCP servers") + it("omits the edit-restriction suffix when no edit tool is available", () => { + const result = getCapabilitiesSection( + policyFor([["edit", { fileRegex: "\\.md$" }]], { disabledTools: ["write_to_file", "apply_diff"] }), + ) + expect(result).not.toContain("only files matching") }) - it("excludes MCP reference when mcpHub exposes no servers", () => { - const mockMcpHub = createMockMcpHub([]) - const result = getCapabilitiesSection(cwd, mockMcpHub) + it("keeps the capability clause and restriction for a model-included standalone edit tool", () => { + // The restricted edit group's default edit tools are disabled, but the + // model catalog re-adds the standalone `edit` tool: it is still an edit + // capability, so the clause and the file restriction both render. + const result = getCapabilitiesSection( + policyFor([["edit", { fileRegex: "\\.md$", description: "Markdown files only" }]], { + disabledTools: ["write_to_file", "apply_diff"], + modelInfo: { contextWindow: 128_000, supportsPromptCache: true, includedTools: ["edit"] }, + }), + ) - expect(result).not.toContain("MCP servers") + expect(result).toContain("write and edit files") + expect(result).toContain("(in this mode only files matching '\\.md$' can be edited — Markdown files only)") }) - it("includes MCP reference when allowedMcpServers matches a connected server", () => { - const mockMcpHub = createMockMcpHub(["allowed-server", "other-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub, ["allowed-server"]) + it("lists files guidance only when list_files is available", () => { + const withListFiles = getCapabilitiesSection(policyFor(["read"])) + expect(withListFiles).toContain("you can use the list_files tool") + // the file-tree *fact* lives in SYSTEM INFORMATION, not CAPABILITIES + expect(withListFiles).not.toContain("a recursive list of all filepaths") - expect(result).toContain("MCP servers") + const withoutListFiles = getCapabilitiesSection(policyFor(["command"])) + expect(withoutListFiles).not.toContain("you can use the list_files tool") }) - it("excludes MCP reference when allowedMcpServers is an empty array", () => { - const mockMcpHub = createMockMcpHub(["test-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub, []) + it("only emits the execute_command paragraph when execute_command is available", () => { + const withCmd = getCapabilitiesSection(policyFor(["command"])) + expect(withCmd).toContain("You can use the execute_command tool") - expect(result).not.toContain("MCP servers") + const withoutCmd = getCapabilitiesSection(policyFor(["read"])) + expect(withoutCmd).not.toContain("You can use the execute_command tool") }) - it("excludes MCP reference when allowedMcpServers matches no connected server", () => { - const mockMcpHub = createMockMcpHub(["test-server"]) - const result = getCapabilitiesSection(cwd, mockMcpHub, ["nonexistent-server"]) + it("emits the MCP bullet only when the mode has the mcp group AND effective MCP availability", () => { + // mcp group, server with a prompt-enabled tool -> present + const hasTools = getCapabilitiesSection( + policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]) }), + ) + expect(hasTools).toContain("MCP servers") + + // mcp group, server with no tools but a resource -> present via resources + const hasResources = getCapabilitiesSection( + policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "x" }] }]) }), + ) + expect(hasResources).toContain("MCP servers") + + // mcp group, empty server (no tools, no resources) -> absent + const nothing = getCapabilitiesSection(policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s" }]) })) + expect(nothing).not.toContain("MCP servers") + // no mcp group -> absent even with a working server + const noGroup = getCapabilitiesSection( + policyFor(["read"], { mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]) }), + ) + expect(noGroup).not.toContain("MCP servers") + }) + + it("omits the MCP bullet when every tool is enabledForPrompt:false and no resources exist", () => { + const result = getCapabilitiesSection( + policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d", enabledForPrompt: false }] }]), + }), + ) expect(result).not.toContain("MCP servers") }) + + it("omits the MCP bullet when a disallowed server is the only one with tools/resources", () => { + const result = getCapabilitiesSection( + policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { name: "allowed", tools: [] }, + { name: "blocked", tools: [{ name: "t", description: "d" }], resources: [{ uri: "x" }] }, + ]), + allowedMcpServers: [], + }), + ) + expect(result).not.toContain("MCP servers") + }) + + it("includes the MCP bullet for an allowed server under an allowlist", () => { + const result = getCapabilitiesSection( + policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "allowed", tools: [{ name: "t", description: "d" }] }]), + allowedMcpServers: ["allowed"], + }), + ) + expect(result).toContain("MCP servers") + }) }) describe("getRulesSection", () => { const cwd = "/test/path" + const settings = { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + } + it("includes standard rules", () => { - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) expect(result).toContain("RULES") expect(result).toContain("project base directory") @@ -102,14 +273,8 @@ describe("getRulesSection", () => { }) it("includes vendor confidentiality section when isStealthModel is true", () => { - const settings = { - todoListEnabled: true, - useAgentRules: true, - newTaskRequireTodos: false, - isStealthModel: true, - } - - const result = getRulesSection(cwd, settings) + const stealthSettings = { ...settings, isStealthModel: true } + const result = getRulesSection(cwd, stealthSettings, policyFor(["read", "edit", "command"])) expect(result).toContain("VENDOR CONFIDENTIALITY") expect(result).toContain("Never reveal the vendor or company that created you") @@ -119,31 +284,218 @@ describe("getRulesSection", () => { }) it("excludes vendor confidentiality section when isStealthModel is false", () => { - const settings = { - todoListEnabled: true, - useAgentRules: true, - newTaskRequireTodos: false, - isStealthModel: false, - } - - const result = getRulesSection(cwd, settings) + const stealthSettings = { ...settings, isStealthModel: false } + const result = getRulesSection(cwd, stealthSettings, policyFor(["read", "edit", "command"])) expect(result).not.toContain("VENDOR CONFIDENTIALITY") expect(result).not.toContain("Never reveal the vendor or company") }) it("excludes vendor confidentiality section when isStealthModel is undefined", () => { - const settings = { - todoListEnabled: true, - useAgentRules: true, - newTaskRequireTodos: false, - } - - const result = getRulesSection(cwd, settings) + const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) expect(result).not.toContain("VENDOR CONFIDENTIALITY") expect(result).not.toContain("Never reveal the vendor or company") }) + + it("omits the execute_command bullet when execute_command is absent", () => { + const result = getRulesSection(cwd, settings, policyFor(["read"])) + + expect(result).not.toContain("Before using the execute_command tool") + expect(result).not.toContain("Actively Running Terminals") + // the terminal-aware "working directory" clause is gone too + expect(result).not.toContain("commands may change directories in terminals") + // but the base path rule stays + expect(result).toContain("All file paths must be relative to this directory") + }) + + it("includes the execute_command bullet when execute_command is present", () => { + const result = getRulesSection(cwd, settings, policyFor(["command"])) + + expect(result).toContain("Before using the execute_command tool") + expect(result).toContain("Actively Running Terminals") + }) + + it("uses ask_followup_question when the tool is available", () => { + const result = getRulesSection(cwd, settings, policyFor(["read"])) + expect(result).toContain("ask the user questions using the ask_followup_question tool") + }) + + it("uses the replacement bullet when ask_followup_question is absent", () => { + // Both sub-cases — list_files present and list_files absent — take the single + // best-effort replacement bullet, emitted exactly when ask_followup_question is absent. + const withListFiles = getRulesSection( + cwd, + settings, + policyFor(["read"], { disabledTools: ["ask_followup_question"] }), + ) + expect(withListFiles).toContain("Provide your best-effort result and state your assumptions") + expect(withListFiles).not.toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(withListFiles).not.toContain("enumerate the filesystem yourself") + + const withoutListFiles = getRulesSection( + cwd, + settings, + policyFor(["edit", "command"], { disabledTools: ["ask_followup_question", "list_files"] }), + ) + expect(withoutListFiles).toContain("Provide your best-effort result and state your assumptions") + expect(withoutListFiles).not.toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(withoutListFiles).not.toContain("enumerate the filesystem yourself") + }) + + it("uses the fallback phrasing in the terminal-output rule when ask_followup_question is absent", () => { + // The execute_command bullet is always present, but its tail must not reference a disabled tool. + const withoutAsk = getRulesSection( + cwd, + settings, + policyFor(["command"], { disabledTools: ["ask_followup_question"] }), + ) + expect(withoutAsk).toContain("When executing commands") + expect(withoutAsk).toContain("note what you expected and proceed with the task, stating your assumptions") + expect(withoutAsk).not.toContain("ask_followup_question") + + const withAsk = getRulesSection(cwd, settings, policyFor(["command"])) + expect(withAsk).toContain( + "use the ask_followup_question tool to request the user to copy and paste it back to you", + ) + }) + + it("omits the read_file rule when read_file is absent", () => { + const result = getRulesSection(cwd, settings, policyFor(["command"])) + expect(result).not.toContain("The user may provide a file's contents directly") + }) + + it("includes the read_file rule when read_file is present", () => { + const result = getRulesSection(cwd, settings, policyFor(["read"])) + expect(result).toContain("The user may provide a file's contents directly") + }) + + it("keeps a stable RULES baseline", () => { + // duplicate guard: ensure the describe still asserts a stable baseline even if other tests change + const result = getRulesSection(cwd, settings, policyFor(["read", "edit", "command"])) + expect(result).toContain("RULES") + }) + + it("uses tool-neutral completion guidance when attempt_completion is unavailable", () => { + const rawPolicy: EffectiveToolPolicy = { + tools: new Set(["read_file"]), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } + + expect(rawPolicy.tools.has("attempt_completion")).toBe(false) + const result = getRulesSection(cwd, settings, rawPolicy) + expect(result).not.toContain("attempt_completion") + expect(result).toContain("present the result to the user") + }) + + it("only emits file-restriction guidance for an effective restricted edit tool", () => { + const restricted = policyFor([["edit", { fileRegex: "\\.md$" }]]) + expect(getRulesSection(cwd, settings, restricted)).toContain("FileRestrictionError") + + const disabled = policyFor([["edit", { fileRegex: "\\.md$" }]], { + disabledTools: ["write_to_file", "apply_diff"], + }) + expect(getRulesSection(cwd, settings, disabled)).not.toContain("FileRestrictionError") + }) + + it.each([ + ["tools", makeMcpHub([{ name: "s", tools: [{ name: "t" }] }]), true], + ["resources", makeMcpHub([{ name: "s", resources: [{ uri: "r", name: "r" }] }]), true], + ["neither", makeMcpHub([{ name: "s" }]), false], + ] as const)("gates MCP rules for a hub with %s", (_case, mcpHub, expected) => { + const result = getRulesSection(cwd, settings, policyFor(["mcp"], { mcpHub })) + expect(result.includes("MCP operations should be used one at a time")).toBe(expected) + }) +}) + +describe("getSystemInfoSection", () => { + const cwd = "/some/real/path" + + it("keeps the header lines", () => { + const result = getSystemInfoSection(cwd, policyFor(["read", "edit", "command"])) + expect(result).toContain("SYSTEM INFORMATION") + expect(result).toContain("Operating System:") + expect(result).toContain("Default Shell:") + expect(result).toContain("Home Directory:") + expect(result).toContain(`Current Workspace Directory: ${cwd}`) + }) + + it("contains no /test/path literal", () => { + const result = getSystemInfoSection(cwd, policyFor(["read", "edit", "command"])) + expect(result).not.toContain("/test/path") + }) + + it("omits the terminal-cd sentence when execute_command is absent", () => { + const result = getSystemInfoSection(cwd, policyFor(["read"])) + expect(result).not.toContain("New terminals will be created") + expect(result).not.toContain("change directories in a terminal") + }) + + it("includes the terminal-cd sentence when execute_command is present", () => { + const result = getSystemInfoSection(cwd, policyFor(["command"])) + expect(result).toContain("New terminals will be created") + }) + + it("states the file-tree fact once and omits list_files guidance here", () => { + const result = getSystemInfoSection(cwd, policyFor(["read"])) + expect(result).toContain( + "a recursive list of all filepaths in the current workspace directory will be included in environment_details", + ) + // the list_files *guidance* belongs in CAPABILITIES, not SYSTEM INFORMATION + expect(result).not.toContain("you can use the list_files tool") + }) +}) + +describe("getObjectiveSection", () => { + it("names ask_followup_question when the tool is available", () => { + const result = getObjectiveSection(policyFor(["read"])) + expect(result).toContain("ask the user to provide the missing parameters using the ask_followup_question tool") + }) + + it("uses best-effort phrasing when ask_followup_question is absent", () => { + const result = getObjectiveSection( + policyFor(["read", "edit", "command"], { disabledTools: ["ask_followup_question"] }), + ) + expect(result).toContain("state your assumptions and proceed with the best available value") + expect(result).not.toContain("ask the user to provide the missing parameters") + }) +}) + +describe("getToolUseGuidelinesSection", () => { + it("includes the list_files example when list_files is available", () => { + const result = getToolUseGuidelinesSection(policyFor(["read"])) + expect(result).toContain( + "For example using the list_files tool is more effective than running a command like `ls` in the terminal.", + ) + }) + + it("omits the list_files example when list_files is absent", () => { + const result = getToolUseGuidelinesSection(policyFor(["command"])) + expect(result).not.toContain("using the list_files tool is more effective") + }) +}) + +describe("getSkillsSection", () => { + it("returns the skills XML when the skill tool is available", async () => { + const result = await getSkillsSection(makeSkillsManager(2), "code", policyFor(["read", "edit", "command"])) + expect(result).toContain("AVAILABLE SKILLS") + expect(result).toContain("skill-0") + }) + + it("returns an empty string when the skill tool is disabled", async () => { + const result = await getSkillsSection( + makeSkillsManager(2), + "code", + policyFor(["read", "edit", "command"], { disabledTools: ["skill"] }), + ) + expect(result).toBe("") + }) }) describe("getCommandChainOperator", () => { @@ -187,6 +539,9 @@ describe("getCommandChainOperator", () => { describe("getRulesSection shell-aware command chaining", () => { const cwd = "/test/path" + const settings = { todoListEnabled: true, useAgentRules: true, newTaskRequireTodos: false } + + const codePolicy = policyFor(["read", "edit", "command"]) afterEach(() => { vi.restoreAllMocks() @@ -194,7 +549,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("uses && for Unix shells in command chaining example", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("cd (path to project) && (command") expect(result).not.toContain("cd (path to project) ; (command") @@ -205,7 +560,7 @@ describe("getRulesSection shell-aware command chaining", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue( "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ) - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("cd (path to project) ; (command") expect(result).toContain("Note: Using `;` for PowerShell command chaining") @@ -213,7 +568,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("uses && for cmd.exe in command chaining example", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("cd (path to project) && (command") expect(result).toContain("Note: Using `&&` for cmd.exe command chaining") @@ -223,7 +578,7 @@ describe("getRulesSection shell-aware command chaining", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue( "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ) - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("IMPORTANT: When using PowerShell, avoid Unix-specific utilities") expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`") @@ -234,7 +589,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("includes Unix utility guidance for cmd.exe", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\cmd.exe") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).toContain("IMPORTANT: When using cmd.exe, avoid Unix-specific utilities") expect(result).toContain("`sed`, `grep`, `awk`, `cat`, `rm`, `cp`, `mv`") @@ -245,7 +600,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("does not include Unix utility guidance for Unix shells", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/bash") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).not.toContain("IMPORTANT: When using PowerShell") expect(result).not.toContain("IMPORTANT: When using cmd.exe") @@ -254,7 +609,7 @@ describe("getRulesSection shell-aware command chaining", () => { it("does not include note for Unix shells", () => { vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") - const result = getRulesSection(cwd) + const result = getRulesSection(cwd, settings, codePolicy) expect(result).not.toContain("Note: Using") }) diff --git a/src/core/prompts/__tests__/system-prompt.spec.ts b/src/core/prompts/__tests__/system-prompt.spec.ts index d8671b2027..6e04ec9937 100644 --- a/src/core/prompts/__tests__/system-prompt.spec.ts +++ b/src/core/prompts/__tests__/system-prompt.spec.ts @@ -41,11 +41,12 @@ vi.mock("fs/promises") import * as vscode from "vscode" -import { ModeConfig } from "@roo-code/types" +import { ModeConfig, ModelInfo } from "@roo-code/types" import { SYSTEM_PROMPT } from "../system" import { McpHub } from "../../../services/mcp/McpHub" import { defaultModeSlug, modes, Mode } from "../../../shared/modes" +import type { SystemPromptSettings } from "../types" import "../../../utils/path" import { addCustomInstructions } from "../sections/custom-instructions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" @@ -641,6 +642,146 @@ describe("SYSTEM_PROMPT", () => { }) }) + describe("effective tool policy reflected in the system prompt", () => { + // Section-scoped extraction: capture the text between two "====" headers so + // user-authored roleDefinition/customInstructions can't pollute the assertions. + function extractSection(prompt: string, header: string): string { + const marker = `\n\n${header}\n\n` + const idx = prompt.indexOf(marker) + expect(idx).toBeGreaterThan(-1) + const afterHeader = prompt.slice(idx + marker.length) + const nextMarker = afterHeader.indexOf("\n\n====") + return nextMarker === -1 ? afterHeader : afterHeader.slice(0, nextMarker) + } + + const fullToolSettings: SystemPromptSettings = { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, + } + + function run( + mode: string, + extra: Partial<{ + customModes?: ModeConfig[] + mcpHub?: McpHub + settings?: SystemPromptSettings + disabledTools?: string[] + modelInfo?: ModelInfo + }> = {}, + ) { + return SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + extra.mcpHub, + undefined, // diffStrategy + mode, + undefined, // customModePrompts + extra.customModes, + undefined, // globalCustomInstructions + experiments, + undefined, // language + undefined, // rooIgnoreInstructions + extra.settings ?? fullToolSettings, // settings + undefined, // todoList + undefined, // modelId + undefined, // skillsManager + extra.disabledTools, // disabledTools + extra.modelInfo, // modelInfo + ) + } + + it("Code & Debug expose execute_command guidance (CAPABILITIES + RULES)", async () => { + for (const mode of ["code", "debug"]) { + const prompt = await run(mode) + const capabilities = extractSection(prompt, "CAPABILITIES") + const rules = extractSection(prompt, "RULES") + + expect(capabilities).toContain("execute CLI commands on the user's computer") + expect(rules).toContain("Before using the execute_command tool") + expect(rules).toContain('check the "Actively Running Terminals" section') + } + }) + + it("Architect has no execute_command and advertises the \\ .md$ edit restriction", async () => { + const prompt = await run("architect") + const capabilities = extractSection(prompt, "CAPABILITIES") + const rules = extractSection(prompt, "RULES") + const systemInfo = extractSection(prompt, "SYSTEM INFORMATION") + + // No execute_command anywhere. + expect(capabilities).not.toContain("execute CLI commands") + expect(rules).not.toContain("Before using the execute_command tool") + expect(rules).not.toContain('check the "Actively Running Terminals" section') + expect(systemInfo).not.toContain("New terminals will be created") + // Architect-style edit restriction reflected in CAPABILITIES. + expect(capabilities).toContain("in this mode only files matching") + expect(capabilities).toContain("\\.md$") + expect(capabilities).toContain("Markdown files only") + }) + + it("Ask advertises no write clause and no execute_command", async () => { + const prompt = await run("ask") + const capabilities = extractSection(prompt, "CAPABILITIES") + + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("write and edit files") + expect(capabilities).toContain("read files") + }) + + it("Orchestrator advertises no read/list/edit clauses", async () => { + const prompt = await run("orchestrator") + const capabilities = extractSection(prompt, "CAPABILITIES") + + expect(capabilities).not.toContain("read files") + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("write and edit files") + }) + + it("empty groups -> fallback sentence, no per-tool clauses", async () => { + const customModes: ModeConfig[] = [ + { + slug: "empty-mode", + name: "Empty Mode", + roleDefinition: "An empty mode", + groups: [], + }, + ] + const prompt = await run("empty-mode", { customModes }) + const capabilities = extractSection(prompt, "CAPABILITIES") + + // No per-tool clauses remain -> the fallback sentence is emitted. + expect(capabilities).toContain("You have access to a limited set of tools for this mode") + expect(capabilities).not.toContain("You have access to tools that let you") + // A control-only set must never advertise tool-execution clauses. + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("regex search") + expect(capabilities).not.toContain("The project base directory is:") + }) + + it("disabledTools: ['execute_command'] removes command guidance from the prompt", async () => { + const prompt = await run("code", { disabledTools: ["execute_command"] }) + const capabilities = extractSection(prompt, "CAPABILITIES") + const rules = extractSection(prompt, "RULES") + + expect(rules).not.toContain("Before using the execute_command tool") + expect(rules).not.toContain("Actively Running Terminals") + expect(capabilities).not.toContain("execute CLI commands") + }) + + it("modelInfo.excludedTools removes the matching capability clause", async () => { + const prompt = await run("code", { + modelInfo: { contextWindow: 100_000, supportsPromptCache: true, excludedTools: ["read_file"] }, + }) + const capabilities = extractSection(prompt, "CAPABILITIES") + + expect(capabilities).not.toContain("read files") + // other clauses survive, proving the exclusion is scoped to the one tool + expect(capabilities).toContain("execute CLI commands") + }) + }) + afterAll(() => { vi.restoreAllMocks() }) diff --git a/src/core/prompts/sections/__tests__/objective.spec.ts b/src/core/prompts/sections/__tests__/objective.spec.ts index f776a326d2..011cb78b06 100644 --- a/src/core/prompts/sections/__tests__/objective.spec.ts +++ b/src/core/prompts/sections/__tests__/objective.spec.ts @@ -1,19 +1,30 @@ import { getObjectiveSection } from "../objective" +import type { EffectiveToolPolicy } from "../../tools/effective-tool-policy" + +/** Build a policy advertising `tools` as logically available. */ +function policyFor(tools: string[]): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } +} describe("getObjectiveSection", () => { it("should include proper numbered structure", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor([])) // Check that all numbered items are present expect(objective).toContain("1. Analyze the user's task") expect(objective).toContain("2. Work through these goals sequentially") - expect(objective).toContain("3. Remember, you have extensive capabilities") + expect(objective).toContain("3. Remember, use the tools provided to you") expect(objective).toContain("4. Once you've completed the user's task") expect(objective).toContain("5. The user may provide feedback") }) it("should include analysis guidance", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor(["read_file"])) expect(objective).toContain("Before calling a tool, do some analysis") expect(objective).toContain("analyze the file structure provided in environment_details") @@ -21,7 +32,7 @@ describe("getObjectiveSection", () => { }) it("should include parameter inference guidance", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor(["ask_followup_question"])) expect(objective).toContain("Go through each of the required parameters") expect(objective).toContain( @@ -32,16 +43,45 @@ describe("getObjectiveSection", () => { }) it("should include guidance about not engaging in back and forth conversations", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor([])) expect(objective).toContain("DO NOT continue in pointless back and forth conversations") expect(objective).toContain("don't end your responses with questions or offers for further assistance") }) it("should include the OBJECTIVE header", () => { - const objective = getObjectiveSection() + const objective = getObjectiveSection(policyFor([])) expect(objective).toContain("OBJECTIVE") expect(objective).toContain("You accomplish a given task iteratively") }) + + it("drops the broad-tool claim under a zero-clause policy", () => { + // Regression guard: step 3 must not claim "extensive capabilities" or a + // "wide range of tools" when the policy advertises no tool clauses at all. + const objective = getObjectiveSection(policyFor([])) + + expect(objective).not.toContain("extensive capabilities") + expect(objective).not.toContain("wide range of tools") + }) + + it("replaces the ask step with best-effort phrasing when ask_followup_question is absent", () => { + const objective = getObjectiveSection(policyFor([])) + + // Exact substring of the false branch, which no other test asserts. + expect(objective).toContain("state your assumptions and proceed with the best available value") + expect(objective).not.toContain("ask_followup_question tool") + }) + + it("uses tool-neutral completion wording when attempt_completion is not advertised", () => { + const policy = policyFor([]) + + expect(policy.tools.has("attempt_completion")).toBe(false) + expect(getObjectiveSection(policy)).not.toContain("attempt_completion") + expect(getObjectiveSection(policy)).toContain("present the result of the task to the user") + }) + + it("names attempt_completion when it is advertised", () => { + expect(getObjectiveSection(policyFor(["attempt_completion"]))).toContain("attempt_completion tool") + }) }) diff --git a/src/core/prompts/sections/__tests__/skills.spec.ts b/src/core/prompts/sections/__tests__/skills.spec.ts index 707d151252..aa53d2e3c6 100644 --- a/src/core/prompts/sections/__tests__/skills.spec.ts +++ b/src/core/prompts/sections/__tests__/skills.spec.ts @@ -1,4 +1,15 @@ import { getSkillsSection } from "../skills" +import type { EffectiveToolPolicy } from "../../tools/effective-tool-policy" + +/** Build a policy advertising `tools` as logically available. */ +function policyFor(tools: string[]): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } +} describe("getSkillsSection", () => { it("should emit XML with name, description, and location", async () => { @@ -13,7 +24,7 @@ describe("getSkillsSection", () => { ]), } - const result = await getSkillsSection(mockSkillsManager, "code") + const result = await getSkillsSection(mockSkillsManager, "code", policyFor(["skill"])) expect(result).toContain("") expect(result).toContain("") @@ -26,7 +37,22 @@ describe("getSkillsSection", () => { }) it("should return empty string when skillsManager or currentMode is missing", async () => { - await expect(getSkillsSection(undefined, "code")).resolves.toBe("") - await expect(getSkillsSection({ getSkillsForMode: vi.fn() }, undefined)).resolves.toBe("") + await expect(getSkillsSection(undefined, "code", policyFor(["skill"]))).resolves.toBe("") + await expect(getSkillsSection({ getSkillsForMode: vi.fn() }, undefined, policyFor(["skill"]))).resolves.toBe("") + }) + + it("should return empty string when the skill tool is disabled", async () => { + const mockSkillsManager = { + getSkillsForMode: vi.fn().mockReturnValue([ + { + name: "pdf-processing", + description: "Extracts text & tables from PDFs", + path: "/abs/path/pdf-processing/SKILL.md", + source: "global" as const, + }, + ]), + } + + await expect(getSkillsSection(mockSkillsManager, "code", policyFor([]))).resolves.toBe("") }) }) diff --git a/src/core/prompts/sections/__tests__/system-info.spec.ts b/src/core/prompts/sections/__tests__/system-info.spec.ts index 749b53a0fd..7c3b53c426 100644 --- a/src/core/prompts/sections/__tests__/system-info.spec.ts +++ b/src/core/prompts/sections/__tests__/system-info.spec.ts @@ -24,6 +24,14 @@ describe("getSystemInfoSection", () => { vi.spyOn(os, "release").mockReturnValue("5.15.0") }) + /** Minimal policy with execute_command present (the default case these tests exercise). */ + const policyFor = (hasExecuteCommand: boolean = true) => ({ + tools: new Set(hasExecuteCommand ? ["execute_command"] : []), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + }) + afterEach(() => { vi.clearAllMocks() }) @@ -31,7 +39,7 @@ describe("getSystemInfoSection", () => { it("should return system info with os-name when available", () => { mockOsName.mockReturnValue("Ubuntu 22.04") - const result = getSystemInfoSection(mockCwd) + const result = getSystemInfoSection(mockCwd, policyFor()) expect(result).toContain("Operating System: Ubuntu 22.04") expect(result).toContain("Default Shell: /bin/bash") @@ -44,7 +52,7 @@ describe("getSystemInfoSection", () => { throw new Error("Command failed with ENOENT: powershell") }) - const result = getSystemInfoSection(mockCwd) + const result = getSystemInfoSection(mockCwd, policyFor()) expect(result).toContain("Operating System: linux 5.15.0") expect(result).toContain("Default Shell: /bin/bash") @@ -59,8 +67,38 @@ describe("getSystemInfoSection", () => { vi.spyOn(os, "platform").mockReturnValue("win32" as any) vi.spyOn(os, "release").mockReturnValue("10.0.19043") - const result = getSystemInfoSection(mockCwd) + const result = getSystemInfoSection(mockCwd, policyFor()) expect(result).toContain("Operating System: win32 10.0.19043") }) + + it("omits the terminal sentence when execute_command is absent", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const result = getSystemInfoSection(mockCwd, policyFor(false)) + + expect(result).not.toContain("New terminals will be created") + }) + + it("includes the full terminal working-directory sentence when execute_command is present", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const result = getSystemInfoSection(mockCwd, policyFor(true)) + + // Exact substring of the execute_command-gated sentence; also proves the + // `execute_command` lookup itself is not mutated away. + expect(result).toContain( + "New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory.", + ) + }) + + it("joins the workspace sentence directly to the next sentence when execute_command is absent", () => { + mockOsName.mockReturnValue("Ubuntu 22.04") + + const result = getSystemInfoSection(mockCwd, policyFor(false)) + + // The false branch must stay empty: any injected filler (e.g. a mutated + // sentinel string) breaks this exact join. + expect(result).toContain("default directory for all tool operations. When the user initially gives you a task") + }) }) diff --git a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts index 6d1f4b3fbf..ee07bda004 100644 --- a/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts +++ b/src/core/prompts/sections/__tests__/tool-use-guidelines.spec.ts @@ -1,8 +1,19 @@ import { getToolUseGuidelinesSection } from "../tool-use-guidelines" +import type { EffectiveToolPolicy } from "../../tools/effective-tool-policy" + +/** Build a policy advertising `tools` as logically available. */ +function policyFor(tools: string[]): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + } +} describe("getToolUseGuidelinesSection", () => { it("should include proper numbered guidelines", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("1. Assess what information") expect(guidelines).toContain("2. Choose the most appropriate tool") @@ -10,14 +21,14 @@ describe("getToolUseGuidelinesSection", () => { }) it("should include multiple-tools-per-message guidance", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("you may use multiple tools in a single message") expect(guidelines).not.toContain("use one tool at a time per message") }) it("should use simplified footer without step-by-step language", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("carefully considering the user's response after tool executions") expect(guidelines).not.toContain("It is crucial to proceed step-by-step") @@ -25,15 +36,37 @@ describe("getToolUseGuidelinesSection", () => { }) it("should include common guidance", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).toContain("Assess what information you already have") expect(guidelines).toContain("Choose the most appropriate tool") expect(guidelines).not.toContain("") }) it("should not include per-tool confirmation guidelines", () => { - const guidelines = getToolUseGuidelinesSection() + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) expect(guidelines).not.toContain("After each tool use, the user will respond with the result") }) + + it("omits the list_files example when list_files is absent", () => { + const guidelines = getToolUseGuidelinesSection(policyFor([])) + + expect(guidelines).not.toContain("the list_files tool is more effective than running a command like `ls`") + }) + + it("includes the list_files example verbatim when list_files is present", () => { + const guidelines = getToolUseGuidelinesSection(policyFor(["list_files"])) + + // Exact substring of the gated example, and of the exact join around it. + expect(guidelines).toContain( + "gathering this information. For example using the list_files tool is more effective than running a command like `ls` in the terminal. It's critical", + ) + }) + + it("keeps the false branch empty when the example is omitted", () => { + const guidelines = getToolUseGuidelinesSection(policyFor([])) + + // Any injected filler in the false branch breaks this exact join. + expect(guidelines).toContain("gathering this information. It's critical") + }) }) diff --git a/src/core/prompts/sections/capabilities.ts b/src/core/prompts/sections/capabilities.ts index c493692401..d5e542f634 100644 --- a/src/core/prompts/sections/capabilities.ts +++ b/src/core/prompts/sections/capabilities.ts @@ -1,46 +1,84 @@ -import { McpHub } from "../../../services/mcp/McpHub" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" /** * Builds the CAPABILITIES section of the system prompt. * - * The MCP availability line is only emitted when at least one MCP server is actually - * exposed to the current mode. When `allowedMcpServers` is provided, the hub's server - * list is filtered by that allowlist BEFORE deciding whether to advertise MCP, so the - * capability text matches the per-mode tool exposure: - * - `undefined` allowlist → all connected servers count (backward compatible) - * - empty `[]` allowlist → no servers count ⇒ MCP line omitted - * - populated allowlist → only listed servers count + * Every capability claim is now a fragment emitted only when its tool is in the + * request's effective tool policy (the single source of truth shared by prompt + * generation, API tool construction, runtime validation, and preview). This + * keeps the prose consistent with what the model can actually call for the mode. * - * @param cwd Current working directory used in the prompt text. - * @param mcpHub Optional MCP hub. When omitted, the MCP line is never emitted. - * @param allowedMcpServers Optional per-mode allowlist of MCP server names. When provided, - * the hub's servers are filtered to this set before determining MCP availability. + * The file-tree paragraph is stated once as a fact in SYSTEM INFORMATION; the + * `list_files` *guidance* lives here and is gated on the tool being present. + * + * @param policy The request's effective tool policy. */ -export function getCapabilitiesSection(cwd: string, mcpHub?: McpHub, allowedMcpServers?: string[]): string { - // Determine whether any MCP server is actually available to the current mode. - // Filtering the hub's servers by the allowlist (when provided) keeps the capability - // text consistent with the tools that are exposed for the mode. - let hasMcpServers = false - if (mcpHub) { - let servers = mcpHub.getServers() - if (allowedMcpServers) { - const allowSet = new Set(allowedMcpServers) - servers = servers.filter((server) => allowSet.has(server.name)) - } - hasMcpServers = servers.length > 0 +export function getCapabilitiesSection(policy: EffectiveToolPolicy): string { + const tools = policy.tools + const hasEditTool = tools.has("write_to_file") || tools.has("apply_diff") || tools.has("edit") + + const clauses: string[] = [] + if (tools.has("execute_command")) { + clauses.push("execute CLI commands on the user's computer") + } + if (tools.has("list_files")) { + clauses.push("list files") + } + if (tools.has("codebase_search")) { + clauses.push("semantically search the codebase") + } + if (tools.has("search_files")) { + clauses.push("regex search") + } + if (tools.has("read_file")) { + clauses.push("read files") + } + if (hasEditTool) { + clauses.push("write and edit files") + } + + // The catalog clause is the only always-present sentence; when there are no + // per-tool clauses (e.g. a control-tool-only mode) we fall back to a sentence + // that warns the model it may only call provided tools. + const capabilitySentence = + clauses.length > 0 + ? `You have access to tools that let you ${clauses.join(", ")}.` + : "You have access to a limited set of tools for this mode; only the tools you are provided may be called." + + // The edit-restriction suffix binds to the capability sentence (not the last + // emitted bullet) so its position is deterministic regardless of which + // optional bullets follow. + const editRestrictionSuffix = + hasEditTool && policy.editRestriction + ? ` (in this mode only files matching '${policy.editRestriction.fileRegex}' can be edited${ + policy.editRestriction.description ? ` — ${policy.editRestriction.description}` : "" + })` + : "" + + let body = `${capabilitySentence}${editRestrictionSuffix}\n` + + body += `- These tools help you accomplish tasks.\n` + + // `list_files` guidance only — the file-tree *fact* is stated once in + // SYSTEM INFORMATION (and carries the cwd there). + if (tools.has("list_files")) { + body += `- If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.\n` + } + + if (tools.has("execute_command")) { + body += `- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.\n` + } + + // MCP bullet — only when MCP is effectively available (group + enabled tools/resources). + if (policy.hasMcpGroup && (policy.hasMcpTools || policy.hasMcpResources)) { + body += `- You have access to MCP servers that may provide additional tools and/or resources actually available to this mode. Each server may provide different capabilities that you can use to accomplish tasks more effectively.\n` } + body = body.replace(/\n$/, "") + return `==== CAPABILITIES -- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as writing code, making edits or improvements to existing files, understanding the current state of a project, performing system operations, and much more. -- When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('${cwd}') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop. -- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Interactive and long-running commands are allowed, since the commands are run in the user's VSCode terminal. The user may keep commands running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.${ - hasMcpServers - ? ` -- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. -` - : "" - }` +${body}` } diff --git a/src/core/prompts/sections/objective.ts b/src/core/prompts/sections/objective.ts index 2ef32bc144..2056eae588 100644 --- a/src/core/prompts/sections/objective.ts +++ b/src/core/prompts/sections/objective.ts @@ -1,4 +1,22 @@ -export function getObjectiveSection(): string { +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + +/** + * Builds the OBJECTIVE section of the system prompt. + * + * Step 3's guidance to ask the user via ask_followup_question is replaced with + * best-effort phrasing when that tool is not in the request's effective policy. + * Step 4 only names attempt_completion when the policy advertises the tool. + * + * @param policy The request's effective tool policy. + */ +export function getObjectiveSection(policy: EffectiveToolPolicy): string { + const askStep = policy.tools.has("ask_followup_question") + ? "ask the user to provide the missing parameters using the ask_followup_question tool" + : "state your assumptions and proceed with the best available value" + const completionStep = policy.tools.has("attempt_completion") + ? "you must use the attempt_completion tool to present the result of the task to the user" + : "present the result of the task to the user" + return `==== OBJECTIVE @@ -7,7 +25,7 @@ You accomplish a given task iteratively, breaking it down into clear steps and w 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. 2. Work through these goals sequentially, utilizing available tools one at a time as necessary. Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go. -3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ask the user to provide the missing parameters using the ask_followup_question tool. DO NOT ask for more information on optional parameters if it is not provided. -4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. +3. Remember, use the tools provided to you in powerful and clever ways as necessary to accomplish each goal. Before calling a tool, do some analysis. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Next, think about which of the provided tools is the most relevant tool to accomplish the user's task. Go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params) and instead, ${askStep}. DO NOT ask for more information on optional parameters if it is not provided. +4. Once you've completed the user's task, ${completionStep}. 5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.` } diff --git a/src/core/prompts/sections/rules.ts b/src/core/prompts/sections/rules.ts index 4f6e573fa7..49cd53a48d 100644 --- a/src/core/prompts/sections/rules.ts +++ b/src/core/prompts/sections/rules.ts @@ -2,6 +2,8 @@ import type { SystemPromptSettings } from "../types" import { getShell } from "../../../utils/shell" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + /** * Returns the appropriate command chaining operator based on the user's shell. * - Unix shells (bash, zsh, etc.): `&&` (run next command only if previous succeeds) @@ -62,34 +64,135 @@ When asked about your creator, vendor, or company, respond with: - "I don't have information about specific vendors"` } -export function getRulesSection(cwd: string, settings?: SystemPromptSettings): string { - // Get shell-appropriate command chaining operator +/** + * Builds the RULES section of the system prompt. + * + * Fragments that describe tool-specific behavior are emitted only when that tool + * is in the request's effective tool policy. + * + * @param cwd Current working directory used in the prompt text. + * @param settings System prompt settings (used for the stealth-model confidentiality section). + * @param policy The request's effective tool policy. + */ +export function getRulesSection( + cwd: string, + settings: SystemPromptSettings | undefined, + policy: EffectiveToolPolicy, +): string { const chainOp = getCommandChainOperator() const chainNote = getCommandChainNote() + const hasExecuteCommand = policy.tools.has("execute_command") + const hasAskFollowupQuestion = policy.tools.has("ask_followup_question") + const hasListFiles = policy.tools.has("list_files") + const hasReadFile = policy.tools.has("read_file") + const hasAttemptCompletion = policy.tools.has("attempt_completion") + const hasEditTool = ["apply_diff", "write_to_file", "edit", "search_replace", "edit_file", "apply_patch"].some( + (tool) => policy.tools.has(tool), + ) + + const rules: string[] = [] + + rules.push(`The project base directory is: ${cwd.toPosix()}`) + + rules.push( + hasExecuteCommand + ? `All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command.` + : "All file paths must be relative to this directory.", + ) + + rules.push( + `You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path.`, + ) + + rules.push("Do not use the ~ character or $HOME to refer to the home directory.") + + if (hasExecuteCommand) { + rules.push( + `Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory ${chainOp} then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) ${chainOp} (command, in this case npm install)\`.${chainNote ? ` ${chainNote}` : ""}`, + ) + } + + if (hasEditTool && policy.editRestriction) { + rules.push( + "Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode.", + ) + } + + rules.push( + "Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.", + ) + + rules.push( + "When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.", + ) + + rules.push( + hasAttemptCompletion + ? "Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again." + : "Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, present the result to the user. The user may provide feedback, which you can use to make improvements and try again.", + ) + + if (hasAskFollowupQuestion) { + rules.push( + `You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so.${ + hasListFiles + ? ` For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.` + : "" + }`, + ) + } else { + // ask_followup_question unavailable: fall back to best-effort guidance. + rules.push( + "Provide your best-effort result and state your assumptions; the user may respond with feedback after completion.", + ) + } + + if (hasExecuteCommand) { + rules.push( + `When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, ${ + hasAskFollowupQuestion + ? "use the ask_followup_question tool to request the user to copy and paste it back to you" + : "note what you expected and proceed with the task, stating your assumptions" + }.`, + ) + } + + if (hasReadFile) { + rules.push( + "The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.", + ) + } + + rules.push( + "Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.", + hasAttemptCompletion + ? "NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user." + : "NEVER end your result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.", + 'You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I\'ve updated the CSS" but instead something like "I\'ve updated the CSS". It is important you be clear and technical in your messages.', + "When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.", + "At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.", + ) + + if (hasExecuteCommand) { + rules.push( + 'Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn\'t need to start it again. If no active terminals are listed, proceed with command execution as normal.', + ) + } + + if (policy.hasMcpGroup && (policy.hasMcpTools || policy.hasMcpResources)) { + rules.push( + "MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.", + ) + } + + rules.push( + "It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.", + ) + return `==== RULES -- The project base directory is: ${cwd.toPosix()} -- All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command. -- You cannot \`cd\` into a different directory to complete a task. You are stuck operating from '${cwd.toPosix()}', so be sure to pass in the correct 'path' parameter when using tools that require a path. -- Do not use the ~ character or $HOME to refer to the home directory. -- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory ${chainOp} then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) ${chainOp} (command, in this case npm install)\`.${chainNote ? ` ${chainNote}` : ""} -- Some modes have restrictions on which files they can edit. If you attempt to edit a restricted file, the operation will be rejected with a FileRestrictionError that will specify which file patterns are allowed for the current mode. -- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write. - * For example, in architect mode trying to edit app.js would be rejected because architect mode can only edit files matching "\\.md$" -- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices. -- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again. -- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. When you ask a question, provide the user with 2-4 suggested answers based on your question so they don't need to do so much typing. The suggestions should be specific, actionable, and directly related to the completed task. They should be ordered by priority or logical sequence. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves. -- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you. -- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. -- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation. -- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. -- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the CSS" but instead something like "I've updated the CSS". It is important you be clear and technical in your messages. -- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task. -- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. -- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. -- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. -- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${settings?.isStealthModel ? getVendorConfidentialitySection() : ""}` +- ${rules.join("\n- ")}${settings?.isStealthModel ? getVendorConfidentialitySection() : ""}` } diff --git a/src/core/prompts/sections/skills.ts b/src/core/prompts/sections/skills.ts index 6cd3a71d75..abb0f6b17f 100644 --- a/src/core/prompts/sections/skills.ts +++ b/src/core/prompts/sections/skills.ts @@ -1,4 +1,5 @@ import type { SkillsManager } from "../../../services/skills/SkillsManager" +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" type SkillsManagerLike = Pick @@ -22,7 +23,12 @@ function escapeXml(value: string): string { export async function getSkillsSection( skillsManager: SkillsManagerLike | undefined, currentMode: string | undefined, + policy: EffectiveToolPolicy, ): Promise { + // The protocol in this section mandates the `skill` tool; if it's not available + // the section would be unhelpful/unactionable, so emit nothing. + if (!policy.tools.has("skill")) return "" + if (!skillsManager || !currentMode) return "" // Get skills filtered by current mode (with override resolution) diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts index a4af3c6ac9..98112cd4ed 100644 --- a/src/core/prompts/sections/system-info.ts +++ b/src/core/prompts/sections/system-info.ts @@ -3,7 +3,19 @@ import osName from "os-name" import { getShell } from "../../../utils/shell" -export function getSystemInfoSection(cwd: string): string { +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + +/** + * Builds the SYSTEM INFORMATION section of the system prompt. + * + * The workspace-directory / file-tree facts are stated once here; the + * file-tree fact is cwd-independent. The terminal-cd sentence is gated on + * `execute_command`, since those semantics do not exist without it. + * + * @param cwd Current working directory used in the prompt text. + * @param policy The request's effective tool policy. + */ +export function getSystemInfoSection(cwd: string, policy: EffectiveToolPolicy): string { // Try to get detailed OS name, fall back to basic info if it fails let osInfo: string try { @@ -15,6 +27,12 @@ export function getSystemInfoSection(cwd: string): string { osInfo = `${platform} ${release}` } + const executeCommandAvailable = policy.tools.has("execute_command") + + const executeCommandSentence = executeCommandAvailable + ? " New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory." + : "" + const details = `==== SYSTEM INFORMATION @@ -24,7 +42,7 @@ Default Shell: ${getShell()} Home Directory: ${os.homedir().toPosix()} Current Workspace Directory: ${cwd.toPosix()} -The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations. New terminals will be created in the current workspace directory, however if you change directories in a terminal it will then have a different working directory; changing directories in a terminal does not modify the workspace directory, because you do not have access to change the workspace directory. When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory ('/test/path') will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further. If you need to further explore directories such as outside the current workspace directory, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure, like the Desktop.` +The Current Workspace Directory is the active VS Code project directory, and is therefore the default directory for all tool operations.${executeCommandSentence} When the user initially gives you a task, a recursive list of all filepaths in the current workspace directory will be included in environment_details. This provides an overview of the project's file structure, offering key insights into the project from directory/file names (how developers conceptualize and organize their code) and file extensions (the language used). This can also guide decision-making on which files to explore further.` return details } diff --git a/src/core/prompts/sections/tool-use-guidelines.ts b/src/core/prompts/sections/tool-use-guidelines.ts index 78193372cc..2a34c89966 100644 --- a/src/core/prompts/sections/tool-use-guidelines.ts +++ b/src/core/prompts/sections/tool-use-guidelines.ts @@ -1,8 +1,22 @@ -export function getToolUseGuidelinesSection(): string { +import type { EffectiveToolPolicy } from "../tools/effective-tool-policy" + +/** + * Builds the TOOL USE GUIDELINES section of the system prompt. + * + * Guideline 2's example names `list_files` over `ls`; that example is only kept + * when `list_files` is in the request's effective tool policy. + * + * @param policy The request's effective tool policy. + */ +export function getToolUseGuidelinesSection(policy: EffectiveToolPolicy): string { + const listExample = policy.tools.has("list_files") + ? " For example using the list_files tool is more effective than running a command like `ls` in the terminal." + : "" + return `# Tool Use Guidelines 1. Assess what information you already have and what information you need to proceed with the task. -2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information. For example using the list_files tool is more effective than running a command like \`ls\` in the terminal. It's critical that you think about each available tool and use the one that best fits the current step in the task. +2. Choose the most appropriate tool based on the task and the tool descriptions provided. Assess if you need additional information to proceed, and which of the available tools would be most effective for gathering this information.${listExample} It's critical that you think about each available tool and use the one that best fits the current step in the task. 3. If multiple actions are needed, you may use multiple tools in a single message when appropriate, or use tools iteratively across messages. Each tool use should be informed by the results of previous tool uses. Do not assume the outcome of any tool use. Each step must be informed by the previous step's result. By carefully considering the user's response after tool executions, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.` diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 8496666a43..38283087bf 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -1,8 +1,14 @@ import * as vscode from "vscode" -import { type ModeConfig, type PromptComponent, type CustomModePrompts, type TodoItem } from "@roo-code/types" - -import { Mode, modes, defaultModeSlug, getModeBySlug, getGroupName, getModeSelection } from "../../shared/modes" +import { + type ModeConfig, + type PromptComponent, + type CustomModePrompts, + type TodoItem, + type ModelInfo, +} from "@roo-code/types" + +import { Mode, modes, defaultModeSlug, getModeBySlug, getModeSelection } from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" import { formatLanguage } from "../../shared/language" import { isEmpty } from "../../utils/object" @@ -12,6 +18,8 @@ import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-m import { SkillsManager } from "../../services/skills/SkillsManager" import type { SystemPromptSettings } from "./types" +import type { EffectiveToolPolicy } from "./tools/effective-tool-policy" +import { resolveEffectiveToolPolicy } from "./tools/effective-tool-policy" import { getRulesSection, getSystemInfoSection, @@ -55,6 +63,8 @@ async function generatePrompt( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + disabledTools?: string[], + modelInfo?: ModelInfo, ): Promise { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -64,29 +74,29 @@ async function generatePrompt( const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] const { roleDefinition, baseInstructions } = getModeSelection(mode, promptComponent, customModeConfigs) - // Check if MCP functionality should be included - const hasMcpGroup = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") - const allowedMcpServers = modeConfig.allowedMcpServers - - // Hoist the allowlist Set once (matches the sibling call sites, e.g. mcp_server.ts) instead - // of constructing a new Set on every `.filter` iteration. - const allowSet = allowedMcpServers ? new Set(allowedMcpServers) : undefined - - let hasMcpServers = false - if (mcpHub) { - const servers = allowSet ? mcpHub.getServers().filter((s) => allowSet.has(s.name)) : mcpHub.getServers() - hasMcpServers = servers.length > 0 - } - const shouldIncludeMcp = hasMcpGroup && hasMcpServers - const codeIndexManager = CodeIndexManagerRegistry.getOrCreate(context, cwd) + // Resolve the single, request-scoped effective tool policy ONCE, then have every + // prompt section and the MCP short-circuit derive from it. This is the one source of + // truth shared by prompt generation, API tool construction, runtime validation, and + // preview, so the prose never advertises a tool the model cannot actually call. + const policy = resolveEffectiveToolPolicy({ + mode, + customModes: customModeConfigs, + mcpHub, + disabledTools, + modelInfo, + experiments, + todoListEnabled: settings?.todoListEnabled, + codeIndexManager, + }) + // Tool calling is native-only. const effectiveProtocol = "native" const [modesSection, skillsSection] = await Promise.all([ getModesSection(context), - getSkillsSection(skillsManager, mode as string), + getSkillsSection(skillsManager, mode as string, policy), ]) // Tools catalog is not included in the system prompt. @@ -98,25 +108,17 @@ ${markdownFormattingSection()} ${getSharedToolUseSection()}${toolsCatalog} - ${getToolUseGuidelinesSection()} + ${getToolUseGuidelinesSection(policy)} -${ - // Forward the hub only when the mode actually exposes the MCP group, and pass the per-mode - // allowlist through so the capabilities section filters servers using the SAME convention as - // the tool-listing layer (a single source of truth for which servers are visible). This keeps - // the capability text consistent with the tools exposed in mixed cases (e.g. one allowed + - // one disallowed server), preventing the section from advertising MCP based on a disallowed - // server. `shouldIncludeMcp` is still used to short-circuit when no allowed server exists. - getCapabilitiesSection(cwd, hasMcpGroup ? mcpHub : undefined, allowedMcpServers) -} +${getCapabilitiesSection(policy)} ${modesSection} ${skillsSection ? `\n${skillsSection}` : ""} -${getRulesSection(cwd, settings)} +${getRulesSection(cwd, settings, policy)} -${getSystemInfoSection(cwd)} +${getSystemInfoSection(cwd, policy)} -${getObjectiveSection()} +${getObjectiveSection(policy)} ${await addCustomInstructions(baseInstructions, globalCustomInstructions || "", cwd, mode, { language: language ?? formatLanguage(vscode.env.language), @@ -144,6 +146,8 @@ export const SYSTEM_PROMPT = async ( todoList?: TodoItem[], modelId?: string, skillsManager?: SkillsManager, + disabledTools?: string[], + modelInfo?: ModelInfo, ): Promise => { if (!context) { throw new Error("Extension context is required for generating system prompt") @@ -172,5 +176,7 @@ export const SYSTEM_PROMPT = async ( todoList, modelId, skillsManager, + disabledTools, + modelInfo, ) } diff --git a/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts new file mode 100644 index 0000000000..8e2c3cb80f --- /dev/null +++ b/src/core/prompts/tools/__tests__/effective-tool-policy.spec.ts @@ -0,0 +1,723 @@ +import { customToolRegistry } from "@roo-code/core" +import type { ModeConfig, ModelInfo } from "@roo-code/types" + +import type { EffectiveToolPolicy } from "../effective-tool-policy" +import { + PROTOCOL_TOOLS, + resolveEffectiveToolPolicy, + resolveToolAlias, + buildToolRequirements, + isToolDisabledOrExcluded, +} from "../effective-tool-policy" +import { getModeBySlug, defaultModeSlug } from "../../../../shared/modes" +import type { CodeIndexManager } from "../../../../services/code-index/manager" + +/** Build a policy by giving the custom mode `groups` (derived from a real custom mode config). */ +function policyFor( + groups: ModeConfig["groups"], + extra: Partial<{ + mcpHub: ReturnType + disabledTools: string[] + modelInfo: ModelInfo + experiments: Record + todoListEnabled: boolean + codeIndexManager: CodeIndexManager + allowedMcpServers: string[] + }> = {}, +): EffectiveToolPolicy { + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups, + } + return resolveEffectiveToolPolicy({ + mode: "policy-test", + customModes: [customMode], + ...extra, + }) +} + +/** Minimal McpHub stub. Mirrors the McpServer shape the resolver reads (getServers, resources). */ +function makeMcpHub( + servers: Array<{ + name: string + resources?: Array<{ uri: string; name?: string }> + tools?: Array<{ name: string; description?: string; enabledForPrompt?: boolean }> + }>, +) { + return { getServers: () => servers } +} + +/** CodeIndexManager stub with all "ready" flags true. */ +function enabledCodeIndexManager(): CodeIndexManager { + return { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } as CodeIndexManager +} + +/** Build a ModelInfo satisfying the required schema fields, merged with test-specific overrides. */ +function modelInfo(partial?: Partial): ModelInfo { + return { contextWindow: 100_000, supportsPromptCache: true, ...partial } +} + +describe("resolveEffectiveToolPolicy - groups", () => { + it("grants read-group tools for a read mode", () => { + const policy = policyFor(["read"]) + expect(policy.tools.has("read_file")).toBe(true) + expect(policy.tools.has("codebase_search")).toBe(false) // gated by code index, off by default + expect(policy.tools.has("list_files")).toBe(true) + expect(policy.tools.has("search_files")).toBe(true) + }) + + it("grants edit-group tools for an edit mode", () => { + const policy = policyFor(["edit"]) + expect(policy.tools.has("write_to_file")).toBe(true) + expect(policy.tools.has("apply_diff")).toBe(true) + }) + + it("grants command-group tools for a command mode", () => { + const policy = policyFor(["command"]) + expect(policy.tools.has("execute_command")).toBe(true) + expect(policy.tools.has("read_command_output")).toBe(true) + }) + + it("combines groups", () => { + const policy = policyFor(["read", "edit", "command"]) + expect(policy.tools.has("read_file")).toBe(true) + expect(policy.tools.has("write_to_file")).toBe(true) + expect(policy.tools.has("execute_command")).toBe(true) + }) + + it("keeps always-available tools regardless of groups", () => { + const policy = policyFor([]) + // switch_mode/new_task are in the "modes" group but also always-available + expect(policy.tools.has("ask_followup_question")).toBe(true) + expect(policy.tools.has("update_todo_list")).toBe(true) + expect(policy.tools.has("skill")).toBe(true) + // run_slash_command is always-available but gated by the runSlashCommand experiment + expect(policy.tools.has("run_slash_command")).toBe(false) + }) + + it("sets hasMcpGroup only when the mode has the mcp group", () => { + expect(policyFor(["mcp"]).hasMcpGroup).toBe(true) + expect(policyFor(["read"]).hasMcpGroup).toBe(false) + }) + + it("extracts the first edit-restriction tuple with fileRegex", () => { + const policy = policyFor(["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }]]) + expect(policy.editRestriction).toEqual({ fileRegex: "\\.md$", description: "Markdown files only" }) + }) + + it("returns undefined editRestriction when no edit tuple has a fileRegex", () => { + expect(policyFor(["edit"]).editRestriction).toBeUndefined() + }) +}) + +describe("resolveEffectiveToolPolicy - disabledTools", () => { + it("removes tools listed in disabledTools (canonical)", () => { + const policy = policyFor(["read", "edit", "command"], { disabledTools: ["execute_command"] }) + expect(policy.tools.has("execute_command")).toBe(false) + expect(policy.tools.has("read_file")).toBe(true) + }) + + it("removes tools by alias (alias normalization)", () => { + const policy = policyFor(["edit"], { disabledTools: ["write_file"] }) + expect(policy.tools.has("write_to_file")).toBe(false) + }) + + it("removes a protocol tool listed in disabledTools", () => { + expect( + policyFor(["read", "edit", "command"], { disabledTools: [...PROTOCOL_TOOLS] }).tools.has( + "attempt_completion", + ), + ).toBe(false) + }) + + it("keeps the protocol tool when it is neither disabled nor excluded", () => { + expect( + policyFor(["read", "edit", "command"], { disabledTools: ["execute_command"] }).tools.has( + "attempt_completion", + ), + ).toBe(true) + }) +}) + +describe("resolveEffectiveToolPolicy - model customization", () => { + it("removes tools in modelInfo.excludedTools", () => { + const policy = policyFor(["read", "edit", "command"], { + modelInfo: modelInfo({ excludedTools: ["read_file"] }), + }) + expect(policy.tools.has("read_file")).toBe(false) + }) + + it("removes tools by excludedTools alias", () => { + const policy = policyFor(["edit"], { modelInfo: modelInfo({ excludedTools: ["write_file"] }) }) + expect(policy.tools.has("write_to_file")).toBe(false) + }) + + it("removes excludedTools entries even for protocol tools", () => { + const policy = policyFor(["read", "edit", "command"], { + modelInfo: modelInfo({ excludedTools: ["attempt_completion"] }), + }) + expect(policy.tools.has("attempt_completion")).toBe(false) + }) + + it("adds includedTools only when their group is allowed", () => { + // read group is allowed; codebase_search is in read. + const policy = policyFor(["read"], { + modelInfo: modelInfo({ excludedTools: [], includedTools: ["codebase_search"] }), + codeIndexManager: enabledCodeIndexManager(), + }) + expect(policy.tools.has("codebase_search")).toBe(true) + }) + + it("ignores includedTools outside the allowed group", () => { + // command group only; codebase_search is in read -> not added even when requested. + const policy = policyFor(["command"], { modelInfo: modelInfo({ includedTools: ["read_file"] }) }) + expect(policy.tools.has("read_file")).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - conditional gates", () => { + it("drops codebase_search unless the code index is enabled/configured/initialized", () => { + const modeWithIndex = policyFor(["read"], { codeIndexManager: enabledCodeIndexManager() }) + expect(modeWithIndex.tools.has("codebase_search")).toBe(true) + + const modeWithoutIndex = policyFor(["read"]) + expect(modeWithoutIndex.tools.has("codebase_search")).toBe(false) + }) + + it("drops update_todo_list when todoListEnabled is false", () => { + expect(policyFor(["read", "edit", "command"], { todoListEnabled: false }).tools.has("update_todo_list")).toBe( + false, + ) + expect(policyFor(["read", "edit", "command"], { todoListEnabled: true }).tools.has("update_todo_list")).toBe( + true, + ) + }) + + it("drops generate_image unless the imageGeneration experiment is enabled", () => { + expect( + policyFor(["read", "edit", "command"], { experiments: { imageGeneration: true } }).tools.has( + "generate_image", + ), + ).toBe(true) + expect(policyFor(["read", "edit", "command"]).tools.has("generate_image")).toBe(false) + }) + + it("drops run_slash_command unless the runSlashCommand experiment is enabled", () => { + expect( + policyFor(["read", "edit", "command"], { experiments: { runSlashCommand: true } }).tools.has( + "run_slash_command", + ), + ).toBe(true) + expect(policyFor(["read", "edit", "command"]).tools.has("run_slash_command")).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - MCP resource gate", () => { + it("keeps access_mcp_resource iff an allowed server exposes resources", () => { + const hasResources = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "r" }] }]) }) + expect(hasResources.tools.has("access_mcp_resource")).toBe(true) + + const noResources = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s" }]) }) + expect(noResources.tools.has("access_mcp_resource")).toBe(false) + }) + + it("respects an explicit allowlist over the mode-config allowlist", () => { + const allowed = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "allowed", resources: [{ uri: "r" }] }]), + allowedMcpServers: ["allowed"], + }) + expect(allowed.tools.has("access_mcp_resource")).toBe(true) + + const wrongAllow = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "allowed", resources: [{ uri: "r" }] }]), + allowedMcpServers: ["blocked"], + }) + expect(wrongAllow.tools.has("access_mcp_resource")).toBe(false) + }) + + it("falls back to the mode config allowlist when no explicit allowlist is provided", () => { + const customMode: ModeConfig = { + slug: "policy-test", + name: "Restricted Mode", + roleDefinition: "", + groups: ["mcp"], + allowedMcpServers: ["blocked"], + } + const policy = resolveEffectiveToolPolicy({ + mode: "policy-test", + customModes: [customMode], + mcpHub: makeMcpHub([{ name: "allowed", resources: [{ uri: "r" }] }]), + }) + expect(policy.tools.has("access_mcp_resource")).toBe(false) + }) + + it("computes hasMcpTools from effective enabled tools and hasMcpResources from resources", () => { + const hasToolsOnly = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", description: "d" }] }]), + }) + expect(hasToolsOnly.hasMcpTools).toBe(true) + expect(hasToolsOnly.hasMcpResources).toBe(false) + + const hasResourcesOnly = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "r" }] }]) }) + expect(hasResourcesOnly.hasMcpTools).toBe(false) + expect(hasResourcesOnly.hasMcpResources).toBe(true) + + const hasNeither = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s" }]) }) + expect(hasNeither.hasMcpTools).toBe(false) + expect(hasNeither.hasMcpResources).toBe(false) + }) + + it("returns hasMcpTools false when the only tool has enabledForPrompt: false", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: false }] }]), + }) + expect(policy.hasMcpTools).toBe(false) + }) + + it("returns hasMcpTools true when a tool has enabledForPrompt: true", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: true }] }]), + }) + expect(policy.hasMcpTools).toBe(true) + }) + + it("returns hasMcpTools false for a server excluded by the allowlist", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "excluded", tools: [{ name: "t", enabledForPrompt: true }] }]), + allowedMcpServers: ["other"], + }) + expect(policy.hasMcpTools).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + }) + + it("keeps use_mcp_tool only when an allowed server exposes a prompt-enabled tool", () => { + const withTools = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: true }] }]), + }) + expect(withTools.tools.has("use_mcp_tool")).toBe(true) + + const allDisabled = policyFor(["mcp"], { + mcpHub: makeMcpHub([{ name: "s", tools: [{ name: "t", enabledForPrompt: false }] }]), + }) + expect(allDisabled.tools.has("use_mcp_tool")).toBe(false) + }) + + it("drops use_mcp_tool when mcpHub is undefined even though the mcp group is granted", () => { + const policy = policyFor(["mcp"]) + expect(policy.hasMcpGroup).toBe(true) + expect(policy.hasMcpTools).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + expect(policy.tools.has("access_mcp_resource")).toBe(false) + }) + + it("drops use_mcp_tool when the allowedMcpServers list is empty", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { name: "s", tools: [{ name: "t", enabledForPrompt: true }], resources: [{ uri: "r" }] }, + ]), + allowedMcpServers: [], + }) + // An empty allowlist permits no servers: both MCP group tools must go, + // even though the hub itself exposes a live tool and a resource. + expect(policy.hasMcpTools).toBe(false) + expect(policy.hasMcpResources).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + expect(policy.tools.has("access_mcp_resource")).toBe(false) + }) + + it("keeps use_mcp_tool with resources-only hub and access_mcp_resource pruned", () => { + // The two group tools are gated independently: resources alone keep + // access_mcp_resource but must not resurrect use_mcp_tool. + const policy = policyFor(["mcp"], { mcpHub: makeMcpHub([{ name: "s", resources: [{ uri: "r" }] }]) }) + expect(policy.tools.has("access_mcp_resource")).toBe(true) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - worst case (control-tools-only mode)", () => { + it("only exposes always-available + protocol tools when groups is empty", () => { + const policy = policyFor([]) + expect(policy.tools.has("read_file")).toBe(false) + expect(policy.tools.has("write_to_file")).toBe(false) + expect(policy.tools.has("execute_command")).toBe(false) + expect(policy.tools.has("attempt_completion")).toBe(true) // protocol guarantee + expect(policy.tools.has("switch_mode")).toBe(true) // always-available + }) +}) + +describe("buildToolRequirements", () => { + it("returns an empty map when disabledTools is undefined or empty", () => { + expect(buildToolRequirements(undefined)).toEqual({}) + expect(buildToolRequirements([])).toEqual({}) + }) + + it("maps disabled tools to false (including alias + canonical)", () => { + const reqs = buildToolRequirements(["write_file"]) + expect(reqs).toEqual({ write_file: false, write_to_file: false }) + }) + + it("maps a disabled protocol tool to false like any other tool", () => { + const reqs = buildToolRequirements([...PROTOCOL_TOOLS, "ask_followup_question", "switch_mode"]) + expect(reqs).toEqual({ attempt_completion: false, ask_followup_question: false, switch_mode: false }) + }) + + it("adds alias + canonical for real aliases", () => { + const reqs = buildToolRequirements(["write_file"]) + expect(Object.keys(reqs).sort()).toEqual(["write_file", "write_to_file"].sort()) + }) + + it("keeps protocol-tool and regular entries together in a mixed list", () => { + // An explicit protocol-tool disable reaches the validator beside the + // regular tools in the same list. + expect(buildToolRequirements(["attempt_completion", "write_file"])).toEqual({ + attempt_completion: false, + write_file: false, + write_to_file: false, + }) + }) + + it("maps a protocol tool excluded by the model to false", () => { + // A model excludedTools entry suppresses attempt_completion just as a + // disabledTools entry does, so the execution gate sees it too. + const reqs = buildToolRequirements(undefined, modelInfo({ excludedTools: ["attempt_completion"] })) + expect(reqs).toEqual({ attempt_completion: false }) + }) + + it("maps ordinary model exclusions to runtime requirements, including aliases", () => { + const reqs = buildToolRequirements(undefined, modelInfo({ excludedTools: ["read_file", "write_file"] })) + expect(reqs).toEqual({ read_file: false, write_file: false, write_to_file: false }) + }) + + it("returns an empty map for a model customization without exclusions", () => { + expect(buildToolRequirements(undefined, modelInfo())).toEqual({}) + }) +}) + +describe("resolveToolAlias", () => { + it("resolves every registered alias to its canonical tool", () => { + // Exercises the module-load ALIAS_TO_CANONICAL map for both registered aliases. + expect(resolveToolAlias("write_file")).toBe("write_to_file") + expect(resolveToolAlias("search_and_replace")).toBe("edit") + }) + + it("returns canonical and unknown names unchanged", () => { + expect(resolveToolAlias("read_file")).toBe("read_file") + expect(resolveToolAlias("not_a_tool")).toBe("not_a_tool") + }) +}) + +describe("isToolDisabledOrExcluded", () => { + it("matches disabled and model-excluded entries through aliases", () => { + expect(isToolDisabledOrExcluded("write_file", ["write_to_file"], undefined)).toBe(true) + expect(isToolDisabledOrExcluded("write_to_file", undefined, modelInfo({ excludedTools: ["write_file"] }))).toBe( + true, + ) + expect(isToolDisabledOrExcluded("use_mcp_tool", ["write_file"], undefined)).toBe(false) + }) +}) + +describe("PROTOCOL_TOOLS", () => { + it("lists the single protocol tool by canonical name", () => { + expect([...PROTOCOL_TOOLS]).toEqual(["attempt_completion"]) + }) +}) + +describe("resolveEffectiveToolPolicy - edit restriction edge cases", () => { + it("skips non-edit group tuples even when they declare a fileRegex", () => { + // Only an actual `edit` tuple can establish the restriction: a `read` tuple + // carrying a fileRegex must be skipped, and an edit tuple without a fileRegex + // must not produce one either. + const policy = policyFor([["read", { fileRegex: "\\.ts$" }], ["edit", {}], "command"]) + expect(policy.editRestriction).toBeUndefined() + }) + + it("does not crash on a malformed edit tuple without options", () => { + // Runtime guard: the extraction uses `group[1]?.fileRegex`, so an options-less + // tuple must be skipped rather than throwing. + const groups = JSON.parse('[["edit"]]') as ModeConfig["groups"] + expect(policyFor(groups).editRestriction).toBeUndefined() + }) +}) + +describe("resolveEffectiveToolPolicy - step 3 validator removal", () => { + it("drops granted tools when the validator does not recognize the mode", () => { + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups: ["read", "edit", "command"], + } + // The requested slug matches no mode, so the fallback (architect) grants its + // read/edit/mcp tools but the per-tool validator rejects every non-always-available + // tool, and the step-3 removal loop drops them. + const policy = resolveEffectiveToolPolicy({ mode: "ghost-mode", customModes: [customMode] }) + expect(policy.tools.has("read_file")).toBe(false) + expect(policy.tools.has("write_to_file")).toBe(false) + expect(policy.tools.has("use_mcp_tool")).toBe(false) + expect(policy.tools.has("switch_mode")).toBe(true) + expect(policy.tools.has("attempt_completion")).toBe(true) + }) + + it("re-adds validator-removed group tools via includedTools (regular-tool mapping)", () => { + // The includedTools branch maps every regular group tool through + // TOOL_GROUPS; when a granted tool was dropped by the step-3 validator + // (unknown mode slug), including it re-adds it because its group is allowed + // by the fallback mode config. + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups: ["read", "edit", "command"], + } + const policy = resolveEffectiveToolPolicy({ + mode: "ghost-mode", + customModes: [customMode], + modelInfo: modelInfo({ includedTools: ["read_file"] }), + }) + expect(policy.tools.has("read_file")).toBe(true) + }) + + it("threads the experiments flags into the per-mode validator", () => { + // The resolver forwards `experiments ?? {}` to the validator; the customTools + // escape hatch in isToolAllowedForMode only fires when that flag actually + // arrives. A registered custom tool is therefore retained for an otherwise + // unknown mode when (and only when) the flag is passed through. + const customMode: ModeConfig = { + slug: "policy-test", + name: "Policy Under Test", + roleDefinition: "", + groups: ["read"], + } + customToolRegistry.register({ name: "shadow_read_tool", description: "test double", execute: async () => "ok" }) + try { + const withFlag = resolveEffectiveToolPolicy({ + mode: "ghost-mode", + customModes: [customMode], + experiments: { customTools: true }, + }) + // shadow_read_tool is not granted by any group, so the flag alone cannot + // re-add it; instead the flag must keep granted tools that the validator + // would otherwise reject for the unknown mode. + expect(withFlag.tools.has("read_file")).toBe(false) + + // Direct proof of flag threading: register under a granted tool's name. + customToolRegistry.register({ name: "read_file", description: "shadow", execute: async () => "ok" }) + const shadowed = resolveEffectiveToolPolicy({ + mode: "ghost-mode", + customModes: [customMode], + experiments: { customTools: true }, + }) + expect(shadowed.tools.has("read_file")).toBe(true) + + // Without the flag the same shadowed tool is still rejected. + const withoutFlag = resolveEffectiveToolPolicy({ mode: "ghost-mode", customModes: [customMode] }) + expect(withoutFlag.tools.has("read_file")).toBe(false) + } finally { + customToolRegistry.clear() + } + }) + + it("forwards an empty customModes default to the per-mode validator", async () => { + // The step-3 permission filter forwards `customModes ?? []` (and + // `experiments ?? {}`) to isToolAllowedForMode. A phantom default entry would + // behave identically downstream (a non-object never matches a mode slug), so + // the forwarded argument itself is the only observable. Wrap the real + // validator for one fresh module instance and assert what it receives. + const seen: unknown[][] = [] + vi.doMock("../../../../core/tools/validateToolUse", async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + isToolAllowedForMode: (...args: Parameters) => { + seen.push(args) + return original.isToolAllowedForMode(...args) + }, + } + }) + vi.resetModules() + const mod = await import("../effective-tool-policy") + try { + mod.resolveEffectiveToolPolicy({ mode: "code" }) + expect(seen.length).toBeGreaterThan(0) + for (const args of seen) { + expect(args[2]).toEqual([]) + } + + // Provided custom modes are forwarded by reference, unchanged. + const customModes: ModeConfig[] = [ + { slug: "passthrough-test", name: "PT", roleDefinition: "", groups: ["read"] }, + ] + seen.length = 0 + mod.resolveEffectiveToolPolicy({ mode: "code", customModes }) + expect(seen.some((args) => args[2] === customModes)).toBe(true) + } finally { + vi.doUnmock("../../../../core/tools/validateToolUse") + vi.resetModules() + } + }) +}) + +describe("resolveEffectiveToolPolicy - opt-in custom tools via includedTools", () => { + it("adds opt-in custom tools only when their group is allowed", () => { + // "edit" is an opt-in custom tool of the edit group: absent from the group grant, + // it is re-added only when model customization includes it AND the mode allows + // the owning group (the toolToGroup map includes customTools entries). + const withEditGroup = policyFor(["edit"], { modelInfo: modelInfo({ includedTools: ["edit"] }) }) + expect(withEditGroup.tools.has("edit")).toBe(true) + + const withoutEditGroup = policyFor(["read"], { modelInfo: modelInfo({ includedTools: ["edit"] }) }) + expect(withoutEditGroup.tools.has("edit")).toBe(false) + }) + + it("resolves aliased opt-in custom tools through the group's customTools", () => { + // "search_and_replace" is an alias of the opt-in custom tool "edit". + const policy = policyFor(["edit"], { modelInfo: modelInfo({ includedTools: ["search_and_replace"] }) }) + expect(policy.tools.has("edit")).toBe(true) + }) +}) + +describe("resolveEffectiveToolPolicy - code index readiness flags", () => { + it("drops codebase_search when the feature is disabled", () => { + const manager = { + isFeatureEnabled: false, + isFeatureConfigured: true, + isInitialized: true, + } as CodeIndexManager + expect(policyFor(["read"], { codeIndexManager: manager }).tools.has("codebase_search")).toBe(false) + }) + + it("drops codebase_search when the feature is not configured", () => { + const manager = { + isFeatureEnabled: true, + isFeatureConfigured: false, + isInitialized: true, + } as CodeIndexManager + expect(policyFor(["read"], { codeIndexManager: manager }).tools.has("codebase_search")).toBe(false) + }) + + it("drops codebase_search when the index is not initialized", () => { + const manager = { + isFeatureEnabled: true, + isFeatureConfigured: true, + isInitialized: false, + } as CodeIndexManager + expect(policyFor(["read"], { codeIndexManager: manager }).tools.has("codebase_search")).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - MCP capability flags", () => { + it("reports no MCP capabilities without an mcpHub", () => { + const policy = policyFor(["mcp"]) + expect(policy.hasMcpTools).toBe(false) + expect(policy.hasMcpResources).toBe(false) + }) + + it("keeps hasMcpGroup true when mcp is mixed with other groups", () => { + expect(policyFor(["read", "mcp", "command"]).hasMcpGroup).toBe(true) + }) + + it("returns hasMcpTools true for an allowlisted server even when other servers are dropped", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { name: "other", tools: [{ name: "t", enabledForPrompt: true }] }, + { name: "listed", tools: [{ name: "t", enabledForPrompt: true }] }, + ]), + allowedMcpServers: ["listed"], + }) + expect(policy.hasMcpTools).toBe(true) + }) + + it("returns hasMcpTools true when at least one of several tools is prompt-enabled", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { + name: "s", + tools: [ + { name: "off-a", enabledForPrompt: false }, + { name: "off-b", enabledForPrompt: false }, + { name: "live", enabledForPrompt: true }, + ], + }, + ]), + }) + expect(policy.hasMcpTools).toBe(true) + }) + + it("returns hasMcpTools false when every tool of the server is prompt-disabled", () => { + const policy = policyFor(["mcp"], { + mcpHub: makeMcpHub([ + { + name: "s", + tools: [ + { name: "off-a", enabledForPrompt: false }, + { name: "off-b", enabledForPrompt: false }, + ], + }, + ]), + }) + expect(policy.hasMcpTools).toBe(false) + }) +}) + +describe("resolveEffectiveToolPolicy - protocol tool honoring (fresh module)", () => { + // A disabled/excluded protocol tool must stay out of the effective set even + // when aliased: the re-add consults the same alias-resolved predicate as the + // exclusion steps, so an alias in disabledTools suppresses the canonical tool. + async function freshResolve() { + vi.resetModules() + const mod = await import("../effective-tool-policy") + return mod.resolveEffectiveToolPolicy + } + + it("suppresses the protocol tool when disabledTools lists an alias of it", async () => { + // Reset first, then register a temporary alias of attempt_completion, and + // only then load a fresh resolver: its module-load alias map (and with it + // the re-add gate) is built from the shared alias table as it stands at + // import time, so the suppression becomes reachable only through alias + // resolution, not a literal name match. + vi.resetModules() + const toolsMod = await import("../../../../shared/tools") + toolsMod.TOOL_ALIASES.wp4_attempt_alias = "attempt_completion" + const mod = await import("../effective-tool-policy") + try { + expect( + mod + .resolveEffectiveToolPolicy({ mode: "code", disabledTools: ["wp4_attempt_alias"] }) + .tools.has("attempt_completion"), + ).toBe(false) + // Sanity: the injected alias actually resolves through the fresh module. + expect(mod.resolveToolAlias("wp4_attempt_alias")).toBe("attempt_completion") + } finally { + delete toolsMod.TOOL_ALIASES.wp4_attempt_alias + vi.resetModules() + } + }) + + it("does not throw for empty or missing disabledTools", async () => { + const resolve = await freshResolve() + expect(() => resolve({ mode: "code", disabledTools: [] })).not.toThrow() + expect(() => resolve({ mode: "code" })).not.toThrow() + }) + + it("pins the protocol list and re-adds an unlisted tool independently of the always-available roster", async () => { + // Two positive controls for the suppression test above, on a fresh module: + // the exported protocol list is pinned, and with attempt_completion + // stripped from the always-available roster the unlisted tool must STILL + // be callable — so the re-add step, not the roster, is what guarantees it. + vi.resetModules() + const toolsMod = await import("../../../../shared/tools") + const mod = await import("../effective-tool-policy") + const rosterIndex = toolsMod.ALWAYS_AVAILABLE_TOOLS.indexOf("attempt_completion") + expect(rosterIndex).toBeGreaterThanOrEqual(0) + toolsMod.ALWAYS_AVAILABLE_TOOLS.splice(rosterIndex, 1) + try { + expect([...mod.PROTOCOL_TOOLS]).toEqual(["attempt_completion"]) + expect(mod.resolveEffectiveToolPolicy({ mode: "code" }).tools.has("attempt_completion")).toBe(true) + } finally { + toolsMod.ALWAYS_AVAILABLE_TOOLS.splice(rosterIndex, 0, "attempt_completion") + vi.resetModules() + } + }) +}) diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index bc3cd0a360..11198caff9 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -1,8 +1,9 @@ // npx vitest run core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts import type OpenAI from "openai" +import type { ModeConfig } from "@roo-code/types" -import { filterNativeToolsForMode } from "../filter-tools-for-mode" +import { filterMcpToolsForMode, filterNativeToolsForMode } from "../filter-tools-for-mode" function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { return { @@ -90,6 +91,231 @@ describe("filterNativeToolsForMode - disabledTools", () => { }) }) +describe("filterNativeToolsForMode - settings round-trips", () => { + const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [makeTool("read_file"), makeTool("update_todo_list")] + + function resultNames(result: OpenAI.Chat.ChatCompletionTool[]): string[] { + return result.map((t) => ("function" in t && t.function ? t.function.name : "")) + } + + it("works when the settings argument is omitted entirely", () => { + // settings?.disabledTools / settings?.todoListEnabled must tolerate an + // absent settings object rather than dereferencing it. + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined) + expect(resultNames(result)).toContain("read_file") + }) + + it("applies settings.todoListEnabled=false to the native tool set", () => { + const without = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, { + todoListEnabled: false, + }) + expect(resultNames(without)).not.toContain("update_todo_list") + expect(resultNames(without)).toContain("read_file") + + const enabled = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, { + todoListEnabled: true, + }) + expect(resultNames(enabled)).toContain("update_todo_list") + }) + + it("keeps todoListEnabled=undefined as enabled (default semantics)", () => { + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, {}) + expect(resultNames(result)).toContain("update_todo_list") + }) + + it("tolerates a modelInfo without an includedTools property", () => { + // resolveModelAliasRenames guards with `modelInfo?.includedTools?.length`; + // a present-but-incomplete modelInfo must take the early-return path rather + // than dereferencing the missing property. + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, { + modelInfo: {}, + }) + expect(resultNames(result)).toContain("read_file") + expect(resultNames(result)).toContain("update_todo_list") + }) +}) + +describe("filterNativeToolsForMode - alias renaming", () => { + function resultNames(result: OpenAI.Chat.ChatCompletionTool[]): string[] { + return result.map((t) => ("function" in t && t.function ? t.function.name : "")) + } + + it("renames an allowed canonical tool to its alias from includedTools", () => { + // "search_and_replace" is an alias of the opt-in custom tool "edit"; listing + // it in modelInfo.includedTools both enables "edit" and renames it, so the + // advertised definition must carry the alias name, not the canonical one. + const nativeTools = [makeTool("edit")] + const settings = { modelInfo: { includedTools: ["search_and_replace"] } } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + expect(resultNames(result)).toEqual(["search_and_replace"]) + }) + + it("keeps non-aliased tool definitions identical (no needless copies)", () => { + // A canonical name in includedTools is not an alias; the tool must be passed + // through as the exact same definition object rather than renamed/copied. + const readFileTool = makeTool("read_file") + const settings = { modelInfo: { includedTools: ["read_file"] } } + + const result = filterNativeToolsForMode([readFileTool], "code", undefined, undefined, undefined, settings) + + expect(result).toHaveLength(1) + expect(result[0]).toBe(readFileTool) + }) + + it("does not advertise an alias whose canonical tool is not allowed", () => { + // "edit" needs the edit group; a read-only mode must drop it even when the + // alias is requested through includedTools. + const nativeTools = [makeTool("edit"), makeTool("read_file")] + const settings = { modelInfo: { includedTools: ["search_and_replace"] } } + const readOnlyMode: ModeConfig = { + slug: "read-only", + name: "Read Only", + roleDefinition: "", + groups: ["read"], + } + + const result = filterNativeToolsForMode( + nativeTools, + "read-only", + [readOnlyMode], + undefined, + undefined, + settings, + ) + + const names = resultNames(result) + expect(names).not.toContain("search_and_replace") + expect(names).not.toContain("edit") + expect(names).toContain("read_file") + }) + + it("reuses the cached renamed definition for repeated calls", () => { + // Uses the write_file pair exclusively: the module-level rename cache is + // shared across tests in this file, so the first call below must be the one + // that stores the entry (dropping the cache write would return fresh objects). + const nativeTools = [makeTool("write_to_file")] + const settings = { modelInfo: { includedTools: ["write_file"] } } + + const first = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + const second = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + expect(resultNames(first)).toEqual(["write_file"]) + expect(second[0]).toBe(first[0]) + }) + + it("keeps separate cache entries per canonical/alias pair", () => { + // Two different renames must not collide in the rename cache: each advertised + // tool carries its own alias name. + const nativeTools = [makeTool("edit"), makeTool("write_to_file")] + const settings = { modelInfo: { includedTools: ["search_and_replace", "write_file"] } } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + expect(resultNames(result).sort()).toEqual(["search_and_replace", "write_file"]) + }) + + it("skips non-function (custom) tool definitions without throwing", () => { + // The filter loop only inspects definitions that carry a function schema; + // a custom tool definition must be dropped, not dereferenced. + const customTool: OpenAI.Chat.ChatCompletionTool = { type: "custom", custom: { name: "custom_tool" } } + const nativeTools = [makeTool("read_file"), customTool] + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, {}) + + expect(resultNames(result)).toEqual(["read_file"]) + }) + + it("skips a malformed function definition whose schema is missing", () => { + // Defensive branch: a definition that declares the "function" key but carries + // a nullish schema must be skipped by the loop guard rather than dereferenced. + // The double assertion is required because the SDK types forbid this shape. + const malformedTool = { + ...makeTool("broken_tool"), + function: undefined, + } as unknown as OpenAI.Chat.ChatCompletionTool + const nativeTools = [makeTool("read_file"), malformedTool] + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, {}) + + expect(resultNames(result)).toEqual(["read_file"]) + }) +}) + +describe("filterMcpToolsForMode", () => { + const mcpTools = [makeTool("mcp_server_tool")] + + it("returns the MCP tools for a mode whose groups include mcp", () => { + expect(filterMcpToolsForMode(mcpTools, "code", undefined, undefined)).toBe(mcpTools) + }) + + it("returns the MCP tools when the mode is undefined (default-mode fallback)", () => { + // `mode ?? defaultModeSlug` must fall back to the default mode (code), which + // allows use_mcp_tool. + expect(filterMcpToolsForMode(mcpTools, undefined, undefined, undefined)).toBe(mcpTools) + }) + + it("returns an empty array for a mode without the mcp group", () => { + const readOnlyMode: ModeConfig = { + slug: "read-only", + name: "Read Only", + roleDefinition: "", + groups: ["read"], + } + expect(filterMcpToolsForMode(mcpTools, "read-only", [readOnlyMode], undefined)).toEqual([]) + }) + + it("resolves a custom mode from the customModes argument", () => { + // The customModes array must be forwarded to the permission check: the mode + // slug only exists in the custom list. + const mcpCustomMode: ModeConfig = { + slug: "custom-mcp", + name: "Custom MCP", + roleDefinition: "", + groups: ["mcp"], + } + expect(filterMcpToolsForMode(mcpTools, "custom-mcp", [mcpCustomMode], undefined)).toBe(mcpTools) + }) + + it("accepts experiment flags without affecting the result", () => { + expect(filterMcpToolsForMode(mcpTools, "code", undefined, { imageGeneration: true })).toBe(mcpTools) + }) + + it("returns an empty array when disabledTools disables use_mcp_tool even though the mode allows it", () => { + // The matching entry sits among unrelated ones: suppression is a + // membership test, not a demand that the whole list match. + expect( + filterMcpToolsForMode(mcpTools, "code", undefined, undefined, { + disabledTools: ["web_fetch", "use_mcp_tool"], + }), + ).toEqual([]) + }) + + it("returns an empty array when modelInfo.excludedTools excludes use_mcp_tool", () => { + const modelInfo = { + contextWindow: 128_000, + supportsPromptCache: false, + excludedTools: ["edit", "use_mcp_tool"], + } + expect(filterMcpToolsForMode(mcpTools, "code", undefined, undefined, { modelInfo })).toEqual([]) + }) + + it("returns the MCP tools when disabledTools lists an unrelated tool", () => { + expect(filterMcpToolsForMode(mcpTools, "code", undefined, undefined, { disabledTools: ["web_fetch"] })).toBe( + mcpTools, + ) + }) + + it("returns the MCP tools when both policy lists are set but neither names use_mcp_tool", () => { + const settings = { + disabledTools: ["web_fetch"], + modelInfo: { contextWindow: 128_000, supportsPromptCache: false, excludedTools: ["edit"] }, + } + expect(filterMcpToolsForMode(mcpTools, "code", undefined, undefined, settings)).toBe(mcpTools) + }) +}) + describe("filterNativeToolsForMode - access_mcp_resource allowlist", () => { const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [makeTool("read_file"), makeTool("access_mcp_resource")] diff --git a/src/core/prompts/tools/effective-tool-policy.ts b/src/core/prompts/tools/effective-tool-policy.ts new file mode 100644 index 0000000000..534f646882 --- /dev/null +++ b/src/core/prompts/tools/effective-tool-policy.ts @@ -0,0 +1,361 @@ +import type { ModeConfig, ToolGroup, ModelInfo, GroupEntry } from "@roo-code/types" +import { getModeBySlug, defaultModeSlug, getGroupName, getToolsForMode } from "../../../shared/modes" +import { TOOL_ALIASES, TOOL_GROUPS } from "../../../shared/tools" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" + +type EffectiveMcpHub = { + getServers(): Array<{ + name: string + resources?: Array<{ uri: string; name?: string }> + tools?: Array<{ enabledForPrompt?: boolean }> + }> +} + +/** + * Canonical tool names that participate in the task-completion protocol. + * + * The effective tool policy re-adds these after the mode/permission filters, so a + * mode that grants no groups still advertises them — but a `disabledTools` entry + * or a model `excludedTools` entry takes precedence: honoring an explicit + * restriction takes priority over the re-add, and the runtime validator rejects + * execution of a tool so restricted (see `buildToolRequirements` and the + * requirements-before-always-available precedence in `validateToolUse.ts`). + * + * `attempt_completion` is the only tool with no coherent prompt state when absent + * (the task loop can only exit through it), so it is the sole protocol entry. + */ +export const PROTOCOL_TOOLS: readonly string[] = ["attempt_completion"] + +/** + * Extract the first edit restriction declared by a mode's groups, if any. + * + * A group entry may be either a bare group name (string) or a tuple of + * `[groupName, options]`. Only a tuple entry with a `fileRegex` establishes a + * prompt-visible edit restriction. + * + * Returning only the first restriction is intentional: the mode schema rejects + * duplicate groups (the `rawGroupEntryArraySchema` refine in + * `packages/types/src/mode.ts`), so a mode can declare at most one `edit` group + * with a `fileRegex`; and the runtime validator (`validateToolUse.ts`) likewise + * returns at the first matching group, so the prompt and the validator agree. + * + * @param groups The mode's group entries. + * @returns The first `{ fileRegex, description }` found, or undefined when the + * mode declares no restricted edit group. + */ +function getEditRestriction(groups: readonly GroupEntry[]): + | { + fileRegex: string + description?: string + } + | undefined { + for (const group of groups) { + const groupName = getGroupName(group) + if (groupName !== "edit") { + continue + } + if (Array.isArray(group) && group[1]?.fileRegex) { + return { fileRegex: group[1].fileRegex, description: group[1].description } + } + } + return undefined +} + +/** + * Reverse lookup map - maps alias name to canonical tool name. + * Built once at module load from the central TOOL_ALIASES constant. + */ +const ALIAS_TO_CANONICAL: Map = new Map( + Object.entries(TOOL_ALIASES).map(([alias, canonical]) => [alias, canonical]), +) + +/** + * Resolves a tool name to its canonical name. + * If the tool name is an alias, returns the canonical tool name. + * If it's already a canonical name or unknown, returns as-is. + * + * @param toolName - The tool name to resolve (may be an alias) + * @returns The canonical tool name + */ +export function resolveToolAlias(toolName: string): string { + const canonical = ALIAS_TO_CANONICAL.get(toolName) + return canonical ?? toolName +} + +/** + * True when `toolName` is suppressed by the user's `disabledTools` list or the + * model's `excludedTools` customization, comparing alias-resolved names exactly + * as the resolver's exclusion steps do. + * + * This is the membership test behind those resolver steps, exposed for callers + * (the MCP tool filter, the protocol-tool re-add step) that gate a whole tool + * class on one canonical name without computing the full policy set. It answers + * "is it listed", which is deliberately stricter than "is it finally available" + * for tools that the resolver's later steps could re-grant through + * `includedTools` or group membership. + * + * @param toolName The canonical tool name to test (may itself be an alias). + * @param disabledTools The user's disabled-tools list (may contain aliases). + * @param modelInfo The model customization whose `excludedTools` may list it. + * @returns True when either list suppresses the tool. + */ +export function isToolDisabledOrExcluded( + toolName: string, + disabledTools: string[] | undefined, + modelInfo: ModelInfo | undefined, +): boolean { + const canonical = resolveToolAlias(toolName) + const isSuppressed = (entry: string): boolean => resolveToolAlias(entry) === canonical + return Boolean(disabledTools?.some(isSuppressed)) || Boolean(modelInfo?.excludedTools?.some(isSuppressed)) +} + +export interface EffectiveToolPolicyInput { + mode: string + customModes?: ModeConfig[] + mcpHub?: EffectiveMcpHub + disabledTools?: string[] + modelInfo?: ModelInfo + experiments?: Record + todoListEnabled?: boolean + codeIndexManager?: CodeIndexManager + /** + * Optional explicit per-mode MCP server allowlist. When provided it takes + * precedence; when omitted the resolver falls back to the mode config's own + * allowlist (defense in depth), so a restricted mode can never retain + * `access_mcp_resource` based on resources from disallowed servers. + */ + allowedMcpServers?: string[] +} + +export interface EffectiveToolPolicy { + /** Canonical tool names logically available for this request (after all filters, incl. protocol guarantee) */ + tools: ReadonlySet + hasMcpGroup: boolean // mode's groups include "mcp" + hasMcpTools: boolean // ≥1 dynamic MCP tool enabled for allowed servers + hasMcpResources: boolean // ≥1 accessible resource on allowed servers + /** + * The mode's first edit-group file restriction. First-only is intentional: + * the mode schema rejects duplicate groups, so at most one `edit` group can + * carry a `fileRegex`, and the runtime validator likewise stops at the first + * matching group — prompt and validator agree. + */ + editRestriction?: { fileRegex: string; description?: string } +} + +/** + * True when at least one dynamic MCP tool (e.g. `mcp_serverName_toolName`) is + * enabled for the allowed servers. Used both to gate the MCP capability bullet in + * the prompt and to prune `use_mcp_tool` from the policy's tool set, so servers + * whose every tool is `enabledForPrompt: false` do not count. + * + * Cheap existence check: it inspects the MCP server snapshot directly (allowlist + * + `enabledForPrompt !== false`, mirroring the `getMcpServerTools` filter) and + * never materializes or normalizes tool schemas. + * + * @param mcpHub The MCP hub, or undefined when MCP is unavailable (always false). + * @param allowedServers Optional per-mode server allowlist; when provided only + * these servers are considered. + * @returns True when at least one allowed server exposes a prompt-enabled tool. + */ +function resolveHasMcpTools(mcpHub?: EffectiveMcpHub, allowedServers?: string[]): boolean { + if (!mcpHub) { + return false + } + let servers = mcpHub.getServers() + if (allowedServers) { + const allowSet = new Set(allowedServers) + servers = servers.filter((server) => allowSet.has(server.name)) + } + return servers.some((server) => server.tools?.some((tool) => tool.enabledForPrompt !== false)) +} + +/** + * True when `mcpHub` exposes at least one accessible resource on the allowed servers. + * + * When `allowedServers` is provided, only servers whose name is in the allowlist + * are considered, keeping the `access_mcp_resource` availability check consistent + * with the mode's MCP server allowlist. + * + * @param mcpHub The MCP hub whose server snapshot is inspected. + * @param allowedServers Optional per-mode server allowlist; when provided only + * these servers are considered. + * @returns True when at least one allowed server exposes one or more resources. + */ +function hasAnyMcpResources(mcpHub: EffectiveMcpHub, allowedServers?: string[]): boolean { + let servers = mcpHub.getServers() + if (allowedServers) { + const allowSet = new Set(allowedServers) + servers = servers.filter((server) => allowSet.has(server.name)) + } + return servers.some((server) => server.resources && server.resources.length > 0) +} + +/** + * Computes the request-scoped effective tool policy: the set of tool names + * logically available for a single request, together with the MCP and edit + * metadata the system prompt needs. + * + * This is the single source of truth shared by prompt generation, API tool + * construction, runtime validation, and preview. The numbered steps below (1-10) + * compute the allowed tool set; step 11 re-adds `PROTOCOL_TOOLS` unless an + * explicit disable/exclude suppresses them. + * + * The returned policy is deterministic for a given input and free of side + * effects. + * + * @param input Mode, custom modes, MCP hub, disabled tools, model customization, + * experiment flags, todo-list enablement, and the code index manager. + * @returns An {@link EffectiveToolPolicy} describing the effective tool set. + */ +export function resolveEffectiveToolPolicy(input: EffectiveToolPolicyInput): EffectiveToolPolicy { + const { + mode, + customModes, + mcpHub, + disabledTools, + modelInfo, + experiments, + todoListEnabled, + codeIndexManager, + allowedMcpServers, + } = input + + // 1. Resolve mode config with default-slug fallback (existing behavior). + const modeSlug = mode ?? defaultModeSlug + const modeConfig = getModeBySlug(modeSlug, customModes) || getModeBySlug(defaultModeSlug, customModes)! + + // 2. Start from all tools granted by the mode's groups (including always-available tools). + const allowedToolNames = new Set(getToolsForMode(modeConfig.groups)) + + // 3. Filter through per-mode permission checks (feature/experiment flags, custom-mode overrides). + for (const tool of Array.from(allowedToolNames)) { + if (!isToolAllowedForMode(tool, modeSlug, customModes ?? [], undefined, undefined, experiments ?? {})) { + allowedToolNames.delete(tool) + } + } + + // 4. Apply model-specific tool customization (excluded tools removed; included tools added only when their group is allowed). + if (modelInfo) { + // Exclusions. + if (modelInfo.excludedTools?.length) { + for (const excluded of modelInfo.excludedTools) { + allowedToolNames.delete(resolveToolAlias(excluded)) + } + } + // Inclusions: only tools belonging to an allowed group are added. + if (modelInfo.includedTools?.length) { + const toolToGroup = new Map() + for (const [groupName, groupConfig] of Object.entries(TOOL_GROUPS)) { + groupConfig.tools.forEach((tool) => toolToGroup.set(tool, groupName as ToolGroup)) + groupConfig.customTools?.forEach((tool) => toolToGroup.set(tool, groupName as ToolGroup)) + } + + const allowedGroups = new Set( + modeConfig.groups.map((groupEntry: GroupEntry) => + Array.isArray(groupEntry) ? groupEntry[0] : groupEntry, + ), + ) + + for (const included of modelInfo.includedTools) { + const resolvedTool = resolveToolAlias(included) + const toolGroup = toolToGroup.get(resolvedTool) + if (toolGroup && allowedGroups.has(toolGroup)) { + allowedToolNames.add(resolvedTool) + } + } + } + } + + // 5. Drop codebase_search unless the code index is enabled, configured, and initialized. + if ( + !codeIndexManager || + !(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) + ) { + allowedToolNames.delete("codebase_search") + } + + // 6. Drop update_todo_list when the todo list is disabled. + if (todoListEnabled === false) { + allowedToolNames.delete("update_todo_list") + } + + // 7. Drop generate_image unless the image-generation experiment is enabled. + if (experiments?.imageGeneration !== true) { + allowedToolNames.delete("generate_image") + } + + // 8. Drop run_slash_command unless the run-slash-command experiment is enabled. + if (experiments?.runSlashCommand !== true) { + allowedToolNames.delete("run_slash_command") + } + + // 9. Drop disabledTools entries (alias-resolved). + if (disabledTools?.length) { + for (const toolName of disabledTools) { + allowedToolNames.delete(resolveToolAlias(toolName)) + } + } + + // 10. Drop the MCP group tools unless allowed servers actually expose them. + // Fall back to the mode config's own allowlist when the caller omits the + // parameter, so the restriction is enforced regardless of call site + // (defense in depth). `getToolsForMode` grants both group tools together, so + // each is pruned independently: `access_mcp_resource` when no allowed server + // exposes resources, and `use_mcp_tool` when no allowed server exposes a + // prompt-enabled tool (mirrors `getMcpServerTools`, which would emit none). + const effectiveAllowedMcpServers = allowedMcpServers ?? modeConfig.allowedMcpServers + const hasMcpResources = !!mcpHub && hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers) + if (!hasMcpResources) { + allowedToolNames.delete("access_mcp_resource") + } + const hasMcpTools = resolveHasMcpTools(mcpHub, effectiveAllowedMcpServers) + if (!hasMcpTools) { + allowedToolNames.delete("use_mcp_tool") + } + + // 11. Protocol guarantee: re-add every protocol tool that neither the user's + // disabledTools nor the model's excludedTools suppresses, so the logical + // set and the runtime validator agree in both directions: an unlisted + // protocol tool stays callable — this re-add, not the always-available + // roster, is what guarantees it — while a suppressed one stays out of the + // prompt, the declarations, and (via buildToolRequirements) execution, + // having been removed by steps 4 and 9. + for (const tool of PROTOCOL_TOOLS) { + if (!isToolDisabledOrExcluded(tool, disabledTools, modelInfo)) { + allowedToolNames.add(resolveToolAlias(tool)) + } + } + + const hasMcpGroup = modeConfig.groups.some((groupEntry: GroupEntry) => getGroupName(groupEntry) === "mcp") + + return { + tools: allowedToolNames, + hasMcpGroup, + hasMcpTools, + hasMcpResources, + editRestriction: getEditRestriction(modeConfig.groups), + } +} + +/** + * Builds the runtime `toolRequirements` map (tool name → false) from every entry + * in the user and model exclusion lists. + * A requirements entry outranks the always-available class in `validateToolUse`, + * so every disabled or model-excluded tool is rejected at execution with the + * standard validation error tool_result, matching its removal from the policy. + * + * @param disabledTools The raw disabled-tools list (may contain aliases). + * @param modelInfo The model customization whose `excludedTools` may suppress a + * protocol tool. + * @returns A map of suppressed canonical/alias names to `false`. + */ +export function buildToolRequirements(disabledTools?: string[], modelInfo?: ModelInfo): Record { + const requirements: Record = {} + for (const toolName of [...(disabledTools ?? []), ...(modelInfo?.excludedTools ?? [])]) { + const canonical = resolveToolAlias(toolName) + requirements[toolName] = false + requirements[canonical] = false + } + return requirements +} diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..45ccb39c5d 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,49 +1,14 @@ import type OpenAI from "openai" -import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types" -import { getModeBySlug, getToolsForMode } from "../../../shared/modes" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" +import type { ModeConfig, ModelInfo } from "@roo-code/types" import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" +import { resolveEffectiveToolPolicy, resolveToolAlias, isToolDisabledOrExcluded } from "./effective-tool-policy" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" -/** - * Reverse lookup map - maps alias name to canonical tool name. - * Built once at module load from the central TOOL_ALIASES constant. - */ -const ALIAS_TO_CANONICAL: Map = new Map( - Object.entries(TOOL_ALIASES).map(([alias, canonical]) => [alias, canonical]), -) - -/** - * Canonical to aliases map - maps canonical tool name to array of alias names. - * Built once at module load from the central TOOL_ALIASES constant. - */ -const CANONICAL_TO_ALIASES: Map = new Map() - -// Build the reverse mapping (canonical -> aliases) -for (const [alias, canonical] of Object.entries(TOOL_ALIASES)) { - const existing = CANONICAL_TO_ALIASES.get(canonical) ?? [] - existing.push(alias) - CANONICAL_TO_ALIASES.set(canonical, existing) -} - -/** - * Pre-computed alias groups map - maps any tool name (canonical or alias) to its full group. - * Built once at module load for O(1) lookup. - */ -const ALIAS_GROUPS: Map = new Map() - -// Build alias groups for all tools -for (const [canonical, aliases] of CANONICAL_TO_ALIASES.entries()) { - const group = Object.freeze([canonical, ...aliases]) - // Map canonical to group - ALIAS_GROUPS.set(canonical, group) - // Map each alias to the same group - for (const alias of aliases) { - ALIAS_GROUPS.set(alias, group) - } -} +// Re-exported so this module remains a stable import site for the canonical +// alias resolver; the implementation lives in effective-tool-policy.ts. +export { resolveToolAlias } /** * Cache for renamed tool definitions. @@ -85,130 +50,6 @@ function getOrCreateRenamedTool( return renamedTool } -/** - * Resolves a tool name to its canonical name. - * If the tool name is an alias, returns the canonical tool name. - * If it's already a canonical name or unknown, returns as-is. - * - * @param toolName - The tool name to resolve (may be an alias) - * @returns The canonical tool name - */ -export function resolveToolAlias(toolName: string): string { - const canonical = ALIAS_TO_CANONICAL.get(toolName) - return canonical ?? toolName -} - -/** - * Applies tool alias resolution to a set of allowed tools. - * Resolves any aliases to their canonical tool names. - * - * @param allowedTools - Set of tools that may contain aliases - * @returns Set with aliases resolved to canonical names - */ -export function applyToolAliases(allowedTools: Set): Set { - const result = new Set() - - for (const tool of allowedTools) { - // Resolve alias to canonical name - result.add(resolveToolAlias(tool)) - } - - return result -} - -/** - * Gets all tools in an alias group (including the canonical tool). - * Uses pre-computed ALIAS_GROUPS map for O(1) lookup. - * - * @param toolName - Any tool name in the alias group - * @returns Array of all tool names in the alias group, or just the tool if not aliased - */ -export function getToolAliasGroup(toolName: string): readonly string[] { - return ALIAS_GROUPS.get(toolName) ?? [toolName] -} - -/** - * Apply model-specific tool customization to a set of allowed tools. - * - * This function filters tools based on model configuration: - * 1. Removes tools specified in modelInfo.excludedTools - * 2. Adds tools from modelInfo.includedTools (only if they belong to allowed groups) - * - * @param allowedTools - Set of tools already allowed by mode configuration - * @param modeConfig - Current mode configuration to check tool groups - * @param modelInfo - Model configuration with tool customization - * @returns Modified set of tools after applying model customization - */ -/** - * Result of applying model tool customization. - * Contains the set of allowed tools and any alias renames to apply. - */ -interface ModelToolCustomizationResult { - allowedTools: Set - /** Maps canonical tool name to alias name for tools that should be renamed */ - aliasRenames: Map -} - -export function applyModelToolCustomization( - allowedTools: Set, - modeConfig: ModeConfig, - modelInfo?: ModelInfo, -): ModelToolCustomizationResult { - if (!modelInfo) { - return { allowedTools, aliasRenames: new Map() } - } - - const result = new Set(allowedTools) - const aliasRenames = new Map() - - // Apply excluded tools (remove from allowed set) - if (modelInfo.excludedTools && modelInfo.excludedTools.length > 0) { - modelInfo.excludedTools.forEach((tool) => { - const resolvedTool = resolveToolAlias(tool) - result.delete(resolvedTool) - }) - } - - // Apply included tools (add to allowed set, but only if they belong to an allowed group) - if (modelInfo.includedTools && modelInfo.includedTools.length > 0) { - // Build a map of tool -> group for all tools in TOOL_GROUPS (including customTools) - const toolToGroup = new Map() - for (const [groupName, groupConfig] of Object.entries(TOOL_GROUPS)) { - // Add regular tools - groupConfig.tools.forEach((tool) => { - toolToGroup.set(tool, groupName as ToolGroup) - }) - // Add customTools (opt-in only tools) - if (groupConfig.customTools) { - groupConfig.customTools.forEach((tool) => { - toolToGroup.set(tool, groupName as ToolGroup) - }) - } - } - - // Get the list of allowed groups for this mode - const allowedGroups = new Set( - modeConfig.groups.map((groupEntry) => (Array.isArray(groupEntry) ? groupEntry[0] : groupEntry)), - ) - - // Add included tools only if they belong to an allowed group - // If the tool was specified as an alias, track the rename - modelInfo.includedTools.forEach((tool) => { - const resolvedTool = resolveToolAlias(tool) - const toolGroup = toolToGroup.get(resolvedTool) - if (toolGroup && allowedGroups.has(toolGroup)) { - result.add(resolvedTool) - // If the tool was specified as an alias, rename it in the API - if (tool !== resolvedTool) { - aliasRenames.set(resolvedTool, tool) - } - } - }) - } - - return { allowedTools: result, aliasRenames } -} - /** * Filters native tools based on mode restrictions and model customization. * This ensures native tools are filtered consistently with mode/tool permissions. @@ -235,94 +76,38 @@ export function filterNativeToolsForMode( mcpHub?: McpHub, allowedMcpServers?: string[], ): OpenAI.Chat.ChatCompletionTool[] { - // Get mode configuration and all tools for this mode - const modeSlug = mode ?? defaultModeSlug - let modeConfig = getModeBySlug(modeSlug, customModes) - - // Fallback to default mode if current mode config is not found - // This ensures the agent always has functional tools even if a custom mode is deleted - // or configuration becomes corrupted - if (!modeConfig) { - modeConfig = getModeBySlug(defaultModeSlug, customModes)! - } - - // Get all tools for this mode (including always-available tools) - const allToolsForMode = getToolsForMode(modeConfig.groups) - - // Filter to only tools that pass permission checks - let allowedToolNames = new Set( - allToolsForMode.filter((tool) => - isToolAllowedForMode( - tool as ToolName, - modeSlug, - customModes ?? [], - undefined, - undefined, - experiments ?? {}, - ), - ), - ) - - // Apply model-specific tool customization + // Resolve the single, request-scoped effective tool policy. The filter below + // consumes only its `tools` set (plus alias renames from model customization), + // so prompt generation and API tool construction agree on the logical allowed + // set — including the protocol-tool rule: unlisted, attempt_completion is + // advertised; listed in disabledTools/excludedTools, it is not. const modelInfo = settings?.modelInfo as ModelInfo | undefined - const { allowedTools: customizedTools, aliasRenames } = applyModelToolCustomization( - allowedToolNames, - modeConfig, - modelInfo, - ) - allowedToolNames = customizedTools - - // Conditionally exclude codebase_search if feature is disabled or not configured - if ( - !codeIndexManager || - !(codeIndexManager.isFeatureEnabled && codeIndexManager.isFeatureConfigured && codeIndexManager.isInitialized) - ) { - allowedToolNames.delete("codebase_search") - } - - // Conditionally exclude update_todo_list if disabled in settings - if (settings?.todoListEnabled === false) { - allowedToolNames.delete("update_todo_list") - } - - // Conditionally exclude generate_image if experiment is not enabled - if (!experiments?.imageGeneration) { - allowedToolNames.delete("generate_image") - } - - // Conditionally exclude run_slash_command if experiment is not enabled - if (!experiments?.runSlashCommand) { - allowedToolNames.delete("run_slash_command") - } - - // Remove tools that are explicitly disabled via the disabledTools setting - if (settings?.disabledTools?.length) { - for (const toolName of settings.disabledTools) { - // Normalize aliases so disabling a legacy alias (e.g. "search_and_replace") - // also disables the canonical tool (e.g. "edit"). - const resolvedToolName = resolveToolAlias(toolName) - allowedToolNames.delete(resolvedToolName) - } - } - // Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources. - // When the mode restricts MCP servers via allowedMcpServers, only resources from allowed - // servers count — otherwise a restricted mode could still read resources from disallowed servers. - // Fall back to the mode config's own allowlist when the caller omits the parameter, so the - // restriction is enforced regardless of call site (defense in depth). - const effectiveAllowedMcpServers = allowedMcpServers ?? modeConfig.allowedMcpServers - if (!mcpHub || !hasAnyMcpResources(mcpHub, effectiveAllowedMcpServers)) { - allowedToolNames.delete("access_mcp_resource") - } - - // Filter native tools based on allowed tool names and apply alias renames + const policy = resolveEffectiveToolPolicy({ + mode: mode ?? defaultModeSlug, + customModes, + mcpHub, + disabledTools: settings?.disabledTools, + modelInfo, + experiments, + todoListEnabled: settings?.todoListEnabled, + codeIndexManager, + allowedMcpServers, + }) + + // Apply model-specific alias renames (canonical -> alias) to the allowed set. + // Included-tools customization may rename a tool to the alias the caller asked + // for; excluded/always-available semantics are already resolved by the resolver. + const aliasRenames = resolveModelAliasRenames(modelInfo, policy.tools) + + // Filter native tools based on the allowed tool names and apply alias renames const filteredTools: OpenAI.Chat.ChatCompletionTool[] = [] for (const tool of nativeTools) { // Handle both ChatCompletionTool and ChatCompletionCustomTool if ("function" in tool && tool.function) { const toolName = tool.function.name - if (allowedToolNames.has(toolName)) { + if (policy.tools.has(resolveToolAlias(toolName))) { // Check if this tool should be renamed to an alias const aliasName = aliasRenames.get(toolName) if (aliasName) { @@ -339,116 +124,39 @@ export function filterNativeToolsForMode( } /** - * Helper function to check if any MCP server has resources available. - * - * When `allowedServers` is provided, only servers whose name is in the allowlist are considered. - * This keeps the `access_mcp_resource` availability check consistent with the mode's MCP server - * allowlist so a restricted mode cannot retain the tool based on resources from disallowed servers. + * Computes canonical -> alias renames from model-specific included-tools + * customization, but only for tools that remain in the effective policy's allowed + * set (exclusions are already applied by the resolver). An alias listed in + * includedTools renames the canonical tool to that alias. */ -function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean { - let servers = mcpHub.getServers() - if (allowedServers) { - const allowSet = new Set(allowedServers) - servers = servers.filter((server) => allowSet.has(server.name)) +function resolveModelAliasRenames( + modelInfo: ModelInfo | undefined, + allowedTools: ReadonlySet, +): Map { + const aliasRenames = new Map() + if (!modelInfo?.includedTools?.length) { + return aliasRenames } - return servers.some((server) => server.resources && server.resources.length > 0) -} - -/** - * Checks if a specific tool is allowed in the current mode. - * This is useful for dynamically filtering system prompt content. - * - * @param toolName - Name of the tool to check - * @param mode - Current mode slug - * @param customModes - Custom mode configurations - * @param experiments - Experiment flags - * @param codeIndexManager - Code index manager for codebase_search feature check - * @param settings - Additional settings for tool filtering - * @returns true if the tool is allowed in the mode, false otherwise - */ -export function isToolAllowedInMode( - toolName: ToolName, - mode: string | undefined, - customModes: ModeConfig[] | undefined, - experiments: Record | undefined, - codeIndexManager?: CodeIndexManager, - settings?: Record, -): boolean { - const modeSlug = mode ?? defaultModeSlug - - // Check if it's an always-available tool - if (ALWAYS_AVAILABLE_TOOLS.includes(toolName)) { - // But still check for conditional exclusions - if (toolName === "codebase_search") { - return !!( - codeIndexManager && - codeIndexManager.isFeatureEnabled && - codeIndexManager.isFeatureConfigured && - codeIndexManager.isInitialized - ) - } - if (toolName === "update_todo_list") { - return settings?.todoListEnabled !== false - } - if (toolName === "generate_image") { - return experiments?.imageGeneration === true - } - if (toolName === "run_slash_command") { - return experiments?.runSlashCommand === true + for (const included of modelInfo.includedTools) { + const canonical = resolveToolAlias(included) + if (canonical !== included && allowedTools.has(canonical)) { + aliasRenames.set(canonical, included) } - return true } - - // Check if the tool is allowed by the mode's groups - // Resolve to canonical name and check that single value - const canonicalTool = resolveToolAlias(toolName) - return isToolAllowedForMode( - canonicalTool as ToolName, - modeSlug, - customModes ?? [], - undefined, - undefined, - experiments ?? {}, - ) + return aliasRenames } /** - * Gets the list of available tools from a specific tool group for the current mode. - * This is useful for dynamically building system prompt content based on available tools. - * - * @param groupName - Name of the tool group to check - * @param mode - Current mode slug - * @param customModes - Custom mode configurations - * @param experiments - Experiment flags - * @param codeIndexManager - Code index manager for codebase_search feature check - * @param settings - Additional settings for tool filtering - * @returns Array of tool names that are available from the group - */ -export function getAvailableToolsInGroup( - groupName: ToolGroup, - mode: string | undefined, - customModes: ModeConfig[] | undefined, - experiments: Record | undefined, - codeIndexManager?: CodeIndexManager, - settings?: Record, -): ToolName[] { - const toolGroup = TOOL_GROUPS[groupName] - if (!toolGroup) { - return [] - } - - return toolGroup.tools.filter((tool) => - isToolAllowedInMode(tool as ToolName, mode, customModes, experiments, codeIndexManager, settings), - ) as ToolName[] -} - -/** - * Filters MCP tools based on whether use_mcp_tool is allowed in the current mode. + * Filters MCP tools based on whether use_mcp_tool is allowed in the current mode + * and not suppressed by the effective tool policy's disabled/excluded lists. * * @param mcpTools - Array of MCP tools * @param mode - Current mode slug * @param customModes - Custom mode configurations * @param experiments - Experiment flags + * @param settings - Optional disabled-tools list and model customization. When + * omitted (or missing these fields) no disabled/excluded policy is known, so + * only the mode check applies. * @returns Filtered array of MCP tools if use_mcp_tool is allowed, empty array otherwise */ export function filterMcpToolsForMode( @@ -456,6 +164,7 @@ export function filterMcpToolsForMode( mode: string | undefined, customModes: ModeConfig[] | undefined, experiments: Record | undefined, + settings?: { disabledTools?: string[]; modelInfo?: ModelInfo }, ): OpenAI.Chat.ChatCompletionTool[] { const modeSlug = mode ?? defaultModeSlug @@ -469,5 +178,12 @@ export function filterMcpToolsForMode( experiments ?? {}, ) - return isMcpAllowed ? mcpTools : [] + // The mode check alone would let every mcp--* declaration reach the provider + // even when the user disabled (or the model excluded) use_mcp_tool, so the + // dynamic declarations must honor the same policy as the native filter. + if (!isMcpAllowed || isToolDisabledOrExcluded("use_mcp_tool", settings?.disabledTools, settings?.modelInfo)) { + return [] + } + + return mcpTools } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 92ee8184d6..55798437c3 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -142,6 +142,17 @@ import { type TaskExecutionContext } from "./providerHandoff" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds +// Upper bound on awaiting lazily loaded model metadata. Some model-catalog +// fetchers (e.g. OpenRouter's bare axios GET) have no request timeout, so a +// hung endpoint must not stall streaming, condense, or context-window +// handling. On expiry the caller aborts its per-call AbortSignal (detaching +// the provider-side waiter) and falls back to the handler's existing +// getModel().info metadata — the same degradation a rejected fetch produces. +// Kept in sync with PREVIEW_MODEL_FETCH_TIMEOUT_MS in the preview path +// (src/core/webview/generateSystemPrompt.ts). Deliberately duplicated, not +// shared: importing from the webview layer would close a +// Task -> generateSystemPrompt -> ClineProvider -> Task circular import. +export const MODEL_FETCH_TIMEOUT_MS = 5_000 const QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS = [250, 1_000, 4_000] as const type QueuedAskResolution = { response: ClineAskResponse; requiresDurableAck: boolean } @@ -313,6 +324,13 @@ export class Task extends EventEmitter implements TaskLike { private readonly globalStoragePath: string abort: boolean = false currentRequestAbortController?: AbortController + /** + * Controller for the waiter on an in-flight `ensureModelFetched()` call (see + * safeEnsureModelFetched). Aborting it detaches this task from the provider-side + * metadata fetch; it is aborted when the bounded wait expires and when the task's + * current request is cancelled (cancel/dispose path in cancelCurrentRequest). + */ + metadataFetchAbortController?: AbortController skipPrevResponseIdOnce: boolean = false // TaskStatus @@ -1852,10 +1870,27 @@ export class Task extends EventEmitter implements TaskLike { // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() - const systemPrompt = await this.getSystemPrompt() + // Capture provider state and one model-info snapshot once and thread them into + // getSystemPrompt so the prompt and the condensing tool array below resolve + // from one snapshot. + const state = await this.providerRef.deref()?.getState() + const requestModelInfo = await this.safeEnsureModelFetched() + + // A cancellation landing during the bounded metadata wait must stop + // manual condensation before any prompt build or summarization request. + if (this.abort || this.abandoned) { + return + } + + const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) + + // A cancellation landing during the prompt build's bounded MCP wait must + // stop manual condensation before any summarization request is issued. + if (this.abort || this.abandoned) { + return + } // Get condensing configuration - const state = await this.providerRef.deref()?.getState() const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() @@ -1867,7 +1902,6 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] if (provider) { - const modelInfo = this.api.getModel().info const toolsResult = await buildNativeToolsArrayWithRestrictions({ provider, cwd: this.cwd, @@ -1876,7 +1910,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, - modelInfo, + modelInfo: requestModelInfo, includeAllToolsWithRestrictions: false, }) allTools = toolsResult.tools @@ -1904,6 +1938,12 @@ export class Task extends EventEmitter implements TaskLike { const filesReadByRoo = await this.getFilesReadByRooSafely("condenseContext") + // A cancellation landing while the summarization inputs are gathered must + // stop manual condensation before any summarization request is issued. + if (this.abort || this.abandoned) { + return + } + const { messages, summary, @@ -1925,6 +1965,11 @@ export class Task extends EventEmitter implements TaskLike { cwd: this.cwd, rooIgnoreController: this.rooIgnoreController, }) + // A cancellation landing during the summarization request must stop + // manual condensation before it replaces and persists the history. + if (this.abort || this.abandoned) { + return + } if (error) { await this.say( "condense_context_error", @@ -2613,6 +2658,12 @@ export class Task extends EventEmitter implements TaskLike { this.currentRequestAbortController.abort() this.currentRequestAbortController = undefined } + // A metadata-fetch waiter still in flight is as stale as an abandoned + // stream: detach it from the provider-side fetch on cancel/dispose. + if (this.metadataFetchAbortController) { + this.metadataFetchAbortController.abort() + this.metadataFetchAbortController = undefined + } } /** @@ -2709,6 +2760,15 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error flushing shutdown telemetry:", error) } + // A task being disposed is no longer serving requests: set the same + // cancellation state `abortTask()` sets, synchronously before the aborts + // below, so the request-construction guard (`abort || abandoned` in + // attemptApiRequest) and the outer loop's `abort` checks observe disposal + // even when it lands before any explicit cancel. Without this, only the + // signals below are cancelled and a request already past those checks + // could still build tools and call `createMessage()`. + this.abort = true + // Cancel any in-progress HTTP request try { this.cancelCurrentRequest() @@ -3199,7 +3259,10 @@ export class Task extends EventEmitter implements TaskLike { // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). - const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true }) + const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { + skipProviderRateLimit: true, + requestModelInfo: streamModelInfo, + }) let assistantMessage = "" let reasoningMessage = "" const pendingGroundingSources: GroundingSource[] = [] @@ -4161,8 +4224,26 @@ export class Task extends EventEmitter implements TaskLike { return false } - private async getSystemPrompt(): Promise { - const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {} + /** + * Builds the SYSTEM_PROMPT from the caller's provider-state snapshot. This + * method never reads provider state itself: callers that also construct + * runtime tools for the same request (attemptApiRequest, condenseContext, + * handleContextWindowExceededError) must thread the very snapshot they build + * those tools from, so the prompt and the runtime tool array resolve from one + * consistent set of values — otherwise a settings change during the MCP wait + * can make the prompt advertise a tool the runtime rejects, or hide a + * callable tool. An `undefined` snapshot declares that the caller's own read + * came back empty because the provider was already gone; the prompt then + * resolves from defaults. Pass `requestModelInfo` (captured via + * safeEnsureModelFetched) in the same situation so the prompt's tool + * guidance and the request's tool arrays resolve from one model-metadata + * snapshot. + */ + private async getSystemPrompt( + requestState: Awaited> | undefined, + requestModelInfo?: ModelInfo, + ): Promise { + const { mcpEnabled } = requestState ?? {} let mcpHub: McpHub | undefined if (mcpEnabled ?? true) { const provider = this.providerRef.deref() @@ -4186,10 +4267,8 @@ export class Task extends EventEmitter implements TaskLike { const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() - const state = await this.providerRef.deref()?.getState() - const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = - state ?? {} + requestState ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() const apiConfiguration = this.apiConfiguration @@ -4201,7 +4280,10 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Provider not available") } - const modelInfo = this.api.getModel().info + // Load dynamically discovered model metadata (router providers) before + // reading it, so the prompt's included/excluded tool guidance matches + // the runtime path; prefer the caller's per-request snapshot when threaded. + const modelInfo = requestModelInfo ?? (await this.safeEnsureModelFetched()) return SYSTEM_PROMPT( provider.context, @@ -4229,6 +4311,8 @@ export class Task extends EventEmitter implements TaskLike { undefined, // todoList this.api.getModel().id, provider.getSkillsManager(), + requestState?.disabledTools, + modelInfo, ) })() } @@ -4243,20 +4327,67 @@ export class Task extends EventEmitter implements TaskLike { /** * Ensures router-provider model metadata is loaded before getModel() is used for * context management or streaming. Failures fall back to hardcoded defaults rather - * than aborting the task. + * than aborting the task; the wait is bounded by MODEL_FETCH_TIMEOUT_MS (see the + * constant for the degradation semantics). On expiry or cancellation the per-call + * AbortSignal detaches this task's waiter from the provider-side fetch instead of + * leaving a handler-side promise waiting on it indefinitely. + * + * The return value is the settled post-wait read of getModel().info: request + * entry points (attemptApiRequest, condenseContext) await this once before + * prompt generation and share the returned snapshot between getSystemPrompt + * and every tool-array build of the same request, so a fetch that resolves after + * the bounded wait was abandoned cannot move model-specific tool policy between + * prompt time and request time. Callers that do not thread a snapshot keep + * awaiting this immediately before their own getModel() read; that per-site guard + * remains the standalone/fallback read path, and repeat awaits stay cheap once a + * fetch has succeeded because the provider caches successes. RouterProvider + * already negative-caches catalog misses with a TTL (`missingModelRefreshAt`). */ - private async safeEnsureModelFetched(): Promise { + private async safeEnsureModelFetched(): Promise { + // Per-call controller: its signal makes ensureModelFetched() settle on + // abort, so neither this race nor the provider-side waiter outlives the + // bounded wait. cancel/dispose aborts it early via cancelCurrentRequest. + const controller = new AbortController() + this.metadataFetchAbortController = controller + // Promise.race attaches handlers to both inputs, so the fetch rejecting + // after the abort is already considered handled — no extra .catch needed. + let timeoutId: ReturnType | undefined + let timedOut = false try { - await this.api.ensureModelFetched?.() + await Promise.race([ + this.api.ensureModelFetched?.(controller.signal), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + timedOut = true + resolve() + }, MODEL_FETCH_TIMEOUT_MS) + }), + ]) + if (timedOut) { + console.warn( + `[Task#${this.taskId}] Timed out after ${MODEL_FETCH_TIMEOUT_MS}ms fetching model metadata; using fallback model info.`, + ) + } } catch (error) { console.error( `[Task#${this.taskId}] Failed to fetch model metadata:`, error instanceof Error ? error.message : error, ) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + if (this.metadataFetchAbortController === controller) { + this.metadataFetchAbortController = undefined + } + // Unconditional: settles any waiter still attached to this call. + controller.abort() } + // The post-wait read is the settled snapshot callers must share per request. + return this.api.getModel().info } - private async handleContextWindowExceededError(): Promise { + private async handleContextWindowExceededError(requestModelInfo: ModelInfo): Promise { const state = await this.providerRef.deref()?.getState() const { profileThresholds = {} } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. @@ -4264,8 +4395,11 @@ export class Task extends EventEmitter implements TaskLike { const apiConfiguration = this.apiConfiguration const { contextTokens } = this.getTokenUsage() - await this.safeEnsureModelFetched() - const modelInfo = this.api.getModel().info + // Truncation permanently rewrites apiConversationHistory, and the retry + // hop that consumes the result builds from the caller's snapshot; sizing + // against a fresh read here could discard history the retry would still + // have fit, so recovery shares the caller's snapshot instead of re-fetching. + const modelInfo = requestModelInfo const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, @@ -4339,7 +4473,7 @@ export class Task extends EventEmitter implements TaskLike { apiHandler: this.api, autoCondenseContext: true, autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, - systemPrompt: await this.getSystemPrompt(), + systemPrompt: await this.getSystemPrompt(state, modelInfo), taskId: this.taskId, profileThresholds, currentProfileId, @@ -4429,7 +4563,7 @@ export class Task extends EventEmitter implements TaskLike { public async *attemptApiRequest( retryAttempt: number = 0, - options: { skipProviderRateLimit?: boolean } = {}, + options: { skipProviderRateLimit?: boolean; requestModelInfo?: ModelInfo } = {}, ): ApiStream { const state = await this.providerRef.deref()?.getState() @@ -4460,12 +4594,36 @@ export class Task extends EventEmitter implements TaskLike { // in the caller. this.rateLimitClock.recordRequest() - const systemPrompt = await this.getSystemPrompt() + // Thread the request state snapshot into prompt generation so the prompt + // and the runtime tools (built below from the same `state`) stay aligned + // even if settings change while this method waits on MCP or rate limits. + // Capture one bounded-wait model-info snapshot per request, shared by the + // prompt and every tool array built below; prefer the caller's snapshot + // when one was threaded. + const requestModelInfo = options.requestModelInfo ?? (await this.safeEnsureModelFetched()) + // Retry recursions must reuse this snapshot instead of re-deriving it: a + // metadata fetch landing between attempts would otherwise move + // model-specific tool policy or `preserveReasoning` mid-request. When the + // caller threaded a snapshot its options object is forwarded unchanged — + // same reference, and never mutated. + const retryOptions = options.requestModelInfo === undefined ? { ...options, requestModelInfo } : options + const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) + + // A cancellation landing during the rate-limit countdown, the bounded metadata + // wait, or the MCP wait inside getSystemPrompt must stop this request before any + // tool array, AbortController, or createMessage call is issued for it. + if (this.abort || this.abandoned) { + throw new Error( + `[Task#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during request construction`, + ) + } + const { contextTokens } = this.getTokenUsage() if (contextTokens) { - await this.safeEnsureModelFetched() - const modelInfo = this.api.getModel().info + // Context sizing resolves from the same model-info snapshot as the prompt and + // every tool array of this request, not from a fresh getModel() re-read. + const modelInfo = requestModelInfo const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, @@ -4527,7 +4685,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, - modelInfo, + modelInfo: requestModelInfo, includeAllToolsWithRestrictions: false, }) contextMgmtTools = toolsResult.tools @@ -4652,7 +4810,10 @@ export class Task extends EventEmitter implements TaskLike { // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) - const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[]) + const cleanConversationHistory = this.buildCleanConversationHistory( + messagesWithoutImages as ApiMessage[], + requestModelInfo, + ) // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( @@ -4666,8 +4827,9 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Auto-approval limit reached and user did not approve continuation") } - // Whether we include tools is determined by whether we have any tools to send. - const modelInfo = this.api.getModel().info + // Tool policy resolves from the same model-info snapshot as the system prompt + // built earlier in this request, not from a fresh getModel() re-read. + const modelInfo = requestModelInfo // Build complete tools array: native tools + dynamic MCP tools // When includeAllToolsWithRestrictions is true, returns all tools but provides @@ -4786,9 +4948,9 @@ export class Task extends EventEmitter implements TaskLike { `Retry attempt ${retryAttempt + 1}/${MAX_CONTEXT_WINDOW_RETRIES}. ` + `Attempting automatic truncation...`, ) - await this.handleContextWindowExceededError() + await this.handleContextWindowExceededError(requestModelInfo) // Retry the request after handling the context window error - yield* this.attemptApiRequest(retryAttempt + 1) + yield* this.attemptApiRequest(retryAttempt + 1, retryOptions) return } @@ -4808,7 +4970,7 @@ export class Task extends EventEmitter implements TaskLike { // Delegate generator output from the recursive call with // incremented retry count. - yield* this.attemptApiRequest(retryAttempt + 1) + yield* this.attemptApiRequest(retryAttempt + 1, retryOptions) return } else { @@ -4826,7 +4988,7 @@ export class Task extends EventEmitter implements TaskLike { await this.say("api_req_retried") // Delegate generator output from the recursive call. - yield* this.attemptApiRequest() + yield* this.attemptApiRequest(0, retryOptions) return } } @@ -4925,6 +5087,7 @@ export class Task extends EventEmitter implements TaskLike { private buildCleanConversationHistory( messages: ApiMessage[], + requestModelInfo: ModelInfo, ): Array< Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] } > { @@ -5024,10 +5187,13 @@ export class Task extends EventEmitter implements TaskLike { continue } else if (hasPlainTextReasoning) { - // Check if the model's preserveReasoning flag is set + // Check if the model's preserveReasoning flag is set, resolved from + // the request's threaded model snapshot (same per-request source as + // the prompt and tool arrays) rather than a fresh getModel() re-read, + // so a mid-request metadata refresh cannot change what this request sends. // If true, include the reasoning block in API requests // If false/undefined, strip it out (stored for history only, not sent back to API) - const shouldPreserveForApi = this.api.getModel().info.preserveReasoning === true + const shouldPreserveForApi = requestModelInfo.preserveReasoning === true let assistantContent: Anthropic.Messages.MessageParam["content"] if (shouldPreserveForApi) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 9dd51d7412..c35acf864d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -18,10 +18,11 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Task } from "../Task" +import { MODEL_FETCH_TIMEOUT_MS, Task } from "../Task" import { SYSTEM_PROMPT } from "../../prompts/system" import { createRateLimitClock } from "../RateLimitClock" import { summarizeConversation } from "../../condense" +import { getEnvironmentDetails } from "../../environment/getEnvironmentDetails" import { ClineProvider } from "../../webview/ClineProvider" import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" @@ -29,9 +30,12 @@ import { processUserContentMentions } from "../../mentions/processUserContentMen import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" import type { ApiMessage } from "../../task-persistence" import { asyncStreamFrom } from "../../../test-utils/stream" +import { McpHub } from "../../../services/mcp/McpHub" +import { McpServerManager } from "../../../services/mcp/McpServerManager" type TaskTestAccess = { - getSystemPrompt: () => Promise + getSystemPrompt: (requestState: ProviderState | undefined, requestModelInfo?: ModelInfo) => Promise + handleContextWindowExceededError: (requestModelInfo: ModelInfo) => Promise getEnabledMcpToolsCount: () => Promise<{ enabledToolCount: number; enabledServerCount: number }> initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise startTask: (task?: string, images?: string[]) => Promise @@ -40,9 +44,14 @@ type TaskTestAccess = { addToClineMessages: (message: import("@roo-code/types").ClineMessage) => Promise updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise saveClineMessages: () => Promise - safeEnsureModelFetched: () => Promise + safeEnsureModelFetched: () => Promise + getFilesReadByRooSafely: (context: string) => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise resetAssistantMessagePersistence: () => void + buildCleanConversationHistory: ( + messages: ApiMessage[], + requestModelInfo: ModelInfo, + ) => Array<{ role: string; content: unknown } | { type: "reasoning"; encrypted_content: string }> } type TaskAskResult = Awaited> @@ -286,13 +295,33 @@ const mockMessages = [ }, ] +// Model-info stand-in for tests that stub safeEnsureModelFetched and only need +// the settled snapshot to be a defined ModelInfo. +const stubModelInfo: ModelInfo = { + contextWindow: 200_000, + maxTokens: 4096, + supportsPromptCache: true, +} + describe("Cline", () => { let mockProvider: ClineProvider let mockApiConfig: ProviderSettings let mockOutputChannel: vscode.OutputChannel let mockExtensionContext: vscode.ExtensionContext - beforeEach(() => { + // Builds provider-state doubles on top of the real getState() result + // captured before any test stubs it, so required fields stay + // compile-checked while each test states only its own overrides. + // mcpEnabled defaults to false so doubles skip the MCP-hub path unless a + // test opts in. + let baseProviderState: ProviderState + const providerStateWith = (overrides: Partial = {}): ProviderState => ({ + ...baseProviderState, + mcpEnabled: false, + ...overrides, + }) + + beforeEach(async () => { if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) } @@ -407,6 +436,8 @@ describe("Cline", () => { }, ], })) + + baseProviderState = await mockProvider.getState() }) describe("empty-response retries", () => { @@ -439,7 +470,8 @@ describe("Cline", () => { let retryUserMessageCount: number | undefined vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) - vi.spyOn(task, "attemptApiRequest") + const attemptApiRequestSpy = vi + .spyOn(task, "attemptApiRequest") .mockImplementationOnce(() => stream([])) .mockImplementationOnce(() => { retryHistory = structuredClone(task.apiConversationHistory) @@ -453,6 +485,14 @@ describe("Cline", () => { await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) expect(retryHistory).toHaveLength(1) + // The retry iteration must reach the request seam with its own incremented + // retry count; passing the initial-attempt value instead would make retries + // indistinguishable from the first attempt downstream. + expect(attemptApiRequestSpy).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ skipProviderRateLimit: true }), + ) expect(retryHistory?.[0]).toMatchObject({ role: "user", content: expect.arrayContaining([expect.objectContaining({ text: "original user request" })]), @@ -516,7 +556,7 @@ describe("Cline", () => { for (const task of [firstTask, secondTask]) { vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) - vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) } vi.spyOn(firstTask, "attemptApiRequest").mockImplementation(() => firstStream()) @@ -577,7 +617,7 @@ describe("Cline", () => { }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) - vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) const firstStream = async function* (): AsyncGenerator { @@ -625,7 +665,7 @@ describe("Cline", () => { }) vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) - vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {}) vi.spyOn(task, "attemptApiRequest").mockImplementation(() => asyncStreamFrom([ @@ -804,10 +844,7 @@ describe("Cline", () => { ...mockApiConfig, todoListEnabled: true, } - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "architect", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, @@ -817,14 +854,14 @@ describe("Cline", () => { }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "code", - mcpEnabled: false, + // The focused provider's state diverges from the task's own + // configuration; threading it must not change what the prompt resolves. + const focusedProviderState = providerStateWith({ apiConfiguration: { ...mockApiConfig, todoListEnabled: false }, - } as unknown as ProviderState) + }) vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") - await getTaskTestAccess(task).getSystemPrompt() + await getTaskTestAccess(task).getSystemPrompt(focusedProviderState) const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) const [, , , , , mode, , , , , , , settings] = systemPromptCall @@ -832,11 +869,342 @@ describe("Cline", () => { expect(settings).toMatchObject({ todoListEnabled: true }) }) + it("passes undefined disabledTools when the threaded snapshot carries none", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // The threaded snapshot's mcpEnabled:false skips the MCP-hub path; its + // disabledTools is undefined, so the prompt call receives undefined for + // that argument. + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(providerStateWith())).resolves.toBe( + "mock system prompt", + ) + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`. + expect(systemPromptCall[16]).toBeUndefined() + }) + + it("passes undefined disabledTools to the system prompt when the threaded snapshot is undefined", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // A caller whose own read came back empty threads undefined; the prompt + // path must still deliver undefined disabledTools rather than fail, and + // it must not read provider state to fill the gap. providerRef stays + // alive, so the MCP-hub branch below can run. + const getStateSpy = vi.spyOn(mockProvider, "getState") + // An unavailable snapshot leaves the mcpEnabled gate open, so the hub branch + // runs; the spy also witnesses that the branch really was taken. The + // awaited connect wait is a no-op under this file's p-wait-for mock, so + // the hub double needs no members. + const hubSpy = vi.spyOn(McpServerManager, "getInstance") + hubSpy.mockResolvedValue(Object.create(McpHub.prototype)) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(undefined)).resolves.toBe("mock system prompt") + + expect(getStateSpy).not.toHaveBeenCalled() + expect(hubSpy).toHaveBeenCalledTimes(1) + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`. + expect(systemPromptCall[16]).toBeUndefined() + + hubSpy.mockRestore() + }) + + it("builds the prompt from the threaded snapshot without reading provider state", async () => { + // A live provider whose state read returns a divergent snapshot must + // not be able to influence the prompt: the prompt path performs no + // provider-state read at all, so a re-read here would pick up the + // divergent disabledTools instead of the threaded ones. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const getStateSpy = vi + .spyOn(mockProvider, "getState") + .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") + + expect(getStateSpy).not.toHaveBeenCalled() + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is disabledTools: the threaded snapshot's value, + // not the value a provider-state re-read would have produced. + expect(systemPromptCall[16]).toEqual(["execute_command"]) + }) + + it("forwards non-empty disabledTools and modelInfo to the system prompt call", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // The threaded snapshot feeds `requestState?.disabledTools`; mcpEnabled + // stays false so the MCP-hub path is skipped. + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) + + const modelInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: false, + maxTokens: 1234, + } + vi.spyOn(task.api, "getModel").mockReturnValue({ id: "distinctive-model-id", info: modelInfo }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is the disabledTools parameter fed by `requestState?.disabledTools`; + // index 17 is the modelInfo from `this.api.getModel().info`. + expect(systemPromptCall[16]).toEqual(["execute_command"]) + expect(systemPromptCall[17]).toBe(modelInfo) + }) + + it("fetches dynamic model metadata before reading model info for the prompt", async () => { + // Router providers discover model metadata (including included/excluded + // tools) lazily. getSystemPrompt must await ensureModelFetched() before + // reading getModel().info, otherwise the prompt is built from fallback + // metadata with different tool guidance than the runtime path. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const snapshot = providerStateWith() + + const fallbackInfo: ModelInfo = { + contextWindow: 32_000, + supportsPromptCache: false, + } + const fetchedInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + excludedTools: ["execute_command"], + } + let currentInfo = fallbackInfo + const ensureModelFetched = vi.fn(async () => { + currentInfo = fetchedInfo + }) + Object.assign(task.api, { ensureModelFetched }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ id: "router-model", info: currentInfo })) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).resolves.toBe("mock system prompt") + + expect(ensureModelFetched).toHaveBeenCalledTimes(1) + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: it must be the post-fetch metadata. + expect(systemPromptCall[17]).toBe(fetchedInfo) + }) + + it("uses the threaded model-info snapshot and skips the fetch guard when one is provided", async () => { + // A threaded snapshot replaces the per-call guard entirely: the prompt + // must be built from the caller's snapshot without touching + // ensureModelFetched or the handler's current model info. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const handlerInfo: ModelInfo = { contextWindow: 100_000, supportsPromptCache: false } + const threadedInfo: ModelInfo = { contextWindow: 64_000, supportsPromptCache: true, excludedTools: [] } + const ensureModelFetched = vi.fn(async () => {}) + Object.assign(task.api, { ensureModelFetched }) + vi.spyOn(task.api, "getModel").mockReturnValue({ id: "threaded-model", info: handlerInfo }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + await expect(getTaskTestAccess(task).getSystemPrompt(providerStateWith(), threadedInfo)).resolves.toBe( + "mock system prompt", + ) + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the threaded snapshot, not the handler's. + expect(systemPromptCall[17]).toBe(threadedInfo) + expect(ensureModelFetched).not.toHaveBeenCalled() + }) + + it("threads the request state snapshot into the system prompt when provider state changes mid-request", async () => { + // attemptApiRequest captures provider state, then getSystemPrompt waits + // on MCP initialization. A settings change during that window must NOT + // leak into the prompt: the prompt and the runtime tool array (both fed + // from the request snapshot) have to stay aligned. The prompt must be + // built from that snapshot: if the prompt path re-read provider state, + // it would pick up the divergent disabledTools stubbed for later calls. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const realState = await mockProvider.getState() + // First call: the snapshot captured by attemptApiRequest. + vi.spyOn(mockProvider, "getState") + .mockResolvedValueOnce({ + ...realState, + mcpEnabled: false, + autoApprovalEnabled: false, + disabledTools: ["execute_command"], + }) + // Any later getState() read returns different disabledTools, so a + // re-read along the prompt path would change the observed behavior. + .mockResolvedValue({ + ...realState, + mcpEnabled: false, + autoApprovalEnabled: false, + disabledTools: ["read_file"], + }) + + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.spyOn(task.api, "createMessage").mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { type: "text", text: "ok" } + }, + async next() { + return { done: true, value: undefined } + }, + async return() { + return { done: true, value: undefined } + }, + async throw(error: unknown) { + throw error + }, + async [Symbol.asyncDispose]() {}, + } as AsyncGenerator) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + + const iterator = task.attemptApiRequest(0) + await iterator.next() + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 16 is disabledTools: the snapshot value from the first + // getState call, not the changed value from later reads. + expect(systemPromptCall[16]).toEqual(["execute_command"]) + }) + + it("threads the captured state snapshot into the system prompt when manually condensing", async () => { + // condenseContext captures provider state once and threads it into + // getSystemPrompt so the prompt and the condensing tool array resolve + // from one snapshot. Without threading, getSystemPrompt would re-read + // provider state here and pick up the divergent second state below. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) + vi.spyOn(mockProvider, "getState") + // First call: the snapshot captured by condenseContext. + .mockResolvedValueOnce(snapshot) + // Any later getState() read returns different disabledTools, so a + // re-read along the prompt path would change the observed behavior. + .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) + + const getSystemPromptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + + await task.condenseContext() + + // Reference equality: without threading, the arguments would be + // undefined and getSystemPrompt would re-read the divergent state and + // re-guard the model metadata. The second argument must be the handler's + // own settled snapshot, not just any object. + expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot, task.api.getModel().info) + }) + + it("threads the captured state snapshot into the system prompt when the context window is exceeded", async () => { + // handleContextWindowExceededError captures provider state up front + // and threads it into the getSystemPrompt call feeding manageContext; + // a settings change mid-handler must not leak into the condensing prompt. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + const snapshot = providerStateWith({ disabledTools: ["execute_command"] }) + vi.spyOn(mockProvider, "getState") + // First call: the snapshot captured at the top of + // handleContextWindowExceededError. + .mockResolvedValueOnce(snapshot) + // Any later getState() read returns different disabledTools, so a + // re-read along the prompt path would change the observed behavior. + .mockResolvedValue(providerStateWith({ disabledTools: ["read_file"] })) + + // Overflow the 50k window so manageContext takes the condense branch + // (the module-mocked summarizeConversation returns a summary). + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 100_000, + }) + const ctxModelInfo: ModelInfo = { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: "ctx-model", + info: ctxModelInfo, + }) + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + task.apiConversationHistory = [{ role: "user", content: [{ type: "text", text: "x" }], ts: Date.now() }] + + const getSystemPromptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + + await getTaskTestAccess(task).handleContextWindowExceededError(ctxModelInfo) + + // Reference equality: without threading, the arguments would be + // undefined and getSystemPrompt would re-read the divergent state and + // the model info; the second argument must be the snapshot threaded + // into the handler, not a fresh re-read. + expect(getSystemPromptSpy).toHaveBeenCalledWith(snapshot, ctxModelInfo) + }) + it("uses the task mode when manually condensing after focused state changes", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "architect", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -844,10 +1212,7 @@ describe("Cline", () => { startTask: false, }) await task.getTaskMode() - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "code", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "code" })) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") await task.condenseContext() @@ -857,12 +1222,9 @@ describe("Cline", () => { }) it("uses the task mode in request metadata when focused provider state differs", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "ask", - mcpEnabled: false, - autoApprovalEnabled: true, - requestDelaySeconds: 0, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ mode: "ask", autoApprovalEnabled: true, requestDelaySeconds: 0 }), + ) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -872,12 +1234,9 @@ describe("Cline", () => { await task.getTaskMode() vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "code", - mcpEnabled: false, - autoApprovalEnabled: true, - requestDelaySeconds: 0, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ mode: "code", autoApprovalEnabled: true, requestDelaySeconds: 0 }), + ) const stream = (async function* () { yield { type: "text", text: "response" } as ApiStreamChunk })() @@ -891,6 +1250,105 @@ describe("Cline", () => { const metadata = requireDefined(createMessage.mock.calls[0])[2] expect(metadata?.mode).toBe("ask") }) + + it("condenses with an undefined state snapshot when the provider is gone", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // Condensing must tolerate a collected provider: the snapshot read resolves to undefined and completes. + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + writable: false, + configurable: true, + }) + const getSystemPromptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.mocked(summarizeConversation).mockResolvedValueOnce({ + messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: Date.now() }], + summary: "summary", + cost: 0, + newContextTokens: 1, + condenseId: "condense-id", + }) + const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + + await expect(task.condenseContext()).resolves.toBeUndefined() + + // The state snapshot stays undefined for a gone provider; the model-info + // snapshot is still captured from the task's own api handler. + expect(getSystemPromptSpy).toHaveBeenCalledWith(undefined, task.api.getModel().info) + expect(overwriteSpy).toHaveBeenCalledTimes(1) + }) + + it("rejects with the view-transition error when the provider ref is lost", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // A collected provider must reject with the view-transition error, not with a state-read TypeError. + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + writable: false, + configurable: true, + }) + + // The undefined snapshot keeps the mcpEnabled gate open, so the request + // reaches the provider guard and rejects there. + await expect(getTaskTestAccess(task).getSystemPrompt(undefined)).rejects.toThrow( + "Provider reference lost during view transition", + ) + }) + + it("rejects with the provider-unavailable error when the provider dies between state reads", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + // The caller's own snapshot read performs the only deref that finds the + // provider alive - it flips the mock's liveness flag - so the provider + // guard at the top of the prompt-building closure is what answers for the + // ref that died before the prompt was built. + let providerAlive = true + const providerRef = { + deref: () => { + if (providerAlive) { + providerAlive = false + return mockProvider + } + return undefined + }, + } + Object.defineProperty(task, "providerRef", { + value: providerRef, + writable: false, + configurable: true, + }) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + const snapshot = await providerRef.deref()?.getState() + + await expect(getTaskTestAccess(task).getSystemPrompt(snapshot)).rejects.toThrow("Provider not available") + }) }) describe("sayAndCreateMissingParamError", () => { @@ -2031,10 +2489,7 @@ describe("Cline", () => { }) it("uses a mode selected through submitUserMessage in the next API request", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "ask", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "ask" })) vi.spyOn(mockProvider, "setMode").mockResolvedValue(undefined) const task = new Task({ provider: mockProvider, @@ -2073,10 +2528,12 @@ describe("Cline", () => { }) task.setTaskApiConfigName("previous-profile") vi.spyOn(mockProvider, "setProviderProfile").mockResolvedValue(undefined) - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - currentApiConfigName: "selected-profile", - apiConfiguration: selectedConfiguration, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ + currentApiConfigName: "selected-profile", + apiConfiguration: selectedConfiguration, + }), + ) vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) await task.submitUserMessage("switch profiles", undefined, undefined, "selected-profile") @@ -3226,10 +3683,7 @@ describe("Cline", () => { }) it("should propagate AbortController signal through attemptApiRequest context-window retry path", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - mode: "architect", - mcpEnabled: false, - } as unknown as ProviderState) + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "architect" })) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -3335,17 +3789,219 @@ describe("Cline", () => { expect(options.metadata?.abortSignal).toBeInstanceOf(AbortSignal) expect(options.metadata?.abortSignal?.aborted).toBe(false) }) - }) - }) - describe("safeEnsureModelFetched", () => { - it("loads model metadata before getModel is used", async () => { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - startTask: false, - }) + // Shared harness for the retry-options-forwarding tests: the first + // createMessage fails on the first chunk, the retry attempt streams a + // success chunk, and the attemptApiRequest spy exposes the arguments + // each retry site's recursion passes downstream. A same-reference + // assertion on options is the point: a rebuilt object would silently + // refetch model metadata and restart the rate-limit wait on retries. + async function createRetryForwardingTask(stateOverrides: Partial = {}) { + vi.spyOn(mockProvider, "getState").mockResolvedValue( + providerStateWith({ + autoApprovalEnabled: false, + requestDelaySeconds: 0, + ...stateOverrides, + }), + ) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.spyOn(task, "say").mockResolvedValue(undefined) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + return task + } + + const failingStream = (error: unknown): AsyncGenerator => + (async function* () { + // Yield nothing, then fail the first next() like a first-chunk + // stream error would. + yield* [] + throw error + })() + + const retryForwardingOptions = (): { skipProviderRateLimit: boolean; requestModelInfo: ModelInfo } => ({ + skipProviderRateLimit: true, + requestModelInfo: { contextWindow: 200_000, maxTokens: 4096, supportsPromptCache: true }, + }) + + it("forwards the caller's options to the context-window retry recursion", async () => { + const task = await createRetryForwardingTask() + vi.spyOn(getTaskTestAccess(task), "handleContextWindowExceededError").mockResolvedValue(undefined) + vi.spyOn(task.api, "createMessage") + .mockImplementationOnce(() => failingStream({ status: 400, message: "context length exceeded" })) + .mockImplementationOnce(() => + asyncStreamFrom([{ type: "text", text: "retry response" }]), + ) + + const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") + const options = retryForwardingOptions() + const iterator = task.attemptApiRequest(0, options) + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: "text", text: "retry response" }, + }) + + expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) + expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(1) + expect(attemptApiRequestSpy.mock.calls[1]?.[1]).toBe(options) + }) + + it("forwards the caller's options to the auto-approval backoff retry recursion", async () => { + const task = await createRetryForwardingTask({ autoApprovalEnabled: true }) + // An ask landing here means the auto-approval branch was not taken, + // so the rejection names the wrong-site failure explicitly. + vi.spyOn(task, "ask").mockRejectedValue(new Error("auto-approval retry must not prompt the user")) + vi.spyOn(task.api, "createMessage") + .mockImplementationOnce(() => failingStream({ status: 500, message: "server error" })) + .mockImplementationOnce(() => + asyncStreamFrom([{ type: "text", text: "retry response" }]), + ) + + const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") + const options = retryForwardingOptions() + const iterator = task.attemptApiRequest(0, options) + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: "text", text: "retry response" }, + }) + + expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) + expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(1) + expect(attemptApiRequestSpy.mock.calls[1]?.[1]).toBe(options) + }) + + it("forwards the caller's options and resets the counter on the user-clicked retry recursion", async () => { + const task = await createRetryForwardingTask() + vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) + vi.spyOn(task.api, "createMessage") + .mockImplementationOnce(() => failingStream({ status: 500, message: "server error" })) + .mockImplementationOnce(() => + asyncStreamFrom([{ type: "text", text: "retry response" }]), + ) + + const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") + const options = retryForwardingOptions() + const iterator = task.attemptApiRequest(0, options) + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: "text", text: "retry response" }, + }) + + // The user-confirmed retry restarts the retry counter at 0 (its own + // pacing is the user's click), unlike the automatic backoff retries. + expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) + expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(0) + expect(attemptApiRequestSpy.mock.calls[1]?.[1]).toBe(options) + }) + + it("carries the derived model snapshot into the retry recursion when the caller omitted one", async () => { + const task = await createRetryForwardingTask() + vi.spyOn(getTaskTestAccess(task), "handleContextWindowExceededError").mockResolvedValue(undefined) + const safeEnsureModelFetchedSpy = vi + .spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") + .mockResolvedValue(stubModelInfo) + vi.spyOn(task.api, "createMessage") + .mockImplementationOnce(() => failingStream({ status: 400, message: "context length exceeded" })) + .mockImplementationOnce(() => + asyncStreamFrom([{ type: "text", text: "retry response" }]), + ) + + const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") + // No caller-supplied snapshot: the first hop derives one locally. + const iterator = task.attemptApiRequest(0) + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: "text", text: "retry response" }, + }) + + expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2) + expect(attemptApiRequestSpy.mock.calls[1]?.[0]).toBe(1) + // The retry hop carries the snapshot derived at the first hop, so a + // metadata update landing between attempts cannot move model-specific + // tool policy mid-request. + expect(attemptApiRequestSpy.mock.calls[1]?.[1]?.requestModelInfo).toBe(stubModelInfo) + // Derivation ran once per logical request, not once per hop. + expect(safeEnsureModelFetchedSpy).toHaveBeenCalledTimes(1) + }) + + it("recovers from a context-window overflow against the pinned request snapshot when metadata changes in between", async () => { + // The pinned-snapshot invariant covers the recovery half too: + // truncation permanently rewrites apiConversationHistory for the + // retry hop to consume, so recovery must size against the same + // snapshot the retry uses — never a fresh metadata read that + // landed between the failed attempt and recovery. + const task = await createRetryForwardingTask() + // Distinct object, same window as the stub: identity is what the + // retry hop must carry forward. + const pinnedInfo: ModelInfo = { ...stubModelInfo } + // A narrower window arriving after the first failure would drive + // harsher truncation math than the retry hop actually needs. + const freshInfo: ModelInfo = { ...stubModelInfo, contextWindow: 32_000 } + const safeEnsureModelFetchedSpy = vi + .spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") + .mockResolvedValueOnce(pinnedInfo) + .mockResolvedValue(freshInfo) + const getSystemPromptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + vi.spyOn(task.api, "createMessage") + .mockImplementationOnce(() => failingStream({ status: 400, message: "context length exceeded" })) + .mockImplementationOnce(() => + asyncStreamFrom([{ type: "text", text: "retry response" }]), + ) + + const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest") + const iterator = task.attemptApiRequest(0) + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: "text", text: "retry response" }, + }) + + // hop 1 prompt, the recovery handler's condensing prompt, hop 2 prompt. + expect(getSystemPromptSpy).toHaveBeenCalledTimes(3) + // Without threading, the handler re-fetches and this second call + // carries freshInfo, so truncation is sized against a window the + // retry never uses. + expect(getSystemPromptSpy.mock.calls[1]?.[1]).toBe(pinnedInfo) + // Recovery and the retry hop share one snapshot object. + expect(attemptApiRequestSpy.mock.calls[1]?.[1]?.requestModelInfo).toBe(pinnedInfo) + // The handler performs no metadata fetch of its own. + expect(safeEnsureModelFetchedSpy).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe("safeEnsureModelFetched", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("loads model metadata before getModel is used", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) const ensureModelFetched = vi.fn().mockResolvedValue(undefined) Object.assign(task.api, { ensureModelFetched }) @@ -3365,15 +4021,16 @@ describe("Cline", () => { const ensureModelFetched = vi.fn().mockRejectedValue(new Error("network down")) Object.assign(task.api, { ensureModelFetched }) + const expectedInfo = task.api.getModel().info const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + // A swallowed failure still returns the handler's settled fallback info. + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("Failed to fetch model metadata"), "network down", ) - errorSpy.mockRestore() }) it("is a no-op when the api handler does not implement ensureModelFetched", async () => { @@ -3384,7 +4041,578 @@ describe("Cline", () => { startTask: false, }) - await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBeUndefined() + const expectedInfo = task.api.getModel().info + + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) + }) + + it("settles at the bound when ensureModelFetched never resolves", async () => { + // A hung metadata endpoint (some fetchers issue unbounded GETs) must + // not stall the task: the race resolves at MODEL_FETCH_TIMEOUT_MS and + // callers proceed with the handler's fallback metadata. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const expectedInfo = task.api.getModel().info + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + + // The timeout path returns the handler's settled fallback snapshot. + await expect(settled).resolves.toBe(expectedInfo) + } finally { + vi.useRealTimers() + } + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + }) + + it("aborts the fetch signal when the bounded wait expires", async () => { + // The bound must detach this task's waiter, not just stop waiting on + // it: a handler that observes its signal stops serving the abandoned + // fetch, and one that ignores it at least sees the task move on. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + let capturedSignal: AbortSignal | undefined + Object.assign(task.api, { + ensureModelFetched: (signal?: AbortSignal) => { + capturedSignal = signal + return new Promise(() => {}) + }, + }) + vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await settled + } finally { + vi.useRealTimers() + } + expect(capturedSignal?.aborted).toBe(true) + }) + + it("aborts an in-flight metadata wait when the current request is cancelled", async () => { + // Cancel/dispose must reach the metadata waiter, not only the stream: + // cancelCurrentRequest aborts the controller feeding the handler's + // signal, so a signal-observing handler settles the waiter immediately + // and the call degrades to fallback info instead of hanging until the + // bound. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + let capturedSignal: AbortSignal | undefined + Object.assign(task.api, { + ensureModelFetched: (signal?: AbortSignal) => + new Promise((_resolve, reject) => { + capturedSignal = signal + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }) + }), + }) + const expectedInfo = task.api.getModel().info + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + // Let the waiter attach to the handler before cancelling. + await Promise.resolve() + + task.cancelCurrentRequest() + + await expect(settled).resolves.toBe(expectedInfo) + expect(capturedSignal?.aborted).toBe(true) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to fetch model metadata"), + expect.anything(), + ) + }) + + it("releases the metadata abort controller once the wait completes", async () => { + // The ownership guard in safeEnsureModelFetched's finally block must + // clear the field for the call that still owns it: a never-cleared + // controller would let cancelCurrentRequest abort a long-dead signal. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + + await getTaskTestAccess(task).safeEnsureModelFetched() + + expect(task.metadataFetchAbortController).toBeUndefined() + }) + + it("does not clear a controller owned by a newer metadata wait", async () => { + // Overlap guard: if a newer call replaced this call's controller while + // the bounded wait was pending, the finished call's finally block must + // leave the foreign controller in place — clearing unconditionally + // would silently orphan the newer wait from cancel/dispose. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + let settleFetch!: () => void + Object.assign(task.api, { + ensureModelFetched: () => + new Promise((resolve) => { + settleFetch = resolve + }), + }) + + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + // The per-call controller is installed synchronously before the race. + expect(task.metadataFetchAbortController).toBeInstanceOf(AbortController) + const foreignController = new AbortController() + task.metadataFetchAbortController = foreignController + + settleFetch() + await settled + + expect(task.metadataFetchAbortController).toBe(foreignController) + }) + + it("does not block getSystemPrompt when ensureModelFetched never settles", async () => { + // The prompt/condense guard site must proceed with fallback model + // info once the bounded wait expires instead of hanging the request. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + const callsBefore = vi.mocked(SYSTEM_PROMPT).mock.calls.length + vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const promptPromise = getTaskTestAccess(task).getSystemPrompt(providerStateWith()) + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + + await expect(promptPromise).resolves.toBe("mock system prompt") + } finally { + vi.useRealTimers() + } + expect(vi.mocked(SYSTEM_PROMPT).mock.calls.length).toBe(callsBefore + 1) + }) + + it("refuses to send a request when the task is cancelled during the bounded metadata wait", async () => { + // Cancellation must be honored before any provider-visible work of + // the request: an abort landing while the metadata wait is pending + // rejects the generator instead of quietly sending a request the + // user already cancelled. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + // The wait never settles on its own; only the bound expires it. + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const createMessageSpy = vi + .spyOn(task.api, "createMessage") + .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const first = task.attemptApiRequest(0).next() + // Observe the rejection the moment it can land: the generator + // rejects during the timer advance below, before the assertion + // line runs, and an unobserved rejection would surface as an + // unhandled rejection independent of the awaited assertion. + void first.catch(() => {}) + await vi.advanceTimersByTimeAsync(0) + // The user cancels while the bounded metadata wait is still pending. + const cancelling = task.abortTask() + // The wait itself still expires at the bound, as it normally would. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + + await expect(first).rejects.toThrow(/aborted during request construction/) + expect(createMessageSpy).not.toHaveBeenCalled() + // The per-request controller is only created once the request is + // committed, so a cancelled construction never reaches it. + expect(task.currentRequestAbortController).toBeUndefined() + await cancelling + } finally { + vi.useRealTimers() + } + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + }) + + it("refuses to send a request when the task is disposed during the bounded metadata wait", async () => { + // Disposal alone — no cancel button, no abortTask — must make the + // task observe cancellation: disposeOnce sets the abort state + // synchronously in its call, before its aborts land, so once the + // metadata wait settles at its bound the request-construction + // guard refuses to build tools or call createMessage for a task + // nobody owns anymore. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + // The wait never settles on its own; only the bound expires it. + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const createMessageSpy = vi + .spyOn(task.api, "createMessage") + .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 0, + }) + vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt") + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const first = task.attemptApiRequest(0).next() + // Observe the rejection the moment it can land: the generator + // rejects during the timer advance below, before the assertion + // line runs, and an unobserved rejection would surface as an + // unhandled rejection independent of the awaited assertion. + void first.catch(() => {}) + await vi.advanceTimersByTimeAsync(0) + // The task is disposed while the bounded metadata wait is still + // pending; the abort state is set synchronously in this call. + const disposal = task.dispose() + expect(task.abort).toBe(true) + // The wait itself still expires at the bound, as it normally would. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + + await expect(first).rejects.toThrow(/aborted during request construction/) + expect(createMessageSpy).not.toHaveBeenCalled() + // The per-request controller is only created once the request is + // committed, so a disposed construction never reaches it. + expect(task.currentRequestAbortController).toBeUndefined() + await disposal + } finally { + vi.useRealTimers() + } + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + }) + + it("stops manual condensation when the task is aborted during the metadata wait", async () => { + // Cancellation must be honored before any provider-visible work of + // the condense: an abort landing while the metadata wait is pending + // ends condenseContext instead of quietly issuing a summarization + // request the user already cancelled. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + // The wait never settles on its own; only the bound expires it. + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + // Spying on the prompt build pins the cancellation to the entry + // checkpoint: skipping summarization alone is also achieved by the + // check placed after the prompt await, so only an unstarted + // prompt build proves the entry check did its work. + const promptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + // The summarizeConversation module mock is never cleared, so pin the + // call count this condense starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const condensing = task.condenseContext() + await vi.advanceTimersByTimeAsync(0) + // The user cancels while the bounded metadata wait is still pending. + const cancelling = task.abortTask() + // The wait itself still expires at the bound, as it normally would. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await condensing + await cancelling + } finally { + vi.useRealTimers() + } + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + expect(promptSpy).not.toHaveBeenCalled() + expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) + }) + + it("stops manual condensation on an abandoned task", async () => { + // Abandonment is the other cancellation flavor: the metadata wait + // settles normally, yet condenseContext must still end before the + // prompt build and the summarization request. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + task.abandoned = true + // Only an unstarted prompt build attributes the skip to the entry + // checkpoint rather than one of the later cancellation checks. + const promptSpy = vi + .spyOn(getTaskTestAccess(task), "getSystemPrompt") + .mockResolvedValue("mock system prompt") + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + + await task.condenseContext() + + expect(promptSpy).not.toHaveBeenCalled() + expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) + }) + + it("stops manual condensation when the task is aborted while the system prompt is pending", async () => { + // A cancellation landing inside the prompt build (whose bounded MCP + // wait is cancellation-blind) must stop condenseContext before it + // issues the summarization request. The prompt gate is released only + // after abortTask has synchronously set its flag, so whenever the + // prompt await resumes the cancellation is observed. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + let resolvePrompt!: (value: string) => void + const promptGate = new Promise((resolve) => { + resolvePrompt = resolve + }) + const promptSpy = vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockReturnValue(promptGate) + // The early return this guards never reaches say; the spy only keeps + // the aborted task's post-overwrite say from throwing before the + // summarize/overwrite assertions can report a regression. + vi.spyOn(task, "say").mockResolvedValue(undefined) + const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + // The summarizeConversation module mock is never cleared, so pin the + // call count this condense starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + // Attribution pin: the summarize-count assertion alone is also + // satisfied by the later input-gathering checkpoint, so the collectors + // must additionally stay unstarted to prove THIS prompt-boundary check + // (not a downstream one) stopped the condense. + const envCallsBefore = vi.mocked(getEnvironmentDetails).mock.calls.length + const filesReadSpy = vi.spyOn(getTaskTestAccess(task), "getFilesReadByRooSafely") + + const condensing = task.condenseContext() + // Suspend inside the prompt build, past the entry guard, so this + // exercises the prompt-boundary check rather than the entry one. + await vi.waitFor(() => expect(promptSpy).toHaveBeenCalled()) + const cancelling = task.abortTask() + resolvePrompt("mock system prompt") + await condensing + await cancelling + + expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) + expect(overwriteSpy).not.toHaveBeenCalled() + expect(vi.mocked(getEnvironmentDetails).mock.calls.length).toBe(envCallsBefore) + expect(filesReadSpy).not.toHaveBeenCalled() + }) + + it("stops manual condensation from overwriting history when the task is aborted during summarization", async () => { + // The summarization round-trip is the widest cancellation window on + // the condense path: a cancellation landing while it is pending must + // stop condenseContext before it replaces and persists the history. + // The gate is released only after abortTask has synchronously set + // its flag, so whenever the summarize await resumes the cancellation + // is observed. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + // The early return this guards never reaches say; the spy only keeps + // the aborted task's post-overwrite say from throwing before the + // overwrite assertion can report the regression. + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + // The summarizeConversation module mock is never cleared, so pin the + // call count this condense starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + type SummarizeResult = Awaited> + let releaseSummarize!: (value: SummarizeResult) => void + const summarizeGate = new Promise((resolve) => { + releaseSummarize = resolve + }) + vi.mocked(summarizeConversation).mockImplementationOnce(() => summarizeGate) + + const condensing = task.condenseContext() + await vi.waitFor(() => + expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore + 1), + ) + const cancelling = task.abortTask() + releaseSummarize({ + messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: Date.now() }], + summary: "summary", + cost: 0, + newContextTokens: 1, + }) + await condensing + await cancelling + + expect(overwriteSpy).not.toHaveBeenCalled() + expect(saySpy).not.toHaveBeenCalled() + }) + + it("stops manual condensation when the task is aborted while environment details are pending", async () => { + // Gathering the summary inputs suspends twice before the + // summarization request: a cancellation landing while the + // environment-details collector is pending must stop condenseContext + // before any summarization request or history rewrite is issued. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "say").mockResolvedValue(undefined) + const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + // Suspend inside the collector so the abort lands while the + // summarization request cannot have started yet. + let releaseEnvDetails!: (value: string) => void + const envDetailsGate = new Promise((resolve) => { + releaseEnvDetails = resolve + }) + vi.mocked(getEnvironmentDetails).mockReturnValueOnce(envDetailsGate) + // The summarizeConversation module mock is never cleared, so pin the + // call count this condense starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + // Same for the environment-details module mock: waiting on a count + // delta proves THIS condense reached the collector before the abort. + const envCallsBefore = vi.mocked(getEnvironmentDetails).mock.calls.length + + const condensing = task.condenseContext() + await vi.waitFor(() => expect(vi.mocked(getEnvironmentDetails).mock.calls.length).toBe(envCallsBefore + 1)) + const cancelling = task.abortTask() + releaseEnvDetails("") + await condensing + await cancelling + + expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) + expect(overwriteSpy).not.toHaveBeenCalled() + }) + + it("stops manual condensation when the task is aborted while the files-read collector is pending", async () => { + // The second input-gathering suspension sits between the + // environment-details collector and the summarization request: a + // cancellation landing while the files-read collector is pending must + // likewise stop condenseContext before either is issued. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + vi.spyOn(task, "dispose").mockResolvedValue(undefined) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + vi.spyOn(task, "say").mockResolvedValue(undefined) + const overwriteSpy = vi.spyOn(task, "overwriteApiConversationHistory").mockResolvedValue(undefined) + let releaseFilesRead!: (value: string[] | undefined) => void + const filesReadGate = new Promise((resolve) => { + releaseFilesRead = resolve + }) + const filesReadSpy = vi + .spyOn(getTaskTestAccess(task), "getFilesReadByRooSafely") + .mockReturnValue(filesReadGate) + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + + const condensing = task.condenseContext() + await vi.waitFor(() => expect(filesReadSpy).toHaveBeenCalled()) + const cancelling = task.abortTask() + releaseFilesRead(undefined) + await condensing + await cancelling + + expect(vi.mocked(summarizeConversation).mock.calls.length).toBe(summarizeCallsBefore) + expect(overwriteSpy).not.toHaveBeenCalled() }) it("calls safeEnsureModelFetched from attemptApiRequest when context tokens are present", async () => { @@ -3402,7 +4630,7 @@ describe("Cline", () => { totalTokensOut: 0, contextTokens: 50_000, }) - const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(undefined) + const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) vi.spyOn(task.api, "getModel").mockReturnValue({ id: mockApiConfig.apiModelId!, info: { @@ -3499,7 +4727,6 @@ describe("Cline", () => { value: { type: "text", text: "ok" }, }) expect(errorSpy).toHaveBeenCalled() - errorSpy.mockRestore() }) it("fetches model metadata before caching the streaming model", async () => { @@ -3527,7 +4754,7 @@ describe("Cline", () => { }) const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") const resetPersistenceSpy = vi.spyOn(getTaskTestAccess(task), "resetAssistantMessagePersistence") - vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + const attemptApiRequestSpy = vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { throw new Error("stop after model metadata fetch") }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) @@ -3560,8 +4787,464 @@ describe("Cline", () => { expect(safeSpy).toHaveBeenCalled() expect(resetPersistenceSpy).toHaveBeenCalledTimes(1) expect(ensureModelFetched).toHaveBeenCalled() + // Exact-object match on purpose: a partial matcher would stop pinning the + // options literal the streaming loop passes to attemptApiRequest. + expect(attemptApiRequestSpy).toHaveBeenCalledWith(0, { + skipProviderRateLimit: true, + requestModelInfo: task.cachedStreamingModel?.info, + }) expect(task.cachedStreamingModel?.id).toBe(mockApiConfig.apiModelId) }) + + it("stays silent when the api handler lacks ensureModelFetched", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const expectedInfo = task.api.getModel().info + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // A missing optional fetcher is the normal case for static providers, + // so the call must resolve quietly instead of surfacing a caught TypeError. + await expect(getTaskTestAccess(task).safeEnsureModelFetched()).resolves.toBe(expectedInfo) + + expect(errorSpy.mock.calls.flat().join(" ")).not.toContain("Failed to fetch model metadata") + }) + + it("does not warn when the fetch resolves within the bound", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + await getTaskTestAccess(task).safeEnsureModelFetched() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + } finally { + vi.useRealTimers() + } + + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("warns only once the bound elapses", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => new Promise(() => {}) }) + const expectedInfo = task.api.getModel().info + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const settled = getTaskTestAccess(task).safeEnsureModelFetched() + // The 5000 ms bound is asserted in absolute milliseconds so any + // change to it changes observed behavior, not just the schedule. + await vi.advanceTimersByTimeAsync(4_999) + + expect(warnSpy).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + expect(warnSpy).toHaveBeenCalledTimes(1) + await expect(settled).resolves.toBe(expectedInfo) + } finally { + vi.useRealTimers() + } + }) + + it("clears the race timer after the fetch wins", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + const clearSpy = vi.spyOn(globalThis, "clearTimeout") + + await getTaskTestAccess(task).safeEnsureModelFetched() + + // The armed handle must be handed to clearTimeout on the winning + // path; a never-armed or never-cleared timer leaks a pending handle. + expect(clearSpy).toHaveBeenCalledWith(expect.any(Object)) + }) + + it("only clears a timer handle that was actually armed", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Hand production a falsy handle while still arming the real timer, + // so skipping clearTimeout for an unarmed handle is observable. + const realSetTimeout = globalThis.setTimeout + let armedHandle: ReturnType | undefined + vi.stubGlobal("setTimeout", (callback: () => void, delay?: number) => { + armedHandle = realSetTimeout(callback, delay) + return 0 + }) + const clearSpy = vi.spyOn(globalThis, "clearTimeout") + Object.assign(task.api, { ensureModelFetched: () => Promise.resolve() }) + + try { + await getTaskTestAccess(task).safeEnsureModelFetched() + + expect(clearSpy).not.toHaveBeenCalled() + } finally { + if (armedHandle !== undefined) { + clearTimeout(armedHandle) + } + vi.unstubAllGlobals() + } + }) + + it("keeps prompt and request tools on one snapshot when the fetch stalls past the bound and resolves late", async () => { + // Stalled-then-late fetch: the entry snapshot times out at + // MODEL_FETCH_TIMEOUT_MS and captures fallback metadata; the fetch + // then resolves while the request is still being built. The prompt + // and every tool array of this request must both come from the same + // (fallback) snapshot, even though the handler's model info has + // already flipped to the loaded metadata with different exclusions. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + const fallbackInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } + const loadedInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + // Divergent tool policy: only the loaded metadata excludes it. + excludedTools: ["read_file"], + } + const fetchState = { resolved: false } + let resolveFetch!: () => void + const metadataFetch = new Promise((resolve) => { + resolveFetch = () => { + fetchState.resolved = true + resolve() + } + }) + Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ + id: "lazy-router-model", + info: fetchState.resolved ? loadedInfo : fallbackInfo, + })) + + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + // Far above allowedTokens under either snapshot (32k or 128k window), + // so the request-scoped condense sub-call always runs and its metadata + // tools are observable on the mocked summarizeConversation. + contextTokens: 500_000, + }) + vi.spyOn(task.api, "countTokens").mockResolvedValue(1_000) + const createMessageSpy = vi + .spyOn(task.api, "createMessage") + .mockReturnValue(asyncStreamFrom([{ type: "text", text: "ok" }])) + // Hold the prompt build open so the late flip lands after the request + // snapshot was captured but before any tool array is built: a flip + // between those points must not move either consumer. + let releasePrompt!: () => void + const promptGate = new Promise((resolve) => { + releasePrompt = () => resolve("mock system prompt") + }) + vi.mocked(SYSTEM_PROMPT).mockImplementationOnce(() => promptGate) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + // The summarizeConversation module mock is never cleared, so pin the + // call count this request starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + + vi.useFakeTimers() + try { + const first = task.attemptApiRequest(0).next() + // The entry snapshot times out and resolves with fallback info; + // the prompt is then built from it and suspends on the gate. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + // Late success flips the handler's metadata mid-request. + resolveFetch() + releasePrompt() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await expect(first).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" } }) + } finally { + vi.useRealTimers() + } + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the prompt used the captured snapshot. + expect(systemPromptCall[17]).toBe(fallbackInfo) + const [, , metadata] = requireDefined(createMessageSpy.mock.calls[0]) + // Indexed-access type keeps the helper import-free (the spec does not + // import the OpenAI types) and metadata itself stays possibly-undefined. + type MetadataTools = NonNullable["tools"] + const toolNames = (tools: MetadataTools): string[] => + requireDefined(tools).map((tool) => { + if (tool.type !== "function") { + throw new Error(`Unexpected tool type: ${tool.type}`) + } + return tool.function.name + }) + // The request's tools were built from the same fallback snapshot, so + // the tool the loaded metadata excludes is still declared. + expect(toolNames(metadata?.tools)).toContain("read_file") + + // The condense sub-call of this same request carries the same guarantee: + // manageContext forwards its metadata verbatim into summarizeConversation, + // so that array is the one built at the context-management tool site. + expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) + const [condenseOptions] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + expect(condenseOptions.isAutomaticTrigger).toBe(true) + // Reverting that build to a fresh guarded re-read (like the per-site + // guard at the top of this block) would pick up the flipped (loaded) + // metadata and drop read_file from this array. + expect(toolNames(condenseOptions.metadata?.tools)).toContain("read_file") + }) + + it("keeps manual condense prompt and tools on one snapshot when the fetch resolves late", async () => { + // condenseContext twin of the stalled-fetch regression: the prompt and + // the condensing metadata's tool array must resolve from the single + // snapshot captured before prompt generation. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + const fallbackInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } + const loadedInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + excludedTools: ["read_file"], + } + const fetchState = { resolved: false } + let resolveFetch!: () => void + const metadataFetch = new Promise((resolve) => { + resolveFetch = () => { + fetchState.resolved = true + resolve() + } + }) + Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ + id: "lazy-router-model", + info: fetchState.resolved ? loadedInfo : fallbackInfo, + })) + // Hold the prompt build open so the test can flip the handler's + // metadata after the snapshot is captured but before the tools are + // built: a flip between those points must not move either consumer. + let releasePrompt!: () => void + const promptGate = new Promise((resolve) => { + releasePrompt = () => resolve("mock system prompt") + }) + vi.mocked(SYSTEM_PROMPT).mockReturnValueOnce(promptGate) + vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + vi.useFakeTimers() + try { + const condensing = task.condenseContext() + // The entry snapshot times out with fallback info and the prompt + // build suspends on the gate. + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Timed out")) + // Late success lands after the snapshot but before the tools. + resolveFetch() + releasePrompt() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await condensing + } finally { + vi.useRealTimers() + } + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the prompt used the captured snapshot. + expect(systemPromptCall[17]).toBe(fallbackInfo) + const [options] = requireDefined(vi.mocked(summarizeConversation).mock.calls.at(-1)) + const toolNames = requireDefined(options.metadata?.tools).map((tool) => { + if (tool.type !== "function") { + throw new Error(`Unexpected tool type: ${tool.type}`) + } + return tool.function.name + }) + // Same fallback snapshot: the loaded metadata's exclusion never applied. + expect(toolNames).toContain("read_file") + }) + + it("uses the caller's model-info snapshot for both the prompt and context sizing", async () => { + // A streaming turn captures its model-info snapshot before opening + // the request; attemptApiRequest must reuse it for the prompt, the + // context-window sizing, and every tool array, so a metadata fetch + // that lands mid-request cannot re-decide whether condensing runs. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + await task.getTaskMode() + vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith()) + + const threadedInfo: ModelInfo = { contextWindow: 32_000, supportsPromptCache: false } + const lateInfo: ModelInfo = { + contextWindow: 128_000, + supportsPromptCache: true, + // Divergent policy: only the late metadata excludes it. + excludedTools: ["read_file"], + } + const fetchState = { resolved: false } + let resolveFetch!: () => void + const metadataFetch = new Promise((resolve) => { + resolveFetch = () => { + fetchState.resolved = true + resolve() + } + }) + Object.assign(task.api, { ensureModelFetched: () => metadataFetch }) + vi.spyOn(task.api, "getModel").mockImplementation(() => ({ + id: "lazy-router-model", + info: fetchState.resolved ? lateInfo : threadedInfo, + })) + + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + // Above the hard limit for the 32k threaded window (~24.7k tokens) + // but well below the limit for a 128k re-read (~111k), so whether + // condensing runs exposes which snapshot the sizing resolved from. + contextTokens: 50_000, + }) + vi.spyOn(task.api, "countTokens").mockResolvedValue(1_000) + vi.spyOn(task.api, "createMessage").mockReturnValue( + asyncStreamFrom([{ type: "text", text: "ok" }]), + ) + const safeSpy = vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched") + const cleanHistorySpy = vi.spyOn(getTaskTestAccess(task), "buildCleanConversationHistory") + // Hold the prompt build open so the metadata fetch can land after the + // snapshot was captured but before context sizing runs. + let releasePrompt!: () => void + const promptGate = new Promise((resolve) => { + releasePrompt = () => resolve("mock system prompt") + }) + vi.mocked(SYSTEM_PROMPT).mockImplementationOnce(() => promptGate) + task.apiConversationHistory = [ + { role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() }, + ] + + // The summarizeConversation module mock is never cleared, so pin the + // call count this request starts from. + const summarizeCallsBefore = vi.mocked(summarizeConversation).mock.calls.length + + vi.useFakeTimers() + try { + const first = task.attemptApiRequest(0, { requestModelInfo: threadedInfo }).next() + await vi.advanceTimersByTimeAsync(0) + // Late metadata arrives while the request is still being built; a + // fresh re-read here would return the wider 128k window instead. + resolveFetch() + releasePrompt() + await vi.advanceTimersByTimeAsync(MODEL_FETCH_TIMEOUT_MS) + await expect(first).resolves.toMatchObject({ done: false, value: { type: "text", text: "ok" } }) + } finally { + vi.useRealTimers() + } + + const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1)) + // Argument index 17 is modelInfo: the prompt used the caller's snapshot. + expect(systemPromptCall[17]).toBe(threadedInfo) + // The threaded snapshot replaces the per-request guard entirely. + expect(safeSpy).not.toHaveBeenCalled() + // The cleaned request history resolves its model-dependent flags from + // the same threaded snapshot, not from a fresh handler re-read. + expect(cleanHistorySpy).toHaveBeenCalledWith(expect.any(Array), threadedInfo) + // Condensing ran because sizing resolved from the 32k threaded + // window; a 128k re-read would have cleared the threshold instead. + expect(summarizeConversation).toHaveBeenCalledTimes(summarizeCallsBefore + 1) + }) + }) + + describe("buildCleanConversationHistory", () => { + // Assistant message carrying a plain-text (unencrypted) reasoning block: + // whether the block survives into the sent history depends solely on the + // model snapshot's preserveReasoning flag. + const reasoningMessage: ApiMessage = { + role: "assistant", + content: [ + { + type: "reasoning", + text: "hidden chain of thought", + summary: [], + } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "answer" }, + ], + ts: 1, + } + + function historyFor(preserveReasoning: boolean) { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + // The handler re-read deliberately disagrees with the threaded + // snapshot: any output that follows the re-read instead of the + // parameter flips the assertions below. + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: "lazy-router-model", + info: { contextWindow: 1_000, supportsPromptCache: false, preserveReasoning: !preserveReasoning }, + }) + + const requestModelInfo: ModelInfo = { + contextWindow: 1_000, + supportsPromptCache: false, + preserveReasoning, + } + return getTaskTestAccess(task).buildCleanConversationHistory([reasoningMessage], requestModelInfo) + } + + it("keeps plain-text reasoning when the threaded snapshot sets preserveReasoning", () => { + const history = historyFor(true) + + expect(history).toEqual([{ role: "assistant", content: reasoningMessage.content }]) + }) + + it("strips plain-text reasoning when the threaded snapshot omits preserveReasoning", () => { + const history = historyFor(false) + + expect(history).toEqual([{ role: "assistant", content: "answer" }]) + }) }) describe("startTask", () => { diff --git a/src/core/task/__tests__/build-tools.spec.ts b/src/core/task/__tests__/build-tools.spec.ts new file mode 100644 index 0000000000..65990932a2 --- /dev/null +++ b/src/core/task/__tests__/build-tools.spec.ts @@ -0,0 +1,286 @@ +// npx vitest src/core/task/__tests__/build-tools.spec.ts +// +// Gemini `includeAllToolsWithRestrictions` path: with the flag on, `tools` +// contains ALL declarations while `allowedFunctionNames` is derived from the +// resolver-filtered set, so every `disabledTools`/`excludedTools` entry — +// protocol tools included — leaves the callable allowlist while the +// declarations stay advertised. + +import type OpenAI from "openai" +import type * as vscode from "vscode" + +import type { McpServer, ModeConfig, ModelInfo } from "@roo-code/types" + +import type { ClineProvider } from "../../webview/ClineProvider" +import type { McpHub } from "../../../services/mcp/McpHub" + +// build-tools resolves the per-cwd CodeIndexManager through the registry; left +// real, getOrCreate would construct a live manager from the stubbed context. +// The all-false flags keep codebase_search out of every filter result, matching +// the disabled-index baseline the assertions below assume. +vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { + getOrCreate: () => ({ isFeatureEnabled: false, isFeatureConfigured: false, isInitialized: false }), + }, +})) + +// Keeps the test independent of the bundled @roo-code/core package; the +// customTools experiment stays off in every case below. +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + loadFromDirectoriesIfStale: vi.fn(), + getAllSerialized: () => [], + }, + formatNative: vi.fn(), +})) + +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +/** + * ClineProvider is a heavy class; build-tools only reads `context` and + * `getMcpHub()` from it, so a minimal object literal stands in. The double + * declares exactly those members, narrowed via Pick to what the MCP helpers + * actually call. ClineProvider itself structurally satisfies this shape, so + * handing the double off as ClineProvider is a single legal assertion. + */ +type ProviderDouble = { + context: Pick + getMcpHub: () => Pick | undefined +} + +function makeProvider(servers: McpServer[] = []): ClineProvider { + const provider: ProviderDouble = { + context: { extensionPath: "/mock", globalStoragePath: "/mock", storagePath: "/mock", logPath: "/mock" }, + getMcpHub: () => ({ getServers: () => servers }), + } + return provider as ClineProvider +} + +function toolNames(tools: OpenAI.Chat.ChatCompletionTool[]): string[] { + return tools + .filter((t): t is OpenAI.Chat.ChatCompletionFunctionTool => "function" in t && Boolean(t.function)) + .map((t) => t.function.name) +} + +describe("buildNativeToolsArrayWithRestrictions — Gemini includeAllToolsWithRestrictions", () => { + const provider = makeProvider() + + it("sends all declarations but restricts allowedFunctionNames (protocol tool follows the allowlist once disabled)", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + disabledTools: ["execute_command", "attempt_completion"], + includeAllToolsWithRestrictions: true, + }) + + // All tools are still advertised (declarations), including the two + // disabled ones. + expect(toolNames(result.tools)).toContain("execute_command") + expect(toolNames(result.tools)).toContain("attempt_completion") + + // The logical set (allowedFunctionNames) honors the policy for both: + // an explicit disable of a protocol tool leaves the callable allowlist + // just like any other tool. + expect(result.allowedFunctionNames).not.toContain("attempt_completion") + expect(result.allowedFunctionNames).not.toContain("execute_command") + // Anchor: code mode still grants read_file, so the allowlist is populated. + expect(result.allowedFunctionNames).toContain("read_file") + }) + + it("flows mode filtering through the resolver into allowedFunctionNames", async () => { + const customModes: ModeConfig[] = [ + { + slug: "arch", + name: "Architect-ish", + roleDefinition: "", + groups: ["read", ["edit", { fileRegex: "\\.md$" }]], + }, + ] + + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "arch", + customModes, + experiments: {}, + apiConfiguration: undefined, + includeAllToolsWithRestrictions: true, + }) + + // The mode's groups do not include "command", so execute_command is not + // in the logical set even though it is advertised in tools. + expect(toolNames(result.tools)).toContain("execute_command") + expect(result.allowedFunctionNames).not.toContain("execute_command") + // Anchor: the mode's read group is still allowed, so the list is populated. + expect(result.allowedFunctionNames).toContain("read_file") + }) + + it("default path (flag omitted) omits disabled tools from the sent declarations", async () => { + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + disabledTools: ["execute_command"], + }) + + // Non-Gemini path: disabled tools are not sent at all. + expect(toolNames(result.tools)).not.toContain("execute_command") + expect(result.allowedFunctionNames).toBeUndefined() + }) + + it("excludes modelInfo.excludedTools from allowedFunctionNames", async () => { + const modelInfo: ModelInfo = { + contextWindow: 100_000, + supportsPromptCache: true, + excludedTools: ["read_file"], + } + + const result = await buildNativeToolsArrayWithRestrictions({ + provider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + modelInfo, + includeAllToolsWithRestrictions: true, + }) + + expect(result.allowedFunctionNames).not.toContain("read_file") + expect(result.allowedFunctionNames).toContain("attempt_completion") + }) + + it("omits dynamic MCP declarations when modelInfo.excludedTools excludes use_mcp_tool", async () => { + // The builder forwards modelInfo to the MCP filter, so a model-level + // exclusion of use_mcp_tool removes every mcp--* declaration from the + // sent tools — exactly like the user-level disable — and from + // allowedFunctionNames on the Gemini path. + const mcpProvider = makeProvider([ + { + name: "test-server", + config: "{}", + status: "connected", + tools: [{ name: "test_tool", description: "a test tool", inputSchema: { type: "object" } }], + }, + ]) + const modelInfo: ModelInfo = { + contextWindow: 100_000, + supportsPromptCache: true, + excludedTools: ["use_mcp_tool"], + } + + const result = await buildNativeToolsArrayWithRestrictions({ + provider: mcpProvider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + modelInfo, + }) + + expect(toolNames(result.tools).some((name) => name.startsWith("mcp--"))).toBe(false) + + const geminiResult = await buildNativeToolsArrayWithRestrictions({ + provider: mcpProvider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + modelInfo, + includeAllToolsWithRestrictions: true, + }) + + // The MCP declaration stays advertised (all tools are sent on this path) + // but drops out of the callable allowlist. + expect(toolNames(geminiResult.tools)).toContain("mcp--test-server--test_tool") + expect(geminiResult.allowedFunctionNames?.some((name) => name.startsWith("mcp--"))).toBe(false) + + // Positive control with a modelInfo present: an exclusion-free model + // info keeps the declarations, proving the removal above comes from the + // exclusion rather than from the modelInfo being ignored. + const controlResult = await buildNativeToolsArrayWithRestrictions({ + provider: mcpProvider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + modelInfo: { contextWindow: 100_000, supportsPromptCache: true }, + }) + + expect(toolNames(controlResult.tools)).toContain("mcp--test-server--test_tool") + }) + + it("omits dynamic MCP declarations when disabledTools disables use_mcp_tool", async () => { + // The builder threads disabledTools/modelInfo into the MCP filter, so a + // disabled use_mcp_tool removes every mcp--* declaration from the sent + // tools, and from allowedFunctionNames on the Gemini path. + const mcpProvider = makeProvider([ + { + name: "test-server", + config: "{}", + status: "connected", + tools: [{ name: "test_tool", description: "a test tool", inputSchema: { type: "object" } }], + }, + ]) + + const result = await buildNativeToolsArrayWithRestrictions({ + provider: mcpProvider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + disabledTools: ["use_mcp_tool"], + }) + + expect(toolNames(result.tools).some((name) => name.startsWith("mcp--"))).toBe(false) + + const geminiResult = await buildNativeToolsArrayWithRestrictions({ + provider: mcpProvider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + disabledTools: ["use_mcp_tool"], + includeAllToolsWithRestrictions: true, + }) + + // The MCP declaration stays advertised (all tools are sent on this path) + // but drops out of the callable allowlist. + expect(toolNames(geminiResult.tools)).toContain("mcp--test-server--test_tool") + expect(geminiResult.allowedFunctionNames?.some((name) => name.startsWith("mcp--"))).toBe(false) + }) + + it("keeps dynamic MCP declarations when use_mcp_tool is not disabled or excluded", async () => { + const mcpProvider = makeProvider([ + { + name: "test-server", + config: "{}", + status: "connected", + tools: [{ name: "test_tool", description: "a test tool", inputSchema: { type: "object" } }], + }, + ]) + + const result = await buildNativeToolsArrayWithRestrictions({ + provider: mcpProvider, + cwd: "/test/path", + mode: "code", + customModes: undefined, + experiments: {}, + apiConfiguration: undefined, + }) + + expect(toolNames(result.tools)).toContain("mcp--test-server--test_tool") + }) +}) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index ce7d058af6..9d395eaa21 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -51,6 +51,9 @@ interface BuildToolsResult { /** * Extracts the function name from a tool definition. + * + * @param tool A chat-completion tool definition (function tool in practice). + * @returns The tool's function name. */ function getToolName(tool: OpenAI.Chat.ChatCompletionTool): string { return (tool as OpenAI.Chat.ChatCompletionFunctionTool).function.name @@ -132,9 +135,14 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO allowedMcpServers, ) - // Filter MCP tools based on mode restrictions. + // Filter MCP tools based on mode restrictions and the effective tool policy: + // the same disabledTools/modelInfo the native filter consumes also gate the + // dynamic mcp--* declarations, which all represent use_mcp_tool. const mcpTools = getMcpServerTools(mcpHub, allowedMcpServers) - const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments) + const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments, { + disabledTools, + modelInfo, + }) // Add custom tools if they are available and the experiment is enabled. let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = [] diff --git a/src/core/webview/__tests__/generateSystemPrompt.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.spec.ts new file mode 100644 index 0000000000..a71598ae57 --- /dev/null +++ b/src/core/webview/__tests__/generateSystemPrompt.spec.ts @@ -0,0 +1,762 @@ +// npx vitest src/core/webview/__tests__/generateSystemPrompt.spec.ts +// +// Preview parity: generateSystemPrompt (the webview preview path) must produce +// the same CAPABILITIES / RULES / SYSTEM INFORMATION sections as a direct +// SYSTEM_PROMPT call built from the *same* inputs — including a full ModelInfo, +// so model-level excludedTools/includedTools are honored in the preview exactly +// like the runtime path. The old `{ isStealthModel }`-only typing silently +// allowed the preview to ignore them. + +vi.mock("os", () => ({ + default: { + homedir: () => "/home/user", + platform: () => "linux", + arch: () => "x64", + type: () => "Linux", + release: () => "5.4.0", + hostname: () => "test-host", + tmpdir: () => "/tmp", + endianness: () => "LE", + loadavg: () => [0, 0, 0], + totalmem: () => 8589934592, + freemem: () => 4294967296, + cpus: () => [], + networkInterfaces: () => ({}), + userInfo: () => ({ username: "test", uid: 1000, gid: 1000, shell: "/bin/bash", homedir: "/home/user" }), + }, + homedir: () => "/home/user", + platform: () => "linux", + arch: () => "x64", + type: () => "Linux", + release: () => "5.4.0", + hostname: () => "test-host", + tmpdir: () => "/tmp", + endianness: () => "LE", + loadavg: () => [0, 0, 0], + totalmem: () => 8589934592, + freemem: () => 4294967296, + cpus: () => [], + networkInterfaces: () => ({}), + userInfo: () => ({ username: "test", uid: 1000, gid: 1000, shell: "/bin/bash", homedir: "/home/user" }), +})) + +vi.mock("os-name", () => ({ + default: () => "Linux", +})) + +vi.mock("fs/promises") + +import * as vscode from "vscode" + +import type { ModelInfo } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { SYSTEM_PROMPT } from "../../prompts/system" +import { getCapabilitiesSection } from "../../prompts/sections/capabilities" +import { getRulesSection } from "../../prompts/sections/rules" +import type { EffectiveToolPolicy } from "../../prompts/tools/effective-tool-policy" +import { generateSystemPrompt } from "../generateSystemPrompt" +import type { ClineProvider } from "../ClineProvider" +import "../../../utils/path" + +// Mock vscode — generateSystemPrompt reads env.language and workspace config. +vi.mock("vscode", () => ({ + env: { + language: "en", + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/test/path" } }], + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + }), + getWorkspaceFolder: vi.fn().mockReturnValue({ uri: { fsPath: "/test/path" } }), + }, + window: { + activeTextEditor: undefined, + }, + EventEmitter: vi.fn().mockImplementation(function () { + return { + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + } + }), +})) + +// getShell feeds the command-chaining text in RULES; stub it so the real +// implementation never touches the environment. vi.hoisted keeps the double +// initialized before the hoisted module-factory mock evaluates it. +const shellMock = vi.hoisted(() => ({ shell: "/bin/zsh" })) + +vi.mock("../../../utils/shell", () => ({ + getShell: () => shellMock.shell, +})) + +// Mock the section builders that touch the filesystem / extension context so the +// parity comparison is stable and independent of workspace state. +vi.mock("../../prompts/sections/modes", () => ({ + getModesSection: vi.fn().mockImplementation(async () => `====\n\nMODES\n\n- Test modes section`), +})) + +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: vi.fn().mockImplementation(async () => ""), +})) + +// The preview must consume a *complete* ModelInfo from the API handler. This +// locks in that contract: if generateSystemPrompt ever narrows the local +// modelInfo back down, the excludedTools sub-assertion below fails. +const fullModelInfo: ModelInfo = { + contextWindow: 100_000, + supportsPromptCache: true, + excludedTools: ["read_file"], +} + +// Fallback metadata a lazily loaded router model exposes BEFORE its network +// fetch resolves. Deliberately distinct from fullModelInfo on the OUTPUT axis: +// it excludes list_files (fullModelInfo excludes read_file), so the three +// states — fallback / fetched / undefined — render three different CAPABILITIES +// sections. The parity tests only pass if generateSystemPrompt awaits +// ensureModelFetched() before reading getModel().info, and the rejection test +// below only passes if a failed fetch degrades to THIS fixture (not undefined). +const fallbackModelInfo: ModelInfo = { + contextWindow: 32_000, + supportsPromptCache: false, + excludedTools: ["list_files"], +} + +const modelMock = vi.hoisted(() => { + const state = { fetched: false } + const ensureModelFetched = vi.fn(async () => { + state.fetched = true + }) + return { state, ensureModelFetched } +}) + +// Note: the module under test imports `../../api` from src/core/webview, which +// resolves to src/api — from this spec's directory (one level deeper) that is +// `../../../api`. +vi.mock("../../../api", () => ({ + buildApiHandler: () => ({ + ensureModelFetched: modelMock.ensureModelFetched, + // The handler only knows its full metadata (incl. excludedTools) after + // ensureModelFetched() resolves, mirroring router providers. + getModel: () => ({ id: "m", info: modelMock.state.fetched ? fullModelInfo : fallbackModelInfo }), + }), +})) + +// Minimal mock ExtensionContext, mirroring the pattern in system-prompt.spec.ts. +const mockContext = { + extensionPath: "/mock/extension/path", + globalStoragePath: "/mock/storage/path", + storagePath: "/mock/storage/path", + logPath: "/mock/log/path", + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + setKeysForSync: () => {}, + }, + extensionUri: { fsPath: "/mock/extension/path" }, + globalStorageUri: { fsPath: "/mock/settings/path" }, + asAbsolutePath: (relativePath: string) => `/mock/extension/path/${relativePath}`, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, +} as unknown as vscode.ExtensionContext + +const fullSettings = { + todoListEnabled: true, + useAgentRules: true, + newTaskRequireTodos: false, +} + +describe("generateSystemPrompt preview parity", () => { + // Spy lifecycle owned by the describe (mirrors Task.spec.ts's consoleErrorSpy + // pattern): a failed assertion inside the rejection test must not leak a + // stubbed console.error into later tests. afterEach restores only this spy; + // the shared vi.fn() doubles (getStateMock, modelMock) are deliberately left + // untouched so their defaults persist for the other tests in this file + // (vi.resetAllMocks() would clobber them). + let errorSpy: ReturnType + + // The temp handler starts every test in the lazy (pre-fetch) state so the + // parity tests genuinely prove the fetch is awaited before getModel().info + // is read. + beforeEach(() => { + modelMock.state.fetched = false + modelMock.ensureModelFetched.mockClear() + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + errorSpy.mockRestore() + }) + + // Section-scoped extraction: capture the text between two "====" headers so + // the comparison is limited to the sections the tool policy drives. + function extractSection(prompt: string, header: string): string { + const marker = `\n\n${header}\n\n` + const idx = prompt.indexOf(marker) + expect(idx).toBeGreaterThan(-1) + const afterHeader = prompt.slice(idx + marker.length) + const nextMarker = afterHeader.indexOf("\n\n====") + return nextMarker === -1 ? afterHeader : afterHeader.slice(0, nextMarker) + } + + /** + * ClineProvider is a heavy class; the preview only touches these members, so + * a minimal object literal stands in for it. This is the single double + * assertion in this spec. + */ + // The preview only destructures a handful of getState() fields, so the mock + // returns that subset instead of a full ExtensionState; keeping the raw + // vi.fn() (rather than vi.mocked) avoids casting the partial doubles. + const getStateMock = vi.fn().mockResolvedValue({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4o" }, + customModePrompts: undefined, + customInstructions: undefined, + mcpEnabled: false, + experiments: {}, + language: undefined, + enableSubfolderRules: false, + disabledTools: undefined, + }) + + const fakeProvider = { + context: mockContext, + cwd: "/test/path", + getState: getStateMock, + getMcpHub: vi.fn(), + getCurrentTask: vi.fn().mockReturnValue(undefined), + getSkillsManager: vi.fn().mockReturnValue(undefined), + customModesManager: { + getCustomModes: vi.fn().mockResolvedValue([]), + }, + } as unknown as ClineProvider + + it("produces identical CAPABILITIES, RULES, and SYSTEM INFORMATION sections for the same inputs", async () => { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + // The direct SYSTEM_PROMPT call uses exactly the inputs the webview path + // builds: same disabledTools (undefined), same full modelInfo, same + // settings shape. + const direct = await SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + undefined, // mcpHub + undefined, // diffStrategy + "code", + undefined, // customModePrompts + undefined, // customModes + undefined, // globalCustomInstructions + {}, // experiments + undefined, // language + undefined, // rooIgnoreInstructions + fullSettings, // settings + undefined, // todoList + undefined, // modelId + undefined, // skillsManager + undefined, // disabledTools + fullModelInfo, // modelInfo + ) + + for (const header of ["CAPABILITIES", "RULES", "SYSTEM INFORMATION"]) { + expect(extractSection(preview, header)).toEqual(extractSection(direct, header)) + } + }) + + it("honors the full modelInfo.excludedTools in the preview output", async () => { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + const capabilities = extractSection(preview, "CAPABILITIES") + + // read_file is excluded by the model info: no "read files" clause. + expect(capabilities).not.toContain("read files") + // Other clauses survive, proving the exclusion is scoped to that tool. + expect(capabilities).toContain("execute CLI commands") + }) + + it("awaits ensureModelFetched before reading model info", async () => { + // A lazily loaded router model exposes only fallback metadata until the + // fetch resolves. The preview must await ensureModelFetched() first, or + // it would build tool guidance from the fallback metadata (which excludes + // list_files, not read_file) and diverge from the runtime path. + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(modelMock.ensureModelFetched).toHaveBeenCalledTimes(1) + // "read files" only appears with the fallback metadata; the preview must + // reflect the fetched model info instead. + const capabilities = extractSection(preview, "CAPABILITIES") + expect(capabilities).not.toContain("read files") + expect(capabilities).toContain("execute CLI commands") + }) + + it("falls back to handler model info when ensureModelFetched rejects", async () => { + // A network failure must not drop model guidance entirely: the runtime + // path (Task.safeEnsureModelFetched) degrades to getModel().info + // fallback metadata, and the preview must do the same instead of + // passing modelInfo = undefined to SYSTEM_PROMPT. The fixtures make + // the three states distinguishable: fallbackModelInfo excludes + // list_files, fullModelInfo excludes read_file, and undefined excludes + // neither — so the assertion pair below pins the prompt to the + // fallback fixture, and fails if the inner try/catch is removed: the + // rejection would then skip getModel() and the prompt would be built + // with modelInfo === undefined. + modelMock.ensureModelFetched.mockRejectedValueOnce(new Error("network down")) + + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + const capabilities = extractSection(preview, "CAPABILITIES") + // Absent only when modelInfo === fallbackModelInfo (its exclusion). + expect(capabilities).not.toContain("list files") + // Present only when read_file was NOT excluded — rules out fullModelInfo. + expect(capabilities).toContain("read files") + expect(capabilities).toContain("execute CLI commands") + expect(errorSpy).toHaveBeenCalled() + // The context string is part of the contract: an empty or generic log + // line would erase the only trace of a degraded preview. + expect(errorSpy).toHaveBeenCalledWith( + "Error fetching model metadata for system prompt preview:", + expect.anything(), + ) + }) + + it("degrades to fallback metadata when ensureModelFetched hangs past the preview timeout", async () => { + // A hung metadata endpoint (some fetchers issue unbounded GETs) must not + // block the user-triggered preview: after PREVIEW_MODEL_FETCH_TIMEOUT_MS + // (5s) the race resolves and the prompt is built from the fallback + // metadata, identical to the rejected-fetch degradation. + vi.useFakeTimers() + try { + modelMock.ensureModelFetched.mockImplementationOnce(() => new Promise(() => {})) + + const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + await vi.advanceTimersByTimeAsync(5_000) + const preview = await previewPromise + + const capabilities = extractSection(preview, "CAPABILITIES") + // Fallback fixture signature (see fallbackModelInfo): list_files + // excluded, read_file still advertised — proves fallback metadata, + // not undefined (which would advertise both) and not fullModelInfo + // (which would drop "read files"). + expect(capabilities).not.toContain("list files") + expect(capabilities).toContain("read files") + } finally { + vi.useRealTimers() + } + }) + + it("omits command guidance from the preview when execute_command is disabled", async () => { + // The preview must forward state.disabledTools to SYSTEM_PROMPT: with + // execute_command disabled, the CAPABILITIES section drops every + // command-related fragment. The once-value overrides the shared default + // without mutating it for other tests. + getStateMock.mockResolvedValueOnce({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, apiModelId: "gpt-4o" }, + customModePrompts: undefined, + customInstructions: undefined, + mcpEnabled: false, + experiments: {}, + language: undefined, + enableSubfolderRules: false, + disabledTools: ["execute_command"], + }) + + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + const capabilities = extractSection(preview, "CAPABILITIES") + + expect(capabilities).not.toContain("execute CLI commands") + expect(capabilities).not.toContain("You can use the execute_command tool") + // Anchor: the section is still populated, proving only execute_command + // guidance was removed. + expect(capabilities).toContain("list files") + }) + + it("resolves when settings are omitted instead of dereferencing them", async () => { + // generatePrompt reads `settings?.todoListEnabled`; without the optional + // chain this call rejects with a TypeError on the undefined settings object. + const prompt = await SYSTEM_PROMPT( + mockContext, + "/test/path", + false, + undefined, // mcpHub + undefined, // diffStrategy + "code", + undefined, // customModePrompts + undefined, // customModes + undefined, // globalCustomInstructions + {}, // experiments + undefined, // language + undefined, // rooIgnoreInstructions + undefined, // settings -> exercises the `settings?.` optional chain + ) + + expect(prompt).toContain("OBJECTIVE") + }) + + describe("preview metadata-fetch robustness", () => { + it("skips the metadata fetch silently when the handler has no ensureModelFetched", async () => { + // Providers without lazy model discovery legitimately lack + // ensureModelFetched: the optional call must skip it and still build + // the preview from the handler's current metadata, without logging. + // The property is redefined to undefined on the shared double (then + // restored) because the mocked factory reads it per buildApiHandler() + // call, so a missing method reaches the code under test untyped. + const descriptor = Object.getOwnPropertyDescriptor(modelMock, "ensureModelFetched") + Object.defineProperty(modelMock, "ensureModelFetched", { + value: undefined, + configurable: true, + writable: true, + }) + try { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(errorSpy).not.toHaveBeenCalled() + const capabilities = extractSection(preview, "CAPABILITIES") + // Fallback-fixture signature (see fallbackModelInfo): the preview is + // still built from a complete ModelInfo, not from undefined. + expect(capabilities).not.toContain("list files") + expect(capabilities).toContain("read files") + } finally { + if (descriptor) { + Object.defineProperty(modelMock, "ensureModelFetched", descriptor) + } + } + }) + + it("clears the pending preview timer once the fetch resolves first", async () => { + vi.useFakeTimers() + try { + modelMock.ensureModelFetched.mockResolvedValueOnce(undefined) + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + // The fetch won the race, so the still-pending timeout must have been + // cancelled inside the same turn; a leftover timer means every fast + // preview leaves a five-second handle behind. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it("resolves the preview race exactly at the fetch timeout bound", async () => { + // The race bound is an absolute wall: a hung endpoint must be released + // precisely after 5000 ms, never a tick earlier, so a slow-but-alive + // fetch still wins at 4999 ms. + vi.useFakeTimers() + try { + modelMock.ensureModelFetched.mockImplementationOnce(() => new Promise(() => {})) + + let settled = false + const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }).then( + (prompt) => { + settled = true + return prompt + }, + ) + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + const preview = await previewPromise + + // Degradation at the bound mirrors the rejected-fetch path: fallback + // metadata, and no error logged (a timeout is not a failure). + const capabilities = extractSection(preview, "CAPABILITIES") + expect(capabilities).not.toContain("list files") + expect(capabilities).toContain("read files") + expect(errorSpy).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it("aborts the handler signal when the preview fetch times out", async () => { + // The preview's bound must detach the handler-side waiter, mirroring + // the runtime path: a signal-observing handler stops serving the + // abandoned fetch once the bound expires. + let capturedSignal: AbortSignal | undefined + modelMock.ensureModelFetched.mockImplementationOnce((signal?: AbortSignal) => { + capturedSignal = signal + return new Promise(() => {}) + }) + // Both abort sites are pinned by count: the timeout callback fires + // exactly when the bound elapses — detaching a hung waiter before + // the prompt is even built — and the finally block re-aborts on + // completion. Deleting either call leaves the other as the sole, + // strictly-too-late detach, and the count drops to one. + const abortSpy = vi.spyOn(AbortController.prototype, "abort") + + vi.useFakeTimers() + try { + const previewPromise = generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + await vi.advanceTimersByTimeAsync(5_000) + // Both abort sites have fired by the time the preview resolves: + // the finally block runs before generateSystemPrompt returns, so + // the count is asserted while the spy still holds its history + // (mockRestore would clear it). + await previewPromise + expect(abortSpy).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + abortSpy.mockRestore() + } + expect(capturedSignal?.aborted).toBe(true) + }) + + it("aborts the handler signal after a fast fetch so the waiter detaches on completion", async () => { + // The finally-block detach also covers the fetch-wins path: a + // signal-observing handler must not keep serving waiters for a + // preview that already finished. Without the finally abort, the + // captured signal is never aborted on this path (no timer fires). + let capturedSignal: AbortSignal | undefined + modelMock.ensureModelFetched.mockImplementationOnce((signal?: AbortSignal) => { + capturedSignal = signal + return Promise.resolve() + }) + + await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(capturedSignal?.aborted).toBe(true) + }) + + it("logs and degrades when the model info cannot be read", async () => { + // A throw while reading the model info escapes the fetch race and lands + // in the outer handler: the preview must still resolve — without model + // guidance — and log the outer-catch context string. The state double + // is swapped for a throwing getter because the mocked factory reads it + // inside getModel().info, which is the read the preview performs. + const stateDescriptor = Object.getOwnPropertyDescriptor(modelMock, "state") + Object.defineProperty(modelMock, "state", { + value: { + get fetched(): never { + throw new Error("model info unavailable") + }, + }, + configurable: true, + writable: true, + }) + try { + const preview = await generateSystemPrompt(fakeProvider, { type: "mode", mode: "code" }) + + expect(errorSpy).toHaveBeenCalledWith( + "Error reading model info for system prompt preview:", + expect.anything(), + ) + const capabilities = extractSection(preview, "CAPABILITIES") + // modelInfo === undefined excludes nothing: both clause families appear. + expect(capabilities).toContain("read files") + expect(capabilities).toContain("list files") + } finally { + if (stateDescriptor) { + Object.defineProperty(modelMock, "state", stateDescriptor) + } + } + }) + }) +}) + +// --------------------------------------------------------------------------- +// Raw-policy fragment tests for the CAPABILITIES and RULES builders: drives the +// branch cells the resolver-backed specs in core/prompts/__tests__/sections.spec.ts +// cannot produce (policy objects are built directly, bypassing the resolver). +// --------------------------------------------------------------------------- +describe("getCapabilitiesSection / getRulesSection fragment gating", () => { + const cwd = "/test/path" + const settings = { ...fullSettings } + + /** + * Raw policy double: the section builders only read `tools` plus the MCP and + * edit-restriction fields, so a literal captures every branch the resolver + * could produce for these two sections. + */ + function sectionPolicy( + tools: string[], + extra: Partial< + Pick + > = {}, + ): EffectiveToolPolicy { + return { + tools: new Set(tools), + hasMcpGroup: false, + hasMcpTools: false, + hasMcpResources: false, + ...extra, + } + } + + describe("getCapabilitiesSection", () => { + it("emits every clause and paragraph when all capability tools are advertised", () => { + const result = getCapabilitiesSection( + sectionPolicy( + [ + "execute_command", + "list_files", + "codebase_search", + "search_files", + "read_file", + "write_to_file", + "apply_diff", + ], + { hasMcpGroup: true, hasMcpTools: true }, + ), + ) + + expect(result).toContain("====\n\nCAPABILITIES\n\n") + expect(result).toContain( + "You have access to tools that let you execute CLI commands on the user's computer, list files, semantically search the codebase, regex search, read files, write and edit files.", + ) + expect(result).toContain("\n- These tools help you accomplish tasks.\n") + expect(result).toContain("you can use the list_files tool") + expect(result).toContain("You can use the execute_command tool to run commands on the user's computer") + expect(result).toContain( + "You have access to MCP servers that may provide additional tools and/or resources", + ) + expect(result).not.toContain("Stryker was here") + // The trailing newline is trimmed; the result must end with the last bullet. + expect(result.endsWith("accomplish tasks more effectively.")).toBe(true) + }) + + it("falls back to the limited-tools sentence and omits every fragment when no capability tools are advertised", () => { + const result = getCapabilitiesSection(sectionPolicy([])) + + expect(result).toContain( + "You have access to a limited set of tools for this mode; only the tools you are provided may be called.", + ) + expect(result).not.toContain("You have access to tools that let you") + expect(result).not.toContain("execute CLI commands") + expect(result).not.toContain("list files") + expect(result).not.toContain("semantically search the codebase") + expect(result).not.toContain("regex search") + expect(result).not.toContain("read files") + expect(result).not.toContain("write and edit files") + expect(result).not.toContain("you can use the list_files tool") + expect(result).not.toContain("You can use the execute_command tool") + expect(result).not.toContain("MCP servers") + }) + + it("gates each clause on exactly its advertised tool", () => { + expect(getCapabilitiesSection(sectionPolicy(["list_files"]))).toContain( + "You have access to tools that let you list files.", + ) + expect(getCapabilitiesSection(sectionPolicy(["codebase_search"]))).toContain( + "You have access to tools that let you semantically search the codebase.", + ) + expect(getCapabilitiesSection(sectionPolicy(["search_files"]))).toContain( + "You have access to tools that let you regex search.", + ) + expect(getCapabilitiesSection(sectionPolicy(["search_files"]))).not.toContain( + "semantically search the codebase", + ) + expect(getCapabilitiesSection(sectionPolicy(["read_file"]))).toContain( + "You have access to tools that let you read files.", + ) + expect(getCapabilitiesSection(sectionPolicy(["write_to_file"]))).toContain("write and edit files") + expect(getCapabilitiesSection(sectionPolicy(["apply_diff"]))).toContain("write and edit files") + expect(getCapabilitiesSection(sectionPolicy(["read_file"]))).not.toContain("write and edit files") + }) + }) + + describe("getRulesSection", () => { + it("includes every tool-gated fragment when all relevant tools are advertised", () => { + const result = getRulesSection( + cwd, + settings, + sectionPolicy( + [ + "execute_command", + "ask_followup_question", + "list_files", + "read_file", + "write_to_file", + "attempt_completion", + ], + { editRestriction: { fileRegex: "\\.md$" } }, + ), + ) + + expect(result).toContain("====\n\nRULES\n\n- ") + expect(result).toContain("The project base directory is: /test/path") + expect(result).toContain( + "All file paths must be relative to this directory. However, commands may change directories in terminals, so respect working directory specified by the response to execute_command.", + ) + expect(result).toContain("You are stuck operating from '/test/path'") + expect(result).toContain("Do not use the ~ character or $HOME to refer to the home directory.") + expect(result).toContain( + "Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context", + ) + expect(result).toContain("Some modes have restrictions on which files they can edit") + expect(result).toContain("Be sure to consider the type of project") + expect(result).toContain("When making changes to code, always consider the context") + expect(result).toContain("Do not ask for more information than necessary") + expect(result).toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(result).toContain("you should use the list_files tool to list the files in the Desktop") + expect(result).not.toContain("Provide your best-effort result") + expect(result).toContain("When executing commands, if you don't see the expected output") + expect(result).toContain( + "use the ask_followup_question tool to request the user to copy and paste it back to you", + ) + expect(result).not.toContain("note what you expected and proceed with the task") + expect(result).toContain("The user may provide a file's contents directly") + expect(result).toContain( + "Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.", + ) + expect(result).toContain("NEVER end attempt_completion result with a question") + expect(result).toContain("STRICTLY FORBIDDEN from starting your messages") + expect(result).toContain("When presented with images, utilize your vision capabilities") + expect(result).toContain("you will automatically receive environment_details") + expect(result).toContain('"Actively Running Terminals"') + expect(result).toContain("It is critical you wait for the user's response after each tool use") + expect(result).not.toContain("MCP operations should be used one at a time") + expect(result).not.toContain("VENDOR CONFIDENTIALITY") + // join separator: rules are bulleted one per line, not concatenated + expect(result).toContain("/test/path\n- All file paths must be relative") + expect(result).not.toContain("Stryker was here") + }) + + it("keeps the ask guidance but drops the list_files example when only ask_followup_question is advertised", () => { + const result = getRulesSection(cwd, settings, sectionPolicy(["ask_followup_question"])) + + expect(result).toContain( + "You are only allowed to ask the user questions using the ask_followup_question tool", + ) + expect(result).not.toContain("the list_files tool") + expect(result).not.toContain("Stryker was here!") + }) + + it("emits the MCP usage rule only when the mcp group is present and tools or resources are effective", () => { + const mcpRule = "MCP operations should be used one at a time" + + expect( + getRulesSection(cwd, settings, sectionPolicy([], { hasMcpGroup: true, hasMcpTools: true })), + ).toContain(mcpRule) + expect( + getRulesSection(cwd, settings, sectionPolicy([], { hasMcpGroup: true, hasMcpResources: true })), + ).toContain(mcpRule) + expect(getRulesSection(cwd, settings, sectionPolicy([], { hasMcpGroup: true }))).not.toContain(mcpRule) + expect( + getRulesSection(cwd, settings, sectionPolicy([], { hasMcpTools: true, hasMcpResources: true })), + ).not.toContain(mcpRule) + }) + + it("tolerates undefined settings and emits vendor confidentiality only for stealth models", () => { + const full = sectionPolicy(["execute_command", "ask_followup_question", "list_files", "read_file"]) + + // The `settings?.isStealthModel` optional chain must survive an undefined settings + // object; dropping the chain throws a TypeError inside getRulesSection. + expect(() => getRulesSection(cwd, undefined, full)).not.toThrow() + expect(getRulesSection(cwd, undefined, full)).not.toContain("VENDOR CONFIDENTIALITY") + expect(getRulesSection(cwd, { ...settings, isStealthModel: true }, full)).toContain( + "VENDOR CONFIDENTIALITY", + ) + }) + }) +}) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index 8af2f5ff5d..c9fb1b47c9 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import type { ModelInfo } from "@roo-code/types" import { WebviewMessage } from "../../shared/WebviewMessage" import { defaultModeSlug } from "../../shared/modes" import { buildApiHandler } from "../../api" @@ -9,6 +10,14 @@ import { Package } from "../../shared/package" import { ClineProvider } from "./ClineProvider" +// Upper bound on the preview's wait for lazily loaded model metadata. The +// preview is a user-triggered UI action: some model-catalog fetchers (e.g. +// OpenRouter's bare axios GET) have no request timeout, so a hung endpoint +// must not block it indefinitely. On timeout we degrade to the handler's +// fallback metadata — the same degradation a rejected fetch produces — and +// the next preview re-attempts after the (persistent) cache refreshes. +const PREVIEW_MODEL_FETCH_TIMEOUT_MS = 5_000 + export const generateSystemPrompt = async (provider: ClineProvider, message: WebviewMessage) => { const { apiConfiguration, @@ -18,6 +27,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web experiments, language, enableSubfolderRules, + disabledTools, } = await provider.getState() const diffStrategy = new MultiSearchReplaceDiffStrategy() @@ -29,14 +39,46 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web const rooIgnoreInstructions = provider.getCurrentTask()?.rooIgnoreController?.getInstructions() - // Create a temporary API handler to check model info for stealth mode. + // Create a temporary API handler to fetch the full model info for the preview. // This avoids relying on an active Cline instance which might not exist during preview. - let modelInfo: { isStealthModel?: boolean } | undefined + // The full ModelInfo flows into SYSTEM_PROMPT so the preview honors + // excludedTools/includedTools exactly like the runtime path. + // ensureModelFetched() must be awaited before reading getModel().info: + // router providers discover model metadata over the network, and reading the + // info beforehand would build the preview from fallback metadata with different + // tool guidance than the runtime path (which fetches before tool construction). + let modelInfo: ModelInfo | undefined try { const tempApiHandler = buildApiHandler(apiConfiguration) + // A failed OR stalled metadata fetch degrades to the handler's fallback + // metadata (mirroring Task.safeEnsureModelFetched) rather than dropping + // model guidance entirely, so the preview keeps matching the runtime + // prompt's failure semantics. The controller's signal makes the handler's + // waiter settle when the bound expires or this call ends, instead of + // leaving it pending on the shared catalog fetch. Promise.race attaches + // handlers to both inputs, so the fetch rejecting after the timeout is + // already considered handled — no extra .catch is needed here. + let timeoutId: ReturnType | undefined + const controller = new AbortController() + try { + await Promise.race([ + tempApiHandler.ensureModelFetched?.(controller.signal), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + controller.abort() + resolve() + }, PREVIEW_MODEL_FETCH_TIMEOUT_MS) + }), + ]) + } catch (error) { + console.error("Error fetching model metadata for system prompt preview:", error) + } finally { + clearTimeout(timeoutId) + controller.abort() + } modelInfo = tempApiHandler.getModel().info } catch (error) { - console.error("Error fetching model info for system prompt preview:", error) + console.error("Error reading model info for system prompt preview:", error) } const systemPrompt = await SYSTEM_PROMPT( @@ -64,6 +106,8 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web undefined, // todoList undefined, // modelId provider.getSkillsManager(), + disabledTools, + modelInfo, ) return systemPrompt diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0e5207046c..93741e9174 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -761,7 +761,7 @@ }, "core/prompts/tools/filter-tools-for-mode.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 1 } }, "core/prompts/tools/native-tools/__tests__/converters.spec.ts": { From 332b83f22cba0dffdee636a92b96522a0402b37d Mon Sep 17 00:00:00 2001 From: "zoomote[bot]" <305051434+zoomote[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:31:55 +0000 Subject: [PATCH 09/21] [Fix] Awaiting-author label clears before maintainer re-review after author pushes (#1672) * fix(ci): preserve awaiting-author until maintainer re-review (#1671) * perf(ci): memoize collaborator permission lookups in review-state workflow --------- Co-authored-by: roomote[bot] --- .github/workflows/label-pr-review-state.yml | 98 ++++- .../pr-review-state-workflow.test.ts | 408 +++++++++++++++++- 2 files changed, 497 insertions(+), 9 deletions(-) diff --git a/.github/workflows/label-pr-review-state.yml b/.github/workflows/label-pr-review-state.yml index 0df64ffd2b..98f7428c7a 100644 --- a/.github/workflows/label-pr-review-state.yml +++ b/.github/workflows/label-pr-review-state.yml @@ -14,7 +14,7 @@ on: # This workflow only reads PR metadata and never checks out or executes PR code. # pull_request_target gives fork PRs a token that can update labels and comments. pull_request_target: - types: [opened, reopened, ready_for_review, synchronize, review_requested, labeled, unlabeled] + types: [opened, reopened, ready_for_review, synchronize, review_requested, review_request_removed, labeled, unlabeled] pull_request_review: types: [submitted, dismissed] # Fork review events have a read-only token. CodeRabbit's status-comment update @@ -316,14 +316,24 @@ jobs: return match?.[1] ?? null; } + // Collaborator permissions are repository-level, so memoize them for + // the whole run: a maintainer's current-head CHANGES_REQUESTED reaches + // both review loops, and scheduled sweeps reconcile every open PR. + const permissionCache = new Map(); async function permissionFor(username) { + const key = username.toLowerCase(); + if (permissionCache.has(key)) return permissionCache.get(key); try { const result = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username, }); + permissionCache.set(key, result.data.permission); return result.data.permission; } catch (error) { - if (error.status === 404) return 'none'; + if (error.status === 404) { + permissionCache.set(key, 'none'); + return 'none'; + } throw error; } } @@ -339,7 +349,7 @@ jobs: 'coderabbit-changes': 'Address automated review findings and push fixes.', coderabbit: 'Required CI passed. Waiting for automated review of the latest commit.', 'draft-approved': 'Automated review complete for the latest commit. Mark the draft ready.', - 'maintainer-changes': 'Address maintainer or CODEOWNER feedback, then push an update.', + 'maintainer-changes': 'Address maintainer or CODEOWNER feedback, push an update, then re-request review from the blocking maintainer.', maintainer: 'Awaiting fresh human maintainer or CODEOWNER approval.', approved: 'The required review sequence passed. Remaining merge requirements apply.', }; @@ -641,6 +651,81 @@ jobs: } } + // Durable per-maintainer change-request blockers (issue #1671). + // Unlike approvals and CodeRabbit reviews, a human maintainer's + // CHANGES_REQUESTED stays binding across author pushes, base-branch + // merges, CI runs, and CodeRabbit reviews until that same + // maintainer's blocker is cleared by one of: + // 1. the PR author explicitly re-requesting review from them, + // 2. a newer review from that maintainer (its state decides), or + // 3. GitHub dismissing the blocking review. + // Latest state per reviewer is keyed by review id (monotonically + // increasing) so reordered or duplicate history cannot change the + // result. COMMENTED reviews are neutral and never clear a blocker; + // a DISMISSED latest review clears it. + const latestHumanReview = new Map(); + for (const r of reviews) { + const reviewer = r.user?.login?.toLowerCase(); + if (!reviewer || + r.user?.type === 'Bot' || + codeRabbitLogins.has(reviewer) || + reviewer === pr.user?.login?.toLowerCase() || + r.state === 'COMMENTED') { + continue; + } + const previous = latestHumanReview.get(reviewer); + if (!previous || r.id > previous.id) { + latestHumanReview.set(reviewer, r); + } + } + const maintainerBlockers = new Map(); + for (const [reviewer, review] of latestHumanReview) { + if (review.state !== 'CHANGES_REQUESTED') continue; + if (['admin', 'maintain', 'write'].includes(await permissionFor(review.user.login))) { + maintainerBlockers.set(reviewer, review); + } + } + + // Clear blockers the author explicitly re-requested. Only a + // review_requested timeline event whose actor is the PR author and + // whose requested reviewer is the blocking maintainer clears that + // maintainer's blocker. Team requests carry no requested_reviewer + // and never clear an individual blocker; review_request_removed + // events only trigger reconciliation and are not clearing evidence. + // If the timeline cannot be reconstructed, fail closed: keep every + // blocker so awaiting-author is preserved. + if (maintainerBlockers.size > 0) { + let timelineEvents = null; + try { + timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, repo, issue_number: pr.number, per_page: 100, + }); + } catch (error) { + core.warning( + `PR #${pr.number}: could not reconstruct review-request history; ` + + `preserving maintainer blockers: ${error.message}` + ); + } + if (timelineEvents) { + const authorLogin = pr.user?.login?.toLowerCase(); + for (const event of timelineEvents) { + if (event.event !== 'review_requested') continue; + if (event.actor?.login?.toLowerCase() !== authorLogin) continue; + const requested = event.requested_reviewer?.login?.toLowerCase(); + if (!requested) continue; + const blocker = maintainerBlockers.get(requested); + if (!blocker) continue; + const requestedAt = Date.parse(event.created_at ?? ''); + const blockedAt = Date.parse(blocker.submitted_at ?? ''); + // A re-request only clears blockers it follows; missing or + // unparsable timestamps fail closed and keep the blocker. + if (!Number.isNaN(requestedAt) && !Number.isNaN(blockedAt) && requestedAt >= blockedAt) { + maintainerBlockers.delete(requested); + } + } + } + } + const codeRabbitReview = latest.get(codeRabbitLogin); const freshCodeRabbitReview = codeRabbitReview?.commit_id === pr.head.sha ? codeRabbitReview @@ -657,9 +742,6 @@ jobs: freshMaintainerReviews.push(review); } } - const maintainerChangeRequest = freshMaintainerReviews.find( - review => review.state === 'CHANGES_REQUESTED' - ); const automatedAuthor = pr.user?.type === 'Bot'; const codeRabbitEligibleAuthor = !automatedAuthor || codeRabbitEligibleBotLogins.has(pr.user?.login.toLowerCase()); @@ -676,7 +758,7 @@ jobs: let phase; let activateCodeRabbit = false; let recycleCodeRabbitLabel = false; - if (codeRabbitChangesRequested || maintainerChangeRequest) { + if (codeRabbitChangesRequested || maintainerBlockers.size > 0) { desiredLabel = 'awaiting-author'; phase = codeRabbitChangesRequested ? 'coderabbit-changes' : 'maintainer-changes'; } else if (!codeRabbitEligibleAuthor) { @@ -740,7 +822,7 @@ jobs: core.info( `PR #${pr.number}: CI passing, reviews=${latest.size}, ` + `coderabbit=${freshCodeRabbitReview?.state ?? (codeRabbitEligibleAuthor ? 'pending' : 'optional')}, ` + - `maintainer=${maintainerApproval?.state ?? 'pending'} → ${desiredLabel ?? '(none)'}` + `maintainer=${maintainerApproval?.state ?? 'pending'}, blockers=${maintainerBlockers.size} → ${desiredLabel ?? '(none)'}` ); const readyForMaintainer = phase === 'maintainer' || phase === 'approved'; diff --git a/src/services/__tests__/pr-review-state-workflow.test.ts b/src/services/__tests__/pr-review-state-workflow.test.ts index b7329719c6..9cb178f774 100644 --- a/src/services/__tests__/pr-review-state-workflow.test.ts +++ b/src/services/__tests__/pr-review-state-workflow.test.ts @@ -54,7 +54,16 @@ interface HarnessOptions { state: ReviewState submittedAt: number commitId?: string + id?: number + }> + timelineEvents?: Array<{ + event: string + actor?: string + requestedReviewer?: string + requestedTeam?: string + createdAt?: number }> + timelineErrorStatus?: number permissions?: Record permissionErrorStatus?: number requiredContexts?: string[] @@ -141,7 +150,7 @@ async function runWorkflow(options: HarnessOptions = {}) { : []), ] const reviews = (options.reviews ?? []).map((review, index) => ({ - id: index + 1, + id: review.id ?? index + 1, state: review.state, commit_id: review.commitId ?? SHA, submitted_at: new Date(review.submittedAt).toISOString(), @@ -210,6 +219,19 @@ async function runWorkflow(options: HarnessOptions = {}) { } return existingComments }) + const listEventsForTimeline = vi.fn(async () => { + if (options.timelineErrorStatus) { + throw Object.assign(new Error("List timeline events failed"), { status: options.timelineErrorStatus }) + } + return (options.timelineEvents ?? []).map((event, index) => ({ + id: index + 1, + event: event.event, + created_at: new Date(event.createdAt ?? REVIEWED_AT).toISOString(), + actor: event.actor ? { login: event.actor } : undefined, + requested_reviewer: event.requestedReviewer ? { login: event.requestedReviewer } : undefined, + requested_team: event.requestedTeam ? { slug: event.requestedTeam } : undefined, + })) + }) const createCommitStatus = vi.fn( async (args: { sha: string; state: string; context: string; description: string; target_url: string }) => { if (options.createCommitStatusErrorStatus) { @@ -295,6 +317,7 @@ async function runWorkflow(options: HarnessOptions = {}) { removeLabel, addLabels, listComments, + listEventsForTimeline, createComment, updateComment, }, @@ -393,6 +416,7 @@ async function runWorkflow(options: HarnessOptions = {}) { createComment, updateComment, listComments, + listEventsForTimeline, createCommitStatus, createLabel, setFailed, @@ -401,6 +425,7 @@ async function runWorkflow(options: HarnessOptions = {}) { getPullRequest, listPullRequests: github.rest.pulls.list, listCommitStatusesForRef: github.rest.repos.listCommitStatusesForRef, + permissionFor, } } @@ -1829,4 +1854,385 @@ describe("PR review-state workflow", () => { expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) expect(latestGateStatus(result)?.state).toBe("pending") }) + + describe("maintainer change-request blockers (#1671)", () => { + const coderabbitApproval = { + login: "coderabbitai[bot]", + type: "Bot" as const, + state: "APPROVED" as const, + submittedAt: REVIEWED_AT, + } + const staleMaintainerChangeRequest = { + login: "maintainer", + type: "User" as const, + state: "CHANGES_REQUESTED" as const, + submittedAt: REVIEWED_AT + 1_000, + commitId: OLD_SHA, + } + const authorReRequest = { + event: "review_requested", + actor: "contributor", + requestedReviewer: "maintainer", + createdAt: REVIEWED_AT + 2_000, + } + + it("reconciles on review_request_removed without treating removal as clearing evidence", async () => { + expect(workflow.on.pull_request_target.types).toContain("review_request_removed") + + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + timelineEvents: [ + { + event: "review_request_removed", + actor: "contributor", + requestedReviewer: "maintainer", + createdAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(result.setFailed).not.toHaveBeenCalled() + }) + + it("keeps awaiting-author after an author push without a re-request", async () => { + const result = await runWorkflow({ + labels: ["awaiting-author"], + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["awaiting-maintainer"] }), + ) + expect(result.removeLabel).not.toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-author" })) + expect(latestGuide(result)).toContain("re-request review") + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("keeps the blocker after a base-only merge", async () => { + const result = await runWorkflow({ + eventName: "push", + labels: ["awaiting-author"], + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + }) + + expect(result.removeLabel).not.toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-author" })) + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["awaiting-maintainer"] }), + ) + }) + + it("does not clear a human blocker with a current-head CodeRabbit approval", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("does not clear the blocker when another maintainer approves", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write", approver: "admin" }, + reviews: [ + coderabbitApproval, + staleMaintainerChangeRequest, + { + login: "approver", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("clears the blocker when the author re-requests review from the blocking maintainer", async () => { + const result = await runWorkflow({ + labels: ["awaiting-author"], + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + timelineEvents: [authorReRequest], + }) + + expect(result.removeLabel).toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-author" })) + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("clears the blocker when the blocking maintainer submits a newer approval", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + coderabbitApproval, + staleMaintainerChangeRequest, + { + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(latestGateStatus(result)?.state).toBe("success") + expect(latestGateStatus(result)?.description).toContain("required review sequence passed") + }) + + it("keeps the blocker when the blocking maintainer only comments", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + coderabbitApproval, + staleMaintainerChangeRequest, + { + login: "maintainer", + type: "User", + state: "COMMENTED", + submittedAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("re-blocks when the maintainer's newer review also requests changes", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + coderabbitApproval, + staleMaintainerChangeRequest, + { + login: "maintainer", + type: "User", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT + 3_000, + }, + ], + timelineEvents: [authorReRequest], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("clears the blocker when the blocking review is dismissed", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + coderabbitApproval, + { + login: "maintainer", + type: "User", + state: "DISMISSED", + submittedAt: REVIEWED_AT + 1_000, + commitId: OLD_SHA, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("keeps multiple maintainer blockers independent", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write", "second-maintainer": "maintain" }, + reviews: [ + coderabbitApproval, + staleMaintainerChangeRequest, + { + login: "second-maintainer", + type: "User", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT + 1_500, + commitId: OLD_SHA, + }, + ], + timelineEvents: [authorReRequest], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(latestGateStatus(result)?.state).toBe("pending") + }) + + it("produces the same blocker state for reordered and duplicate timeline events", async () => { + const reordered = await runWorkflow({ + labels: ["awaiting-author"], + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + timelineEvents: [ + authorReRequest, + { + event: "review_requested", + actor: "contributor", + requestedReviewer: "maintainer", + createdAt: REVIEWED_AT + 2_000, + }, + { + event: "review_request_removed", + actor: "maintainer", + requestedReviewer: "maintainer", + createdAt: REVIEWED_AT + 3_000, + }, + authorReRequest, + ], + }) + + expect(reordered.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ labels: ["awaiting-maintainer"] }), + ) + expect(reordered.setFailed).not.toHaveBeenCalled() + }) + + it("produces the same blocker state for reordered review history", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + { + id: 3, + login: "maintainer", + type: "User", + state: "APPROVED", + submittedAt: REVIEWED_AT + 2_000, + }, + { ...staleMaintainerChangeRequest, id: 2 }, + { ...coderabbitApproval, id: 1 }, + ], + }) + + expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(latestGateStatus(result)?.state).toBe("success") + }) + + it("fails closed when timeline reconstruction is incomplete", async () => { + const result = await runWorkflow({ + labels: ["awaiting-author"], + permissions: { maintainer: "write" }, + timelineErrorStatus: 500, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + }) + + expect(result.warning).toHaveBeenCalledWith( + expect.stringContaining("could not reconstruct review-request history"), + ) + expect(result.removeLabel).not.toHaveBeenCalledWith(expect.objectContaining({ name: "awaiting-author" })) + expect(result.addLabels).not.toHaveBeenCalledWith( + expect.objectContaining({ labels: ["awaiting-maintainer"] }), + ) + expect(result.setFailed).not.toHaveBeenCalled() + }) + + it("does not clear an individual blocker for a team review request", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + timelineEvents: [ + { + event: "review_requested", + actor: "contributor", + requestedTeam: "maintainers", + createdAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("does not clear the blocker when a non-author re-requests review", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write", "other-maintainer": "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + timelineEvents: [ + { + event: "review_requested", + actor: "other-maintainer", + requestedReviewer: "maintainer", + createdAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("does not clear the blocker for a re-request that predates the blocking review", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + timelineEvents: [ + { + event: "review_requested", + actor: "contributor", + requestedReviewer: "maintainer", + createdAt: REVIEWED_AT + 500, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("does not clear the blocker for a re-request naming a different reviewer", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write", "other-maintainer": "write" }, + reviews: [coderabbitApproval, staleMaintainerChangeRequest], + timelineEvents: [ + { + event: "review_requested", + actor: "contributor", + requestedReviewer: "other-maintainer", + createdAt: REVIEWED_AT + 2_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + }) + + it("memoizes collaborator permission lookups across both review loops", async () => { + const result = await runWorkflow({ + permissions: { maintainer: "write" }, + reviews: [ + coderabbitApproval, + { + login: "maintainer", + type: "User", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT + 1_000, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(result.permissionFor.mock.calls.filter(([args]) => args.username === "maintainer")).toHaveLength(1) + }) + + it("ignores blockers from non-collaborator reviewers", async () => { + const result = await runWorkflow({ + reviews: [ + coderabbitApproval, + { + login: "drive-by-reviewer", + type: "User", + state: "CHANGES_REQUESTED", + submittedAt: REVIEWED_AT + 1_000, + commitId: OLD_SHA, + }, + ], + }) + + expect(result.addLabels).toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-maintainer"] })) + expect(result.addLabels).not.toHaveBeenCalledWith(expect.objectContaining({ labels: ["awaiting-author"] })) + expect(result.setFailed).not.toHaveBeenCalled() + }) + }) }) From 8535808dab8c0c804e1853a22c044ce8008e12f9 Mon Sep 17 00:00:00 2001 From: edelauna <54631123+edelauna@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:25:13 +0000 Subject: [PATCH 10/21] chore: prepare v3.82.2 release (#1677) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ src/CHANGELOG.md | 27 +++++++++++++++++++++++++++ src/package.json | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 978efd867a..4b16bfb62f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Zoo Code Changelog +## [3.82.2] + +### Patch Changes + +- Prevent unavailable tools from appearing in system prompts (#505 by @DScoNOIZ, #1240 by @JunyongParkDev, PR #1505 by @DaubnerF) +- Fix DeepSeek Flash image input by adding the new deepseek-flash model ID (PR #1618 by @app/zoomote) +- Fix token usage tracking for Grok and xAI-compatible endpoints whose domains contain "x.ai" (#1483 by @BambinoSK, PR #1484 by @BambinoSK) +- Apply the configured reasoning effort consistently across OpenAI-compatible requests (#993 by @Gringo675, PR #1604 by @JunyongParkDev) +- Preserve the configured LiteLLM model ID in the model picker (#1367 by @easonLiangWorldedtech, PR #1368 by @easonLiangWorldedtech) +- Fix delegated subtasks reading the parent mode in environment details and tool validation (#1623 by @edelauna, PR #1625 by @edelauna) +- Add a file version token to the guarded-write path to prevent stale overwrites (PR #1383 by @easonLiangWorldedtech) +- Extract the code-index manager registry for clearer ownership (PR #1622 by @WebMad) +- Route Roomote pull requests through the CodeRabbit review path (PR #1598 by @app/zoomote) +- Make mutation-testing findings advisory instead of blocking (PR #1610 by @app/zoomote) +- Group mutation warnings by source location to remove duplicate warnings (PR #1619 by @app/zoomote) +- Skip mutation testing while pull requests are in draft (PR #1645 by @app/zoomote) +- Scope the mutation diff against the exact merge base so unrelated changes on main stop inflating the scope (PR #1655 by @app/zoomote) +- Model test bundle dependencies in Turbo so caching stays correct (#114 by @edelauna, PR #1611 by @app/zoomote) +- Separate extension unit tests from bundle smoke tests (PR #1614 by @app/zoomote) +- Move extension source coverage to cacheable test lanes (#118 by @edelauna, PR #1620 by @app/zoomote) +- Cache extension coverage by ownership lanes (#115 by @edelauna, PR #1631 by @app/zoomote) +- Keep coverage caches valid when only verification scripts change (PR #1649 by @app/zoomote) +- Union ownership-lane coverage reports before uploading to Codecov (#1647 by @DaubnerF, PR #1650 by @app/zoomote) +- Validate coverage lanes dynamically in the merge queue (PR #1644 by @app/zoomote) +- Stabilize the accessibility contrast audit during theme changes (#1612 by @edelauna, PR #1613 by @app/zoomote) +- Make CodeRabbit completeness checks advisory (PR #1621 by @app/zoomote) + ## [3.82.1] ### Patch Changes diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 978efd867a..4b16bfb62f 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,32 @@ # Zoo Code Changelog +## [3.82.2] + +### Patch Changes + +- Prevent unavailable tools from appearing in system prompts (#505 by @DScoNOIZ, #1240 by @JunyongParkDev, PR #1505 by @DaubnerF) +- Fix DeepSeek Flash image input by adding the new deepseek-flash model ID (PR #1618 by @app/zoomote) +- Fix token usage tracking for Grok and xAI-compatible endpoints whose domains contain "x.ai" (#1483 by @BambinoSK, PR #1484 by @BambinoSK) +- Apply the configured reasoning effort consistently across OpenAI-compatible requests (#993 by @Gringo675, PR #1604 by @JunyongParkDev) +- Preserve the configured LiteLLM model ID in the model picker (#1367 by @easonLiangWorldedtech, PR #1368 by @easonLiangWorldedtech) +- Fix delegated subtasks reading the parent mode in environment details and tool validation (#1623 by @edelauna, PR #1625 by @edelauna) +- Add a file version token to the guarded-write path to prevent stale overwrites (PR #1383 by @easonLiangWorldedtech) +- Extract the code-index manager registry for clearer ownership (PR #1622 by @WebMad) +- Route Roomote pull requests through the CodeRabbit review path (PR #1598 by @app/zoomote) +- Make mutation-testing findings advisory instead of blocking (PR #1610 by @app/zoomote) +- Group mutation warnings by source location to remove duplicate warnings (PR #1619 by @app/zoomote) +- Skip mutation testing while pull requests are in draft (PR #1645 by @app/zoomote) +- Scope the mutation diff against the exact merge base so unrelated changes on main stop inflating the scope (PR #1655 by @app/zoomote) +- Model test bundle dependencies in Turbo so caching stays correct (#114 by @edelauna, PR #1611 by @app/zoomote) +- Separate extension unit tests from bundle smoke tests (PR #1614 by @app/zoomote) +- Move extension source coverage to cacheable test lanes (#118 by @edelauna, PR #1620 by @app/zoomote) +- Cache extension coverage by ownership lanes (#115 by @edelauna, PR #1631 by @app/zoomote) +- Keep coverage caches valid when only verification scripts change (PR #1649 by @app/zoomote) +- Union ownership-lane coverage reports before uploading to Codecov (#1647 by @DaubnerF, PR #1650 by @app/zoomote) +- Validate coverage lanes dynamically in the merge queue (PR #1644 by @app/zoomote) +- Stabilize the accessibility contrast audit during theme changes (#1612 by @edelauna, PR #1613 by @app/zoomote) +- Make CodeRabbit completeness checks advisory (PR #1621 by @app/zoomote) + ## [3.82.1] ### Patch Changes diff --git a/src/package.json b/src/package.json index e047f661d8..f5db0d79e1 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "ZooCodeOrganization", - "version": "3.82.1", + "version": "3.82.2", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From 9e4a52d99ed4d910bb9a03f6194bc5d3a2489f67 Mon Sep 17 00:00:00 2001 From: Alexei Gubin <36731953+WebMad@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:16:34 +0000 Subject: [PATCH 11/21] fix: align codebase search readiness across mode filters (#1630) * test(code-index): readiness coverage * test(code-index): clarify search readiness scenarios * test(tools): cover codebase search mode permissions directly * test(build-tools-readiness): add liveness check and extract callable helper --------- Co-authored-by: Elliott de Launay --- .../codebase-search-readiness.spec.ts | 160 ++++++++++++++++ .../build-tools-readiness.integration.spec.ts | 179 ++++++++++++++++++ .../tools/__tests__/validateToolUse.spec.ts | 24 ++- 3 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 src/core/prompts/tools/__tests__/codebase-search-readiness.spec.ts create mode 100644 src/core/task/__tests__/build-tools-readiness.integration.spec.ts diff --git a/src/core/prompts/tools/__tests__/codebase-search-readiness.spec.ts b/src/core/prompts/tools/__tests__/codebase-search-readiness.spec.ts new file mode 100644 index 0000000000..2e05f6c6cd --- /dev/null +++ b/src/core/prompts/tools/__tests__/codebase-search-readiness.spec.ts @@ -0,0 +1,160 @@ +import type OpenAI from "openai" +import { toolNamesSchema, type ModeConfig } from "@roo-code/types" +import type { CodeIndexManager } from "../../../../services/code-index/manager" +import { filterNativeToolsForMode } from "../filter-tools-for-mode" +import { resolveEffectiveToolPolicy } from "../effective-tool-policy" +import { getNativeTools } from "../native-tools" + +const tools = toolNamesSchema.enum +const ordinaryReadTools = [tools.read_file, tools.list_files, tools.search_files] +type Readiness = Pick + +function makeManager(flags: Readiness): CodeIndexManager { + // These consumers only read the public readiness getters, not manager services. + return flags as CodeIndexManager +} + +function toolNames(definitions: OpenAI.Chat.ChatCompletionTool[]) { + return definitions.flatMap((tool) => ("function" in tool ? [tool.function.name] : [])) +} + +describe("codebase_search readiness", () => { + it("excludes search without a manager but retains ordinary read tools", () => { + const policy = resolveEffectiveToolPolicy({ mode: "code" }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}) + + expect(policy.tools).not.toContain(tools.codebase_search) + expect(toolNames(filtered)).not.toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }) + + it.each([ + { isFeatureEnabled: false, isFeatureConfigured: false, isInitialized: false }, + { isFeatureEnabled: false, isFeatureConfigured: false, isInitialized: true }, + { isFeatureEnabled: false, isFeatureConfigured: true, isInitialized: false }, + { isFeatureEnabled: false, isFeatureConfigured: true, isInitialized: true }, + { isFeatureEnabled: true, isFeatureConfigured: false, isInitialized: false }, + { isFeatureEnabled: true, isFeatureConfigured: false, isInitialized: true }, + { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: false }, + ])( + "excludes search for enabled=$isFeatureEnabled, configured=$isFeatureConfigured, initialized=$isInitialized", + (flags) => { + const manager = makeManager(flags) + const policy = resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager) + + expect(policy.tools).not.toContain(tools.codebase_search) + expect(toolNames(filtered)).not.toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }, + ) + + it("includes search when all three readiness conditions are met", () => { + const manager = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const policy = resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager) + + expect(policy.tools).toContain(tools.codebase_search) + expect(toolNames(filtered)).toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }) + + it.each(["isFeatureEnabled", "isFeatureConfigured", "isInitialized"] as const)( + "rereads %s when the same manager becomes unavailable and recovers", + (flag) => { + const flags = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } + const manager = makeManager(flags) + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).toContain( + tools.codebase_search, + ) + + flags[flag] = false + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).not.toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).not.toContain( + tools.codebase_search, + ) + + flags[flag] = true + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).toContain( + tools.codebase_search, + ) + }, + ) + + it("does not reuse readiness from another manager or a missing manager", () => { + const ready = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const disabled = makeManager({ isFeatureEnabled: false, isFeatureConfigured: true, isInitialized: true }) + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: ready }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, ready))).toContain( + tools.codebase_search, + ) + + for (const manager of [disabled, undefined]) { + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager }).tools).not.toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager))).not.toContain( + tools.codebase_search, + ) + } + + expect(resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: ready }).tools).toContain( + tools.codebase_search, + ) + expect(toolNames(filterNativeToolsForMode(getNativeTools(), "code", [], {}, ready))).toContain( + tools.codebase_search, + ) + }) + + it("does not grant read permissions merely because the manager is ready", () => { + const manager = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const mode: ModeConfig = { slug: "no-read", name: "No read", roleDefinition: "No reading", groups: ["command"] } + const policy = resolveEffectiveToolPolicy({ mode: mode.slug, customModes: [mode], codeIndexManager: manager }) + const filtered = filterNativeToolsForMode(getNativeTools(), mode.slug, [mode], {}, manager) + + for (const tool of [tools.codebase_search, ...ordinaryReadTools]) { + expect(policy.tools).not.toContain(tool) + expect(toolNames(filtered)).not.toContain(tool) + } + // The mode remains usable; this is not an accidentally empty result. + expect(policy.tools).toContain(tools.execute_command) + expect(toolNames(filtered)).toContain(tools.execute_command) + }) + + it("honors explicit search disabling without disabling ordinary read tools", () => { + const manager = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const disabledTools = [tools.codebase_search] + const policy = resolveEffectiveToolPolicy({ mode: "code", codeIndexManager: manager, disabledTools }) + const filtered = filterNativeToolsForMode(getNativeTools(), "code", [], {}, manager, { disabledTools }) + + expect(policy.tools).not.toContain(tools.codebase_search) + expect(toolNames(filtered)).not.toContain(tools.codebase_search) + for (const tool of ordinaryReadTools) { + expect(policy.tools).toContain(tool) + expect(toolNames(filtered)).toContain(tool) + } + }) +}) diff --git a/src/core/task/__tests__/build-tools-readiness.integration.spec.ts b/src/core/task/__tests__/build-tools-readiness.integration.spec.ts new file mode 100644 index 0000000000..e1f8888c9d --- /dev/null +++ b/src/core/task/__tests__/build-tools-readiness.integration.spec.ts @@ -0,0 +1,179 @@ +import type OpenAI from "openai" +import { toolNamesSchema } from "@roo-code/types" +import type { CodeIndexManager } from "../../../services/code-index/manager" +import { CodeIndexManagerRegistry } from "../../../services/code-index/code-index-manager-registry" +import { makeExtensionContext } from "../../../test-utils/vscode" +import type { ClineProvider } from "../../webview/ClineProvider" +import { buildNativeToolsArrayWithRestrictions } from "../build-tools" + +vi.mock("../../../services/code-index/code-index-manager-registry", () => ({ + CodeIndexManagerRegistry: { getOrCreate: vi.fn() }, +})) + +const tools = toolNamesSchema.enum +const ordinaryReadTools = [tools.read_file, tools.list_files, tools.search_files] + +function toolNames(definitions: OpenAI.Chat.ChatCompletionTool[]) { + return definitions.flatMap((tool) => ("function" in tool ? [tool.function.name] : [])) +} + +function makeManager(flags: Pick) { + // The real filter only consumes these public readiness getters, not manager services. + return flags as CodeIndexManager +} + +describe.each([ + { strategy: "filtered definitions", includeAllToolsWithRestrictions: false }, + { strategy: "all definitions with an allowlist", includeAllToolsWithRestrictions: true }, +])("task readiness with $strategy", ({ includeAllToolsWithRestrictions }) => { + beforeEach(() => vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReset()) + + function makeOptions() { + const context = makeExtensionContext() + // Only context and getMcpHub are needed; constructing a webview provider is unrelated to this test. + const provider = { context, getMcpHub: () => undefined } as ClineProvider + return { + provider, + cwd: "/tasks/ready", + mode: "code", + customModes: [], + experiments: {}, + apiConfiguration: {}, + includeAllToolsWithRestrictions, + } + } + + function callable(result: Awaited>) { + return includeAllToolsWithRestrictions ? result.allowedFunctionNames : toolNames(result.tools) + } + + it("uses the task context and cwd without leaking readiness between workspaces", async () => { + const options = makeOptions() + const ready = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }) + const unready = makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: false }) + const managers = new Map([ + ["/tasks/ready", ready], + ["/tasks/unready", unready], + ]) + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockImplementation((_context, cwd) => managers.get(cwd ?? "")) + + const first = await buildNativeToolsArrayWithRestrictions(options) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenLastCalledWith(options.provider.context, "/tasks/ready") + expect(callable(first)).toContain(tools.codebase_search) + + const other = await buildNativeToolsArrayWithRestrictions({ ...options, cwd: "/tasks/unready" }) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenLastCalledWith( + options.provider.context, + "/tasks/unready", + ) + if (includeAllToolsWithRestrictions) { + expect(other.allowedFunctionNames).toBeDefined() + expect(other.allowedFunctionNames).not.toContain(tools.codebase_search) + // Keep definitions for historical calls, while forbidding new calls. + expect(toolNames(other.tools)).toContain(tools.codebase_search) + } else { + expect(other.allowedFunctionNames).toBeUndefined() + expect(toolNames(other.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(other)).toContain(tool) + } + + const restored = await buildNativeToolsArrayWithRestrictions(options) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenLastCalledWith(options.provider.context, "/tasks/ready") + expect(callable(restored)).toContain(tools.codebase_search) + expect(CodeIndexManagerRegistry.getOrCreate).toHaveBeenCalledTimes(3) + }) + + it("omits search without a manager while retaining ordinary read tools", async () => { + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue(undefined) + const result = await buildNativeToolsArrayWithRestrictions({ ...makeOptions(), cwd: "/tasks/missing" }) + + if (includeAllToolsWithRestrictions) { + expect(result.allowedFunctionNames).toBeDefined() + expect(result.allowedFunctionNames).not.toContain(tools.codebase_search) + expect(toolNames(result.tools)).toContain(tools.codebase_search) + } else { + expect(result.allowedFunctionNames).toBeUndefined() + expect(toolNames(result.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(result)).toContain(tool) + } + }) + + it.each(["isFeatureEnabled", "isFeatureConfigured", "isInitialized"] as const)( + "rereads %s on subsequent builds with the same manager", + async (flag) => { + const options = makeOptions() + const flags = { isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true } + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue(makeManager(flags)) + + const initial = await buildNativeToolsArrayWithRestrictions(options) + expect(callable(initial)).toContain(tools.codebase_search) + + flags[flag] = false + const unavailable = await buildNativeToolsArrayWithRestrictions(options) + if (includeAllToolsWithRestrictions) { + expect(unavailable.allowedFunctionNames).toBeDefined() + expect(unavailable.allowedFunctionNames).not.toContain(tools.codebase_search) + expect(toolNames(unavailable.tools)).toContain(tools.codebase_search) + } else { + expect(unavailable.allowedFunctionNames).toBeUndefined() + expect(toolNames(unavailable.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(unavailable)).toContain(tool) + } + + flags[flag] = true + const recovered = await buildNativeToolsArrayWithRestrictions(options) + expect(callable(recovered)).toContain(tools.codebase_search) + }, + ) + + it("does not grant read tools to a command-only mode even with a ready manager", async () => { + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue( + makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }), + ) + const result = await buildNativeToolsArrayWithRestrictions({ + ...makeOptions(), + mode: "no-read", + customModes: [{ slug: "no-read", name: "No read", roleDefinition: "No reading", groups: ["command"] }], + }) + + if (includeAllToolsWithRestrictions) { + expect(result.allowedFunctionNames).toBeDefined() + expect(toolNames(result.tools)).toContain(tools.codebase_search) + } else { + expect(result.allowedFunctionNames).toBeUndefined() + } + const callableSet = callable(result) + for (const tool of [tools.codebase_search, ...ordinaryReadTools]) { + expect(callableSet).not.toContain(tool) + } + expect(callableSet).toContain(tools.execute_command) + }) + + it("honors disabledTools with a ready manager without disabling ordinary read tools", async () => { + vi.mocked(CodeIndexManagerRegistry.getOrCreate).mockReturnValue( + makeManager({ isFeatureEnabled: true, isFeatureConfigured: true, isInitialized: true }), + ) + const result = await buildNativeToolsArrayWithRestrictions({ + ...makeOptions(), + disabledTools: [tools.codebase_search], + }) + + if (includeAllToolsWithRestrictions) { + expect(result.allowedFunctionNames).toBeDefined() + expect(result.allowedFunctionNames).not.toContain(tools.codebase_search) + expect(toolNames(result.tools)).toContain(tools.codebase_search) + } else { + expect(result.allowedFunctionNames).toBeUndefined() + expect(toolNames(result.tools)).not.toContain(tools.codebase_search) + } + for (const tool of ordinaryReadTools) { + expect(callable(result)).toContain(tool) + } + }) +}) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 9e4a8bbd0c..d839cdc646 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -1,6 +1,6 @@ // npx vitest run src/core/tools/__tests__/validateToolUse.spec.ts -import type { ModeConfig } from "@roo-code/types" +import { toolNamesSchema, type ModeConfig } from "@roo-code/types" import { modes } from "../../../shared/modes" import { TOOL_GROUPS } from "../../../shared/tools" @@ -49,6 +49,28 @@ describe("mode-validator", () => { }) describe("custom modes", () => { + it("allows codebase search in a read-only mode without manager readiness input", () => { + const mode: ModeConfig = { + slug: "read-only", + name: "Read only", + roleDefinition: "Read the codebase", + groups: ["read"], + } + + expect(isToolAllowedForMode(toolNamesSchema.enum.codebase_search, mode.slug, [mode])).toBe(true) + }) + + it("rejects codebase search in a command-only mode", () => { + const mode: ModeConfig = { + slug: "command-only", + name: "Command only", + roleDefinition: "Run commands without read tools", + groups: ["command"], + } + + expect(isToolAllowedForMode(toolNamesSchema.enum.codebase_search, mode.slug, [mode])).toBe(false) + }) + it("allows tools from custom mode configuration", () => { const customModes: ModeConfig[] = [ { From 7c302a51be3636c664c2e1b3b0c9c99caef4a820 Mon Sep 17 00:00:00 2001 From: edelauna <54631123+edelauna@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:28:04 +0000 Subject: [PATCH 12/21] fix(visual): mask context-token counter in electron sidebar snapshot (#1680) --- .../electron-chat-dark-sidebar.png | Bin 30867 -> 30321 bytes apps/vscode-e2e/src/visual/electron.visual.ts | 11 ++++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index cb69da51e546a37537df7ec2fabfe60064a6f316..e8497070ef3440fe4a3bbb3ef49f9a1de9d4c4d9 100644 GIT binary patch literal 30321 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 literal 30867 zcmeFYbx@UW`!9-$gh+@8N`nZBgwhQn3Zj5?hk$g~BG(cD6_FAVX%GQv=>`E|iF7xM zZdi23zIgZEf1H^!`<&T(&dmAa_ssBqd8MA`ey;nvK6MAaRFoyYLVX1X2Z#8XoQw(% z&IJw}oQv}W7vW#323P;We=azy$V%hnw$m=+;4tDmlX;}(9=9^)A*I$gg}?4>ZfG<} zb@|Z)Wimao8`ly4?zE}2>J2AW8`LbRD`o9@4%y{#ctvV=6!7t@8U}IZez>@nRPgf+ za{>0j1De9KQ=yYIOS9h>nnH&5wx~wS_eMn#bE5O3vvDl}@7|H)7_l%izCnA^;G8ay zN#ivAuRd_I1#y1UZQjbN(fZ2B?0@r0zr_Bi-RnFeS{KZ7?ly*%ofZn$u8;u@JC4rn zk{P?l3Iw%Gul7Gh)eM%{j!<#85l~e;tQDKAxe=-uOis>Y@v@9*uU1L2dhQ#m7?X~i zzp^TWzN6vWDzxtBZvqWvj&<3F99ntN5nmVDH9V-Tr1x_O8qCFNt->wP&Y={c-lwVR z9TD>yYq?9tNRN7-tW#lhhlL!yFw>S(S=CbIbYo-8bW{{)OyNrE>+9q))Q^|gyRtfR zB*%LGwRA2@)@B$4I2`Y9T*4PwjoYHZ2iYtYMg&hgv&j|uQ ze2`O6xZ`h8JiImEF+V?lU0Q~@>+_xGHc!;m)M$kqk_53tV%W3m`}IVX|33X#>DwX1 zvdI!6G;fLOGrn5uac6Ma?7$u*6j4_ zwzaikeD=oHCmXQUdj>JTXIr8eB$3|O@#^PaIikfpc$j|JV`{>~!_yVxD_j=4v$YH9 zeYTaQKfJsfC+6X_c6OhWb9Kk=LMZvuF}o-!=1&zT(MKuV4BTsczW!t;b?tOd>g%qP zdq-Xt)s%4Rcq=Qx!RQ~B8LxyXoCvSYlN@KH=3}bEZno2v$Fs_R`zDa$x*G3h+b)X9 zPIM5BIDgn1^)WFqu-u7~C<$RTKQC-G6%`fQakM^wNYHlp zOH>qMW2%wgx}QU*=tFz98avUID^5;M2B?#rZ{Kd}+@J|=syfru(CDx9xk!Hp9&t8m zeuQlu{+FgiV$aI+@c@y=szG8d{lR*{MvebXe(ID5?k|n&lZ=v;Q@Y5QUzFJo;W`+9|P z&^YBd-PFaGAm&l9XMaV^?`en-+pnN^cbQn2Z2P)zDNiyPFuVHPdPpu^Z(dborTunP z>O-25xcE0dtE)&}O&{LmQ>US|osLzGh5mQ9Lb0R5^QmnZj-%I~D=g6edc>7nI(7v7 z4GavPM{((wJEg7~>JD0b$Gzy=61W>6m#Q+7oD!_ZrJ13o&+&wy%Yu(DnihRe2XfRsO`Y6KrLL$jFSmJ-71lEH~<4F4mr!8fmB9J23D*AYjN~wI^N4 z)z$U!-;VvB7!{L zK*OHua#O*-=^QOpFdd+YC?7)d-R!jrKFP+TVtYW07kr2~D;&v+d2r{>6V`~;kl&-qoaD-+R-sF@=7}6ZX>p^Ve`4?C+hq{B~SO^ z?YoHROH0@L6JB@2j>jh?AP4jjIogHq$arjri~R6OW7^D)Qpx{wNu2JR%u|zVMX*U8 zY@TdhQj$Db#;ut!xETEJ?bN$lLF1U?U&+Nh&Z`)VxIvY*`9|jJ*jfWC#+-8N0gC_B zYTrj;zZ03I!n?|o_#Do%Op zR)$6vF$EgQw|4X<|R1Rmklnie!Nw*U9EmSvtMji=j&%O=BxC4LVsRrtEKUs zR)s{7J84RszIrqtCEQfIt!_2QfSD+B}!>I9WZFYoqR z6*n5^Coas+zJ731MAT+_>wqV^^;9T~jF~d%z`I5xtD2IAaNw!?uoqUL&F8SZ=emJG zxQ)dQQJ2js@**K&t6i%`hzMok9s7%2j#NlSawM&G->=MjFyjnS857K#pTdwFArT`$V2PH0a$ z&wZk}#l`K3wXJN$=;f)+v;y&7TDN&?R`8=Jhk(tlJ5=fk6N1!?Pt<0 z_q33o3OF_Xoq5z4Gh?B!5mRhWxk8IV>HOW;5zRy5$9x#nuO=f;{=4|PDYx$-(;L~H z?E4XbHq_7J7*_lG#6DqJtKYGi0ff2fOY=tDpX|-U|2H3`+uwib5GUah~aaq{$oDXlK)ymO`5_Y+oEJ`@D;SLRx zC;DogpCk``DEzp;bg~+Iu@;9;PQS-DC48%hTdm^Q6~ZaJLhXnROCglEM*mq-Bd}&K02v zPS9;r_T;a>K7zL{%M>m*nqBI?rslUsg^xplc3h*q1vi>C;IEN&qmNOFRE~wbl&i|n zl_X?ZhIyB?4QGGsTA<7tACqGv>6?cLrB=No&I@H-Ww&TcLkPw5#c@zF3Bs(nZ>*PM z-a8BxydD$DyH0z4d9RkR0h;0B%H5l7jpk(&5gER&J5yigIQ=x3+BNtJqURE+m_rUW ze9801$G9{L|P7^O^V-&mU7BIx+VLLPA2q z!b2k?l~q;83tcIcL8$<{9zA-*8euv3>lHlUty{P7{OU@YBzz8KWMvtIo%~Nl-Bt#x zs;d6}{rmCbl^l)xWCTs0Ze722?Ug`mN1U)TAXn+XFfN>(ofk`jX@v{Y($Y#wG+4;b zu`3713q~@%8t;R_e3SUq3jxv(&04=rOc)jGoCATTrl)~2pTB%@nf(_DSm0jdzwPbq z4<9~srO4io{09T0J100W@Roqh&BPA4BvGeTkE5SGd&d8tiwiOFQ#r+5Y&`Hf8*(T4 z3hs1MaHaRbCX65C;UZg(#`3QuIhG2Tqd^}(s;H>EU{%%7&~SF<9Xlnw&U%;PBg|98 zMxF2%%e3i9Uaf9KZGIEpFe-b zyL!{;!A-?DA!U7i1p>I8a)-(KndXR&cu^{H^3PFGg0JdvB@$8V6OZUa1s$hk=tB_; zolJ-QT8`<|TUyRvrpulLQD&%R(uumMsi|@3m31P=;;Ti>+i9i$?qN`vuC9mH{n^LI z#~(j_Y>DDFfBpKJbbWojimEE?v(3%Tix)3aa_N0b1jw+5!N>wAv&-reu^r|+Qcz)!ym#+j(X-o>LGMZEGW)DjQc`@py$P5Ab&_1YI$CT!0BddD z`t2G8h0HlzD;%38qoY%xc@EQ!B2WecfHYUXa%yS{kCc9NNU>}=xAKeptRTi4meOr+ z4ADcMc>C$6n-7eB!vJR?BUr22YKIpHh(no!QYlU-oQ#}-LDF&R&! zHq}TS@+zyU_6(7%^rVMwUFtaSMtXan^Mp_a$y|-Bcq)o30fB*mu(tT5^i=@p#}F8W zD4i;IM@!2^xFJ{#3lweCaxu4Is>%@RAFzOo?CjARA1^nzU7$*IB0FWZA6r{ncXxNs zpM;WARypr%0p)6@5`D#K0dc^-zKFK z=^YT3t$m+9lm6d(3m~L%tokFzaQUNzIH#hg=$B*ydzu(u)Eh;U=j#UaieAwmo zGdey#M~xje;W?5!Qs`cEdHwn|AMeA54;vdBck{X(sDqm_Gc(~u2?z+d?e_1kjv)5e zU!`qp)Ke-jl-TuzSP8$aEy( zD}u0f`1nj;zrMr9HXV{qNs}LzCi2d!A^U`^7priKtT#W#wxpm$UQo z5JFW5)D8L6ZX1Ep(o$OdJDXin#qk=57V*B38}u!EN4G6Wpx_}Dx6>0<)eM{iX! zT3cGS_V$1Z7pY{3InP}^v8`@*?W0}oXL@90loT4;0YrA878d!``%{Z1Y)@#13xa*d z`72|U1LcF6ne2z}6B7+BE$^M^rgXt*3y^+9kRO1y5g@{S8x|I(YgZmXrt)?Fr&X8R-dD*4C9!c6EOEZDxfnrD-qknzhF@JI2YUs@$cB zjEpRavMZmZj7jCxEz!#9PedNx{+cU9DN zsTbNdteOr7<;YPg6Ptf)tRT0zc>L+_^dCR|&N}Hb!KO;v>aoy2XXUBSoyx^{c${Fp z6%-U;*yv*gkM{0F%G=h3HJd{59DI9d0^l04K2ev8$m1OuXrF!SR6aWC1uJimS6ch* z8*AO{lyUyx?7%QA82la}O(@I9X26R{4C(hYUv(Hg(Bk;o7AuI@5XjS|FSBhsp<@K{>f zCVg(k$wO83I`LC)?+x2RAJ^ zp{c>uAFDoCLkMNJy1Gh8NVqMkAX%2RHn=Qn-w{%g*Z})wjP6}_89nX8ft3X zi;n;;n!ms0S$+C9{5H%DLocsNHTImWtV;KdDfK)TsG+pfRPoKfw_!_^l=M&b-fx@y z@hrk^b$HeZzq=>1eO}F0Z_Oi0(%IJ5R$E&eIK3^jS3A2jPVuN9gxkRnfE`6e=T!zz zs*|&Oe6U60Tv2;PS`HZ7r(3*mSNYEK?RmAQo}jnDWKqc|Dk~d2__6HT62I@T9oE!H zL~CBGV|lGz(lUN>!<{GkMOI%0?S9*A7A?AY)@h`UyxI-`Cm(gNGt+j7OO&fo#|If^ zX!d;6tAZ*Vl*=w6?>VF7Q@3${t3OLsK|D=%(fVWbK(E-bZ)a`i?_25QmyBdWwLW_T z_bINx#G|?$QeJx{L+#)6hE1hDR}@s)k?NxX$-~DzPg3|88IyX*hM|4!Jtn}}&4iVb z;J8Xa@Vhz%=M$6v|Ne)5#KRvRVhM6AQycC;2|}8H4=gx{4;Z`R)LJKyGSStK)d)WH zk@_OXQV<-7iz7f!lB~y-e(C$It9U70a`+09TL0IP=G#Wpo%g{*g84A@lg@t8}=(6}Cp9*by+Wk- zg^Yf`L7p5Am6T*;+!GVi&uB>hGkENV5`{{2;u-NJ)@-rJ+g)6F$d~2 zgsc(II4CQ!hkYlPxc88?8m1#l%n_J(SKX2GuW89;Aaf*_PFX z1tHX_SWd^t$Ox3v$@G=#swx19-q4iW6$vm-Eu2MHIPcvnZ?c~fG3KL=lm}e2YJ&&s ztD3HOhn2O$o1dAvepjj(5H1@l>(|K0;F}M>7Z>+oFowx5fmg7?TVapX;rI&QzhkD_ zX14W}Q`b}V?U5LzG<9UwyH2;vrh_{oyIHG-0QFZ__0`p%pe1X)51d}S_|)LHpL;J~ zi}uDR9Wd=e-e zfmH}(lwX%S|sCP5u2_ z3WY))W{YuibNfn>(ad>xx7+P8`(UFZju!1Cc zbp}QD}Gh2TVMoYaJBvi|wLSn!!SnNsnS~9K{9Uv4xR%1OH>0cTbQOP1-%OUdWZ-oVI(%;{7nPhm$lki8c zl1LLcJP`LkWdY?+X&%va^#~d75q{|F*quSNrm5 zIYze%AGWa%nf#fzlJ#4ddtdUyP}bU*KAIohQSXUe>x`P6_IDR**;F3oC6 zrir?{GEzr!BiA`#C?^=F%+6I{rL+Mto_w-6_i|fJPOkVqN^fnPT z?Baat=w$vwv$*?Gw3PM4t$v2s1ijJ66*ziJ+I*hm^>*sTcH@FL4s=Uvnx*FNjRs8r zKK2sg@yFz0=0=vIz1m(5)G>sm*X)XuCZA9ao*6NQIb5owG(1Yc40mfQ1n;tq{uVne zo)>Cw&}Vo_TuP6W~uOqO5i@|Vg;JbvZKrl1SY()ZZe*%xlxh^tWlCT^t^ z>5xoZ2W4$L8BI(~yvEU;XIT3`{G`c`*RNk+oJM+x8F+ael{?R8Ygk)ZEx}h;qNs%& zj4(AmmV)v@jZOe_zka3csG%|kNegEE?oC^q(EU69cH>T-Eu5t;{jm+FnEm~IP$DV{ zFN4%0pUML`tEMKwt_glEv{_CT9-d9W^zF|bX~9a<0EM0)Tt3yL>60)t?_voy3TkQ{ zZS6oZY3xv`_+=7qy&bPA)Y)mSd05NrZP^fxd-oj8RsYjyaxyXqiHVB?I!kHu#m z3N@y(_e*<=o{nytV|x6qb3US^xEPpGpoZapXVvQLkv!2WfSCcwYv$@HXQ=sy!N^%z zv624J@Rk-$OegYXS}6JD&z{9AL`3*kZ%)k5gDPreWyOB~{+I^>hfA3DL|8z!vhQ*O zfUFe4?5r$j^HU(b(1qawmMf8{B+CK(5+EFVCMqY%hRmcoVcD+2R6*YqE@FWYTd|KQ>yL zo0Eynl3#*gwlq7duB_ZOjgGohcFa>L#9FoVAW;89NQj%HZ;`L2o?h$p2FSrLU%rI? zh<5Di>SB#(Hsy(oiW=zecPq($!Me4z1^5cOCOU-fwm&W&9=Wu!i3uSSbNF8j24hv+ zjYjmCsEPPF=b6u625kG8R~G0*~_Uz``HjFJRJj!s=XjnkIzf?OZ8~M z#KZ&$J~QCK&(9}{dkH|bnX4-RV>C5o5r4P6wZ%&v{4^yWDhTLEb+tZgL|~(_r-R%) zc3}ZHamkg=*|$KQz(j^g4jdAspv|3~RFhoeBJ~AIgy*$#(czd?EWBaB4v8YJ|By-8 zg!8iNi-TXAlapgkbrTjwRYe6D9hYH^7*sJRem%*5`>gnx;QmcPhGmcZc=~*v0dOPi zBz5*1;u+IbPzynzQVMD2L?aef2_qNSjPaV}R`o5VK8oAqoQ!S=S zMn=}T8ngp2+z*e~DV^2SmAb(=Ux;-7kt}XLs@Vg6sNH0~^UKs#V)roJj_a-o5l{bn?bRs-23VYe^+WLDm0oP-*B)(Q~NzzFqEYh^86C(UgqO zv-HOs_ob`Y&RkUn!z3t!iZ@@pKJ>ap$6g&9P-(J|ja#0$GDyXySHr#&iMLDoQ%rYO zQF;84L80b&$xfc3hr`yyUi}t-GV>p=E_|Z5_y#A>{6rHE+!{`3c+X;U>{RT_P}A>@ z1Pz++Ay7}q1N^yX5WAUD9N+eu=-M=62HFg3k&}2S<1WiR8EaH)B*`+2pMyla`1x2_ zERo-1pE!yov}!JuEBIlqlMeVZmoLDL&)am^mbvekH-6h@mHsefQKFNRMiOD`IhvE- zWpenO;&WQToJp+WLZ7FvR+tdN9mC|q!TP*z&G|JOQeAiR$?;5MiW17_=|KOhSRr?( ztDzk&;|^c60*7oQwkIdO&MLK*Zg&;C;%@IL#Uw7!E$D}9dMst>Werh_2+3Sq;rtwg zmo}=FpzCLCQV(i!INei#)f0Fgwi@D(+8L>bs7fY>o+=b}mdvRMyw>fy7K1V+Z4K6U$+#dY-<-MK6=1{&5_6KQfJQ=T}k?W(KfbH-7 zROI&q=)SNwy9QX|bX)rd1ck&?2t(lUn;5HMA^>37wA-t|;( zo(y7+E)z=mQElM5C}+9;CNa?kb^1Hte)_+x%}nPr1S`@r&L_G#NY>oP=|{1AhGl=; zpp(cGT6}qA9_Cpo6w}RK%)>y{bHm5`pYwfWLb|$a z_8&HxM+99Z$Hx<|BF0Uh=M9-sK0`_@6}EM%bcwicG9K|IpfSV!C%WD0>zIyxOlxhj_c$jQRRsg!*Dx#AL32<)Az-3? z=3!*x>=Bf!fLQ!H`z`R&W#bD>EL^S*vqS8HD4};Rf@a-mnM^zXe7m2Hys-~8_}Kn= z5-L>Z0neTjkz7`lmdLFr3sm21?duN zj{9fsq!3JIQ?`WWVT%oaNESpU0{itqn{t}CTQgZzgFN?a#WyiDk`CO%AJ%vg8gw_dgEZ@Cxi@D0|w#oiY{ym9f|rV-|us_R(mv%WgN zH@&jJph?OQwb~==xL3JV>ofU2BUGWnDf4)tX*ywyBO~<3;~a2rD4P8#3nL8%9TH`j z@?$tj`r@USCpJ9V@$KWIVThEADrwHC6UiL6TPfy8eIxs>dFCXEgPyNCo~_s=sc5^H zFLn3E(w7QD+l10V(+7w>g*xKf0Ds=UXSOX~)lQ_>UT?4JPKgpvkOFYKgZD3qe>dr8 zh#S?DA;YgOi9;3wG;Q(KRt%E=tV)Ix9Um?#w7=R~Q4m}A>TQS#`$&5W`tg?R{*677 z%>s1`9C`{s5?N#yrMNHS;vQe{RL0T6{I3F~|94*#yIvn6R4)7P!u};`1%lr0k!Q(_z~g$nP;A_b11rc)=_IrNYZNNDm~0 zBrj5tt#(nYzrVlxkDu=uknW&G(ck-MEIbDW88+6w#$#iuBju$o2c`4}Fv(utb*Uu_ zm)o9+P%?Y{T0bSY_20jx{#}=+fOk!Klx)I@;FFe?W$GJ96R_!KH^fwD!DPwTS_%Jt zx9O53&DD1yBvdJo=6f0}ub==*5V-kpxl93eM}N8nrXgs5W_;AxO00yh)Gy=4kKnh1 zYWw%E9ApdP;*O_+xY;Q_f_@?^E32ugX+Kl|oLqrD5=@ITaPT4CC`cV_h&*_$-pAcR z3JNY{#5y5gX2VBK$OI$_tiA0ceO1-R1kpUN|A96O(FZ%bH3Y^7lyyLXqrJUqFJDR# zfTjNpRsf_U%-mcsbDlnJj~5lZd-pCkw>@x%ZFCFk!-vYrFKI}!4ge7GhOZ#Ul_4_KVppF_J~2J}CWzs}9{YOlQv)pPg!%Bb&23E>8rl_b0 z_$8!Vfrj*~Mhn`HgHR8dz-ch_TE9K?J5P?%fQdDag%=1u7LzIH#Mjvr^=gqPE!?#r zvS7>S6WYg5;Z=+P^M~8tTh6ORZg+BYo|j*OYFvCVq=e13Ois?5j|-NAZB%l zZCzzz5feL()HD!wU!REKw+0iB-gANJ=pyKaQ0O%vy-NS}BbB&${rdT4Tp7%tjy6wl zwEGlHC%UVII_v@00NAwtnr&2U)R!;k77{#RUd`cB9X>mbeA-+H1ceIBE-gIPmcBm=xV-V`nP*?W|i#ikK=Eg#fd1mWCj2;X3 zXK81L-09PVn8@B{3%976_AWzKOSC>R`=akepQd43bF;^MJOYoH8t9@diUW$^N&w_E z42mqez*`#|9bIv%1-}^rHC2#l0g(h^7|x+JEAhs(^p-qPkjOJgKSBw>POw~oR||S(e0Vrhq&!_cYN0b3!Ze+vQ254`%V?FFey$a7r3b*tHN)V6fY4Sp6FH(m~tiN-5Ng@-2(EKg6O2y{S=!5 z9+$5(Z{WudAGC@rl)(u;(ah?*5>TI%lr%h?h#tY@o8XK07Z_$16@3w4g>0}T&F9R# zJW^6p5cvnJiXl1g^1aK##bT5}!rQ&;`xE1QcL>^$prgT=SCo_6UvD5)XYYfSFK9Qa zWoU?XY6*~@j8Kvo0#~V`Lc~|9vvH##3VQyZ>q<6b6~(!^Yv-|7K|2V+_=olPYl!$6 z^LDuV~`2KJhrzlT|F20hRqxX`@mW8SxA1r2E74{D;QZ2 zB7x`)GYgApU#7Z?V&M=+q=y#QsGW%Ai^J8_N}ZBWq6OrJ^HZ;S2Nc zpMih`nW*t`F7iNd3B*N;SFPSxDb|b7@Tl`^ z{+KHc(sLYl?gTU$LF@(!i8}b*d0a&W)Iu$-pBh;pd4ax6O-;Q}15P(|A~A9Ixy9#%W^Y~1C)+b`Z)*z<4c*(>x#JINT4y0zQ-5?83ANm7!MAPt z2nNHVV=(^1TtbZdG_kIFe`n|V-`k;s2E&jX{dnU(#KFyZqTw#jmyb-kKhN+Ca`xbd z9v&Xr4HpF_xVHZN>kj#F2zagP>dB`%0er~P$T_#BHaB&uJ)v!fSkyd;BE}x3dK7?;?NKvxy|vYLfxO&YyPc&z^1vU^Gywi4X-+2i><)ol*fP5bC!Ee3 zKi9zbJ}U@{oWkNm!p+mmE9&c4I1CS*dZVzK>+~}>bu{SwlvOe8jc;nl!P+}W{n?jfb7m1u?TmhkCYjki+;5(IyLio zi(1Jh^!DMUIfsuJG+GuS3BwEiWZ;Wd8z?Q~dO;+CE2^}(*u%;TRD3NCw{iM;b+8P6 zs9xW2ig0}$M+WI|WujiKC(+QKiA=a-DRUAT!I}QlIKa0 zf!CrzCXnZ_nfJ>5cM`qhS-q7PD;gwPOEoe?7dq`Vqe!G0Cf7L_G5@s=s|dO>ob6bB zfwv8scjdUXblJH3F|N2?t-0)lQ^x-bL8@Q=vT9dm(0*w^@+;FhOR(9~t+*{9unS$! zF<}jvz6f@Dm)&)!HE5>F2!RsKxc5Zg;H(l%5=+V1a9R4~tskkWwu8SW0TGM=m;}zG zPbmaBSJ;}~0OsJ3lmp7h7Xk|-n24EsATpEe4OPr!JiKBj#__le(1V@+Gni?HrFQXK zE_vQ%zaHN*P3F*R@IDD+Pxoaz%7CG$^al#I(Pv z6op>Z`-64w+RrHhF-!m+U>>EWrb4PiHLDL=&N&EHLZ1qaIV29_bYD*ybsCAUBH1;KImhJ*gh%S4nm<5khSo3z#bVUy*=U7d(jFEV}On>WpCcods=|cu93yh!NI}G%6j{D zN}>LFejIM;r&?x?ZmEW*CbxD0%l-RVRaJ3rG9f`h);2ap!OEQg&|&=mf{y1~6Ry3TmlnIZ%!z1_(28;_v0zKcAdSEi%;LJSQh5 z;Ea~Ton?&YGUw4jWuN76T20Nk1)l&vf0roE9r9-A;o6#-=Ye2DLqnLlhzj?S3L{b) zfmRQ*pvD3K44zRCPxgWN=;GpHjujCV^#TeD39P#mfXTm{?ixZ92j&u0TYCma_9|VM z`+-D=x?D@*)Ee#?sH^fgj|+hfs%&6zIvZs;4iv@zyl8DkN^&6P`t#?9{^6OOt)NrHm27n95+q;VySXfw`Pf|GoyIqN+>PjveHkfC1 zO+i8TDVKxJ-=s%=3$PCSCp-B2Zl-Kxh9&#sgk1s zqQBjMg2zb6^1MM=iAT7yGeS4c?GR#i}_v!6fjRv5t{0Dx&wf(r!#0DD2xx^nX5 zCXXqBct5QE<;$1h;nJ)3BO)RIC0Z^UK{>?hXO=(IqvB64gs>@`E`S8BEJPKdO8{cB zgYy7}m3FQ%Zg&O5#ZS6q8R7+Ohw&M_C#qBs@(Z4GU%!3@?EwI065Mf44ks^f;qvo~ zP<8N|wv8echiZH(r5MjS8{j_aCexVn#0S6v*FOyJFlg}(H6%M z+tRLNK8$bL|I7l&=+`VmL=AKitNv_lB1#Sd5|F^BLl+~{VI~1^2eSB{s}Z8PaCTw2 zKgZhE_67kiG*J3@DaPr^|3(Kf;hd2rUeDF-gns$d{oOJTSijHxI zh=P}w*Ve%?m#6t|O;9PETDuYuPrSd(3i~x)ao`EU3(^aSkOntEi4c~3kFAE2VgQL3 za2E;~szsq0JC#GsuA-+fVeL+anrCoD^Jxe!F0S4H6K>fg=)!vG;0U8=|DT-T1VSde zs0#}Zd;bq&w*My)-T(U8|NVILf34krt=)gs!2iE%fKW_5OqipKT)2r^?AzZ0+ktjCPFnRZg04S!4yWmcc@ z&fk&?dI)#gNyoUbj=+6d!;wE7Lw_rO#TLeR=^05$Np(E7xDz*<_H3?;^xKV-qNhP! zpJwV9g2FnQ{l$>FsS`7Jx!Ys|DbjGBavwOH_{hbUH_y`Ubu9QJsr@diGaO&g&X%z+ z(7AI3xviBudqqim$WIy^Ez-vpAqhrhvzLnl?WHagpln!)9+F>>G~-dyuCuQ09&3ag zno@g+&T&exYJw_FAR#{^4ijsTx-CmnbBESN`=z?6F_PrS>1g|ts3vwEI4FJAVUur_ z!`#!0(kizz&m1Arnlf9w5iUf0Y2vq03AOj3?v$A;qHZmqlle=GtYi|uPDLg48_A3D z%~tXi81^r&)*0|M5u@+Q3w~}tpNoa8d%At4wxci$>f_ibzWS{*C$$2B{#1;0xXrv^ z`Z8&uLG+M>@LJHmo}DZDHm3JW$xQ;L48i6tD^Y5GJN;$_f@{~MuPp75^hiD@qUli* zFP&yDs(+Rv4BbTAhdeNj_b6e`4P3>?AYx#@$ok_ zv0XMd@X(tSU39SQJiS~7~V4a5hhE(WdDMcZi3sX zFzo2*;h4bSh(|@@7yJCrC`H2WXPZLgGksaH#Ao@Xr)i~JA=+NTf1W>Bk&LqQT%HQT zryeyN)otA(pZq$l7|o$E#W0lfq(b|;=X*xKcD&0U5&AE?vP!Y^mRwf7&a|bAxiwv< z^_q6XL#p3BMYDv54fp?)z1b%V?6)|WFVMCC@>^i3^}C*cZB;rPvL1PDir)$>-lS=r|6f&_{ID; zx;*uW+1{i+Z3PB;>*kv8Lz>@_J4Pwa2MRh{@FdvL-7K1`4L9DNpq6T9ZARz})o_~+ zux)FP2^Ob0ztJr#5Jb~0pQuTM$p_6llGZfl!ZljN&a3HO>T zLG<jNAb#*qh=52v|1bJyP-A=G># zF(o$HXQ{If87B3W}JeQ?F%%DZ!Pz-7Dj&3MI4|4R{g^YC?6wj(Z3|frR7kP zle}I_D0Ypgd%^!mo)`A(^;@}=e~(!V?lXZ`H9M%I%BQu&Tvmk;}+p-(dJN`}k) zzWnd9hV&;&Yvq*5ex7B+@u|t0#8#rpUK=5ejVyNKC7itZbXGwH@6ZQ)edNu zPS~&6^%M4C_11fu{^xQ~%JCBuW~2m7g5k(D#%XNjPXo!)ywVQ?Xx();jV)Whs!OKF zm!f*tzMQrGNfLd3S6=t3EuEP}=-($*Ip&cO-)H9&Xa_TS%?-Q6O4AcgpT4-oh<(s} z{KGpVtY`Y;M&5={y`}Exw+*VXtH~t+^L}Kf@_N7a=j4Xi(sc-#{*kl{PjeQUR7c!4 z-DO(AvU#Hv6DQBgWorN6(=RFIy>?knc)zXE7JzqRh{o$gse87hr|V!^ZJfTXszt6b zMEHe#vEKXg)8HF5#a$}zm>$o*`lwIq(Kk663g*7Io6b7RY&p@&erPwXHuvhgB--5h zvKhmMixiOb?nbo`F!?&wmAOVGz98I3&m`Tb923tu#IKtsNUuC3IqPE{(|tUBR*aad zv(+d*)$9uu^t8Jp;Cnz!!lmaiN`{mrVlgl`2OO)=UfUD);=mrTg=!(k){ zOM0b@-NPsHb{{(#e~iNJkMC7?-1GH%Rofx&f5K3ziNPe}ow@bOdC|;h>J#EVs()6a zapw`)4;`jJ-o?}hL5zRjJd+rVGw0Q8)q8`+IGr^ncl6Wbg~oA1OAYZZGFqJ#W8kTKj%zZVbg*GZ0Moi23g>*=_6T#F@sKrMXQ5%snM zl~Z@>K0@8LIJ0kJpKWG9{<>RIymt4$wf5%mP={~dw`FKZnwab~Bn`>FOtOs-vP8nz zijZXAWy=&o_MNhZQ1&HcCxnnByR0E(D5g^E{99 zb9_GUQ*Ql@->ziQ<%%iICvDocd#~OVyb(GUnda9N zxtR(gdSe>@>TM*({rnQfg^WRFejUqTH50I92@xIXjhRLWM{Ar9ym$t_exe@yMBe*xss~W9w?y=|enkyCXIjg(OZh6<|;^+pz|B zMT2%yrSDOm`jq`pGiygESS2HZ()s+g>DP4kI%AL6)8!gNx+VSl>4_KvYbvAn7~$2# zo3|g324o>?2BPj_mF{jPh54-P?=F8^uvgI5y+V6r*BG|G&m(=n=zKoPcXvHLu_UCL z&wbZ{!N=DFk}PXDbX@7wZ^uMPxF&ZqL2RQclJYx+FMS1t1J zB_9o1;s5bd+f61yh@yga9CW-@xN&K#+_VAXxL?wJP7neT({LH5ynbWBQdHr!PClBN zB2|cRcRvTuzJm}06OE-0mW_%tY6|E%Wx9yoNhASi=gY58qoN3(zTS`;^0Jp zdG`edIb9BkC~Gl@Qed9+T7f7%3SwJJ3$fR#Rj6mll~}9FO$TW-y}}16X~6Z$(IH3< zFS&x`gjTu`s~X%&nxd?jJwS4=c5m-d@$gkqQAl{}7#V^70+~JoQ*eoaV?vcXX8!oY zvg8nq4ns?>(tSXX@RH`Z8{Yr~8 zr-ZU_bOa&JwAPgug`}aOv6#4?ulsv@8~hePfH}r*fc9O({6_Cm-xeS`;QD}UOVbMM z1!#x>gMt$YVcNmTN&D(CNNx03x%m3Z5HZj*1yKnkXLt9i@^X5%Ym-w`=OQ}`=l@kS;* zchrg96le+fmohRk^S8T5s%{9k3V{Y+@b;|zTSrQ!m6T88|&~(3g z3__6;_SJB+laj#oh9u(HXrUQUR;Ccc9hxQGcY-VcBDDboSrrR{JG}P1b3hw7`!2?+ zvUNO2iBRMMX;>#8>>WX^W(Yj=R-Y?p_bqns$JyB!buP*aF`@5g4h;=KxO>(8JQ74@ zcq4FsnA0o6M+e{`fTRt)hasEm*X6Dj8oaRkU@jb*B}9{rwQ+PraZ?kcEN!AR^Pst5 zXLng+kiR<%8#?QmfC{}F1L4+?r@PFyNd>xmsl8UE22EwDmIMSIqW!gXb)Yh94pt1W zRF17Kxjuq^2H+pzUPY_G1!C^WIYs$o$VMXv?gh*U=1dEJ_U$B6^$IPSa*K*+tv_$d zQk0RC(7h`wBTHbC?voS~i_@1Z4_9~xRLy|_Gm&Hn!zW=wQA|d-h?9W|&=)jb=Yd>w z@v#5y_u%OQFssGcD1LGp|MrcE>7Al=%CY$A#0D0dV}qRmKW%U;+@gBZ7;Zqnf$0He zIdCaLN*WL*QFi?V2p(F9jEs(kVxA2yA+kaMR-9*^M8rQq!DDF>{xz$H^E%xpea5avxvr+kfXePx$L!u5?vVOl9G!BBLILY+H<^Q zyKYtAJr9>UT4m0oFIENY2VAjT42+D;?}VX?kHaOYaKF-dP~`6F*}kk|9mxapqLR85g|*kgTWz@s)0h_QAMWpADvzdE=fDj!6n2X zx!7pQ2e~6VTp)7Rzb23v#@%&S$hy*Se}Df19TgrWrU4Eu;q_a9-2nXkq!qwRRyPjE<~@#PXi1n4}~n6)9FhO^C=m^3Dp-=MQ(Ho)3iDnzjuc;BZTfy$jAP5wYA{N z1=`LdtpMZeaG_Gwc@iu^0RXbz*u*7tD71-B*=a*BHOvcOEK^0%H*<8r)e4F^bOEEI;c92$-LCO@6J46{l%*MvY=o&{|)Up)8Z4OCRo3#k1H$1**bO} z`N9v}e@IO%fiVrFtq;NW!zwDMr^?x2zDpL@#-hOiaN^w}D-C6(VYrzAU;L)0^aYJb zPDy0^IVwUzuWl&W%*HNllzji4glfU35d+{^m}}ba=-7xC78ZgR^`D==l{t76dnKpFVX~#6>}KVuA_8g?o^uDLAL~*V z7{boRhG)IrRI~&{BP^2gJzs3xz<;&7`;I9-zqIt&4#_BBOyZQMq|QU%Dl{L13Q;5@ z`q?MFJ1fT03mthi>MmUy@v+fSc%oC{ttC?P^70@Dc!T#RBRfs2yulFbi%q22!-p&I z2CTnAD-dFSj=1;0@2WSIlaz$SJiJI1d>M7(Zm9vx6`pB%SQ}SY^wU>h!^YT(KYR5O zkYq3g7`|obZ~~ivtCTjnU8u*V(nc($$LYL-Igfde0l&nRD_u@(L=2cbd19$rOW=u& zk3ZY*(J%J)7;RHOyz&w#RQdT2U}?sp!OohVlmvqa=6EOr=t2;R64$j|VMY=a745Rn z$sTw;WD~<(UO-@a(u@jME`L-At2Cs;Ym8~RLV-2~p1>O82Iz#!$efarg-3AK6BbKo z&6U~1HE?1}PftG)ovs>FVZaYJjU!S^LqnJbDyk9N-~0a*?CeY{i^aLjm;Kdl#A3*%Q`K}|5Et#=kXc=53OG`_@E&!uR z!H^AF3Gh8oOG7T!I)#IYiGk-RXUGwSMt%jp2^|t`8r?k`w=xyK`V+tCvt$Ke7xj+G>78G1yU$81?jw^>? zYrZb+sZ+h5KDoQQGc+oWGRNVOa8=uC^uU7Dw|E|@kT(e5CCU$CEFS3n`mW2@UdG>D z7NSVRJcHFA9H3B)ruQwn*TG#YOA~>`N?g2s%SY7FTilCtz`*o2b_y?_II2> zvihd9)?ft&DUoVtZq9Y=_2q>c*pxSKF#jf&mP7i7dtI}>xwxdQDzw+(qBFh#l8q%H zB^PX0E-qW}+>MSJaz!(-vI3ika?36>3$Ae4^4RCkB9fAfP$Yc+)z*e!N`TrfHT6c0 z4vZ;W1kBVW&(qV=GaqGsc5LVjPE*&UU+nT>XtEbT=7O`Z8iQ!1zyJI~vtqhl zF==o-yEWOSj;2;xF!Ea+t$fqwDP43E+jPIw1?meAyL_E|TZza2qLY`HA>fxufqg(~}VhX|c<_KWB)N-2UGqOpi zat9xjeoYQB59YFJ5_(ftXOyq|Vb+PJwXM*Z^=7_qs7QhecT)yzJBB7Ed=GY=QgZ7} zizn>+-geXG(kOH~z4-P6Xkmqga0fmCff7R04Lt&OpFE~X=(*=v)tIb3;jP=9r?z{K zOF)CKo428;sCnSvaE^n+v~i>?zo1~o%dZuWDSyorWX6=B&I72N$?AkfFPLj&$qBtS z%RL|M0|CgT$mO*3`9l9Iuu70G)trL&ySpEHHuJ$AI5<8yK?D2PIAvaH(Oc*tVVM*G z<+IZdWN@n=AmkeTt*jQnQkVSVg*23%7t?)W^vMVb6vaq`>?h*nL1y6WH$OTBH<%|j zz&;+tDp7{8QhuQC;S*2i-u9vwC8d{$DjHt-^K;}0rgM3j@7+a%3X*npQ(Od;@Y7rW zS0UN#dWkN?xYiZQt;cyTWX)+LBuu==X9PSOLI~Cm+4P!h5Yuv!+_J}3VVXL@8Uz$k zJ3EaRL33ow6u-l3Yio7+;=vYomPggu-CY8Ov{+Eob_J^yKn>_ZNGNjEaul>(Jw4x5S3`ydNhFz* za4=`kFE(9WS^`Taw<__Iw~b8!n4q8{)yNx!J)6Eb7aeurO*A_jn`@2n|1MWyWJRk0 z%(8QS6OfKj!EH#($;wWSjQsr`e9lTQb`}S27nq|Ho~h-~DGdNb%j{v@1T5(O;HU^7 zc&u3|^S~I#h!Qcf>3y>s8Ca-Or2bPdbJKOfib6=3*b|^l3IOU<6!#UmWAoo|_Z9dM z5&}Y;HX1|#iiv>M??2Yoszz+@o+amlsTlxB;X>S5^7IBYUgF`8VFgaG1on(5ee;{* zUp(Rwv&peqG2AB?`%+1J^2>6=;6VVY1wmAph~8j9`<;FH$%% zGz93Lgq^swv@}TB7>nx!ux4Qm@6z-jdiF#_v$1^9D%6Qy^tQWOqzHVJS`L3Vk<$>d zO_8O2BFN19&l)=`>m>(HImu00lA0eodz(pk2qMf=i?#}}9&$_f-4{5L=t-9(ZxyO( zNsttd9j`=nmnkiii@3?j|0UoW4K#=tFvNbng$<2S%W1!Xd1n?)hintPRE|MfCN`P- z$&!R-(X?8qVqWX!65^G!&r~ybNX#=+l~bNjQALYdJ~sA1%h4rtnDeY#k-SpL*qxY` zFZB5zajJ(F-NL@$dZH(i&xE>DLWhHV4cv&8ojs5+FV=V}VFTF52L0(8FH(g(wsJpJLjKG6T@;_=1*>zm@0MdB( zCfdZnpmQ_|<^}}5ZFXa4e6?*EB9Jt#P-#JVtQv#Rw_NDjv>NXh791p$HZ;hhsY&lc zUQa2tH^?k$!v4Cp?g0(Q#l-HgH}|6Fo$4llo-)|eL&J8ObeEfHNB~%iQ7A^z#->mdv?7TqWh?)*W#OeA0^- zJ>#TWJh913tO=;3Jg@r6%uG($Co6l?w6d_Wg03*Qq|QwTjzuUUjya**+pZoS)c`Ze z*M<8}8+^~!_Bpp|f@S%q9ayjo-@40qT#Yv1ud1#6DAWVHP>sQv+gvr1&sn7wETt}y zowmY+!=?sODX?yVr>U~i4WdDIcBc*^kVG`0W2PGlB-r%1#nqVcMxthLYmPYU6!#iq zShO(Db`HLhus-?+TNIjB1Rj9ev$L~Vfv+&gCMriSuPZD1=$C6;(a2w4GapC|>TtvU zGM7<+)3f_l{+H!t8@~9mu~l;^C2jB;gS`s%6r><>M$TX308$NCrl+B7{q88uafd)4 z&7E6`Ua`N}QfAA~KwkC6>#fn|BT)v&9-}_O8H21rvHFUNS2wXxB|k`k2nLGcU8hun zZkho|x3)7qoZlw(-?=ier zn)KsLxl;LT%LN3p$ekJWV5x!6qX71N`xTxUwH(0o1ey6YPFa;ds5A^RgJppF8`0rM zu|d-N$>$VoThzEqR<-s45~OSnARGkD<9a|2RJlmtI!=Aa zP1O`0sz+bF>P_oSPj8pPdR-$0VoW>lW94kphgGm8`5!69a9SwfZR~KWaPd9UjZdL9 z=sO)TAG2>_KYG&6?`V!xWD|sFVppnp{OU4?)2G&9M+mbQtHvPc=WjS}@HK%jmw7Gt z!0!ms3y+p4)R4yEdAYfOtxd2HVGdUg5D<>NiG>hW1cwYr&bRX8y63r6LAVBf`r)A; zNcAX$#vsUG;}NEE!>LX(9q(seo^|1Eh-go|jzUx#n#2rue#*5qMgLuIi2lzNMc{2m zmz4S97u^1KsZW(W#YNm>(!-GBbd-9I!QIE9kUFS0_N<0eX1sLEkGW_suNQZ zhj}{Yq;mmLcAi(&Kn>M0qm>#cZ||r^mq7P4D))hBl_0bfvaTCM$5rvXAIOqaz@%bW z!tIAsQ138!Ng?Db5?Hd>DJnQ~s9@&zYm{RMYmOD-jN(V&!LAJTo$k@kUQg}E^mVZ8 zscOMAtB8YIG_sdVLDofP*dzZx@9;lw)=KoDLTDD6MMf2c z*sEEaC@1?+VkRIZ&BoZ)L!za!N!j7y;P%_-%HJ43<%C6Yq&{_j>bk_jgLJ=odC9S z$DPAd?;WwldfB7hsG;JUv%gxd)@=G8s*ewhQ(~BjA2_aj%-?n7OYo&Ms72($LT`<9?-kTYX1Xzm8MX7bAkmlNrf6*S!HJAAa%as|CPB?2(fOWC(v#g4|_4B5aw zPH9{gc1ZcUE0-Pk3`$mrEpPRuL1>=tcMVa@(#e0Nlh4HIaLtizeQk|$@y(u3!^j9B zu{&O!2bxWM@c^+}*-3DszBQ_%GK1Cd)uuHLyXB^1ZZagG8WUgU1cJ2u>sV@rpS2l| zEAxt<_9qLFpkVXDGp_ypH zh_sMN*Us-Nd^%StJ)LFqaR-S+IKt!!QIa1(2$lt@TVoJHi!9;9 z$3B3H%z6!s#0TxZw)=l5ZVIgKEAw8}7FO=~^8U!@l!_pROp&mFHepV+XS^ZrgIcCU&2 z^ugcsBd-now@ck=GTzWAko35WM-4xSYN}hS4YM49D4+j-c7lprXEMxB1-IH)A7kP{ z`QuhC14!FbFN%$C)Z|u~?TKctt`%_EAMWfvFAINql)HMQ_*MFi_3s-)NaDBoQ#_;B zoK^~I8itB1nY;9ZTEtTG1P5-#2omCoX@a**YH!2#7=pzRHcH=zbp<4azsCppP!MY1 ztPKmL_>Xgta)an)Bj8*EK|s+MKZv-x24f$hOCyHNm!P-uWo1VT@)BRZ#Eq@e1}fZb z&K+D5W6|*St?#u078*z7A_S+}$4kI%4WA2af%*iC=NUu|92fGIm48o~T<_|RDfId0;uoysg&=)}= zoWOyE`pK%7f_BNzaz2~B@f9-aJzh7~!g~SpR!BT&W0u=zqN|ikTFL1~IpIdsL z2V{{U%8liST-n)!!uam-LeW{qk4M2tUI6Tc1lM`w3>M4f3T!iy5?22@w&XsM|^yC74^ex$Quh5IQ55 zOi#kJ;L>{H3}U1I+G*ck%_opVX~^-*?F~ehiM-O`jaRF5@6Yg`;~AoaPFTrH{IV3|AXt*WB=gcgw^*hAdcY7WD%F~Jt0BP_7PFh+> zz7JG{Kk%3IG@H&fY;Bd3(C38{03g`}(ycedjG$10sKGD&#=W7&!<9zA6{9Jg!lC5q zz0u)@-^bb4tsc;KH-YnkB(ll+yaS2*zT7?mL~6e3I6@ zLVWK5V*!e=N(^GdLKJ3e!^@M2tdSRxgh^WugZLw1_9I6g z*>8$nj&Z6djSY9huI)8Y{yjalg`iItpNfnaZcE;-3EWIVS5;R-qUp*@O*n>);}s1p ztuvd)T<00I5_E>V((WHCGj}V@piLSRl7&TOGN^lgY%qp28j!k*{dNlF4rk>kXTJlu zAl*aRaQA}uYUQwX%5k#ft=_|;L)6dC4e8V7X*<7vn}XKM5$P?R1{;mlsm4agYrs_U zWgE~E(9VTZFkrKEnaRzG%^#QxbRlg3q5x8Is-#lpO&FfP_@VkU)PZ#B&Yli?pUc3K z>)rEyVP1-x)MnBgfA;^unE2t*u*II|8%`8@4|mg6X_mhhAKK5F7O7Rzio% zor+|VbI_Ut;q^vYLv#yDu-4XA%;$GFs|CJ$B0$wGWr~=^7GjO}ehw-#Tn%o+_Nx z22Ye@8qK}`4`eOPeIkI znRgOFc2^dJ_X9tF92;z^0BZtLI&8pJv_{zA*t=h0f(^*S!<{=vZtyOEEB-x@u}R&T z{^wNV(Q(!@xD}*0QU#{4b`T0c&BWEr<7nUKg0c&lp(|dS+(ZmO3t{ph7#kzJfZzxp zlaK}%%A^Q^&1UZcTma~n2+KR@eXN?<`il*HG!t^M>Bq~O-!S9D4gpS0KvSP}FsJ+= zCj48-fD9DPACP4N83{=g6BlQf^Lvr|pl(870q{FDvx_{~a+JWCTK|GNm++f;rEcp! z;{^w6tm#xk-h&&B_4QJ}^lFu~4yKN54YO+u&u-j5>dVRF<~5Z^&Gi@`TqUoaPx88zO^pe`Dn~{`zD^ zeOvwA!QtIwEq0vf1m4ar91kgoZ29%81RKzl1~6#9KXp@0!N0I15%?28c;s&f-8*?6W%)4SQwm)lujV@CV z3}Gv3vq{yTJXlVZ*CVzKp3~>37nj2Ne5d|>Whyw*-qL4&!Jm(%qlum|ke-TAGNm;> zI6Uz23+Cy+w3rKLSD_RWUiyuYcNG2EZn+cB?ntSh`}s8Rj5|aUlx^2EMf zG^EH~oAEj9kUQLfgWx!0yV??ZG)BbQ#Rg!>w8NwP{&wH77mic`_Ox<#*SO~U^@e>A zbY6Nz>A=(r-TqC;(}nR$q+UiLZMCl&9wzNbNsKb5-v7m|xA#8%#`$rHRM+`a@{PBA zRh!S;P3El`(#p}vierdM=iiZehEGkFle9GY?v;jl*5XxGo7&=8KM7tQ;5-{<{rg?r z*!z*5Hdyq+&Eny1i?X-tT5)%Tw@R~C1y_y+r^Nj)6J~^vH4(dAT7G3_AFD*b|1&aj z47vbV#Qm4=LXO=|wXk`H)|CG9%;zfk3_>}E-_RLPqod9WQPE$vZDl_{x6ZQPtzB{| zO6ZLHKaQufIGR>Ib3W)y2guC)cO4u8!1o~TE})N30usVY6y765=_^%SnB}EjaxgP5 z!MR4DvjinW2Tn*#OjjT{7xrA=DMC#{qN>WIJ;Sg+hQ90-UJo);iFxxZQbIMRrizj4 zEdMt*{P(Z)XAs|Eyg&|9w{S>Yd_<4Xl;TRSbvjT%Gj9v#G5APUOHqGZKhWQQ)6zJ3 zhBUJIpOfNNAWETixyIAcgu+#f1v;Z=y@tctZ{7!arj29qwTYQNsxC+J0#ImdzElRvoV1vJStf&um6XoXNtV+F!nb;@uJsr+I{-ygRLFoIp-SC znACE{eAlc26WcM{zV7ZL3wytBiYu$!62PiNzA}bWY}2^W)=( ONmP|I6^j(^68{$n%}A>N diff --git a/apps/vscode-e2e/src/visual/electron.visual.ts b/apps/vscode-e2e/src/visual/electron.visual.ts index 8d4072ea7b..d8db7ebec5 100644 --- a/apps/vscode-e2e/src/visual/electron.visual.ts +++ b/apps/vscode-e2e/src/visual/electron.visual.ts @@ -238,7 +238,16 @@ for (const scenario of scenarios) { const sidebar = running.page.locator(".part.sidebar") await expect(sidebar).toBeVisible() - await expect(sidebar).toHaveScreenshot(`electron-${scenario.name}-sidebar.png`) + + // Mask the dynamic token counter so system-prompt changes that alter + // token counts do not cause pixel diffs when layout is unchanged. + const webviewFrame = running.page.frameLocator('iframe[src*="extensionId=ZooCodeOrganization.zoo-code"]') + const tokenCountMask = webviewFrame + .frameLocator("iframe") + .locator('[data-testid="context-tokens-count"],[data-testid="context-window-size"]') + await expect(sidebar).toHaveScreenshot(`electron-${scenario.name}-sidebar.png`, { + mask: [tokenCountMask], + }) if (scenario.webviewSnapshot) { const webview = running.page.locator('iframe[src*="extensionId=ZooCodeOrganization.zoo-code"]') From bac8adcf2d217a0b492c1954f3d6639ee1c42657 Mon Sep 17 00:00:00 2001 From: xcloudx01 Date: Sat, 19 Sep 2026 14:27:23 +0000 Subject: [PATCH 13/21] fix(terminal): prevent inline terminal cmd.exe fallback on Windows (#1673) * fix(terminal): prevent inline terminal cmd.exe fallback on Windows BaseTerminalProcess.execaOptions previously passed shell: BaseTerminal.getExecaShellPath() || true, so an unset execaShellPath fell back to shell:true. On that branch the shell process becomes a bare cmd.exe instead of the resolved PowerShell/Zoo profile, causing the inline terminal to silently downgrade to Windows Command Prompt. Change the fallback to ?? getShell(), which resolves through VS Code profile config -> Zoo override -> userInfo -> env -> allowlisted default (never shell:true). Explicit execaShellPath still wins verbatim, and a deliberately selected cmd.exe profile is preserved via getShell(). Adds a cross-path regression suite covering explicit-win, unset->getShell(), PowerShell-via-configured-profiles, deliberate-Command-Prompt preservation, and never-shell:true. * Fix empty execa shell path fallback Use a truthy fallback so an empty persisted execaShellPath resolves through getShell() instead of being passed through as an empty shell value. * Tighten shell path assertion Assert the exact mocked PowerShell path instead of matching only the executable name. --- .../terminal/ExecaTerminalProcess.ts | 3 +- .../__tests__/ExecaTerminalProcess.spec.ts | 61 ++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index cde5a1251f..a37646f81d 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -5,6 +5,7 @@ import process from "process" import type { RooTerminal } from "./types" import { BaseTerminal } from "./BaseTerminal" import { BaseTerminalProcess } from "./BaseTerminalProcess" +import { getShell } from "../../utils/shell" export class ExecaTerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef @@ -40,7 +41,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { this.isHot = true this.subprocess = execa({ - shell: BaseTerminal.getExecaShellPath() || true, + shell: BaseTerminal.getExecaShellPath() || getShell(), cwd: this.terminal.getCurrentWorkingDirectory(), all: true, // Ignore stdin to ensure non-interactive mode and prevent hanging diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index 8292875b87..94f627200c 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -25,6 +25,7 @@ vitest.mock("ps-tree", () => ({ import { execa } from "execa" import { ExecaTerminalProcess } from "../ExecaTerminalProcess" +import * as shellUtils from "../../../utils/shell" import { BaseTerminal } from "../BaseTerminal" import type { RooTerminal } from "../types" @@ -63,11 +64,13 @@ describe("ExecaTerminalProcess", () => { describe("UTF-8 encoding fix", () => { it("should set LANG and LC_ALL to en_US.UTF-8", async () => { + // Deterministic shell so the assertion focuses solely on LANG/LC_ALL. + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") await terminalProcess.run("echo test") const execaMock = vitest.mocked(execa) expect(execaMock).toHaveBeenCalledWith( expect.objectContaining({ - shell: true, + shell: "/bin/zsh", cwd: "/test/cwd", all: true, env: expect.objectContaining({ @@ -109,15 +112,19 @@ describe("ExecaTerminalProcess", () => { ) }) - it("should fall back to shell=true when execaShellPath is undefined", async () => { + it("when execaShellPath is unset, Execa resolves through getShell() (never shell:true)", async () => { BaseTerminal.setExecaShellPath(undefined) + const resolved = "/resolved/pwsh.exe" + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue(resolved) await terminalProcess.run("echo test") const execaMock = vitest.mocked(execa) + expect(getShellSpy).toHaveBeenCalledTimes(1) expect(execaMock).toHaveBeenCalledWith( expect.objectContaining({ - shell: true, + shell: resolved, }), ) + expect(execaMock).not.toHaveBeenCalledWith(expect.objectContaining({ shell: true })) }) }) @@ -191,4 +198,52 @@ describe("ExecaTerminalProcess", () => { expect(terminalProcess["lastRetrievedIndex"]).toBe(0) }) }) + + describe("cross-path shell invariant (#705 regression)", () => { + // Bridge through unknown: the mock records the raw options object, whose + // declared type under execa's overloads is string|URL, not a plain record. + const capturedShellOption = (): Record => + vitest.mocked(execa).mock.calls[0][0] as unknown as Record + + beforeEach(() => { + BaseTerminal.setExecaShellPath(undefined) + }) + + it("system-prompt resolved shell == Execa execution shell when no explicit execaShellPath", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toBe("/bin/zsh") + }) + + it("keeps the Execa shell equal to getShell() when a Zoo profile override is set", async () => { + BaseTerminal.setExecaShellPath(undefined) + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\pwsh.exe") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toBe("C:\\Windows\\System32\\pwsh.exe") + }) + + it("uses PowerShell when VS Code resolves PowerShell and execaShellPath is unset", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("powershell.exe") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toBe("powershell.exe") + }) + + it("preserves a deliberately selected Command Prompt profile when execaShellPath is unset", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("cmd.exe") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toBe("cmd.exe") + }) + + it("does NOT delegate to shell:true even when getShell() returns an unusual path", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("/opt/custom/fish") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).not.toBe(true) + expect(capturedShellOption().shell).toBe("/opt/custom/fish") + }) + }) }) From c5b585565be1bfbf53ace90dac33b53cc4d505bf Mon Sep 17 00:00:00 2001 From: "zoomote[bot]" <305051434+zoomote[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:52:55 +0000 Subject: [PATCH 14/21] [Docs] Add lifecycle verification GAP report and remediation blocks (#1626) * test(lifecycle): formalize remaining issue protocols * docs(lifecycle): qualify delegated mode coverage * docs(lifecycle): classify verification coverage * docs(lifecycle): separate open and historical issues * fix(delegation): read task-local mode in getEnvironmentDetails and validateToolUse * test(delegation): cover custom-tool execute context and state fallback * Revert "test(delegation): cover custom-tool execute context and state fallback" This reverts commit 4909065fb20c3b6818791ce27567376853d7e0be. * Revert "fix(delegation): read task-local mode in getEnvironmentDetails and validateToolUse" This reverts commit c83d504d7e4b12a02a783fad017530f943090ec3. * docs(lifecycle): make gap audit self-contained * docs(lifecycle): add exhaustive verification gap report * docs(lifecycle): audit subtask todo isolation * docs(lifecycle): generalize tool state ownership gaps * docs(lifecycle): add remediation portfolio sizing * docs(lifecycle): separate fan-out from baseline * docs(lifecycle): remove remediation time estimates * docs(lifecycle): split remediation into one-point blocks * docs(lifecycle): fix remediation block ordering * docs: correct lifecycle gap references * fix(lifecycle): require exact delegated mode witness * fix(lifecycle): give fan-out invariants teeth and correct GAP-report inventory --------- Co-authored-by: Roomote Co-authored-by: Elliott de Launay Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> Co-authored-by: Elliott de Launay --- .../native-tool-call-parser-scoping-model.md | 2 +- .../task-cleanup-protocol-model.md | 2 + .../architecture/task-lifecycle-gap-report.md | 334 ++++++++++++++++++ docs/architecture/task-lifecycle-model.md | 104 ++++-- .../task-lifecycle-remediation-blocks.md | 136 +++++++ package.json | 1 + scripts/check-provider-handoff-scheduler.ts | 14 +- scripts/check-task-fanout-protocol.ts | 253 +++++++++++++ 8 files changed, 820 insertions(+), 26 deletions(-) create mode 100644 docs/architecture/task-lifecycle-gap-report.md create mode 100644 docs/architecture/task-lifecycle-remediation-blocks.md create mode 100644 scripts/check-task-fanout-protocol.ts diff --git a/docs/architecture/native-tool-call-parser-scoping-model.md b/docs/architecture/native-tool-call-parser-scoping-model.md index ba7fbedd29..6533b8038f 100644 --- a/docs/architecture/native-tool-call-parser-scoping-model.md +++ b/docs/architecture/native-tool-call-parser-scoping-model.md @@ -12,7 +12,7 @@ For focused debugging, run this submodel directly with: pnpm parser-scope:model-check ``` -The command is composed into the same verification suite, but this remains a separate protocol and state space from the persisted task lifecycle model and shared-store concurrency model. It owns its parser-scoping invariants and adds no parser state to `HistoryItem` or `taskLifecycle.ts`; instead, it replays the public production `NativeToolCallParser` APIs using two independent scope objects. +The command runs this check sequentially with the other lifecycle checks, but this remains a separate protocol and state space from the persisted task lifecycle model and shared-store concurrency model. It owns its parser-scoping invariants and adds no parser state to `HistoryItem` or `taskLifecycle.ts`; instead, it replays the public production `NativeToolCallParser` APIs using two independent scope objects. The authoritative [lifecycle coverage audit and issue tracker](./task-lifecycle-model.md#coverage-audit) records its evidence class and cross-model limits. ## Bounds and replay diff --git a/docs/architecture/task-cleanup-protocol-model.md b/docs/architecture/task-cleanup-protocol-model.md index e46991365b..bd401ff159 100644 --- a/docs/architecture/task-cleanup-protocol-model.md +++ b/docs/architecture/task-cleanup-protocol-model.md @@ -14,6 +14,8 @@ pnpm cleanup-protocol:model-check This is a separate child model from the persisted task lifecycle and shared-store concurrency models. It follows the native tool-call parser model pattern: keep an independent bounded state space for an independent protocol, require every action and semantic landmark to remain reachable, and connect the abstract claims to focused production tests. +The authoritative [lifecycle coverage audit and issue tracker](./task-lifecycle-model.md#coverage-audit) classifies how this abstract model relates to production and other submodels. + ## Bounds and environment actions The model uses two tasks and explores every reachable interleaving through depth 20, with an explicit 100,000-state budget. Abort, disposal, final-save, provider abort/drain phases, and shutdown-cursor state are modeled directly. Independent abort and disposal calls may interleave freely, while provider-initiated calls are gated to the current shutdown task. Cleanup and editor-reversion settlement or rejection are environment actions, so the explorer does not assume they eventually occur. diff --git a/docs/architecture/task-lifecycle-gap-report.md b/docs/architecture/task-lifecycle-gap-report.md new file mode 100644 index 0000000000..66db3eb608 --- /dev/null +++ b/docs/architecture/task-lifecycle-gap-report.md @@ -0,0 +1,334 @@ +# Task lifecycle verification GAP report + +## Purpose and scope + +This report inventories Zoo Code task lifecycle state, mutation, persistence, scheduling, streaming, event, and verification boundaries. It is a documentation and formal-model audit, not a claim that the listed production gaps are fixed. + +The audit covers tracked TypeScript, JSON, YAML, and Markdown under `packages/`, `src/`, `apps/cli`, `apps/vscode-e2e`, `scripts/`, `.github/workflows`, and `docs/architecture`. It traces production symbols to bounded models, focused tests, extension-host E2E, and CI entry points. + +“Exhaustive” means exhaustive over the repository paths, symbol families, and search terms listed here at the audited commit. It does not include ignored/generated output, deployment branch-protection settings, runtime telemetry, dynamically constructed names that evade text search, or behavior in dependencies. GitHub issue links are historical provenance only; stable `LIFE-GAP-*` IDs own the active burn-down. + +## Methodology and audit criteria + +The inventory used structural searches for status and lineage fields, lifecycle reducers, store mutations, registry/stack operations, scheduler/semaphore queues, task start/resume/abort/dispose paths, persistence retries, stream scopes, lifecycle events, webview/API/IPC ingress, copied unions, tests, scripts, and workflow commands. Each claim was then classified by whether the checker executes production code or a model-authored proxy. + +Primary references define the audit criteria: + +- Lamport’s [High-Level View of TLA+](https://lamport.azurewebsites.net/tla/high-level-view.html) defines behavior as state sequences, distinguishes invariants from liveness, and explains that fairness is needed for steps that must eventually occur. Repository criterion: a safety explorer must not claim eventual cleanup, progress, retry, or completion without explicit temporal/fairness semantics. +- Quint’s [model-checker documentation](https://quint-lang.org/docs/model-checkers) states that model checking verifies properties of the model and that bounded checking is tied to a maximum execution length. Repository criterion: every pass claim names its state/depth/task/retry bounds and does not imply unbounded production correctness. +- Quint’s [model-based testing guidance](https://quint-lang.org/docs/model-based-testing) explicitly separates “the design is right” from “the implementation matches the design” and recommends replaying model traces or validating production traces. Repository criterion: model-authored transitions are proxy evidence until a production adapter, generated trace driver, or trace validator connects them to code. +- The [Alloy file-system tutorial](https://alloytools.org/tutorials/online/maintext-FS-1.html) states that “no solution found” guarantees only the selected finite scope and warns that facts can overconstrain away examples. Repository criterion: semantic landmarks and action reachability are required alongside invariants, and finite scope is always disclosed. +- Jepsen’s [consistency reference](https://jepsen.io/consistency) defines a consistency model as the set of legal histories. Repository criterion: cross-host claims must state which histories, conflicts, and dependencies are allowed, not merely that a mutex exists. +- SQLite’s [transactional guarantee](https://www.sqlite.org/transactional.html) ties crash atomicity to explicit crash and power-failure simulation. Repository criterion: Zoo Code’s per-file smoke tests cannot support crash-atomic or power-loss claims without an equivalent failure-injection harness. + +Evidence classes used below: + +| Class | Meaning | +| ------------------------- | ------------------------------------------------------------------------------------------ | +| Production-backed bounded | The checker executes production functions for every state/schedule within declared bounds. | +| Abstract bounded | The checker exhausts model-authored transitions; production refinement is separate. | +| Focused production test | A deterministic production path is exercised, but not all model interleavings. | +| E2E witness | A real extension-host boundary is exercised for one controlled history. | +| Proxy-only | The check demonstrates a premise or analogous mechanism, not the production claim. | +| Known-unsafe witness | CI preserves a reproducible violating history; a pass confirms the witness still exists. | +| Unmodeled | No executable property currently covers the boundary. | + +## Exhaustive lifecycle inventory + +### State owners + +| Owner | State | Primary symbols | Authority boundary | +| -------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Persisted history schema | Status, lineage, completion summary, pending action, accounting | `packages/types/src/history.ts`: `historyItemSchema`, `pendingTaskActionSchema` | Restart-visible record shape; optional status normalizes to active in lifecycle code. | +| Lifecycle reducers | Legal persisted transitions and parent-child ownership | `src/core/task-persistence/taskLifecycle.ts`: `delegateTaskToChild`, `interruptDelegatedChild`, `completeDelegatedChild`, `abandonDelegatedChild` | Pure transition authority when inputs are authoritative. | +| History store | Per-task files, cache, deltas, reconciliation, migration, repair | `src/core/task-persistence/TaskHistoryStore.ts`; `taskStoreConcurrency.ts` | Per-task files are authoritative; each extension host has an independent cache. | +| Live task | Abort/dispose, ask state, run ownership, mode/profile, streaming, message and completion readiness | `src/core/task/Task.ts` | Process-local execution state; not equivalent to persisted status. | +| Task registry | Live instances, compatibility stack, current focus | `src/core/task/TaskRegistry.ts` | Focus/publication owner; not scheduler admission or persisted lineage. | +| Provider | Transition queues, current task, registry integration, persistence orchestration, event forwarding | `src/core/webview/ClineProvider.ts` | Coordinates layers but does not make them one transaction. | +| Scheduler/semaphore | Waiting, admission, held permits, cancellation, release | `src/core/task/TaskScheduler.ts`; `src/utils/TaskSemaphore.ts` | Provider-local execution gate; default capacity is one. | +| Message queue | Queued user feedback and claims | `src/core/message-queue/MessageQueueService.ts` | Memory-only; disposal clears membership and claims. | +| Parser scope | Raw-index and tool-ID accumulators | `src/core/assistant-message/NativeToolCallParser.ts` | Request-scope parser state; transport and Task caller protocol are separate. | +| Event surfaces | Task, provider, public API, IPC, and webview lifecycle notifications | `packages/types/src/task.ts`, `events.ts`, `ipc.ts`; `src/extension/api.ts` | Related but non-identical payload and settlement contracts. | +| Tool-originated task state | Child initialization, approvals, partial calls, results, pending actions, and replay identity | `BaseTool`, `NewTaskTool`, `UpdateTodoListTool`, `AttemptCompletionTool`, `presentAssistantMessage`, `Task.todoList` | Ownership spans request, call, task, provider, message, and persisted-history scopes. | + +### Persisted lifecycle vocabulary and mutations + +Persisted statuses are `active`, `completed`, `delegated`, and `interrupted`. `VALID_TASK_STATUS_TRANSITIONS` permits active to delegated/completed/interrupted, delegated to active, interrupted to completed, and no transition from completed. Lineage fields are `rootTaskId`, `parentTaskId`, `delegatedToId`, `childIds`, `awaitingChildId`, `completedByChildId`, `completionResultSummary`, and `pendingAction`. + +| Mutation boundary | Production symbols | Modeled/tested evidence | Not covered by that evidence | +| --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Ordinary upsert | `TaskHistoryStore.upsert`, `upsertCore`, `writeTaskFile` | Store unit/cross-instance tests; shared-store delta model | Arbitrary process count, crashes, lock staleness, malicious/malformed records. | +| Single-record atomic update | `atomicReadAndUpdate` | Provider delegation tests; host-local lock abstraction | Cross-host compare-and-swap ownership. | +| Pair update | `atomicUpdatePair` | Pair-order/failure model and tests | Cross-host or crash atomicity; second-write failure can expose committed prefix. | +| Reconciliation | `reconcile`, `reconcileDelegationState` | Reconciliation tests | Immediate convergence, watcher delivery, concurrent repair histories. | +| Journaled repair | `repairActiveDelegation`, `replayDelegationRepairIntent` | Repair/restart tests | Other pair operations have no WAL/intent record. | +| Legacy migration/import | `migrateFromGlobalState`, `importRooTaskHistory` | Migration/import tests | Unified validation policy across generic store and importer. | +| Deletion | `delete`, `deleteMany`, `ClineProvider.deleteTaskWithId` | Focused deletion tests | Atomic history/checkpoint/directory deletion; unlink failures are best effort. | +| Live message save | `Task.saveClineMessages`, `taskMetadata`, `ClineProvider.updateTaskHistory` | Persistence tests; known stale-save witness | Disk-authoritative lifecycle-field ownership. | + +Copied persisted-status membership occurs in `packages/types/src/task.ts`, `src/core/task-persistence/taskMetadata.ts`, `src/core/task/Task.ts`, `apps/cli/src/ui/types.ts`, `HistoryTrigger.tsx`, and the core task-history reader. The runtime `TaskStatus` vocabulary (`running`, `interactive`, `resumable`, `idle`, `none`) is intentionally separate but similarly named. + +### Scheduler and queue transitions + +| Queue/lock | Transition | Scope | Evidence boundary | +| --------------------------- | ----------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------- | +| Task semaphore | submitted → waiting → admitted → running → released; queued → cancelled | One provider | Unit tested; modeled permits are abstract. | +| Per-parent transition queue | delegation/interruption/completion/abandonment serialization | Static queue keyed by parent ID | Provider tests and handoff model; no cross-process transaction. | +| History restoration queue | request → serialized rehydration → install | One provider | Provider tests; no model state. | +| Provider-profile queue | profile mutation serialization | One provider | Settings/provider tests; outside lifecycle models. | +| Message queue | add → claim → persist → remove, or release on failure | One live task | Claim path tested; legacy dequeue-before-submit path remains. | + +Registry publication can precede scheduler admission. Therefore “current”, “running”, “active”, and “persisted active” are not interchangeable states. + +### Tool-state ownership boundary + +Tool inputs originate in provider stream transforms, are assembled by `NativeToolCallParser`, converted to authoritative `nativeArgs`, validated centrally and inside handlers, optionally edited through webview approval, and can mutate live `Task`, provider, message, and persisted history state. Tool outputs return through `pushToolResult`, parent result injection, pending-action replay, or public lifecycle events. Those stages do not share one canonical identity or transaction. + +| Boundary | Production path | Current evidence | Ownership limitation | +| --------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Argument assembly | Provider transform → parser scope → `ToolUse.nativeArgs` → `BaseTool.handle` | Production-backed bounded parser replay plus provider/tool tests | Provider transforms, parser state, and downstream handler state are separate owners. | +| Validation | `validateToolUse` plus handler-local parsing and policy checks | Focused tests | Validation mode and configuration may come from shared provider state rather than task context. | +| Approval edits | Handler proposal → `askApproval` → webview edit → handler settlement | Focused single-approval tests | Todo edit state is process-global and carries no task/action/call identity. | +| Child initialization | `new_task` args → pending create action → provider delegation → child `Task` | Focused forwarding and pending-action tests | Some tool-originated child state is live-only and lacks a durable rehydration owner. | +| Completion/result injection | `attempt_completion` pending action → parent UI/API messages → lifecycle pair | Focused tests and separate lifecycle/completion models | Message, lifecycle, and replay commits are separate failure domains. | +| Partial presentation | Singleton tool handler partial methods | Tool-local tests | `BaseTool.lastSeenPartialPath` is shared across calls/tasks. | +| Identity/replay | raw call ID → sanitized ID → history/result/pending action | Duplicate-ID helper tests | Sanitization is non-injective; history deduplication and execution do not share a proven bijection. | + +#### `new_task` and todo evidence + +The normal creation path does not inherit the parent list. The model supplies optional `new_task.todos`; `NewTaskTool.execute` parses only that argument into a fresh array, stores it in a pending action while approval is unresolved, and forwards it as `initialTodos` through `delegateParentAndOpenChild` and `createTask`. The parent task supplies lineage and workspace context, not todos. + +`Task` assigns `initialTodos` to its process-local `todoList`. `UpdateTodoListTool` later writes task-scoped `updateTodoList` messages, and `restoreTodoListForTask` reconstructs the latest list from the reopened task's own messages. `ClineProvider.getStateToPostToWebview` publishes the focused task's `currentTaskTodos`; `ChatView` and `TodoListDisplay` render that state or the current task's message-derived fallback. No frontend path intentionally copies a parent list. + +The reported appearance of inheritance therefore needs two controls: + +1. If the model emits child todos matching the parent, that is explicit tool-call content and not evidence of IDE aliasing. +2. If child todos disappear or change after navigation/restart, that is an IDE-side task-state persistence/scoping question. + +Initial child todos are not placed in a task message or `HistoryItem`. Rehydration constructs a new `Task` without `initialTodos`; before the child's first `update_todo_list`, message-derived restoration yields an empty list. Constructor and todo setter APIs also assign arrays directly, creating latent aliasing for programmatic callers even though the normal `new_task` parser creates fresh objects. No current model contains todo state, task-ID/generation-scoped todo publication, or rehydration equivalence. + +#### Provider-mode causality + +Merged PR #1625 changed the confirmed provider-mode readers for environment details, built-in validation, and custom-tool execution to task-local mode and added focused tests. The historical bug could change mode-sensitive prompt context, validation, and tool availability, but no production path uses provider mode to select or transfer the parent's `todoList`. It is not a direct mechanism for parent todos appearing in a child. Matching lists at creation are evidence of explicit model-supplied `new_task.todos` unless a separate ownership witness shows otherwise. A distinct plausible contamination path is the process-global todo approval edit slot described by `LIFE-GAP-036`. + +The delegated-mode reader checker remains proxy refinement evidence: it executes the handoff selector and pure built-in permission comparison, not the VS Code-dependent downstream readers. The wider provider-mode reader inventory therefore remains open even though the three confirmed regression paths are fixed. + +#### Formal-model decision + +No broad tool-state checker is added in this PR. A green model would have to invent a unified owner across parser, handler singleton, webview approval, task state, message files, lifecycle records, and replay. Initial child-state persistence has no production transition to import; approval correlation lacks task/action identity; canonical call identity spans multiple embedded I/O paths. Until those owners are extracted, stable gaps and deterministic witness criteria are stronger evidence than an abstract passing model. The existing parser, lifecycle, store-concurrency, handoff, cleanup, completion, and delegated-mode-reader checkers remain explicitly local, as does the separate optional fan-out checker. + +### Delegation, interruption, cancellation, completion, and abandonment + +| Flow | Production path | Key ordering | Verification | +| --------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Delegate | `NewTaskTool` → `delegateParentAndOpenChild` | Snapshot context; flush/remove parent; create paused child; commit parent ownership; then schedule child | Reducer model, handoff model, provider tests, subtask E2E. | +| Interrupt on eviction | `evictCurrentTask` → `markDelegatedChildInterrupted` | Remove live child; revalidate parent ownership; persist interrupted child | Reducer model and provider/E2E tests. | +| User cancel | `cancelTaskInternal` → request abort → bounded drain/save → interrupted persistence/rehydration | Live flags and persisted status converge through multiple fallbacks | Cleanup model is abstract; provider and E2E tests cover selected paths. | +| Complete standalone | `AttemptCompletionTool` → persistence readiness → `TaskCompleted` | Public event follows accepted completion and assistant-history visibility | Abstract completion model, focused tests, fresh-host E2E. | +| Complete child | `AttemptCompletionTool` → `reopenParentFromDelegation` | Validate IDs; write parent messages; remove child; pair update; publish; schedule parent | Reducer/handoff models and provider/E2E tests; message/lifecycle transaction is unmodeled. | +| Abandon | `abandonSubtask` | Require interrupted child; remove live child; pair-detach; process-local stale guard | Reducer/shared-store models and focused/E2E tests. | +| Resume | webview/API/IPC → `resumeTask`/`showTaskWithId` → rehydrate | Surfaces differ in awaiting, error propagation, and publication | Focused/E2E tests; no unified model. | + +### Streams and event consumers + +Each API request creates a parser scope. Provider `tool_call_partial` chunks pass through `NativeToolCallParser`, then `Task` turns parser events into partial/final assistant blocks. End-of-stream finalization is modeled for two scopes; abort/failure cleanup and provider transform semantics are separate. + +`Task` may detach an iterator to drain final usage. That continuation can update accounting/messages after foreground processing stops. Lifecycle generation ownership is not attached to those writes. + +Task events are forwarded by `ClineProvider`, enriched and re-emitted by `src/extension/api.ts`, and serialized to IPC. Node `EventEmitter.emit()` does not await async listeners, so event notification and listener settlement are separate contracts. Public completion status persistence and downstream consumers are not in the completion model. + +## Production-to-model-to-test-to-CI matrix + +| Production boundary | Model/property | Production tests | E2E | CI path | Classification | +| ------------------------- | --------------------------------------------------------------- | --------------------------------------------------- | ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------------- | +| Lifecycle reducers | Exact parent-child ownership, acyclicity, terminal immutability | `taskLifecycle.spec.ts` | `subtasks.test.ts` | `lifecycle:model-check`, unit, E2E | Production-backed bounded | +| Delta/merge/store | Field preservation, status legality, pair order/failure | store unit, cross-instance, real-lock smoke | None direct | model umbrella, unit | Production-backed bounded plus known-unsafe witnesses | +| Handoff selector/reducers | Commit-before-start, publication, permit/redelegation ordering | provider handoff, scheduler, delegation tests | subtask profile/resume paths | model umbrella, unit, E2E | Mixed production/abstract | +| Optional fan-out | Two siblings, result writer/delivery, orphan cleanup | Scheduler primitives only | None | explicit optional command | Planned-only abstract, excluded from baseline | +| Cleanup | At-most-once abort/dispose, settlement order, provider drain | Task/provider cleanup tests | Indirect cancellation paths | model umbrella, unit, E2E | Abstract bounded plus focused tests | +| Parser scopes | Scope-owned IDs/arguments, exactly-once finalization | parser/provider stream tests | Indirect | model umbrella, unit | Production-backed bounded replay | +| Completion readiness | Durability before event, retry/cancel/reopen ordering | Task/completion tool tests | fresh-host restart | model umbrella, unit, E2E | Abstract bounded plus refinement witnesses | +| Mode handoff/readers | Selector snapshot and observable provider/task divergence | selector plus three focused downstream-reader tests | profile handoff | model umbrella, unit, E2E | Write side production-backed; confirmed readers tested; wider inventory partial | +| Status vocabulary | Shared schema plus copied unions | CLI/history tests | None | typecheck, unit | Type/static convention | + +CI runs lint, typecheck, and `pnpm lifecycle:model-check` in `.github/workflows/code-qa.yml`. Unit/integration tests run separately on Ubuntu and Windows. Mocked extension-host E2E and the explicit restart-persistence phase run in `.github/workflows/e2e.yml`. Workflow files prove invocation, not branch-protection required-check configuration. E2E may reuse an identical-source pass marker on pull requests. + +## Assumptions and exclusions + +- Every checker is finite and protocol-local. Bounds are documented in the parent architecture page and checker constants. +- Safety invariants do not establish liveness. No checker includes fairness sufficient to prove eventual queue admission, cleanup, persistence, retry, or completion. +- A model-authored provider, scheduler, cleanup, fan-out, or durability action is not production refinement by itself. +- Per-file locks are treated as effective mutual exclusion in the abstract store model. Lock implementation, stale-lock recovery, rename semantics, process crashes, and power loss are excluded. +- Pair writes, lifecycle plus message writes, deletion plus filesystem cleanup, and registry plus persistence publication are not transactions. +- Parser replay fixes two scopes, one raw index, and local action order. Transport transforms and arbitrary malformed histories are excluded. +- Focused tests and E2E are representative histories, not exhaustive interleavings. +- No public-runtime telemetry or production traces were available for trace validation. + +## Ranked GAP register + +Severity reflects plausible data loss, ownership corruption, permission/context errors, or stuck work. Confidence reflects direct source evidence, deterministic witness, or inference. + +| ID | Severity | Confidence | Gap and production impact | Witness/reproducer | Dependencies | Objective closure criteria | +| ------------ | ----------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| LIFE-GAP-001 | Critical | High | Stale cross-host completion can clear a newer handoff and orphan its child. | Exact shortest witness in shared-store checker. | Disk-authoritative ownership guard or generation. | Deterministic two-store test passes; authoritative child ID is revalidated under disk lock; witness becomes universal invariant. | +| LIFE-GAP-002 | High | High | Stale message save can restore abandoned lineage. | Exact shortest stale-save/abandon witness. | Lifecycle-field ownership, tombstone, or generation. | Stale save cannot alter detached lineage in production test or bounded model; witness promoted. | +| LIFE-GAP-003 | High | High | Legacy dequeue-before-submit can lose queued user feedback on submission failure. | `Task.processQueuedMessages` removes before async submit. | Standardize claim/persist/ack path. | Every queue consumer claims first, removes after durable acceptance, releases on failure; failure test retains message. | +| LIFE-GAP-004 | High | High | Pair writes are not cross-host/crash atomic; partial lifecycle state is observable. | Modeled second-write failure landmark. | WAL/repair intent or explicitly idempotent recovery per pair operation. | Crash injection at each write boundary converges to a documented legal state. | +| LIFE-GAP-005 | High | High | Delegation creates child state and commits parent ownership in separate failure domains. | Provider rollback tests cover selected failures. | Idempotent operation ID or repair intent. | Failure injection before/after every persistence/publication step restores parent or completes delegation without orphan state. | +| LIFE-GAP-006 | High | Medium-high | Parent completion messages can persist before lifecycle completion commit fails. | Source ordering in `reopenParentFromDelegation`. | Transaction/intent or reorder with recovery. | Inject pair-write failure and prove no stale result context, or prove replay safely completes the lifecycle. | +| LIFE-GAP-007 | Medium | High | Three confirmed task-local mode readers are fixed and production-tested, but the repository-wide reader inventory and enforceable task-local boundary remain incomplete. | Merged focused tests cover environment details, built-in validation, and custom-tool execution; the pure checker proves only divergence and selector storage. | Reader inventory and task-local API boundary. | Every mode-sensitive reader is classified; required task-local consumers have divergent production tests in both permission directions; checker/docs distinguish executed readers from proxy evidence. | +| LIFE-GAP-008 | High | High | Responses API argument-only deltas may be dropped; absent indices can alias calls. | Transform requires ID/name and defaults index to zero. | Provider transform correlation design. | Multi-delta and concurrent index-less production tests reconstruct isolated calls; parser model includes mapped transform events. | +| LIFE-GAP-009 | Medium | High | Async lifecycle event listeners have no awaited settlement contract. | Async listeners registered on Node EventEmitter. | Classify notification versus barrier events. | Barrier side effects move to awaited methods; notification listeners have contained rejection tests and documented ordering. | +| LIFE-GAP-010 | Medium | Medium-high | Detached usage drain can write after abort, replacement, delegation, or a newer request generation. | Background iterator mutates/persists without generation guard. | Request-generation ownership. | Late drain may account valid usage but cannot mutate stale UI/message/lifecycle state; controlled delayed-stream test passes. | +| LIFE-GAP-011 | Medium | High | Completion model excludes terminal status persistence and downstream public consumers. | Model ends at emission readiness. | Consumer inventory and contract. | Consequential consumers are enumerated; required status/metadata ordering has production refinement tests. | +| LIFE-GAP-012 | Medium | High | No persisted attempt/generation distinguishes delayed pre-interruption completion from valid post-resume completion of the same child. | Documented model exclusion. | Persisted generation token. | Reducer/store/API/model reject stale generation while accepting resumed generation; restart E2E covers it. | +| LIFE-GAP-013 | Medium | High | Task lifecycle status vocabulary is copied across schema, task metadata, Task, CLI, and history reader. | Literal union inventory. | Shared exported schema-derived type. | Consumers import one owner; CI/static check rejects incompatible local copies. | +| LIFE-GAP-014 | Medium | High | The serial production contract relies on singular reducer ownership and a default one-permit provider scheduler; optional fan-out must not be mistaken for baseline coverage. | The production-backed lifecycle model rejects multiple active awaited children, while the optional fan-out model has no production imports or E2E. | Serial baseline ratchet; separately ticketed fan-out decision. | Baseline: close cross-host violations, assert provider scheduler capacity and serial ordering, and keep fan-out outside baseline CI. Optional fan-out: implement adapters/E2E before reclassification. | +| LIFE-GAP-015 | Medium | High | Independent checks do not establish end-to-end refinement. | Six baseline state spaces plus one optional fan-out state space remain disjoint. | Boundary mappings and tractable joint bounds. | Add joint checker/trace validation for each cross-model claim, or keep every claim explicitly local. | +| LIFE-GAP-016 | Medium | High | Traceability is documentary and can drift from scripts, symbols, tests, and CI. | Shared-store scenario count has drifted in documentation. | Machine-readable manifest/checker summaries. | CI validates stable IDs, model membership, bounds, symbol/test paths, and workflow invocation. | +| LIFE-GAP-017 | Medium | High | Store cache records are exposed without cloning; external mutation may bypass locking. | `get`/`getAll` return cached objects. | Immutability/read API decision. | Freeze/clone records or prove callers cannot mutate; mutation regression test. | +| LIFE-GAP-018 | Medium | High | Ordinary history-file reads cast JSON instead of applying the shared schema. | Store reconciliation/read path. | Validation/quarantine policy. | Malformed records are rejected or quarantined deterministically with tests and recovery documentation. | +| LIFE-GAP-019 | Medium | High | Generic task IDs lack the importer’s explicit path-safety validation. | Importer validates IDs; generic paths interpolate IDs. | Shared safe-ID boundary. | Every filesystem task ID passes one validator; traversal and separator tests cover all entry points. | +| LIFE-GAP-020 | Medium | Medium-high | Watch/reconcile convergence is eventual and failure-tolerant, not coherent. | Debounced watcher plus periodic scan. | Version/notification or documented eventual contract. | Define stale-read window and convergence property; multi-host test covers missed watcher event and concurrent update. | +| LIFE-GAP-021 | Medium | High | Store disposal does not await queued writes. | Synchronous `dispose` stops watcher/timer only. | Async drain/close contract. | Disposal awaits or explicitly cancels writes; no post-dispose writes in deterministic test. | +| LIFE-GAP-022 | Medium | High | Public clear and webview clear use different delegated-child semantics. | API uses eviction; webview removes directly. | One clear contract. | All ingress paths converge on the same lifecycle transition and tests assert identical persisted results. | +| LIFE-GAP-023 | Medium | High | History deletion can be resurrected after swallowed unlink failure. | Cache removal precedes best-effort unlink. | Tombstone or surfaced failure/retry. | Inject unlink failure and prove item stays deleted or operation reports failure without false success. | +| LIFE-GAP-024 | Medium | High | Parser cleanup depends on normal finalization or garbage collection. | Weak scope maps lack universal request `finally`. | Request-level cleanup owner. | Abort/error/success all clear active parser state in production integration tests. | +| LIFE-GAP-025 | Medium | High | Abort listeners may accumulate during successful stream chunks. | Per-chunk listener removed only by abort. | Settle-time listener cleanup. | Long stream keeps bounded listener count and removes listeners on both race outcomes. | +| LIFE-GAP-026 | Medium | Medium-high | Usage-drain timeout cannot interrupt a permanently pending `iterator.next()`. | Elapsed time checked before await. | Deadline race/abort. | Hung iterator settles drain within wall-clock bound in fake-timer test. | +| LIFE-GAP-027 | Medium | High | Task-level delegation listeners are untyped/dead while provider listeners own the same public events. | `src/extension/api.ts` registers both paths. | Single typed event owner. | Remove duplicate/dead listeners or define one source; public API test proves exactly-once emission. | +| LIFE-GAP-028 | Low-medium | High | `TaskSpawned` payload semantics differ across task/provider/public surfaces. | Child-only, ambiguous task ID, and parent+child forms. | Event contract normalization. | Payloads use explicit names and adapters are type-checked with compatibility tests. | +| LIFE-GAP-029 | Low-medium | High | `Task.taskStatus` and `TaskRegistry.getRunning` are projections, not scheduler/persistence truth. | Ask markers and abort flags only. | Naming/contract clarification. | Rename or document exact predicates; callers stop using them as stronger lifecycle evidence. | +| LIFE-GAP-030 | Low-medium | Medium | `Task.run()` may resolve immediately if another path already started the task. | `_started` short-circuit versus scheduler callback. | Single start owner. | Scheduler-facing start returns the actual run promise; duplicate-start test proves settlement identity. | +| LIFE-GAP-031 | Low-medium | High | Queue state is memory-only and cleared on disposal. | `MessageQueueService.dispose`. | Product durability decision. | Document intentional loss or persist claims/messages with restart tests. | +| LIFE-GAP-032 | Low-medium | Medium | Webview abandonment handler exists without a confirmed production UI sender. | Protocol/handler search only. | Reachability decision. | Add supported sender/E2E or remove/deprecate unreachable command. | +| LIFE-GAP-033 | Low-medium | High | Resume ingress differs in awaiting and error propagation. | Webview/API/IPC adapters diverge. | Shared resume operation contract. | Contract tests compare result/error/publication semantics for each surface. | +| LIFE-GAP-034 | Low | High | Bounds and model metadata are handwritten and not mechanically synchronized. | Constants, prose, and console summaries duplicate values. | Machine-readable checker metadata. | CI compares emitted metadata with docs/manifest and rejects undocumented bound/action/landmark changes. | +| LIFE-GAP-035 | Medium | High | Tool-originated child initialization lacks a complete durable ownership contract; initial todo state is the confirmed witness and can disappear after rehydration. | Create a child with explicit initial todos, switch or restart before `update_todo_list`, then reopen it; restoration finds no todo message and yields an empty list. | Canonical durable task-ID-scoped child-initialization owner and publication contract. | Inventory every `new_task`-originated child field; persist required initial state before visibility/run; restore deep-equal independent state across switching, interrupted resume, checkpoint restore, and fresh-host restart; preserve later-update and explicit-empty precedence; add constructor deep-copy, persistence, webview scoping, and E2E witnesses. | +| LIFE-GAP-036 | High | High | Interactive todo approval edit state is process-global and uncorrelated; one task's delayed edit can be consumed by another task's pending approval. | Start approvals for tasks A and B, send A's edited list through `setPendingTodoList`, then resolve B; B reads the shared `approvedTodoList`. | Task/action/tool-call-correlated approval state and webview protocol. | Carry task ID and action/tool-call ID through proposal, webview edit, approval, cancellation, and settlement; reject stale/mismatched edits; deep-clone inputs; test two interleaved approvals, denial, cancellation, task switch, and delayed edits. | +| LIFE-GAP-037 | Medium-high | High | Singleton tool handlers share partial presentation state across calls/tasks, so interleaved paths can cause false or missed stabilization. | Interleave A:`x`, B:`y`, A:`x` or A:`x`, B:`x` through one handler's `lastSeenPartialPath`. | Per-call handler state keyed by task and tool-call identity. | Isolate partial state by `(taskId, toolCallId)` or handler instance; prove independent stabilization and cleanup after success, malformed finalization, rejection, cancellation, abandonment, and incomplete streams. | +| LIFE-GAP-038 | High | High | Lossy tool-ID canonicalization can deduplicate persisted history without deduplicating execution, results, approvals, or pending-action replay. | Distinct raw IDs such as `call:a` and `call/a` both sanitize to `call_a`; history may retain one call while execution retains both. | One collision-resistant canonical call identity before indexing and persistence. | Reject or disambiguate collisions; prove a bijection among parsed call, durable tool use, approval, execution, result, pending action, and replay; test adversarial native/MCP IDs and restart between approval and settlement. | + +## Portfolio remediation plan + +The [1-SP remediation block register](./task-lifecycle-remediation-blocks.md) decomposes this portfolio into small modeling/documentation increments. It assigns every GAP exactly one primary block, preserves dependencies across workstreams, and keeps optional fan-out separate from baseline ownership. + +The 38 IDs are not 38 independent projects. They group into eight programs with shared root causes and implementation surfaces. Complexity classes reflect implementation breadth, coupling, and verification risk rather than schedule or duration. + +| Cluster | Gap IDs | Root fix and likely ownership | Complexity | Engineering risk | Objective portfolio evidence | +| ------------------------------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| P1. Persisted ownership and generation | 001, 002, 012, 017, 020 | Disk-authoritative lifecycle ownership/generation and immutable store reads across history types, lifecycle reducers, `TaskHistoryStore`, provider delegation, and reconciliation. | XL | High: persisted compatibility and cross-host races | Two-host stale-write tests, promoted invariants, restart/reconciliation evidence, backward-compatible optional data. | +| P2. Durable operation and crash recovery | 004, 005, 006, 021, 023 | Operation intent/replay or explicit idempotent recovery for pair writes, delegation, completion messages, shutdown, and deletion. | XL | Very high: failure ordering can create new corruption | Fault injection at every durable boundary, crash/restart convergence, no false success, recovery reachability. | +| P3. Schema, path, and lifecycle vocabulary | 013, 018, 019 | Schema-derived status ownership, validated ordinary history reads, and one safe task-ID boundary across types, persistence, metadata, CLI, and import paths. | M | Medium: malformed legacy data and downgrade behavior | Migration/quarantine fixtures, traversal tests, type/static ratchets. | +| P4. Request, stream, and tool identity | 008, 010, 024, 025, 026, 030, 037, 038 | Request generation plus canonical call identity, then task/generation/call-scoped parser and partial-handler state. Owners include provider transforms, parser, `Task`, `BaseTool`, editing handlers, and tool-ID utilities. | XL | High: provider compatibility and duplicate execution | Adversarial IDs/index-less streams, delayed/cancelled generation tests, cleanup/deadline checks, production-backed call-state model. | +| P5. Tool-owned task state and queueing | 003, 007, 031, 035, 036 | Task-local context, durable child initialization, correlated approval identity, and claim/persist/ack queueing across tools, `Task`, provider/webview, message queue, and history schema. | XL | High: cross-task contamination and persistence precedence | Omitted/explicit child controls, switch/restart E2E, two-approval schedules, queue failure retention, mode-permission tests. | +| P6. Event and ingress contracts | 009, 011, 022, 027, 028, 029, 032, 033 | Classify barriers versus notifications; normalize lifecycle payloads and clear/resume semantics across Task, provider, public API, IPC, and webview. | L | Medium-high: public compatibility and ordering | Exactly-once event tests, consumer inventory, cross-surface contract matrix, compatibility adapters where required. | +| P7. Serial scheduler baseline | 014 | Ratchet the current one-permit provider scheduler and singular active-child ownership; keep live-parent fan-out under separate future scope. | M | Low-medium: current behavior, but cross-host exceptions remain | Production capacity/order assertions plus lifecycle/store invariants proving the bounded serial contract. | +| P8. Verification and traceability platform | 015, 016, 034 | Machine-readable model metadata/traceability and selected cross-model trace validation across checker scripts, package commands, CI, and architecture docs. | L | Medium: vacuity and CI cost | CI validates IDs, symbols, tests, bounds, actions, landmarks, workflows, and executable mappings for cross-model claims. | + +### Root fixes that close multiple gaps + +- One persisted generation and disk-authoritative ownership design should close 001, 002, and 012; immutable reads and explicit reconciliation semantics address 017/020 around that owner. +- One durable operation-intent/replay framework can support 004, 005, 006, 021, and 023, but each operation still needs its own legal recovery states and fault-injection matrix. +- One request-generation/canonical-call identity established before parser indexing can support 008, 010, 024–026, 030, 037, and 038. +- One correlated `(taskId, actionId, toolCallId)` approval protocol can close 036 and support 007/035; it does not itself make child state durable. +- One typed lifecycle operation layer can normalize P6, but public compatibility requires separate adapters rather than a flag-day payload rewrite. + +### Independent work that should not be collapsed + +- Schema/path hardening (P3) is reviewable independently from transaction recovery (P2), despite shared persistence files. +- Optional fan-out is a separate product program and must not be hidden inside baseline scheduler closure. +- Completion consumer contracts (011) are not solved by safe EventEmitter listeners (009). +- Durable child initialization (035) and approval correlation (036) need separate persistence and cancellation owners. +- Verification platform work can proceed in parallel, but cannot promote another cluster before its production transition exists. + +### Sequencing and critical path + +1. **Foundation:** define lifecycle ownership/generation (P1) and canonical request/tool identity (P4), then ratchet the current serial scheduler baseline (P7) against the P1 ownership vocabulary. +2. **Integrity:** build durable operation recovery (P2) on P1. Run P3 in parallel once legacy-data policy is settled. +3. **Task isolation:** implement P5 using P4 identity and P1/P2 persistence rules. +4. **Surface convergence:** implement P6 after barrier/notification and generation semantics are known. +5. **Mechanical assurance:** start P8 metadata early; add cross-model refinement as production owners land. + +Critical path: **P1 ownership/generation → P7 serial baseline → P2 recovery → P5 durable task state → P6 public contracts**. P3 and P8 metadata can run in parallel from the first tranche. P4 can run beside P1 after agreeing how task and request generations relate. + +### Quick wins versus architectural programs + +| Category | Scope | Implementation shape | Notes | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------- | +| Quick wins | 013 shared status type; 025 listener cleanup; 029 projection naming/contracts; 034 checker metadata. | Narrow, independently reviewable changes | Reduce drift but do not close cross-host integrity. | +| Focused projects | 019 path safety, 021 disposal drain, 022/033 ingress convergence, 024 parser cleanup, 026 hard deadline, 027/028 event ownership, serial 014. | One or two related subsystem boundaries | Require deterministic tests and explicit compatibility checks. | +| Architectural programs | P1, P2, P4 identity/generation, P5 durable task state, P6 public contracts, or full fan-out. | Cross-cutting owner or protocol changes | Require staged PRs, failure injection, compatibility plans, and model refinement. | + +### Portfolio scale and parallel workstreams + +Baseline closure is a multi-program architecture effort spanning P1 through P8, with current serial behavior as the production target. Concurrent fan-out is excluded and remains separately tracked by #369/#372 or their successor ticket. Its live-parent execution, routing, rollback, orphan cleanup, UI scoping, E2E, and model-refinement prerequisites depend on baseline ownership, identity, and recovery foundations. + +Four workstreams can proceed concurrently after foundation decisions: + +1. persistence ownership/recovery (P1/P2); +2. request/tool identity and streaming (P4); +3. schema/path hardening and verification metadata (P3 plus P8 metadata); +4. event/ingress compatibility design (P6 discovery, implementation after generation semantics). + +### Recommended first tranche + +1. Add deterministic failing tests for 001/002/012, then define their shared ownership/generation primitive. +2. Ratchet current serial behavior for 014 against that ownership contract and leave fan-out to its separately scoped ticket. +3. Define canonical call identity and adversarial tests for 038/008; reuse it for 037 and 036. +4. Land independent hardening for 013, 025, 029, and 034. +5. Add P8 machine-readable mapping incrementally so closure PRs name symbols, witnesses, tests, bounds, and evidence class. + +### Planning assumptions and reconciliations + +- Complexity classes include focused/full tests and relevant E2E, not only code edits. +- Crash-consistency closure requires deterministic interruption and rollback fault injection; happy paths do not close P2. +- Prefer lazy optional-field migrations. Existing lost data is unrecoverable; downgrade readers must ignore new fields safely. +- High severity is reserved for demonstrated corruption, cross-task permission/state contamination, or execution/history divergence. Gap 035 remains Medium because its confirmed witness loses planning state; 036 and 038 remain High because they cross task/call ownership. +- Gap 014 remains Medium because baseline seriality is partly production-backed but not statically ratcheted and cross-host ownership violations remain. Optional fan-out does not affect baseline severity. +- Reassess cluster boundaries after P1 and P4 decisions because they define shared interfaces; keep fan-out in its separate scope. + +## Burn-down dependencies + +| Dependency | Enables | +| ---------------------------------------------- | ------------------------------------------------------------- | +| Disk-authoritative ownership/generation design | LIFE-GAP-001, 002, 012, 020 | +| Durable operation intent/recovery design | LIFE-GAP-004, 005, 006, 021, 023 | +| Task-local execution-context owner | LIFE-GAP-007 and optional future fan-out | +| Request generation and terminal cleanup owner | LIFE-GAP-010, 024, 025, 026 | +| Event notification/barrier contract | LIFE-GAP-009, 011, 027, 028 | +| Machine-readable lifecycle manifest | LIFE-GAP-013, 016, 034 | +| Serial scheduler baseline | LIFE-GAP-014 | +| Optional fan-out product program | Historical #369/#372 scope, outside baseline | +| Durable task-scoped child initialization | LIFE-GAP-035 and future tool/lifecycle composition | +| Correlated approval ownership | LIFE-GAP-036 | +| Task/tool-call-scoped partial state | LIFE-GAP-037 with request-generation cleanup gaps 010 and 024 | +| Canonical tool-call identity | LIFE-GAP-038 with generation/replay gap 012 | + +## Mechanically useful follow-up checklist + +- [ ] Assign an owner and target PR to each active `LIFE-GAP-*` ID without renumbering existing IDs. +- [ ] Add the ID to production tests, model actions/properties, and PR descriptions that address it. +- [ ] Preserve a deterministic failing test or shortest witness before changing production behavior. +- [ ] State whether the resulting evidence is production-backed, abstract, proxy, or E2E. +- [ ] Add negative/failure-path coverage, not only the successful transition. +- [ ] Record bounds and prove action/landmark reachability so an overconstrained model cannot pass vacuously. +- [ ] For cross-host work, enumerate legal histories and inject stale cache, partial write, lock, restart, and reconciliation orderings. +- [ ] For crash-consistency claims, add interruption/failure injection at every durable step. +- [ ] For liveness claims, define fairness and progress assumptions explicitly; do not infer them from safety exploration. +- [ ] For cross-model claims, supply an executable boundary mapping or keep the claim local. +- [ ] Update this report’s inventory, matrix, severity, dependencies, and closure evidence in the same PR. +- [ ] Run `pnpm lifecycle:model-check`, focused production tests, `pnpm test`, typecheck, lint, and required E2E before marking a gap closed. +- [ ] Move issue links only within historical provenance; stable burn-down IDs remain the active identity. +- [ ] For LIFE-GAP-035, inventory all tool-originated child state and test both todo controls: omitted child todos must not copy the parent, while explicit initial child todos must survive task switching and restart. +- [ ] For LIFE-GAP-036, correlate every approval edit and settlement with task ID plus action/tool-call ID; reject stale cross-task edits. +- [ ] For LIFE-GAP-037, interleave equal and unequal partial paths across two calls and two tasks, then verify terminal cleanup. +- [ ] For LIFE-GAP-038, use adversarial raw IDs to verify one-to-one durable call, approval, execution, result, pending-action, and replay identity. + +## Completeness statement + +At the audited commit, this report covers every tracked definition and directly discoverable caller matching the lifecycle domains in scope, including tool argument assembly, validation, approval, child initialization, partial presentation, result/pending-action identity, todo rehydration, all seven baseline checkers and the separate optional fan-out checker, their documented bounds/properties, primary focused suites, lifecycle E2E files, root package scripts, and CI workflow invocations. It does not claim semantic completeness for dynamic calls, generated code, dependencies, ignored files, deployment settings, or production histories. A future code change can invalidate completeness; `LIFE-GAP-016` and `LIFE-GAP-034` exist specifically to make this inventory mechanically maintainable. + +## Historical provenance + +Relevant historical reports include [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469), [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021), [#1623](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1623), [#369](https://github.com/Zoo-Code-Org/Zoo-Code/issues/369), [#372](https://github.com/Zoo-Code-Org/Zoo-Code/issues/372), [#612](https://github.com/Zoo-Code-Org/Zoo-Code/issues/612), [#1453](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1453), [#1279](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1279), [#921](https://github.com/Zoo-Code-Org/Zoo-Code/issues/921), [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920), and [#1468](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1468). These links provide provenance only; closure is governed by the objective criteria above. diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 9266d49987..355b08aa7e 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -1,24 +1,29 @@ # Task lifecycle model-check suite -Zoo Code checks task lifecycle protocols through one compositional verification suite. Run the complete suite locally with: +Zoo Code checks task lifecycle protocols through one umbrella command for independent bounded checks. Run the complete suite locally with: ```sh pnpm lifecycle:model-check ``` -The command runs six independent bounded submodels in sequence: +The baseline command runs seven independent bounded checks in sequence: 1. the persisted task delegation lifecycle; 2. shared-store concurrency across task-history hosts; -3. production-backed provider handoff and scheduler ordering; +3. production-backed handoff reducers with an abstract provider/scheduler protocol; 4. the task cleanup protocol; -5. request-stream parser scoping; and -6. completion persistence. +5. request-stream parser scoping; +6. completion persistence; and +7. delegated-mode reader refinement. + +The planned two-sibling fan-out protocol is intentionally outside the baseline and CI umbrella. Run it explicitly with `pnpm fanout-protocol:model-check`; it describes optional future functionality, not current production coverage. This umbrella command is the single model-check entry point in the `compile` CI job after type checking. Command-level composition does not merge the submodels' state spaces: each checker retains its own bounds, transitions, invariant ownership, reachability requirements, and counterexample format. In particular, parser state is not part of the persisted lifecycle graph. The focused parser checker remains directly runnable with `pnpm parser-scope:model-check` for debugging. An individual checker fails if it finds an invariant violation, a modeled action becomes unreachable, a named semantic landmark disappears, or exploration exceeds its declared state budget. A lifecycle violation includes the shortest breadth-first event trace, every intermediate state, and the active bounds so the sequence can be replayed as a focused regression test. +A checker printing `passed` means only that its configured bounded invariants, expected witnesses, reachability requirements, and state budget succeeded. It does not close a linked issue, prove arbitrary-task correctness, or establish refinement for production consumers that the checker does not execute. + Executable cross-model composition should be added only when a correctness claim genuinely spans two or more submodels and there is an explicit, production-grounded boundary mapping between their events or state. That composition must state a bounded joint exploration strategy and own cross-model invariants that cannot be proved within either child model alone. Shared command orchestration or conceptual adjacency is not sufficient reason to multiply independent state spaces. ## Why an executable TypeScript model @@ -63,7 +68,7 @@ The same `pnpm lifecycle:model-check` command also runs a second bounded explore - successful pair-operation cache entries publish together after both file writes; if the second write fails, the cache publishes only the first committed record; - cache refresh is explicit and may occur after an external live-task snapshot was captured. -There is no production record version or compare-and-swap token today. The model therefore does not invent one. It universally checks host-mutex and file-lock ownership, whole-file delta rejection, disk-field preservation, `childIds` union, and pair write order. Six scenarios, including distinct-task writes from #920 and a second-write pair failure, and all seven phases (`read`, `prepare`, `revalidate`, `commit`, `refresh`, `reject`, and `fail`) must remain reachable without exceeding the state/depth budgets. Positive semantic landmarks additionally require a stale cache beside newer disk state, the first pair write committed while the second is pending, and the same committed prefix retained after the second write fails. +There is no production record version or compare-and-swap token today. The model therefore does not invent one. It checks host-mutex and file-lock ownership, whole-file delta rejection, disk-field preservation, `childIds` union, and pair write order in every state reachable within six bounded scenarios. Those scenarios include distinct-task writes from #920 and a second-write pair failure, and all seven phases (`read`, `prepare`, `revalidate`, `commit`, `refresh`, `reject`, and `fail`) must remain reachable without exceeding the state/depth budgets. Positive semantic landmarks additionally require a stale cache beside newer disk state, the first pair write committed while the second is pending, and the same committed prefix retained after the second write fails. Two desired properties are currently false and remain issue-keyed shortest-witness ratchets rather than silently allowed assertion failures: @@ -82,12 +87,26 @@ The umbrella command also runs a separate bounded child model for in-memory abor ## Provider handoff and scheduler model -`scripts/check-provider-handoff-scheduler.ts` is a separate bounded adapter model for the runtime boundary that the persisted lifecycle graph does not represent. Its breadth-first explorer normalizes provider-keyed records and owner arrays before deduplicating canonical states, then exhaustively explores enabled action orderings through depth 15 with a 20,000-state budget. It imports `selectHandoffExecutionContext` and the existing `delegateTaskToChild` and `completeDelegatedChild` reducers. A direct saved, unsaved, and locked-profile matrix verifies task-local configuration isolation. Stale provider lookup is caught before this pure selector, so focused provider tests verify the failed lookup, contextual log, and fallback. The protocol state then models two provider instances, their claims and parent snapshots, authoritative parent/child records, current task publication, commit/start ownership, the child scheduler permit, queued and resumed parent state, and one bounded redelegation generation. +`scripts/check-provider-handoff-scheduler.ts` is a separate bounded adapter model for the runtime boundary that the persisted lifecycle graph does not represent. Its breadth-first explorer normalizes provider-keyed records and owner arrays before deduplicating canonical states, then exhaustively explores enabled action orderings through depth 15 with a 20,000-state budget. It imports `selectHandoffExecutionContext` and the existing `delegateTaskToChild` and `completeDelegatedChild` reducers. A direct saved, unsaved, and locked-profile matrix checks task-local configuration selection within those cases. Stale provider lookup is caught before this pure selector, so focused provider tests check the failed lookup, contextual log, and fallback. The protocol state then models two provider instances, their claims and parent snapshots, authoritative parent/child records, current task publication, commit/start ownership, the child scheduler permit, queued and resumed parent state, and one bounded redelegation generation. Provider locking, paused-child/current-task publication, and semaphore admission/release are explicit model abstractions rather than imported production code. Focused provider and `TaskScheduler` tests cover those concrete adapters. Lifecycle commits and completion use the real reducers. Parent publication and its queued continuation share an explicit transition owner: the fixed policy retains that ownership through matching resume invocation, then models the resumed run settling outside transition ownership. This permits a new delegation generation to begin while the prior resumed run remains active without allowing a stale continuation to start across the newer transition. The fixed policy checks every successor for continuous publication, one child start and commit per generation, exact commit-before-start ownership, permit release before parent resume or redelegation, matching parent transition/continuation ownership at resume invocation, and consistent final child/parent publication. It also requires both resume phases, every other action, and named semantic landmarks to remain reachable and fails if the depth boundary has an unseen successor. Six injected legacy transition policies must produce deterministic shortest counterexamples through the same explorer: start before commit, resume before permit release, redelegation before permit release, empty current-task publication, two stale provider commits from competing snapshots, and releasing parent-transition serialization immediately after publication. The last witness must causally include first-child completion and parent publication, a second-child commit, release of the first child's scheduler permit, and then the stale first-child continuation. The checker prints the distinct reachable-state count, complete scenario/action/landmark coverage, bounds, and each named counterexample trace. It deliberately does not add a WAL, global profile projection, or scheduler state to persisted `HistoryItem` records. +For #921, the execution-context matrix checks saved, unsaved, and locked profile selection at the handoff boundary. For that bounded matrix, it establishes only that delegation writes the requested task-local mode and cloned configuration into the child context. It does not prove that every downstream consumer reads that context. The checker retains a divergent-mode witness in which the child task mode differs from the shared provider mode so reader refinements can demonstrate that choosing the wrong source is observable. + +Merged PR #1625 changed the confirmed downstream readers: `getEnvironmentDetails` obtains `cline.getTaskMode()`, `presentAssistantMessage` passes that task-local mode to `validateToolUse`, and custom tool execution receives the same task-local mode. Focused production tests cover those three paths. + +`scripts/check-delegated-mode-readers.ts` does not execute `getEnvironmentDetails`, `presentAssistantMessage`, or `validateToolUse`. It checks the production handoff selector and a pure built-in-mode permission divergence, establishing that the wrong source is observable. It is refinement support, not exhaustive reader coverage. Other mode-sensitive consumers remain an explicit inventory gap, and this suite does not claim universal task-local reader isolation. + +## Task fan-out protocol model + +`scripts/check-task-fanout-protocol.ts` is a separate, optional bounded protocol model for the #369/#372 fan-out safety contract. It explores a live parent with two sibling slots, a two-permit scheduler, independent result readiness, explicit child-to-parent delivery, parent loss, orphan cancellation, and permit release. It checks scheduler capacity and exact permit ownership, one writer and at-most-once delivery per child, readiness before delivery, and no result routing after parent loss. Named landmarks require concurrent siblings beneath a live parent, out-of-order result delivery, single-writer results, parent loss while work is running, and complete orphan cleanup to remain reachable. Injected unsafe states confirm those invariants reject wrong writers, early and duplicate delivery, post-parent-loss routing, and scheduler over-allocation. + +This model checks an intended abstract composition boundary without claiming that concurrent sibling fan-out is enabled in production. It imports no production fan-out transition and is excluded from `pnpm lifecycle:model-check` and baseline CI. `TaskScheduler` provides generic bounded permits, but `ClineProvider` constructs it at the default capacity of one and production delegation persists a singular `awaitingChildId`. Raising production concurrency still requires live-parent result integration and extension-host coverage before fan-out can ship. + +The production-backed lifecycle checker already models the current serial ownership invariant: a parent has at most one `awaitingChildId`, `delegatedToId` matches it, and every active/delegated linked child is the child currently awaited. The reducer rejects re-delegation while that child is active. This is exhaustive only for the checker's three slots and depth 12 and does not erase the documented cross-host stale-write violations, so baseline closure still requires their production fixes and model promotion. + ## Completion persistence model `scripts/check-completion-persistence.ts` models the completion-readiness protocol that protects the public `TaskCompleted` event. It starts from both standalone and delegated tasks and exhaustively interleaves: @@ -126,22 +145,59 @@ The completion persistence checker additionally enforces: These are safety claims within the documented bounds. The checks do not claim liveness, fairness, power-loss durability, filesystem-lock correctness, or exhaustive coverage of arbitrary task counts or retry counts. The completion explorer specifies the event contract rather than importing `Task` or `AttemptCompletionTool`; focused unit tests and the restart E2E verify that concrete production paths implement the modeled guards. Delegated reopen is abstracted as one success-or-failure event after durable child history; fallback from a failed reopen into the normal standalone completion flow remains production-test coverage rather than part of this model. The lifecycle checker also does not distinguish a delayed pre-interruption completion from a legitimate post-resume completion for the same child ID; that requires a persisted attempt/generation token before it can become a sound invariant. -## Open-issue traceability - -The following map separates issue observations from the architectural interpretation encoded here. Open issues can change after this document is written; follow each link for current status. - -| Issue and directly observed evidence | Derived protocol rule | Production transition and current check | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469): the issue report states that a barrier-controlled two-host run reproduced an old child completion clearing a newer handoff 25/25 times. | Completion is conditional on the parent still awaiting that exact child; a live-linked child must remain owned by its parent. | `completeDelegatedChild` rejects stale authoritative input. The lifecycle explorer checks that reducer rule, while the shared-store explorer reproduces the cross-host stale-cache counterexample with an exact causal witness. | -| [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021): an in-flight `saveClineMessages` can restore parent/root IDs after abandonment cleared them. | Detachment should be monotonic: later lifecycle work must not reattach an abandoned child. | `abandonDelegatedChild` clears both sides. The shared-store explorer proves the detach commit occurs, then reproduces a refreshed-cache delta that preserves interrupted status while restoring stale live-task lineage. | -| [#1453](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1453), under user report [#1279](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1279): CI observed `TaskCompleted` before restart-visible API history once; 120 local repetitions did not reproduce it. | Completion implies restart-visible assistant history. Delayed or failed writes keep completion pending, and cancellation settles readiness without starting stale retries or emitting completion. | The completion persistence explorer checks the bounded event-ordering and cancellation contract for standalone and delegated tasks. Focused `Task` and `AttemptCompletionTool` tests cover the production adapter; `restart-persistence.test.ts` verifies visibility through a fresh extension host. | -| [#921](https://github.com/Zoo-Code-Org/Zoo-Code/issues/921): delegation across parallel tabs lacks coverage for different view-local mode/profile state. | Delegation must bind an explicit immutable execution-context snapshot rather than read whichever view is focused later. | The persisted ownership transition is covered; mode/profile snapshot isolation is outside this state model and belongs in a production adapter/model-based test. | -| [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920): issue analysis identifies a missing cross-instance history-update test and potential lost writes. | Distinct task writes must not overwrite one another, and same-task conflicts need an explicit merge/ownership rule. | The shared-store explorer checks distinct-task writes and same-record independent deltas. Cross-instance store tests retain production API coverage, and the synchronized real-filesystem smoke test exercises the actual lock/write path without claiming exhaustive filesystem proof. | -| [#369](https://github.com/Zoo-Code-Org/Zoo-Code/issues/369) and [#372](https://github.com/Zoo-Code-Org/Zoo-Code/issues/372): planned fan-out keeps a parent live while a child runs and requires completion routing by explicit parent ID, single-writer result readiness, permit release, and orphan cleanup. | Persisted `delegated` status is ownership, not proof that the parent instance is suspended. Completion must route by IDs; scheduler resources and live-instance state need separate invariants. | Nested and sibling lifecycle ownership are covered. Scheduler permits, live/suspended parent selection, orphan cancellation, and single-writer message readiness must be added when fan-out lands; they should not be folded into `HistoryItem` fields prematurely. | -| [#1468](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1468): a late chunk from one request combined tool identity with arguments from another request; rerun passed. | Every stream accumulator needs a request/task generation key, and late events cannot mutate another scope. | Separate protocol. The [native tool-call parser request-scope model](./native-tool-call-parser-scoping-model.md), whose source of truth is `scripts/check-native-tool-call-parser-scoping.ts`, exhaustively replays bounded production-parser interleavings without adding fields to this lifecycle model. | -| [#612](https://github.com/Zoo-Code-Org/Zoo-Code/issues/612): the CLI copied a status union and omitted `interrupted`. | Lifecycle vocabulary should have one type owner. | `HistoryItemStatus` is derived from `HistoryItem`, and production/checker transitions share `taskLifecycle.ts`; consumers should import rather than copy the union. | - -The issue-derived cases intentionally map to bug classes rather than issue-specific flags. In particular, stale event ownership, monotonic terminal/detached state, explicit scope, and single-writer boundaries generalize to future concurrent task work. +## Coverage audit + +| Protocol area | Coverage status | Production/model relationship | Explicit limits and open points | +| ------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Delegation lifecycle | Production-backed bounded universal | The explorer calls the four production reducers for three task slots through depth 12. | Excludes provider instances, persistence failures, scheduler state, most live `Task` behavior, and generation identity for delayed pre-interruption completion. Recovery-compatible active-parent completion is test-only. | +| Shared-store concurrency | Production-backed bounded scenarios plus known-unsafe witnesses | The explorer imports production delta/merge functions and reducers; a real-filesystem test is a smoke check. | Does not prove crash safety, filesystem/lock semantics, arbitrary processes, or loss-free same-field merging. #1469 and #1021 remain unsafe. | +| Provider handoff and scheduler | Mixed: production-backed reducers/selector plus abstract bounded protocol | Commits use production reducers; provider ownership, publication, transition locks, and permits are model abstractions through depth 15. | Selector correctness does not refine all downstream readers. Scheduler tests cover concrete permit behavior separately. | +| Optional fan-out scope | Planned-only abstract bounded protocol outside baseline CI | The model has two sibling slots and two abstract permits and imports no production fan-out transition. | Excluded from baseline closure; production fan-out remains separately scoped future functionality. | +| Cleanup | Abstract bounded universal plus adapter tests | Abort, disposal, settlement, rejection, and provider shutdown are modeled as protocol/environment actions. | No direct execution of all production cleanup methods, filesystem/editor promises, timing liveness, fairness, or arbitrary task counts. | +| Parser request scope | Production-backed bounded schedule replay | The checker executes production parser APIs across 924 order-preserving schedules for two scopes. | Assumes callers stop invoking a finalized scope; transport behavior, arbitrary request counts, indices, and malformed histories are outside the claim. | +| Completion persistence | Abstract bounded universal plus production tests and one fresh-host E2E path | The model abstracts persistence as a durable phase with at most two write starts; production guards and retry paths are tested separately. | Production permits more retries; no power-loss/filesystem proof, fairness, arbitrary retry count, complete delegated fallback, provider status metadata, or downstream event-consumer model. | + +The production mapping above names primary lifecycle transitions, not every mutation or consumer. Generic store upserts, reconciliation, repair replay, migrations, tool entry points, webview/public API abandonment, provider status updates, and public `TaskCompleted` re-emission remain outside the persisted reducer graph unless explicitly named by a submodel or focused test. For task-local mode, the consumers named under #921/#1623 are a confirmed set, not an exhaustive repository-wide inventory; other mode-sensitive tools must be audited before claiming universal reader isolation. + +CI runs `pnpm lifecycle:model-check` in `.github/workflows/code-qa.yml` after lint and type checking. Extension-host subtask and restart-persistence E2E run separately; a green umbrella command therefore says nothing about an omitted E2E boundary or an unmodeled downstream consumer. + +## Gap audit + +This section is a summary tracker. Issue links are historical provenance rather than specifications. Coverage status uses these evidence classes: + +The [Task lifecycle verification GAP report](./task-lifecycle-gap-report.md) is the authoritative register. It holds the exhaustive repository inventory, the ranked stable burn-down register, the source-based methodology, and the follow-up checklist. This page remains the executable model-suite specification. + +- **Production-backed bounded:** exhaustive only for the declared state space while executing production functions. +- **Abstract bounded:** exhaustive only for model-authored transitions; refinement depends on separate adapter tests. +- **Known-unsafe witness:** CI preserves a reproducible violation and does not claim the property holds. +- **Proxy/partial:** evidence covers a premise, adapter, or representative path, not the full claim. +- **Planned-only:** specifies behavior not enabled in production. +- **Type/static convention:** centralized typing or guidance without repository-wide enforcement. + +| Gap | Invariant | Current evidence | Production/model boundary and runtime impact | Missing work and objective closure criteria | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Cross-host completion ownership | A completion may mutate a parent only while that parent authoritatively awaits the same child. | **Known-unsafe witness** plus a production-backed reducer guard. | The reducer rejects stale authoritative input, but disk revalidation checks status legality rather than exact-child ownership. A stale host can orphan the newer child. | Revalidate exact ownership under the disk lock; add deterministic two-store regression coverage; replace the expected witness with a universal bounded invariant. | +| Monotonic detachment | Message persistence must never restore lineage after abandonment clears it. | **Known-unsafe witness** plus a production-backed detach reducer. | A live task rebuilds lineage from stale fields; a later delta can restore parent/root IDs while preserving the interrupted status. | Give lifecycle fields a disk-authoritative write owner or tombstone/generation; prove stale metadata saves cannot alter them; promote the witness to an invariant. | +| Task-local mode consumers | Every mode-sensitive child operation must use the child task's immutable mode, not shared provider/view state. | Write side: **production-backed bounded matrix**. Three confirmed readers: **focused production tests**. Reader checker: **proxy/partial**. | The merged fix covers environment rendering, built-in validation, and custom-tool execution. The pure checker does not execute them, and other provider-mode readers are not exhaustively classified. | Complete the reader inventory, add focused divergent-mode evidence for each required task-local consumer, and enforce a task-local reader boundary before claiming universal isolation. | +| Tool-originated child initialization | Every required child field originating in `new_task` must be task-scoped, durably owned, and equivalent after rehydration. | Argument forwarding has focused tests; durable initial-state refinement is **unmodeled**. | Normal creation uses explicit model-supplied child todos rather than copying the parent, but initial todos demonstrate that some child state is process-local and can disappear. | Inventory all child initialization fields; define durable ownership, deep-copy, precedence, publication, and rehydration contracts with focused and E2E checks (`LIFE-GAP-035`). | +| Tool approval ownership | An interactive edit or settlement may affect only the matching task, action, and tool call. | Single-approval behavior has focused tests; cross-task correlation is **unmodeled and known unsafe by inspection**. | `update_todo_list` uses process-global edit state without task/action identity, allowing delayed or concurrent approval contamination. | Correlate proposal/edit/approval/cancellation by task and action/tool-call ID; reject stale edits and test interleavings (`LIFE-GAP-036`). | +| Tool partial-state isolation | Partial presentation state must be owned by one task and tool call and cleared on every terminal path. | Tool-local tests only; cross-call/task interleavings are **unmodeled**. | Singleton handlers share `lastSeenPartialPath`, so another call can create false or missed path stabilization. | Key state by `(taskId, toolCallId)` or instantiate handlers per call; test interleavings and cleanup (`LIFE-GAP-037`). | +| Tool identity correspondence | Parsed call, durable history, approval, execution, result, pending action, and replay must have one collision-resistant identity. | Duplicate-ID helper tests are **proxy/partial**; end-to-end correspondence is **unmodeled**. | Non-injective sanitization can collapse distinct raw IDs in history while execution still treats them as separate calls. | Reject/disambiguate collisions and prove a one-to-one identity mapping across native/MCP calls and restart (`LIFE-GAP-038`). | +| Serial delegation baseline | Current production permits one awaited active child per parent and resumes the parent only after child release. | Singular ownership is **production-backed bounded**; scheduler/provider ordering is mixed production/abstract. | Reducers and the normal provider path enforce singular ownership, but stale cross-host persistence can still violate the relationship; scheduler capacity is defaulted, not statically fixed. | Close current serial persistence/ordering gaps and ratchet the provider's one-permit baseline. Treat fan-out as separate optional scope. | +| Shared lifecycle status vocabulary | All consumers should derive task status from one exported owner. | **Type/static convention**; the visible interrupted-status symptom is repaired. | Persistence derives its alias from `HistoryItem`, but CLI history adapters still copy the union, allowing future drift. | Export one shared status type, consume it at CLI/persistence boundaries, and rely on typechecking or a static rule to reject copied incompatible vocabulary. | +| Cross-model refinement | No independent checker result may be combined into a stronger end-to-end claim without an executable boundary mapping. | **Explicit limitation** only. | Persistence, provider publication, parser scope, cleanup, and completion durability run as separate state spaces; omitted consumers can violate a premise after another checker passes. | Add production-grounded boundary events and a bounded joint strategy for each genuinely cross-model invariant, or keep claims explicitly local. | +| Completion event consumers | Public completion must not be treated as universally durable beyond the modeled readiness contract. | **Abstract bounded** model plus focused tests and one fresh-host E2E path. | The model abstracts durability and parent reopen; provider status metadata and downstream `TaskCompleted` consumers are outside its state. | Inventory downstream consumers and add refinement checks only for guarantees they require; retain power-loss, filesystem, retry-count, and liveness exclusions. | + +## Historical provenance + +- Cross-host completion ownership: [#1469](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1469). +- Monotonic detachment: [#1021](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1021). +- Task-local mode isolation: [#921](https://github.com/Zoo-Code-Org/Zoo-Code/issues/921), [#1623](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1623), and merged runtime-fix [PR #1625](https://github.com/Zoo-Code-Org/Zoo-Code/pull/1625). +- Fan-out product backlog: [#369](https://github.com/Zoo-Code-Org/Zoo-Code/issues/369) and [#372](https://github.com/Zoo-Code-Org/Zoo-Code/issues/372). +- Shared status vocabulary: [#612](https://github.com/Zoo-Code-Org/Zoo-Code/issues/612). +- Completion visibility history: [#1453](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1453) and [#1279](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1279). +- Cross-instance history preservation: [#920](https://github.com/Zoo-Code-Org/Zoo-Code/issues/920). +- Parser request scoping: [#1468](https://github.com/Zoo-Code-Org/Zoo-Code/issues/1468). ## Extending the model @@ -153,7 +209,7 @@ When production lifecycle behavior changes: 4. Increase depth or task slots only when the new scenario requires it. Keep the state budget explicit and ensure CI completes quickly. 5. Convert any discovered counterexample into a focused production regression test as well as retaining the architectural invariant. -Completion-readiness changes belong in `scripts/check-completion-persistence.ts`; shared-store interleavings belong in `scripts/check-task-store-concurrency.ts`. Do not weaken bounds or remove an invariant merely to make CI pass. If state growth becomes difficult to control, split independent protocols or move the model to TLC/Quint with an implementation trace adapter rather than silently sampling the state space. +Completion-readiness changes belong in `scripts/check-completion-persistence.ts`; shared-store interleavings belong in `scripts/check-task-store-concurrency.ts`. Optional fan-out work belongs in `scripts/check-task-fanout-protocol.ts` and its separately scoped ticket until production transitions exist. Do not weaken bounds or remove an invariant merely to make CI pass. If state growth becomes difficult to control, split independent protocols or move the model to TLC/Quint with an implementation trace adapter rather than silently sampling the state space. Parser request scoping is one such independent bounded submodel within the umbrella suite. Extend `scripts/check-native-tool-call-parser-scoping.ts` and its focused architecture document instead of adding parser state or transitions to `taskLifecycle.ts` or the persisted lifecycle state graph. diff --git a/docs/architecture/task-lifecycle-remediation-blocks.md b/docs/architecture/task-lifecycle-remediation-blocks.md new file mode 100644 index 0000000000..ffbcbb334b --- /dev/null +++ b/docs/architecture/task-lifecycle-remediation-blocks.md @@ -0,0 +1,136 @@ +# Task lifecycle remediation blocks + +## One story point in this report + +One story point (1 SP) is a small, independently reviewable **modeling or documentation increment**, not a time estimate. A 1-SP block owns one bounded behavior or property and must include: + +- an explicit production symbol or boundary mapping; +- one model/checker change when a faithful model boundary exists, otherwise an explicit reason no checker is appropriate; +- focused test or CI evidence references; +- objective acceptance criteria and declared exclusions. + +Completing one block does not close its `LIFE-GAP` unless the parent GAP closure criteria are also satisfied. Blocks may depend on shared primitives or earlier evidence, so story-point size does not imply scheduling independence. + +## Ownership rules + +- Every `LIFE-GAP-001` through `LIFE-GAP-038` has exactly one primary block below. +- A block owns exactly one GAP ID. Dependencies may reference other blocks but do not duplicate ownership. +- Block IDs are stable: `LIFE-BLK-P-`. +- Baseline blocks describe current serial production behavior. Optional fan-out is isolated under `FANOUT-BLK-*` and does not own a baseline `LIFE-GAP`. +- Each block is documentation/formal-model scope. Runtime work named in acceptance criteria belongs in a later implementation PR. + +## P1: Persisted ownership and generation + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------- | +| LIFE-BLK-P1-001 | 001 | Encode authoritative awaited-child revalidation as a model boundary and retain the shortest stale-completion witness. | `TaskHistoryStore.atomicUpdatePair`, `ClineProvider.reopenParentFromDelegation`; shared-store checker; cross-instance tests. | None | Model names lock-time ownership check, witness, bounds, and production test required for promotion. | +| LIFE-BLK-P1-002 | 002 | Specify lifecycle-owned lineage fields versus metadata writes and the stale-save witness. | `Task.saveClineMessages`, `taskMetadata`, `mergeHistoryDelta`; shared-store checker. | P1-001 ownership vocabulary | Field ownership table and monotonic-detachment invariant are explicit; no claim of current safety. | +| LIFE-BLK-P1-012 | 012 | Add attempt-generation state and stale-versus-resumed completion scenarios to the specification. | `PendingTaskAction.actionId`, interruption/resume/completion reducers; lifecycle checker exclusion. | P1-001 | Two generations and acceptance/rejection landmarks are specified with a bounded future checker shape. | +| LIFE-BLK-P1-017 | 017 | Inventory mutable cache read consumers and define immutable read semantics. | `TaskHistoryStore.get/getAll`; store tests. | None | Every direct caller is classified; clone/freeze test criteria and compatibility exclusions are recorded. | +| LIFE-BLK-P1-020 | 020 | Define observable stale-cache and convergence histories. | watcher, `invalidate`, `reconcile`; shared-store landmarks and cross-instance tests. | P1-001 | Missed-watch and explicit-refresh histories have bounded properties and objective convergence evidence. | + +## P2: Durable operation and crash recovery + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------- | +| LIFE-BLK-P2-004 | 004 | Enumerate pair-write interruption points and legal recovered states. | `atomicUpdatePair`; pair-failure landmark/tests. | P1-001 | Every pre/post-write cut has one legal outcome and required fault-injection assertion. | +| LIFE-BLK-P2-005 | 005 | Map delegation create/persist/publish/start cuts and rollback obligations. | `delegateParentAndOpenChild`; provider handoff model/tests. | P1-001, P2-004 | Transition table covers every cut without claiming child/parent atomicity. | +| LIFE-BLK-P2-006 | 006 | Specify completion message/lifecycle commit phases and replay outcomes. | `reopenParentFromDelegation`; completion and shared-store models. | P1-012, P2-004 | Result visibility and lifecycle state are mapped for each injected failure point. | +| LIFE-BLK-P2-021 | 021 | Define store close/drain semantics and post-dispose write exclusion. | `TaskHistoryStore.dispose`, write lock; store tests. | P2-004 recovery vocabulary | A bounded close-state machine and deterministic pending-write test criteria are documented. | +| LIFE-BLK-P2-023 | 023 | Specify deletion unlink failure and reconciliation histories. | `delete/deleteMany`, task directory/checkpoint cleanup; deletion tests. | P2-004 | False-success and resurrection outcomes are explicit with tombstone/retry closure choices. | + +## P3: Schema, path, and vocabulary + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------- | +| LIFE-BLK-P3-013 | 013 | Publish the canonical persisted-status owner and copied-union inventory. | `historyItemSchema`, task metadata, Task, CLI/history reader; typecheck/tests. | None | Every copy is listed with replacement/static-ratchet criteria. | +| LIFE-BLK-P3-018 | 018 | Define normal-read validation and quarantine outcomes for malformed history. | `readTaskFile`, reconciliation, shared Zod schema; fixtures. | None | Missing/invalid/legacy records have distinct expected outcomes and test fixtures. | +| LIFE-BLK-P3-019 | 019 | Inventory every task-ID-to-path entry and one shared safe-ID contract. | store paths, imports, deletion, checkpoints; traversal tests. | P3-018 | All path constructors are mapped and separator/traversal acceptance tests are specified. | + +## P4: Request, stream, and tool identity + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------- | +| LIFE-BLK-P4-008 | 008 | Add transform-to-parser cases for argument-only deltas and absent indices. | Responses transform, parser APIs/tests. | None | Two-call bounded schedules and expected isolated reconstruction are specified. | +| LIFE-BLK-P4-010 | 010 | Define request-generation ownership for detached usage writes. | Task request/drain paths; delayed-stream tests. | P4-038 identity vocabulary | Old/new generation mutations and allowed accounting-only updates are explicit. | +| LIFE-BLK-P4-024 | 024 | Map parser cleanup on success, abort, provider error, and replacement. | parser scope plus Task request terminal paths. | P4-010 | Every terminal path owns cleanup; late-event exclusions are stated. | +| LIFE-BLK-P4-025 | 025 | Specify listener lifetime for one chunk race and long streams. | `nextChunkWithAbort`; listener-count tests. | None | Both race outcomes remove listeners and a bounded stream cannot accumulate them. | +| LIFE-BLK-P4-026 | 026 | Model a true wall-clock deadline around pending iterator reads. | detached usage drain; fake-timer tests. | P4-010 | Permanently pending `next()` has a terminal deadline transition and no stale mutations. | +| LIFE-BLK-P4-030 | 030 | Define duplicate-start/run-promise identity. | `Task.start/run`, scheduler callback; Task tests. | P4-010 | Repeated starts share the actual settlement and cannot bypass scheduler ownership. | +| LIFE-BLK-P4-037 | 037 | Specify call-scoped partial path state and two-call interleavings. | `BaseTool.lastSeenPartialPath`, editing tool singletons; focused tests. | P4-038, P4-010 | Equal/different path interleavings and sibling-safe cleanup are bounded and reachable. | +| LIFE-BLK-P4-038 | 038 | Define canonical raw-to-durable call identity and collision witnesses. | tool-ID utility, parser, Task history, results, pending actions; duplicate-ID tests. | None | Adversarial IDs preserve or explicitly reject one-to-one call/result/replay correspondence. | + +## P5: Tool-owned task state and queueing + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| LIFE-BLK-P5-003 | 003 | Map every queue consumer to claim/persist/ack or dequeue-before-submit. | `MessageQueueService`, Task queue paths; failure tests. | None | Every consumer is classified and message-retention failure evidence is specified. | +| LIFE-BLK-P5-007 | 007 | Inventory remaining mode-sensitive readers and authoritative task/provider source after the merged three-reader fix. | handoff selector, named production readers `getEnvironmentDetails`, `validateToolUse` call sites in `presentAssistantMessage`, custom tool execution, merged environment/validation/custom-tool tests, delegated reader checker. | None | Confirmed readers are marked production-tested; unclassified readers remain listed; the pure checker is not described as executing downstream readers. | +| LIFE-BLK-P5-031 | 031 | Define intentional versus accidental queue loss across task disposal/restart. | queue service disposal and task lifecycle; E2E boundary. | P5-003 | Product contract, excluded durability, and restart witness are explicit. | +| LIFE-BLK-P5-035 | 035 | Specify durable child initialization precedence using initial todos as witness. | `NewTaskTool`, Task constructor, history/messages, rehydration, UI state. | P2-005 | Omitted, explicit-empty, initial, updated, switched, and restarted cases are mapped. | +| LIFE-BLK-P5-036 | 036 | Model two approval identities and stale/cross-task todo edits. | `approvedTodoList`, webview handler, approval callbacks/tests. | P4-038 | Two-task schedules require task/action/call correlation; current unsafe witness is explicit. | + +## P6: Event and ingress contracts + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | +| LIFE-BLK-P6-009 | 009 | Classify lifecycle events as awaited barriers or notifications. | Task/provider/public emitters and listeners; event tests. | P1-012 | Every consequential listener has settlement and rejection semantics. | +| LIFE-BLK-P6-011 | 011 | Inventory `TaskCompleted` consumers and required durable observations. | completion tool, provider status, public API/IPC/telemetry; completion model/tests. | P6-009, P2-006 | Each consumer’s ordering requirement maps to focused evidence or exclusion. | +| LIFE-BLK-P6-022 | 022 | Compare public and webview clear histories. | API eviction versus webview removal; provider tests. | P1-001 | Identical inputs produce an explicit same-or-deliberately-different persisted outcome. | +| LIFE-BLK-P6-027 | 027 | Establish one owner for delegation event emission. | task-level untyped and provider-level listeners; API tests. | P6-009 | Exactly-one source and no duplicate/dead listener are objective acceptance criteria. | +| LIFE-BLK-P6-028 | 028 | Normalize `TaskSpawned` payload semantics in the contract map. | task/provider/public event types and adapters. | P6-027 | Parent/child fields are explicit at each boundary with compatibility requirements. | +| LIFE-BLK-P6-029 | 029 | Document exact predicates behind `taskStatus` and `getRunning`. | Task ask markers, registry abort flags; caller inventory. | None | No caller may infer scheduler admission or persisted status without separate evidence. | +| LIFE-BLK-P6-032 | 032 | Decide supported reachability for webview abandonment. | protocol, handler, UI sender search; host tests. | P6-022 | Add sender evidence or deprecation criteria; no unreachable feature claim remains. | +| LIFE-BLK-P6-033 | 033 | Build a resume-ingress contract matrix. | webview/API/IPC resume adapters; provider/E2E tests. | P1-012, P6-009 | Awaiting, errors, publication, and rehydration results are explicit for each ingress. | + +## P7: Serial baseline and optional fan-out + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------- | +| LIFE-BLK-P7-014 | 014 | Ratchet the current singular-child/one-permit baseline and its cross-host exclusions. | lifecycle reducers/checker, provider scheduler, shared-store witnesses. | P1-001 | Baseline property, bounds, scheduler assumption, and stale-write exceptions are explicit. | + +Optional future fan-out blocks do not own `LIFE-GAP-014` and do not participate in baseline closure: + +| Optional block | Increment | Prerequisites | Acceptance | +| -------------- | ------------------------------------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------- | +| FANOUT-BLK-001 | Map live-parent and two-sibling production boundaries. | LIFE-BLK-P7-014, P1 ownership | No production claim; all missing adapters are named. | +| FANOUT-BLK-002 | Specify reservation, rollback, and permit-release failures. | FANOUT-BLK-001, P2 recovery | Every acquisition/create failure has a legal terminal state. | +| FANOUT-BLK-003 | Specify result writer, explicit routing, and orphan cleanup. | FANOUT-BLK-001, P4 identity | Existing abstract model landmarks map to required production APIs/tests. | +| FANOUT-BLK-004 | Define extension/webview task-scoping E2E matrix. | FANOUT-BLK-001–003 | Focus, messages, profiles, results, cancellation, and orphan behavior are covered. | + +## P8: Verification and traceability platform + +| Block | GAP | 1-SP increment | Production/model/test mapping | Depends on | Acceptance | +| --------------- | --- | ------------------------------------------------------------------- | ----------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------- | +| LIFE-BLK-P8-015 | 015 | Select one cross-model claim and define executable boundary events. | Two relevant checkers plus production adapters. | Owning workstream block | Joint strategy is bounded or the claim remains explicitly local. | +| LIFE-BLK-P8-016 | 016 | Add a machine-readable GAP-to-symbol/test/checker manifest design. | report, scripts, package, workflows. | None | CI validation rules detect missing paths, duplicate ownership, and stale IDs. | +| LIFE-BLK-P8-034 | 034 | Define emitted checker metadata for bounds/actions/landmarks. | all checker scripts and docs. | P8-016 | One schema represents model metadata and docs consume or validate it. | + +## Mechanical coverage check + +The primary tables above map the closed integer range `001..038` exactly once. Reviewers should verify this mechanically before changing the register: + +```sh +rg -o '^\| LIFE-BLK-P[0-9]-[0-9]{3} \|' docs/architecture/task-lifecycle-remediation-blocks.md \ + | sort \ + | uniq -d +``` + +The command must print nothing. It matches only primary table rows, so dependency references and optional `FANOUT-BLK-*` rows are excluded. + +Separately compare block suffixes with the GAP column to detect omissions or mismatches: + +```sh +node -e 'const fs=require("fs");const s=fs.readFileSync("docs/architecture/task-lifecycle-remediation-blocks.md","utf8");const rows=[...s.matchAll(/^\| LIFE-BLK-P\d-(\d{3}) \| (\d{3}) \|/gm)];const gaps=rows.map(r=>r[2]);const want=Array.from({length:38},(_,i)=>String(i+1).padStart(3,"0"));if(rows.length!==38||rows.some(r=>r[1]!==r[2])||want.some(id=>!gaps.includes(id)))process.exit(1)' +``` + +## Block completion template + +- [ ] Stable block and parent GAP IDs are in the PR description. +- [ ] One bounded behavior/property and its exclusions are stated. +- [ ] Production symbols and ownership boundary are linked. +- [ ] Model/checker change is included, or non-applicability is justified. +- [ ] Focused test, E2E, and CI evidence requirements are explicit. +- [ ] Actions/landmarks remain reachable; bounds cannot truncate silently. +- [ ] Completion does not overstate parent GAP closure. +- [ ] Dependencies are satisfied or carried as explicit blockers. diff --git a/package.json b/package.json index df3410bbc1..e4d3867e0b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && tsx scripts/check-delegated-mode-readers.ts", + "fanout-protocol:model-check": "tsx scripts/check-task-fanout-protocol.ts", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs", "mcp:integration-check": "tsx scripts/check-mcp-oauth-integration.ts", diff --git a/scripts/check-provider-handoff-scheduler.ts b/scripts/check-provider-handoff-scheduler.ts index 3fbba11b35..e6c4c29a38 100644 --- a/scripts/check-provider-handoff-scheduler.ts +++ b/scripts/check-provider-handoff-scheduler.ts @@ -147,6 +147,18 @@ for (const scenario of PROFILE_SCENARIOS) { assert.equal(parentContext.apiConfiguration.consecutiveMistakeLimit, 3, `${scenario.name}: parent context mutated`) } +const downstreamConsumerWitness = selectHandoffExecutionContext( + { ...parentContext, mode: "orchestrator" }, + "code", + "orchestrator", + false, +) +assert.equal( + downstreamConsumerWitness.mode, + "code", + "#921/#1623 witness requires task-local and shared provider modes to diverge", +) + const fixed = explore(FIXED_POLICY, false) const counterexamples = LEGACY_POLICIES.map((policy) => { const result = explore(policy, true) @@ -156,7 +168,7 @@ const counterexamples = LEGACY_POLICIES.map((policy) => { }) console.log( - `Provider handoff/scheduler model check passed: ${fixed.states} distinct reachable states, ${PROFILE_SCENARIOS.length}/${PROFILE_SCENARIOS.length} profile scenarios, ${fixed.actions.size}/${EXPECTED_ACTIONS.length} actions, ${fixed.landmarks.size}/${Object.keys(LANDMARKS).length} landmarks, depth <= ${MAX_DEPTH}, states <= ${MAX_STATES}, ${counterexamples.length}/${LEGACY_POLICIES.length} legacy counterexamples`, + `Provider handoff/scheduler model check passed: ${fixed.states} distinct reachable states, ${PROFILE_SCENARIOS.length}/${PROFILE_SCENARIOS.length} profile scenarios, 1/1 downstream shared-mode witness, ${fixed.actions.size}/${EXPECTED_ACTIONS.length} actions, ${fixed.landmarks.size}/${Object.keys(LANDMARKS).length} landmarks, depth <= ${MAX_DEPTH}, states <= ${MAX_STATES}, ${counterexamples.length}/${LEGACY_POLICIES.length} legacy counterexamples`, ) for (const counterexample of counterexamples) { console.log( diff --git a/scripts/check-task-fanout-protocol.ts b/scripts/check-task-fanout-protocol.ts new file mode 100644 index 0000000000..026e3939f8 --- /dev/null +++ b/scripts/check-task-fanout-protocol.ts @@ -0,0 +1,253 @@ +import assert from "node:assert/strict" + +const CHILDREN = ["a", "b"] as const +type Child = (typeof CHILDREN)[number] +type ChildState = "idle" | "running" | "ready" | "delivered" | "cancelled" + +type ModelState = { + parentLive: boolean + children: Record + permitOwners: Child[] + resultWriters: Partial> + deliveries: Child[] + deliveryAfterParentLoss: boolean +} + +type Transition = { name: string; kind: string; next: ModelState } +type TraceStep = { action: string; state: ModelState } + +const MAX_DEPTH = 10 +const MAX_STATES = 500 +const EXPECTED_ACTIONS = ["launch", "finish", "deliver", "lose-parent", "cancel-orphan", "release"] as const +const LANDMARKS = { + "live-parent-with-two-children": (state: ModelState) => + state.parentLive && CHILDREN.every((child) => state.children[child] === "running"), + "out-of-order-results": (state: ModelState) => state.deliveries.join(",") === "b,a", + "single-writer-results": (state: ModelState) => + CHILDREN.every((child) => state.resultWriters[child] === undefined || state.resultWriters[child] === child), + "parent-loss-with-running-child": (state: ModelState) => + !state.parentLive && CHILDREN.some((child) => state.children[child] === "running"), + "orphan-cleanup": (state: ModelState) => + !state.parentLive && + state.permitOwners.length === 0 && + CHILDREN.every((child) => !["running", "ready"].includes(state.children[child])), +} satisfies Record boolean> + +const start = initialState() +const queue: Array<{ state: ModelState; trace: TraceStep[] }> = [ + { state: start, trace: [{ action: "initial", state: start }] }, +] +const visited = new Set([canonical(start)]) +const actions = new Set() +const landmarks = new Set() +const frontier: ModelState[] = [] + +const KNOWN_BAD_STATES: Array<{ name: string; state: ModelState; expected: string }> = [ + { + name: "wrong-result-writer", + state: { + ...initialState(), + children: { a: "ready", b: "idle" }, + permitOwners: ["a"], + resultWriters: { a: "b" }, + }, + expected: "a: result has the wrong writer", + }, + { + name: "early-delivery", + state: { ...initialState(), children: { a: "delivered", b: "idle" }, deliveries: ["a"] }, + expected: "a: result delivered before readiness", + }, + { + name: "duplicate-delivery", + state: { + ...initialState(), + children: { a: "delivered", b: "idle" }, + resultWriters: { a: "a" }, + deliveries: ["a", "a"], + }, + expected: "a: result delivered more than once", + }, + { + name: "post-parent-loss-delivery", + state: { ...initialState(), parentLive: false, deliveryAfterParentLoss: true }, + expected: "result routed after parent loss", + }, + { + name: "scheduler-over-allocation", + state: { ...initialState(), permitOwners: ["a", "b", "a"] }, + expected: "scheduler capacity exceeded", + }, + { + name: "duplicate-permit-owner", + state: { ...initialState(), children: { a: "running", b: "idle" }, permitOwners: ["a", "a"] }, + expected: "duplicate permit owner", + }, + { + name: "active-without-permit", + state: { ...initialState(), children: { a: "running", b: "idle" }, permitOwners: [] }, + expected: "a: active without permit ownership", + }, + { + name: "idle-child-owns-permit", + state: { ...initialState(), permitOwners: ["a"] }, + expected: "a: idle child owns a permit", + }, +] + +for (const unsafe of KNOWN_BAD_STATES) { + assert.ok(invariantViolations(unsafe.state).includes(unsafe.expected), `${unsafe.name}: invariant did not fire`) +} + +for (let index = 0; index < queue.length; index++) { + const node = queue[index]! + for (const [name, predicate] of Object.entries(LANDMARKS)) { + if (predicate(node.state)) landmarks.add(name) + } + const violations = invariantViolations(node.state) + assert.deepEqual(violations, [], formatViolation(violations, node.trace)) + if (node.trace.length - 1 === MAX_DEPTH) { + frontier.push(node.state) + continue + } + + for (const transition of transitions(node.state)) { + actions.add(transition.kind) + const trace = [...node.trace, { action: transition.name, state: transition.next }] + const nextViolations = invariantViolations(transition.next) + assert.deepEqual(nextViolations, [], formatViolation(nextViolations, trace)) + const key = canonical(transition.next) + if (visited.has(key)) continue + visited.add(key) + queue.push({ state: transition.next, trace }) + assert.ok(visited.size <= MAX_STATES, `exceeded ${MAX_STATES}-state budget`) + } +} + +const missingActions = EXPECTED_ACTIONS.filter((action) => !actions.has(action)) +assert.deepEqual(missingActions, [], `unreachable actions: ${missingActions.join(", ")}`) +const missingLandmarks = Object.keys(LANDMARKS).filter((name) => !landmarks.has(name)) +assert.deepEqual(missingLandmarks, [], `unreachable landmarks: ${missingLandmarks.join(", ")}`) +const unseen = frontier.flatMap(transitions).find(({ next }) => !visited.has(canonical(next))) +assert.equal(unseen, undefined, `depth ${MAX_DEPTH} has unseen successor ${unseen?.name}`) + +console.log( + `Task fan-out protocol model check passed: ${visited.size} distinct reachable states, ${actions.size}/${EXPECTED_ACTIONS.length} actions, ${landmarks.size}/${Object.keys(LANDMARKS).length} landmarks, ${KNOWN_BAD_STATES.length}/${KNOWN_BAD_STATES.length} unsafe counterexamples, depth <= ${MAX_DEPTH}, states <= ${MAX_STATES}`, +) + +function transitions(state: ModelState): Transition[] { + const result: Transition[] = [] + for (const child of CHILDREN) { + if (state.parentLive && state.children[child] === "idle" && state.permitOwners.length < 2) { + result.push( + action(`launch(${child})`, "launch", state, (next) => { + next.children[child] = "running" + next.permitOwners.push(child) + }), + ) + } + if (state.children[child] === "running") { + result.push( + action(`finish(${child})`, "finish", state, (next) => { + next.children[child] = "ready" + next.resultWriters[child] = child + }), + ) + } + if (state.parentLive && state.children[child] === "ready" && state.resultWriters[child] === child) { + result.push( + action(`deliver(${child}, parent)`, "deliver", state, (next) => { + next.children[child] = "delivered" + next.deliveries.push(child) + // Record the parent-liveness observed at delivery time so the "result routed + // after parent loss" invariant is coupled to the delivery mechanism, not a flag + // no transition writes. The guard above keeps this false in the correct spec, so + // the model still passes; if a future edit drops the guard, delivery fires with + // !parentLive, this sets the flag, and the invariant catches the regression. + next.deliveryAfterParentLoss ||= !state.parentLive + }), + ) + } + if (!state.parentLive && ["running", "ready"].includes(state.children[child])) { + result.push( + action(`cancel-orphan(${child})`, "cancel-orphan", state, (next) => { + next.children[child] = "cancelled" + }), + ) + } + if (state.permitOwners.includes(child) && ["delivered", "cancelled"].includes(state.children[child])) { + result.push( + action(`release(${child})`, "release", state, (next) => { + next.permitOwners = next.permitOwners.filter((owner) => owner !== child) + }), + ) + } + } + if (state.parentLive && CHILDREN.some((child) => state.children[child] !== "idle")) { + result.push( + action("lose-parent", "lose-parent", state, (next) => { + next.parentLive = false + }), + ) + } + return result +} + +function invariantViolations(state: ModelState): string[] { + const violations: string[] = [] + if (new Set(state.permitOwners).size !== state.permitOwners.length) violations.push("duplicate permit owner") + if (state.permitOwners.length > 2) violations.push("scheduler capacity exceeded") + if (state.deliveryAfterParentLoss) violations.push("result routed after parent loss") + for (const child of CHILDREN) { + const active = ["running", "ready"].includes(state.children[child]) + if (active && !state.permitOwners.includes(child)) violations.push(`${child}: active without permit ownership`) + if (state.children[child] === "idle" && state.permitOwners.includes(child)) { + violations.push(`${child}: idle child owns a permit`) + } + if (state.resultWriters[child] !== undefined && state.resultWriters[child] !== child) { + violations.push(`${child}: result has the wrong writer`) + } + if (state.deliveries.filter((delivered) => delivered === child).length > 1) { + violations.push(`${child}: result delivered more than once`) + } + if (state.children[child] === "delivered" && state.resultWriters[child] !== child) { + violations.push(`${child}: result delivered before readiness`) + } + } + return violations +} + +function initialState(): ModelState { + return { + parentLive: true, + children: { a: "idle", b: "idle" }, + permitOwners: [], + resultWriters: {}, + deliveries: [], + deliveryAfterParentLoss: false, + } +} + +function action(name: string, kind: string, state: ModelState, update: (next: ModelState) => void): Transition { + const next = structuredClone(state) + update(next) + return { name, kind, next } +} + +function canonical(state: ModelState): string { + // resultWriters is built incrementally in finish(), so key insertion order varies by + // interleaving; sort keys so logically identical states dedupe. deliveries stays ordered + // (the out-of-order-results landmark depends on it). + const resultWriters = Object.fromEntries( + (Object.keys(state.resultWriters) as Child[]).sort().map((child) => [child, state.resultWriters[child]]), + ) + return JSON.stringify({ ...state, permitOwners: [...state.permitOwners].sort(), resultWriters }) +} + +function formatViolation(violations: string[], trace: TraceStep[]): string { + return [ + violations.join("; "), + `Bounds: depth=${MAX_DEPTH}, states=${MAX_STATES}`, + ...trace.map((step, index) => `${index}. ${step.action}\n ${canonical(step.state)}`), + ].join("\n") +} From a799355eec5992e0dcc063e2c82f2f06b8de1d53 Mon Sep 17 00:00:00 2001 From: "zoomote[bot]" <305051434+zoomote[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:54:48 +0000 Subject: [PATCH 15/21] chore: replace Navad with James in weekly release reminder rotation (#1700) Co-authored-by: zoomote[bot] <305051434+zoomote[bot]@users.noreply.github.com> --- .github/workflows/release-reminder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-reminder.yml b/.github/workflows/release-reminder.yml index 6f5b6b8ea6..1e384ee834 100644 --- a/.github/workflows/release-reminder.yml +++ b/.github/workflows/release-reminder.yml @@ -26,7 +26,7 @@ jobs: DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }} run: | set -euo pipefail - owners=("Elliott" "Navad" "Toray") + owners=("Elliott" "James" "Toray") anchor_date="2026-07-17" seconds_since_anchor=$(($(date -u +%s) - $(date -u -d "$anchor_date" +%s))) weeks_since_anchor=$((seconds_since_anchor / 604800)) From 914f0c42ad693cace51b9d2c7bb9ac472645376c Mon Sep 17 00:00:00 2001 From: PierrunoYT <95778421+PierrunoYT@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:16:55 +0000 Subject: [PATCH 16/21] test(e2e): poll restart conversation history (#1663) Amp-Thread-ID: https://ampcode.com/threads/T-01a0aa02-b6de-7763-97b8-1650ea4b46da Co-authored-by: Amp --- .../src/suite/restart-persistence.test.ts | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/vscode-e2e/src/suite/restart-persistence.test.ts b/apps/vscode-e2e/src/suite/restart-persistence.test.ts index 4778b05857..699b64e9cc 100644 --- a/apps/vscode-e2e/src/suite/restart-persistence.test.ts +++ b/apps/vscode-e2e/src/suite/restart-persistence.test.ts @@ -25,6 +25,16 @@ async function quitGracefully(): Promise { await vscode.commands.executeCommand("workbench.action.quit") } +async function waitForMarkedCompletion(api: RooCodeAPI, taskId: string): Promise { + await waitFor(() => + api.hasTaskApiConversationHistorySequence(taskId, { + userText: "RESTART_PERSISTENCE_SMOKE", + assistantToolName: "attempt_completion", + assistantToolInputText: MARKER, + }), + ) +} + async function runCreate(api: RooCodeAPI): Promise { let taskId: string | undefined let createPhasePassed = false @@ -86,16 +96,7 @@ async function runVerify(api: RooCodeAPI): Promise { const historyItem = await api.getTaskHistoryItem(taskId) assert.ok(historyItem, "Task history item should be available after restart") assert.ok(historyItem.task.includes("RESTART_PERSISTENCE_SMOKE"), "History title should persist after restart") - const restoredCompletion = await api.hasTaskApiConversationHistorySequence(taskId, { - userText: "RESTART_PERSISTENCE_SMOKE", - assistantToolName: "attempt_completion", - assistantToolInputText: MARKER, - }) - assert.strictEqual( - restoredCompletion, - true, - "Fresh-host history should restore the marked user turn followed by its assistant completion", - ) + await waitForMarkedCompletion(api, taskId) await api.resumeTask(taskId) await waitFor(() => taskMessages.some(({ type, ask }) => type === "ask" && ask === "resume_completed_task")) @@ -106,16 +107,7 @@ async function runVerify(api: RooCodeAPI): Promise { reopenedHistoryItem.task.includes("RESTART_PERSISTENCE_SMOKE"), "Reopened task should retain its persisted history title", ) - const reopenedCompletion = await api.hasTaskApiConversationHistorySequence(taskId, { - userText: "RESTART_PERSISTENCE_SMOKE", - assistantToolName: "attempt_completion", - assistantToolInputText: MARKER, - }) - assert.strictEqual( - reopenedCompletion, - true, - "Reopened-host history should restore the marked user turn followed by its assistant completion", - ) + await waitForMarkedCompletion(api, taskId) await writePhaseResult(getResultsDir(), { version: PHASE_RESULT_VERSION, From 08d05eb0f9f5008a5e8e181e987cd9fdd9308b45 Mon Sep 17 00:00:00 2001 From: simurg79 <84179478+simurg79@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:36:11 +0000 Subject: [PATCH 17/21] fix(vscode-lm): add guarded recovery parser and schema conversion (#1188) * fix(vscode-lm): sanitize surrogates, recover leaked tool calls, and window-safe tool_result truncation Hardens the VS Code Language Model provider (notably GitHub Copilot serving Anthropic Claude) against three failure modes: - Surrogate sanitization: a lone UTF-16 surrogate cannot be encoded as UTF-8, so the backend rejects the entire request with a 400. sanitizeSurrogates() replaces unpaired surrogates with U+FFFD while preserving valid pairs (emoji, CJK ext.), applied to string messages, tool results, and text parts. - Leaked tool-call recovery: some backends stream a tool call as raw XML instead of a structured LanguageModelToolCallPart, leaving the turn with no tool_use block and stalling the task in a "no tools used" retry loop. extractLeakedToolCalls() and trailingPartialToolMarkerLength() detect the markup mid-stream (including markers split across chunk boundaries) and replay it as a real tool call, conservatively: only for names matching a tool actually offered that turn, and only when tools were offered. - Window-safe tool_result truncation: Copilot's backend trims over-window requests without preserving tool_use/tool_result pairing, orphaning a tool_result and causing a 400 (unexpected tool_use_id). truncateToolResultsToFitWindow() and middleOutTruncate() shrink oversized tool_result payloads on our side (largest first, middle-out, pairing preserved) before sending. Ported from simurg79/Roo-Code#12. * test(vscode-lm): cover leaked tool-call salvage and tool_result truncation paths Raises patch coverage on the new vscode-lm reliability code above the 80%% codecov/patch gate by exercising the streaming salvage state machine (marker split across chunks, multi-chunk buffering, unknown-tool passthrough, carried tail) and the tool_result truncation helpers (array-form content, surrogate-safe middle-out, guard clauses). * fix(vscode-lm): guard leaked tool-call recovery against quoted markup Address review feedback on the leaked-tool-call salvage path: a tool name alone was not a sufficient gate, so prose or fenced examples reproducing the invoke markup could be replayed as real calls. Adds the quoted/fenced guard plus coverage. Also records the empirical vscode.lm probe as a project skill (probe-vscode-lm-api) with the scratch probe extension, the false-positive replay harness, representative transcripts, and the consent-gate gotcha. * chore(knip): exclude .roo skill assets from unused-file analysis Skill directories hold reference scripts and captured artifacts that are intentionally never imported by the build. * fix(vscode-lm): address review feedback on leaked tool-call recovery - dispose the probe CancellationTokenSource in a finally block * docs(skill): drop probe transcripts from repo Remove the ~120KB raw probe transcript corpus from the vscode-lm probe skill; keep the measured findings and their stated limits in SKILL.md. * chore: move probe skill scripts under scripts/, drop .roo knip ignore * fix(vscode-lm): harden quoted-markup detection and bound the salvage buffer Loop tag stripping until stable so `<