Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
1c2fad9
initial fix for issues #1240 and #505
DaubnerF Sep 1, 2026
d52e160
Merge branch 'main' into bugfix_for_1240_505
DaubnerF Sep 2, 2026
41a7698
1st round of fixes
DaubnerF Sep 2, 2026
a607ec9
Merge branch 'main' into bugfix_for_1240_505
DaubnerF Sep 2, 2026
f27387b
fixed comments
DaubnerF Sep 2, 2026
8f2245f
Merge branch 'main' into bugfix_for_1240_505
DaubnerF Sep 3, 2026
bc6f8ff
increase test coverage
DaubnerF Sep 3, 2026
6968a98
revert: remove Windows shell invocation from stryker-diff
DaubnerF Sep 4, 2026
e00a2b1
fix: address CodeRabbit review on tool-policy prompt unification
DaubnerF Sep 4, 2026
943a16e
fix(test): correct apiModelId in generateSystemPrompt state mock
DaubnerF Sep 4, 2026
bd7894c
drop use_mcp_tool from policy when no MCP tool is permitted
DaubnerF Sep 4, 2026
c289453
Merge branch 'main' into bugfix_for_1240_505
DaubnerF Sep 5, 2026
26a3a68
code hardening
DaubnerF Sep 7, 2026
7e42bf5
Merge branch 'Zoo-Code-Org:main' into bugfix_for_1240_505
DaubnerF Sep 7, 2026
528b023
bound model fetch with timeout, typed provider state test doubles
DaubnerF Sep 8, 2026
aa8f108
cover preview model fetch timeout path with tests
DaubnerF Sep 8, 2026
415422b
pin completion-time history save ordering with unit tests
DaubnerF Sep 8, 2026
1db50a1
poll history length in restart e2e to tolerate atomic write window
DaubnerF Sep 8, 2026
227ab61
Merge remote-tracking branch 'upstream/main' into bugfix_for_1240_505
DaubnerF Sep 8, 2026
6a769b5
share one model-info snapshot per request between prompt and tools
DaubnerF Sep 8, 2026
92c6f32
resolve provider state once before the MCP wait
DaubnerF Sep 8, 2026
2ce570a
cover the undefined provider state path in the system prompt tests
DaubnerF Sep 8, 2026
ac35870
reuse one model-info snapshot per request and honor cancellation
DaubnerF Sep 8, 2026
b75e73b
pin the retry count the request seam receives
DaubnerF Sep 8, 2026
b1236cd
refactor(task): require callers to thread provider state into system …
DaubnerF Sep 8, 2026
85beb67
fix(api): cancel abandoned model-metadata waits via AbortSignal
DaubnerF Sep 9, 2026
d06883a
test(api): cover abort-signal detach paths and thread request model s…
DaubnerF Sep 9, 2026
2366d23
Apply disabled and excluded tool policy to dynamic MCP declarations
DaubnerF Sep 10, 2026
cfd835e
Forward request options through API retry recursion
DaubnerF Sep 10, 2026
aa894b4
Forward derived model snapshot through API retry recursion
DaubnerF Sep 10, 2026
f943876
Tighten build-tools test assertions and provider double
DaubnerF Sep 10, 2026
28439fb
Use the request model snapshot for context-window recovery math
DaubnerF Sep 10, 2026
0a41225
Merge branch 'Zoo-Code-Org:main' into bugfix_for_1240_505
DaubnerF Sep 10, 2026
9b0a6a5
Stop manual condensation when the task is cancelled
DaubnerF Sep 10, 2026
7955068
Recheck cancellation before summarizing and rewriting history
DaubnerF Sep 10, 2026
17d6176
Make the first cancellation checkpoint observable to tests
DaubnerF Sep 11, 2026
795b639
Correct a rationale comment in the cancellation tests
DaubnerF Sep 11, 2026
855531d
Merge branch 'Zoo-Code-Org:main' into bugfix_for_1240_505
DaubnerF Sep 11, 2026
73670b8
Merge remote-tracking branch 'origin/bugfix_for_1240_505' into bugfix…
DaubnerF Sep 11, 2026
575d35d
Narrow the change set to the tool-policy work and its regression tests
DaubnerF Sep 11, 2026
610acb1
Merge branch 'Zoo-Code-Org:main' into bugfix_for_1240_505
DaubnerF Sep 11, 2026
fddb744
Merge origin/main (01c7357a7) into bugfix_for_1240_505
DaubnerF Sep 11, 2026
4bef223
Remove dead export, untriggerable timer guard, and duplicated prompt-…
DaubnerF Sep 11, 2026
ad6a9a1
Merge remote-tracking branch 'origin/bugfix_for_1240_505' into bugfix…
DaubnerF Sep 11, 2026
24c2fba
Merge branch 'Zoo-Code-Org:main' into bugfix_for_1240_505
DaubnerF Sep 12, 2026
dc2666d
fix(prompts): enforce effective tool policy guidance
roomote Sep 12, 2026
cc8ceaf
Merge branch 'Zoo-Code-Org:main' into bugfix_for_1240_505
DaubnerF Sep 14, 2026
5ae40b8
fix(task): restore caller-layer cancellation for model-metadata fetches
DaubnerF Sep 14, 2026
d015183
fix(task): set disposal state before cancelling metadata waits
DaubnerF Sep 15, 2026
c76dc83
Merge branch 'main' into bugfix_for_1240_505
DaubnerF Sep 15, 2026
a715cf0
chore(ci): bump coverage-contract baseline for branch-added policy mo…
DaubnerF Sep 15, 2026
9de70e1
Merge upstream/main (77e422faf) into restored pre-rewrite state
DaubnerF Sep 17, 2026
60de0ee
test: mock CodeIndexManagerRegistry in build-tools.spec (upstream #16…
DaubnerF Sep 17, 2026
37a21fa
fix: describe codebase_search as semantic search; anchor read_file in…
DaubnerF Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
ensureModelFetched?(signal?: AbortSignal): Promise<void>

/**
* Optional context window for context-management / auto-condense when it must differ from
Expand Down
71 changes: 71 additions & 0 deletions src/api/providers/__tests__/zoo-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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")
Expand Down
31 changes: 29 additions & 2 deletions src/api/providers/router-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,35 @@ export abstract class RouterProvider extends BaseProvider {
return this.modelFetchPromise
}

async ensureModelFetched(): Promise<void> {
await this.fetchModel()
async ensureModelFetched(signal?: AbortSignal): Promise<void> {
// 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<void>((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 } {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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", () => {
Expand Down
16 changes: 6 additions & 10 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, boolean>, tool: string) => {
acc[tool] = false
const resolvedToolName = resolveToolAlias(tool)
acc[resolvedToolName] = false
return acc
},
{} as Record<string, boolean>,
) ?? {}
// 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,
Expand Down
Loading
Loading