Skip to content

Commit fc0945e

Browse files
committed
test: add focused Task-level coverage for policy-snapshot wiring
attemptApiRequest captures the request policy snapshot and hands it to the presenter, but no Task-level test asserted what the presenter actually receives: the existing tests call the presenter directly with hand-built snapshots, so a wrong or missing Task-side snapshot could still pass the suite. Add a request policy snapshot publication describe block to Task.spec.ts with three tests. The first drives attemptApiRequest with request-time provider state and asserts the presenter receives exactly the snapshot read when the request was built (disabledTools, experiments, customModes, modelInfo) despite later setting changes, and that validation rejects a tool the snapshot disables while live settings no longer do. The second makes the first attempt fail with a context-window error and asserts the retry captures the settings that changed in between while the first attempt's snapshot stays frozen. The third calls the presenter wrapper with no snapshot published and asserts the missing-snapshot throw is logged as a real failure instead of being treated as an abort. Model resolution runs once per request by design: every attempt publishes a snapshot carrying that same resolution, so the prompt and the validator always agree on the model.
1 parent c8b361d commit fc0945e

1 file changed

Lines changed: 242 additions & 0 deletions

File tree

‎src/core/task/__tests__/Task.spec.ts‎

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
RooCodeEventName,
1313
type GlobalState,
1414
type HistoryItem,
15+
type ModeConfig,
1516
type ProviderSettings,
1617
type ModelInfo,
1718
type TaskLike,
@@ -29,6 +30,7 @@ import { ContextProxy } from "../../config/ContextProxy"
2930
import { processUserContentMentions } from "../../mentions/processUserContentMentions"
3031
import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace"
3132
import type { ApiMessage } from "../../task-persistence"
33+
import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy"
3234
import { asyncStreamFrom } from "../../../test-utils/stream"
3335
import { McpHub } from "../../../services/mcp/McpHub"
3436
import { McpServerManager } from "../../../services/mcp/McpServerManager"
@@ -45,6 +47,7 @@ type TaskTestAccess = {
4547
updateClineMessage: (message: import("@roo-code/types").ClineMessage) => Promise<void>
4648
saveClineMessages: () => Promise<boolean>
4749
safeEnsureModelFetched: () => Promise<ModelInfo>
50+
requestPolicySnapshot?: RequestPolicySnapshot
4851
getFilesReadByRooSafely: (context: string) => Promise<string[] | undefined>
4952
addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise<void>
5053
resetAssistantMessagePersistence: () => void
@@ -1351,6 +1354,245 @@ describe("Cline", () => {
13511354
})
13521355
})
13531356

