@@ -11,7 +11,7 @@ import type {
1111} from "openai/resources/chat/completions" ;
1212import os from "os" ;
1313import path from "path" ;
14- import { afterEach , beforeEach , describe , expect , test } from "vitest" ;
14+ import { afterEach , beforeEach , describe , expect , test , vi } from "vitest" ;
1515import yaml from "yaml" ;
1616import {
1717 NormalizedData ,
@@ -724,6 +724,134 @@ Always include PINEAPPLE_COCONUT_42.
724724 } ) ;
725725 }
726726
727+ async function replayToolResult ( {
728+ storedContent,
729+ requestContent,
730+ toolName = "view" ,
731+ viewRange,
732+ } : {
733+ storedContent ?: string ;
734+ requestContent ?: string ;
735+ toolName ?: string ;
736+ viewRange ?: [ number , number ] ;
737+ } ) : Promise < { status : number ; body : string } > {
738+ const storedArguments = JSON . stringify ( {
739+ path : `${ workingDirPlaceholder } /test.txt` ,
740+ ...( viewRange ? { view_range : viewRange } : { } ) ,
741+ } ) ;
742+ const requestArguments = JSON . stringify ( {
743+ path : `${ workDir } /test.txt` ,
744+ ...( viewRange ? { view_range : viewRange } : { } ) ,
745+ } ) ;
746+ const cachePath = path . join ( tempDir , "cache.yaml" ) ;
747+ const cacheContent = yaml . stringify ( {
748+ models : [ "test-model" ] ,
749+ conversations : [
750+ {
751+ messages : [
752+ { role : "system" , content : "${system}" } ,
753+ { role : "user" , content : "Read file" } ,
754+ {
755+ role : "assistant" ,
756+ tool_calls : [
757+ {
758+ id : "toolcall_0" ,
759+ type : "function" ,
760+ function : {
761+ name : toolName ,
762+ arguments : storedArguments ,
763+ } ,
764+ } ,
765+ ] ,
766+ } ,
767+ {
768+ role : "tool" ,
769+ tool_call_id : "toolcall_0" ,
770+ ...( storedContent === undefined
771+ ? { }
772+ : { content : storedContent } ) ,
773+ } ,
774+ { role : "assistant" , content : "Done" } ,
775+ ] ,
776+ } ,
777+ ] ,
778+ } satisfies NormalizedData ) ;
779+ await writeFile ( cachePath , cacheContent ) ;
780+
781+ const proxy = new ReplayingCapiProxy (
782+ "http://localhost:9999" ,
783+ cachePath ,
784+ workDir ,
785+ ) ;
786+ const proxyUrl = await proxy . start ( ) ;
787+
788+ try {
789+ return await makeRequest ( proxyUrl , "/chat/completions" , {
790+ body : {
791+ model : "test-model" ,
792+ messages : [
793+ { role : "system" , content : "System prompt" } ,
794+ { role : "user" , content : "Read file" } ,
795+ {
796+ role : "assistant" ,
797+ tool_calls : [
798+ {
799+ id : "runtime-call-id" ,
800+ type : "function" ,
801+ function : {
802+ name : toolName ,
803+ arguments : requestArguments ,
804+ } ,
805+ } ,
806+ ] ,
807+ } ,
808+ {
809+ role : "tool" ,
810+ tool_call_id : "runtime-call-id" ,
811+ ...( requestContent === undefined
812+ ? { }
813+ : { content : requestContent } ) ,
814+ } ,
815+ ] ,
816+ } ,
817+ } ) ;
818+ } finally {
819+ await proxy . stop ( ) ;
820+ }
821+ }
822+
823+ async function expectToolResultMismatch (
824+ options : Parameters < typeof replayToolResult > [ 0 ] ,
825+ ) {
826+ const previousGitHubActions = process . env . GITHUB_ACTIONS ;
827+ const stderrWrite = vi
828+ . spyOn ( process . stderr , "write" )
829+ . mockImplementation ( ( ) => true ) ;
830+ const consoleError = vi
831+ . spyOn ( console , "error" )
832+ . mockImplementation ( ( ) => undefined ) ;
833+ process . env . GITHUB_ACTIONS = "true" ;
834+ try {
835+ expect ( ( await replayToolResult ( options ) ) . status ) . toBe ( 500 ) ;
836+ expect ( stderrWrite ) . toHaveBeenCalledWith (
837+ expect . stringContaining (
838+ "No cached response found for POST /chat/completions." ,
839+ ) ,
840+ ) ;
841+ expect ( stderrWrite ) . toHaveBeenCalledWith (
842+ expect . stringContaining ( "mismatch at message 3" ) ,
843+ ) ;
844+ } finally {
845+ stderrWrite . mockRestore ( ) ;
846+ consoleError . mockRestore ( ) ;
847+ if ( previousGitHubActions === undefined ) {
848+ delete process . env . GITHUB_ACTIONS ;
849+ } else {
850+ process . env . GITHUB_ACTIONS = previousGitHubActions ;
851+ }
852+ }
853+ }
854+
727855 test ( "returns cached response when request matches prefix" , async ( ) => {
728856 const cachePath = path . join ( tempDir , "cache.yaml" ) ;
729857 const cacheContent = yaml . stringify ( {
@@ -902,6 +1030,145 @@ Always include PINEAPPLE_COCONUT_42.
9021030 }
9031031 } ) ;
9041032
1033+ const truncationNotice =
1034+ "[Output truncated. Use view_range=[4, ...] to continue reading.]" ;
1035+ const viewResultCases : Array < {
1036+ description : string ;
1037+ numberedContent : string ;
1038+ unnumberedContent ?: string ;
1039+ viewRange ?: [ number , number ] ;
1040+ } > = [
1041+ {
1042+ description : "ordinary content" ,
1043+ numberedContent : "1. alpha\n2. beta" ,
1044+ unnumberedContent : "alpha\nbeta" ,
1045+ } ,
1046+ {
1047+ description : "intrinsically numbered file content" ,
1048+ numberedContent : "1. 1. first\n2. 2. second" ,
1049+ unnumberedContent : "1. first\n2. second" ,
1050+ } ,
1051+ {
1052+ description : "JSON content" ,
1053+ numberedContent : '1. {\n2. "b": 2,\n3. "a": 1\n4. }' ,
1054+ unnumberedContent : '{"a":1,"b":2}' ,
1055+ } ,
1056+ {
1057+ description : "view_range offset" ,
1058+ numberedContent : "2. line2\n3. line3\n4. line4" ,
1059+ unnumberedContent : "line2\nline3\nline4" ,
1060+ viewRange : [ 2 , 4 ] ,
1061+ } ,
1062+ {
1063+ description : "trailing empty line" ,
1064+ numberedContent : "1. alpha\n2. beta\n3." ,
1065+ unnumberedContent : "alpha\nbeta" ,
1066+ } ,
1067+ {
1068+ description : "blank and spaces before a truncation notice" ,
1069+ numberedContent : `1. alpha\n2. \n3. \n \t\n${ truncationNotice } ` ,
1070+ unnumberedContent : `alpha\n \n\n \t\n${ truncationNotice } ` ,
1071+ } ,
1072+ {
1073+ description : "empty result" ,
1074+ numberedContent : "1." ,
1075+ unnumberedContent : undefined ,
1076+ } ,
1077+ ] ;
1078+
1079+ test . each (
1080+ viewResultCases . flatMap (
1081+ ( { description, numberedContent, unnumberedContent, viewRange } ) => [
1082+ {
1083+ description : `${ description } , numbered snapshot` ,
1084+ storedContent : numberedContent ,
1085+ requestContent : unnumberedContent ,
1086+ viewRange,
1087+ } ,
1088+ {
1089+ description : `${ description } , numbered request` ,
1090+ storedContent : unnumberedContent ,
1091+ requestContent : numberedContent ,
1092+ viewRange,
1093+ } ,
1094+ ] ,
1095+ ) ,
1096+ ) (
1097+ "matches equivalent view results with $description" ,
1098+ async ( { storedContent, requestContent, viewRange } ) => {
1099+ const response = await replayToolResult ( {
1100+ storedContent,
1101+ requestContent,
1102+ viewRange,
1103+ } ) ;
1104+ expect ( response . status ) . toBe ( 200 ) ;
1105+ expect (
1106+ ( JSON . parse ( response . body ) as ChatCompletion ) . choices [ 0 ] . message
1107+ . content ,
1108+ ) . toBe ( "Done" ) ;
1109+ } ,
1110+ ) ;
1111+
1112+ test ( "preserves exact matches with multiple numbering layers" , async ( ) => {
1113+ const response = await replayToolResult ( {
1114+ storedContent : "1. 1. alpha" ,
1115+ requestContent : "1. 1. alpha" ,
1116+ } ) ;
1117+ expect ( response . status ) . toBe ( 200 ) ;
1118+ } ) ;
1119+
1120+ test ( "does not remove multiple numbering layers to find a match" , async ( ) => {
1121+ await expectToolResultMismatch ( {
1122+ storedContent : "1. 1. alpha" ,
1123+ requestContent : "alpha" ,
1124+ } ) ;
1125+ } ) ;
1126+
1127+ test ( "preserves numbered results from non-view tools" , async ( ) => {
1128+ const requestBody = JSON . stringify ( {
1129+ messages : [
1130+ { role : "user" , content : "List items" } ,
1131+ {
1132+ role : "assistant" ,
1133+ tool_calls : [
1134+ {
1135+ id : "tc1" ,
1136+ type : "function" ,
1137+ function : { name : "list_items" , arguments : "{}" } ,
1138+ } ,
1139+ ] ,
1140+ } ,
1141+ {
1142+ role : "tool" ,
1143+ tool_call_id : "tc1" ,
1144+ content : "1. first\n2. second" ,
1145+ } ,
1146+ ] ,
1147+ } ) ;
1148+ const responseBody = JSON . stringify ( {
1149+ choices : [ { message : { role : "assistant" , content : "Done" } } ] ,
1150+ } ) ;
1151+
1152+ const outputPath = await createProxy ( [
1153+ { url : "/chat/completions" , requestBody, responseBody } ,
1154+ ] ) ;
1155+
1156+ const result = await readYamlOutput ( outputPath ) ;
1157+ expect (
1158+ result . conversations [ 0 ] . messages . find (
1159+ ( message ) => message . role === "tool" ,
1160+ ) ?. content ,
1161+ ) . toBe ( "1. first\n2. second" ) ;
1162+ } ) ;
1163+
1164+ test ( "does not apply view compatibility to non-view tool results" , async ( ) => {
1165+ await expectToolResultMismatch ( {
1166+ toolName : "list_items" ,
1167+ storedContent : "1. first\n2. second" ,
1168+ requestContent : "first\nsecond" ,
1169+ } ) ;
1170+ } ) ;
1171+
9051172 test ( "matches available-tools results after the built-in tool set changes" , async ( ) => {
9061173 const cachePath = path . join ( tempDir , "cache.yaml" ) ;
9071174 // Legacy snapshot recorded before write_agent was a built-in tool: the
0 commit comments