@@ -753,6 +753,283 @@ function makeStepWithTool(
753753 } ;
754754}
755755
756+ // ---------------------------------------------------------------------------
757+ // sanitizeToolParts: pending/running tool parts → error state
758+ // Prevents orphaned tool_use blocks (no matching tool_result) from reaching the
759+ // Anthropic API. When a session errors mid-tool-execution, the tool part stays in
760+ // pending/running state. sanitizeToolParts() converts these to error state so the
761+ // SDK generates both tool_use + tool_result(is_error=true).
762+ // ---------------------------------------------------------------------------
763+
764+ function makeStepWithPendingTool (
765+ id : string ,
766+ parentUserID : string ,
767+ toolName : string ,
768+ sessionID = "grad-sess" ,
769+ ) : { info : Message ; parts : Part [ ] } {
770+ const info : Message = {
771+ id,
772+ sessionID,
773+ role : "assistant" ,
774+ time : { created : Date . now ( ) } ,
775+ parentID : parentUserID ,
776+ modelID : "claude-sonnet-4-20250514" ,
777+ providerID : "anthropic" ,
778+ mode : "build" ,
779+ path : { cwd : "/test" , root : "/test" } ,
780+ cost : 0 ,
781+ tokens : {
782+ input : 100 ,
783+ output : 50 ,
784+ reasoning : 0 ,
785+ cache : { read : 0 , write : 0 } ,
786+ } ,
787+ } ;
788+ return {
789+ info,
790+ parts : [
791+ {
792+ id : `step-start-${ id } ` ,
793+ sessionID,
794+ messageID : id ,
795+ type : "step-start" ,
796+ } as Part ,
797+ {
798+ id : `tool-${ id } ` ,
799+ sessionID,
800+ messageID : id ,
801+ type : "tool" ,
802+ callID : `call-${ id } ` ,
803+ tool : toolName ,
804+ state : {
805+ status : "pending" ,
806+ input : { command : "ls" } ,
807+ raw : '{"command": "ls"}' ,
808+ } ,
809+ } as unknown as Part ,
810+ ] ,
811+ } ;
812+ }
813+
814+ function makeStepWithRunningTool (
815+ id : string ,
816+ parentUserID : string ,
817+ toolName : string ,
818+ sessionID = "grad-sess" ,
819+ ) : { info : Message ; parts : Part [ ] } {
820+ const info : Message = {
821+ id,
822+ sessionID,
823+ role : "assistant" ,
824+ time : { created : Date . now ( ) } ,
825+ parentID : parentUserID ,
826+ modelID : "claude-sonnet-4-20250514" ,
827+ providerID : "anthropic" ,
828+ mode : "build" ,
829+ path : { cwd : "/test" , root : "/test" } ,
830+ cost : 0 ,
831+ tokens : {
832+ input : 100 ,
833+ output : 50 ,
834+ reasoning : 0 ,
835+ cache : { read : 0 , write : 0 } ,
836+ } ,
837+ } ;
838+ const startTime = Date . now ( ) - 5000 ;
839+ return {
840+ info,
841+ parts : [
842+ {
843+ id : `step-start-${ id } ` ,
844+ sessionID,
845+ messageID : id ,
846+ type : "step-start" ,
847+ } as Part ,
848+ {
849+ id : `tool-${ id } ` ,
850+ sessionID,
851+ messageID : id ,
852+ type : "tool" ,
853+ callID : `call-${ id } ` ,
854+ tool : toolName ,
855+ state : {
856+ status : "running" ,
857+ input : { command : "build" } ,
858+ title : toolName ,
859+ metadata : { cwd : "/test" } ,
860+ time : { start : startTime } ,
861+ } ,
862+ } as unknown as Part ,
863+ ] ,
864+ } ;
865+ }
866+
867+ describe ( "gradient — sanitizeToolParts (orphaned tool_use fix)" , ( ) => {
868+ const SESSION = "sanitize-sess" ;
869+
870+ beforeEach ( ( ) => {
871+ resetCalibration ( ) ;
872+ resetPrefixCache ( ) ;
873+ resetRawWindowCache ( ) ;
874+ setModelLimits ( { context : 10_000 , output : 2_000 } ) ;
875+ calibrate ( 0 ) ;
876+ ensureProject ( PROJECT ) ;
877+ } ) ;
878+
879+ test ( "no-op when all tool parts are completed — returns same array reference" , ( ) => {
880+ const msgs = [
881+ makeMsg ( "san-u1" , "user" , "build it" , SESSION ) ,
882+ makeStepWithTool ( "san-a1" , "san-u1" , "bash" , "done" , SESSION ) ,
883+ ] ;
884+
885+ const result = transform ( { messages : msgs , projectPath : PROJECT , sessionID : SESSION } ) ;
886+
887+ // Layer 0 for small session — messages should be the same reference
888+ expect ( result . layer ) . toBe ( 0 ) ;
889+ // The tool part should still be completed
890+ const toolPart = result . messages [ 1 ] ! . parts . find ( ( p ) => p . type === "tool" ) ! ;
891+ expect ( ( toolPart as any ) . state . status ) . toBe ( "completed" ) ;
892+ } ) ;
893+
894+ test ( "pending tool part is converted to error state" , ( ) => {
895+ const msgs = [
896+ makeMsg ( "san-u2" , "user" , "run something" , SESSION ) ,
897+ makeStepWithPendingTool ( "san-a2" , "san-u2" , "bash" , SESSION ) ,
898+ ] ;
899+
900+ const result = transform ( { messages : msgs , projectPath : PROJECT , sessionID : SESSION } ) ;
901+
902+ const toolPart = result . messages [ 1 ] ! . parts . find ( ( p ) => p . type === "tool" ) ! as any ;
903+ expect ( toolPart . state . status ) . toBe ( "error" ) ;
904+ expect ( toolPart . state . error ) . toBe ( "[tool execution interrupted — session recovered]" ) ;
905+ expect ( toolPart . state . input ) . toEqual ( { command : "ls" } ) ;
906+ // Pending has no time field — both start and end should be fabricated
907+ expect ( typeof toolPart . state . time . start ) . toBe ( "number" ) ;
908+ expect ( typeof toolPart . state . time . end ) . toBe ( "number" ) ;
909+ } ) ;
910+
911+ test ( "running tool part is converted to error state, preserving time.start" , ( ) => {
912+ const msgs = [
913+ makeMsg ( "san-u3" , "user" , "build the project" , SESSION ) ,
914+ makeStepWithRunningTool ( "san-a3" , "san-u3" , "bash" , SESSION ) ,
915+ ] ;
916+
917+ const result = transform ( { messages : msgs , projectPath : PROJECT , sessionID : SESSION } ) ;
918+
919+ const toolPart = result . messages [ 1 ] ! . parts . find ( ( p ) => p . type === "tool" ) ! as any ;
920+ expect ( toolPart . state . status ) . toBe ( "error" ) ;
921+ expect ( toolPart . state . error ) . toBe ( "[tool execution interrupted — session recovered]" ) ;
922+ expect ( toolPart . state . input ) . toEqual ( { command : "build" } ) ;
923+ // Running has time.start — should be preserved
924+ expect ( toolPart . state . time . start ) . toBeLessThan ( Date . now ( ) ) ;
925+ expect ( toolPart . state . time . end ) . toBeGreaterThanOrEqual ( toolPart . state . time . start ) ;
926+ // Metadata from running state should be carried over
927+ expect ( toolPart . state . metadata ) . toEqual ( { cwd : "/test" } ) ;
928+ } ) ;
929+
930+ test ( "mixed parts: text + completed tool + pending tool — only pending converted" , ( ) => {
931+ const msgs = [
932+ makeMsg ( "san-u4" , "user" , "do stuff" , SESSION ) ,
933+ {
934+ ...makeStepWithTool ( "san-a4" , "san-u4" , "bash" , "first output" , SESSION ) ,
935+ parts : [
936+ // text part
937+ {
938+ id : "text-san-a4" ,
939+ sessionID : SESSION ,
940+ messageID : "san-a4" ,
941+ type : "text" ,
942+ text : "Let me run two commands" ,
943+ time : { start : Date . now ( ) , end : Date . now ( ) } ,
944+ } as Part ,
945+ // completed tool part
946+ {
947+ id : "tool-completed-san-a4" ,
948+ sessionID : SESSION ,
949+ messageID : "san-a4" ,
950+ type : "tool" ,
951+ callID : "call-completed" ,
952+ tool : "bash" ,
953+ state : {
954+ status : "completed" ,
955+ title : "bash" ,
956+ input : { command : "ls" } ,
957+ output : "file1.ts file2.ts" ,
958+ metadata : { } ,
959+ time : { start : Date . now ( ) , end : Date . now ( ) } ,
960+ } ,
961+ } as unknown as Part ,
962+ // pending tool part
963+ {
964+ id : "tool-pending-san-a4" ,
965+ sessionID : SESSION ,
966+ messageID : "san-a4" ,
967+ type : "tool" ,
968+ callID : "call-pending" ,
969+ tool : "bash" ,
970+ state : {
971+ status : "pending" ,
972+ input : { command : "cat file1.ts" } ,
973+ raw : '{"command": "cat file1.ts"}' ,
974+ } ,
975+ } as unknown as Part ,
976+ ] ,
977+ } ,
978+ ] ;
979+
980+ const result = transform ( { messages : msgs , projectPath : PROJECT , sessionID : SESSION } ) ;
981+
982+ const parts = result . messages [ 1 ] ! . parts ;
983+ // Text part unchanged
984+ const textPart = parts . find ( ( p ) => p . type === "text" ) ! ;
985+ expect ( ( textPart as any ) . text ) . toBe ( "Let me run two commands" ) ;
986+ // Completed tool part unchanged
987+ const completedTool = parts . find (
988+ ( p ) => p . type === "tool" && ( p as any ) . callID === "call-completed" ,
989+ ) ! as any ;
990+ expect ( completedTool . state . status ) . toBe ( "completed" ) ;
991+ expect ( completedTool . state . output ) . toBe ( "file1.ts file2.ts" ) ;
992+ // Pending tool part → error
993+ const pendingTool = parts . find (
994+ ( p ) => p . type === "tool" && ( p as any ) . callID === "call-pending" ,
995+ ) ! as any ;
996+ expect ( pendingTool . state . status ) . toBe ( "error" ) ;
997+ expect ( pendingTool . state . error ) . toBe ( "[tool execution interrupted — session recovered]" ) ;
998+ } ) ;
999+
1000+ test ( "user messages are untouched" , ( ) => {
1001+ const userMsg = makeMsg ( "san-u5" , "user" , "hello" , SESSION ) ;
1002+ const msgs = [ userMsg , makeStepWithPendingTool ( "san-a5" , "san-u5" , "bash" , SESSION ) ] ;
1003+
1004+ const result = transform ( { messages : msgs , projectPath : PROJECT , sessionID : SESSION } ) ;
1005+
1006+ // User message should be the same object reference (not cloned)
1007+ expect ( result . messages [ 0 ] ! . info . id ) . toBe ( "san-u5" ) ;
1008+ expect ( result . messages [ 0 ] ! . parts [ 0 ] ! . type ) . toBe ( "text" ) ;
1009+ } ) ;
1010+
1011+ test ( "multiple messages: only affected messages are cloned" , ( ) => {
1012+ const msgs = [
1013+ makeMsg ( "san-u6" , "user" , "first task" , SESSION ) ,
1014+ makeStepWithTool ( "san-a6" , "san-u6" , "bash" , "done" , SESSION ) , // completed — untouched
1015+ makeMsg ( "san-u7" , "user" , "second task" , SESSION ) ,
1016+ makeStepWithPendingTool ( "san-a7" , "san-u7" , "edit" , SESSION ) , // pending — converted
1017+ ] ;
1018+
1019+ const result = transform ( { messages : msgs , projectPath : PROJECT , sessionID : SESSION } ) ;
1020+
1021+ // Completed tool message untouched
1022+ const completedMsg = result . messages . find ( ( m ) => m . info . id === "san-a6" ) ! ;
1023+ const completedTool = completedMsg . parts . find ( ( p ) => p . type === "tool" ) ! as any ;
1024+ expect ( completedTool . state . status ) . toBe ( "completed" ) ;
1025+
1026+ // Pending tool message converted
1027+ const pendingMsg = result . messages . find ( ( m ) => m . info . id === "san-a7" ) ! ;
1028+ const pendingTool = pendingMsg . parts . find ( ( p ) => p . type === "tool" ) ! as any ;
1029+ expect ( pendingTool . state . status ) . toBe ( "error" ) ;
1030+ } ) ;
1031+ } ) ;
1032+
7561033// ---------------------------------------------------------------------------
7571034// Layer 0 trailing-drop: pure-text trailing assistant messages must be dropped
7581035// even when gradient is not active (layer 0 passthrough). This is the fix for
0 commit comments