1357+
describe("request policy snapshot publication", () => {
1358+
// The wrapper is fire-and-forget; this is how the wrapper's own specs
1359+
// settle its catch microtask before asserting.
1360+
const flushMicrotasks = () => new Promise<void>((resolve) => setImmediate(resolve))
1361+
1362+
// Custom mode only present on the request-time read: the live double
1363+
// below disagrees on every policy field, so a snapshot re-derived from
1364+
// live state fails these tests instead of passing vacuously.
1365+
const requestTimeCustomMode: ModeConfig = {
1366+
slug: "snapshot-mode",
1367+
name: "Snapshot Mode",
1368+
roleDefinition: "Frozen at request build time.",
1369+
groups: ["read"],
1370+
}
1371+
1372+
// Common seams so attemptApiRequest runs its capture and streams a first
1373+
// chunk without touching prompt building, context management, or UX.
1374+
function armRequestSeams(task: Task) {
1375+
vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt")
1376+
vi.spyOn(task, "getTokenUsage").mockReturnValue({
1377+
totalCost: 0,
1378+
totalTokensIn: 0,
1379+
totalTokensOut: 0,
1380+
contextTokens: 0,
1381+
})
1382+
vi.spyOn(task, "say").mockResolvedValue(undefined)
1383+
}
1384+
1385+
afterEach(() => {
1386+
vi.restoreAllMocks()
1387+
})
1388+
1389+
it("delivers the exact request-time snapshot to the presenter, immune to later provider-state changes", async () => {
1390+
const requestTimeState = providerStateWith({
1391+
autoApprovalEnabled: false,
1392+
disabledTools: ["read_file"],
1393+
experiments: { customTools: true },
1394+
customModes: [requestTimeCustomMode],
1395+
})
1396+
const laterState = providerStateWith({
1397+
autoApprovalEnabled: false,
1398+
disabledTools: [],
1399+
experiments: { customTools: false },
1400+
customModes: [],
1401+
})
1402+
1403+
const task = new Task({
1404+
provider: mockProvider,
1405+
apiConfiguration: mockApiConfig,
1406+
task: "test task",
1407+
startTask: false,
1408+
})
1409+
await task.getTaskMode()
1410+
vi.spyOn(mockProvider, "getState")
1411+
// First read: the one attemptApiRequest captures at request build.
1412+
.mockResolvedValueOnce(requestTimeState)
1413+
// Every later read returns divergent settings: any surviving
1414+
// live re-read (in publication or consumption) would pick these
1415+
// up and contradict the request's prompt.
1416+
.mockResolvedValue(laterState)
1417+
armRequestSeams(task)
1418+
vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo)
1419+
// The published snapshot disables read_file, so validation must
1420+
// reject the tool call before any approval ask is reached.
1421+
const askSpy = vi.spyOn(task, "ask").mockRejectedValue(new Error("approval path must not be reached"))
1422+
vi.spyOn(task.api, "createMessage").mockReturnValue(
1423+
asyncStreamFrom<ApiStreamChunk>([{ type: "text", text: "ok" }]),
1424+
)
1425+
1426+
// A completed non-partial native tool block: when the wrapper fires,
1427+
// the real presenter validates it against the published snapshot.
1428+
task.assistantMessageContent = [
1429+
{
1430+
type: "tool_use",
1431+
id: "tool_call_snapshot_1",
1432+
name: "read_file",
1433+
params: { path: "test.txt" },
1434+
nativeArgs: { path: "test.txt" },
1435+
partial: false,
1436+
},
1437+
]
1438+
1439+
await expect(task.attemptApiRequest(0).next()).resolves.toMatchObject({
1440+
done: false,
1441+
value: { type: "text", text: "ok" },
1442+
})
1443+
1444+
// Spied through to the original: this records the exact second
1445+
// argument while the real presenter's consumption below still runs.
1446+
const assistantMessageModule = await import("../../assistant-message")
1447+
const presentSpy = vi.spyOn(assistantMessageModule, "presentAssistantMessage")
1448+
1449+
getTaskTestAccess(task).presentAssistantMessageSafe()
1450+
await flushMicrotasks()
1451+
1452+
expect(presentSpy).toHaveBeenCalledTimes(1)
1453+
const delivered = requireDefined(presentSpy.mock.calls[0]?.[1])
1454+
// Exact four-field content — nothing more, nothing less — and each
1455+
// field is the very reference read at request time, not the
1456+
// divergent live values.
1457+
expect(delivered).toEqual({
1458+
disabledTools: ["read_file"],
1459+
experiments: { customTools: true },
1460+
customModes: [requestTimeCustomMode],
1461+
modelInfo: stubModelInfo,
1462+
})
1463+
expect(delivered.disabledTools).toBe(requestTimeState.disabledTools)
1464+
expect(delivered.experiments).toBe(requestTimeState.experiments)
1465+
expect(delivered.customModes).toBe(requestTimeState.customModes)
1466+
expect(delivered.modelInfo).toBe(stubModelInfo)
1467+
1468+
// End-to-end consumption: the rejection can only originate from the
1469+
// snapshot's disabledTools; live state no longer disables read_file.
1470+
const errorResults = task.userMessageContent.filter(
1471+
(block): block is Anthropic.ToolResultBlockParam =>
1472+
block.type === "tool_result" && block.is_error === true,
1473+
)
1474+
expect(errorResults).toHaveLength(1)
1475+
// The wrapped validation error is exactly the snapshot's restriction
1476+
// message; the divergent live list (no disabled tools) could not
1477+
// produce it.
1478+
expect(errorResults[0]?.content).toEqual(expect.stringContaining("not allowed"))
1479+
expect(askSpy).not.toHaveBeenCalled()
1480+
})
1481+
1482+
it("re-captures policy state for the retry attempt while the first attempt's snapshot stays frozen", async () => {
1483+
const firstAttemptState = providerStateWith({
1484+
autoApprovalEnabled: false,
1485+
disabledTools: ["read_file"],
1486+
experiments: { customTools: true },
1487+
customModes: [requestTimeCustomMode],
1488+
})
1489+
const retryAttemptState = providerStateWith({
1490+
autoApprovalEnabled: false,
1491+
disabledTools: ["execute_command"],
1492+
experiments: { customTools: false },
1493+
customModes: [],
1494+
})
1495+
1496+
const task = new Task({
1497+
provider: mockProvider,
1498+
apiConfiguration: mockApiConfig,
1499+
task: "test task",
1500+
startTask: false,
1501+
})
1502+
await task.getTaskMode()
1503+
vi.spyOn(mockProvider, "getState")
1504+
.mockResolvedValueOnce(firstAttemptState)
1505+
// Settings change lands after attempt 1 was built: attempt 2's
1506+
// generator re-entry must re-read, attempt 1 must not be rewritten.
1507+
.mockResolvedValue(retryAttemptState)
1508+
armRequestSeams(task)
1509+
const safeEnsureModelFetchedSpy = vi
1510+
.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched")
1511+
.mockResolvedValue(stubModelInfo)
1512+
1513+
// Seam between attempt 1's first-chunk failure and the retry
1514+
// recursion: whatever the field holds here is exactly what attempt
1515+
// 1's in-flight validation was bound to.
1516+
let firstAttemptSnapshot: RequestPolicySnapshot | undefined
1517+
vi.spyOn(getTaskTestAccess(task), "handleContextWindowExceededError").mockImplementation(async () => {
1518+
firstAttemptSnapshot = getTaskTestAccess(task).requestPolicySnapshot
1519+
})
1520+
const contextWindowFailure: AsyncGenerator<ApiStreamChunk> = (async function* () {
1521+
yield* []
1522+
throw { status: 400, message: "context length exceeded" }
1523+
})()
1524+
vi.spyOn(task.api, "createMessage")
1525+
.mockImplementationOnce(() => contextWindowFailure)
1526+
.mockImplementationOnce(() =>
1527+
asyncStreamFrom<ApiStreamChunk>([{ type: "text", text: "retry response" }]),
1528+
)
1529+
1530+
await expect(task.attemptApiRequest(0).next()).resolves.toMatchObject({
1531+
done: false,
1532+
value: { type: "text", text: "retry response" },
1533+
})
1534+
1535+
// What attempt 1's validation saw in flight: the request-time list.
1536+
const attemptOne = requireDefined(firstAttemptSnapshot)
1537+
expect(attemptOne.disabledTools).toEqual(["read_file"])
1538+
1539+
const retrySnapshot = requireDefined(getTaskTestAccess(task).requestPolicySnapshot)
1540+
// The retry re-executed the generator-body capture and picked up the
1541+
// settings that landed between attempts.
1542+
expect(retrySnapshot.disabledTools).toEqual(["execute_command"])
1543+
// Re-capture replaces the snapshot object; it never mutates the one
1544+
// the first attempt's in-flight validation already consumed.
1545+
expect(retrySnapshot).not.toBe(attemptOne)
1546+
expect(attemptOne.disabledTools).toEqual(["read_file"])
1547+
// modelInfo carries the request-scoped resolution on every attempt:
1548+
// retries forward the once-resolved metadata instead of re-resolving,
1549+
// so model-specific tool policy cannot move mid-request.
1550+
expect(retrySnapshot.modelInfo).toBe(stubModelInfo)
1551+
expect(safeEnsureModelFetchedSpy).toHaveBeenCalledTimes(1)
1552+
1553+
// The retry request's presenter receives the re-captured snapshot.
1554+
const assistantMessageModule = await import("../../assistant-message")
1555+
const presentSpy = vi.spyOn(assistantMessageModule, "presentAssistantMessage").mockResolvedValue(undefined)
1556+
getTaskTestAccess(task).presentAssistantMessageSafe()
1557+
await flushMicrotasks()
1558+
expect(presentSpy).toHaveBeenCalledTimes(1)
1559+
expect(presentSpy.mock.calls[0]?.[1].disabledTools).toEqual(["execute_command"])
1560+
})
1561+
1562+
it("surfaces the presenter's missing-snapshot throw as a real failure without aborting the run", async () => {
1563+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
1564+
const task = new Task({
1565+
provider: mockProvider,
1566+
apiConfiguration: mockApiConfig,
1567+
task: "test task",
1568+
startTask: false,
1569+
})
1570+
// Drain constructor noise so only the wrapper's own log is asserted.
1571+
await flushMicrotasks()
1572+
consoleErrorSpy.mockClear()
1573+
1574+
// No attemptApiRequest ran, so nothing published a snapshot: the
1575+
// wrapper passes undefined and the real presenter's guard throws.
1576+
getTaskTestAccess(task).presentAssistantMessageSafe()
1577+
await flushMicrotasks()
1578+
1579+
const presentErrors = consoleErrorSpy.mock.calls.filter(
1580+
(call: unknown[]) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"),
1581+
)
1582+
// The guard's message deliberately lacks the "aborted" suffix, so the
1583+
// wrapper must log it as a real failure rather than treat the run as
1584+
// cancelled.
1585+
expect(presentErrors).toHaveLength(1)
1586+
const loggedError = presentErrors[0]?.[1]
1587+
if (loggedError instanceof Error) {
1588+
expect(loggedError.message).toMatch(/missing request policy snapshot/)
1589+
} else {
1590+
throw new Error("Expected the missing-snapshot throw to be logged as a real Error")
1591+
}
1592+
expect(task.abort).toBeFalsy()
1593+
})
1594+
})
1595+
13541596
describe("sayAndCreateMissingParamError", () => {
13551597
it("surfaces a localized error notice and returns the missing-parameter tool error for both relPath branches", async () => {
13561598
const cline = new Task({

0 commit comments

Comments
 (0)