@@ -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"
2930import { processUserContentMentions } from "../../mentions/processUserContentMentions"
3031import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace"
3132import type { ApiMessage } from "../../task-persistence"
33+ import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-policy"
3234import { asyncStreamFrom } from "../../../test-utils/stream"
3335import { McpHub } from "../../../services/mcp/McpHub"
3436import { 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 ( / m i s s i n g r e q u e s t p o l i c y s n a p s h o t / )
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