@@ -5,6 +5,7 @@ import { CURRENT_SESSION_VERSION } from "@earendil-works/pi-coding-agent";
55import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
66import type { OpenClawConfig } from "../../config/types.openclaw.js" ;
77import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js" ;
8+ import { CLI_AUTH_EPOCH_VERSION } from "../cli-auth-epoch.js" ;
89import { __testing as cliBackendsTesting } from "../cli-backends.js" ;
910import { hashCliSessionText } from "../cli-session.js" ;
1011import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js" ;
@@ -993,4 +994,279 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
993994 fs . rmSync ( dir , { recursive : true , force : true } ) ;
994995 }
995996 } ) ;
997+
998+ it ( "reseeds prior context from the dead claude-cli transcript when invalidator fires for missing-transcript" , async ( ) => {
999+ const { dir, sessionFile } = createSessionFile ( ) ;
1000+ try {
1001+ cliBackendsTesting . setDepsForTest ( {
1002+ resolvePluginSetupCliBackend : ( ) => undefined ,
1003+ resolveRuntimeCliBackends : ( ) => [
1004+ {
1005+ id : "claude-cli" ,
1006+ pluginId : "anthropic" ,
1007+ bundleMcp : false ,
1008+ config : {
1009+ command : "claude" ,
1010+ args : [ "--print" ] ,
1011+ resumeArgs : [ "--resume" , "{sessionId}" ] ,
1012+ output : "jsonl" ,
1013+ input : "stdin" ,
1014+ sessionMode : "existing" ,
1015+ } ,
1016+ } ,
1017+ ] ,
1018+ } ) ;
1019+ const transcriptCheck = vi . fn ( async ( ) => false ) ;
1020+ const orphanCheck = vi . fn ( async ( ) => false ) ;
1021+ const fallbackPrelude = vi . fn (
1022+ ( ) =>
1023+ "## Prior session context (from claude-cli)\n\nuser: prior question\nassistant: prior answer" ,
1024+ ) ;
1025+ setCliRunnerPrepareTestDeps ( {
1026+ claudeCliSessionTranscriptHasContent : transcriptCheck ,
1027+ claudeCliSessionTranscriptHasOrphanedToolUse : orphanCheck ,
1028+ buildClaudeCliFallbackContextPrelude : fallbackPrelude ,
1029+ } ) ;
1030+
1031+ const context = await prepareCliRunContext ( {
1032+ sessionId : "session-test" ,
1033+ sessionKey : "agent:main:telegram:direct:peer" ,
1034+ sessionFile,
1035+ workspaceDir : dir ,
1036+ prompt : "follow-up ask" ,
1037+ provider : "claude-cli" ,
1038+ model : "opus" ,
1039+ timeoutMs : 1_000 ,
1040+ runId : "run-reseed-missing" ,
1041+ cliSessionBinding : { sessionId : "stale-claude-sid" } ,
1042+ cliSessionId : "stale-claude-sid" ,
1043+ config : createCliBackendConfig ( { systemPromptOverride : null } ) ,
1044+ } ) ;
1045+
1046+ expect ( context . reusableCliSession ) . toEqual ( { invalidatedReason : "missing-transcript" } ) ;
1047+ expect ( fallbackPrelude ) . toHaveBeenCalledWith ( { cliSessionId : "stale-claude-sid" } ) ;
1048+ expect ( context . params . prompt ) . toContain ( "Prior session context (from claude-cli)" ) ;
1049+ expect ( context . params . prompt ) . toContain (
1050+ "[Retry after the previous model attempt failed or timed out]" ,
1051+ ) ;
1052+ expect ( context . params . prompt ) . toContain ( "follow-up ask" ) ;
1053+ } finally {
1054+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
1055+ }
1056+ } ) ;
1057+
1058+ it ( "reseeds prior context when invalidator fires for orphaned-tool-use" , async ( ) => {
1059+ const { dir, sessionFile } = createSessionFile ( ) ;
1060+ try {
1061+ cliBackendsTesting . setDepsForTest ( {
1062+ resolvePluginSetupCliBackend : ( ) => undefined ,
1063+ resolveRuntimeCliBackends : ( ) => [
1064+ {
1065+ id : "claude-cli" ,
1066+ pluginId : "anthropic" ,
1067+ bundleMcp : false ,
1068+ config : {
1069+ command : "claude" ,
1070+ args : [ "--print" ] ,
1071+ resumeArgs : [ "--resume" , "{sessionId}" ] ,
1072+ output : "jsonl" ,
1073+ input : "stdin" ,
1074+ sessionMode : "existing" ,
1075+ } ,
1076+ } ,
1077+ ] ,
1078+ } ) ;
1079+ const transcriptCheck = vi . fn ( async ( ) => true ) ;
1080+ const orphanCheck = vi . fn ( async ( ) => true ) ;
1081+ const fallbackPrelude = vi . fn (
1082+ ( ) => "## Prior session context (from claude-cli)\n\nassistant: stuck mid-tool reply" ,
1083+ ) ;
1084+ setCliRunnerPrepareTestDeps ( {
1085+ claudeCliSessionTranscriptHasContent : transcriptCheck ,
1086+ claudeCliSessionTranscriptHasOrphanedToolUse : orphanCheck ,
1087+ buildClaudeCliFallbackContextPrelude : fallbackPrelude ,
1088+ } ) ;
1089+
1090+ const context = await prepareCliRunContext ( {
1091+ sessionId : "session-test" ,
1092+ sessionKey : "agent:main:telegram:direct:peer" ,
1093+ sessionFile,
1094+ workspaceDir : dir ,
1095+ prompt : "are you there?" ,
1096+ provider : "claude-cli" ,
1097+ model : "opus" ,
1098+ timeoutMs : 1_000 ,
1099+ runId : "run-reseed-orphan" ,
1100+ cliSessionBinding : { sessionId : "orphaned-claude-sid" } ,
1101+ cliSessionId : "orphaned-claude-sid" ,
1102+ config : createCliBackendConfig ( { systemPromptOverride : null } ) ,
1103+ } ) ;
1104+
1105+ expect ( context . reusableCliSession ) . toEqual ( { invalidatedReason : "orphaned-tool-use" } ) ;
1106+ expect ( fallbackPrelude ) . toHaveBeenCalledWith ( { cliSessionId : "orphaned-claude-sid" } ) ;
1107+ expect ( context . params . prompt ) . toContain ( "Prior session context (from claude-cli)" ) ;
1108+ expect ( context . params . prompt ) . toContain ( "are you there?" ) ;
1109+ } finally {
1110+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
1111+ }
1112+ } ) ;
1113+
1114+ it ( "does not call the prelude builder when the claude-cli session is reusable (no invalidation)" , async ( ) => {
1115+ const { dir, sessionFile } = createSessionFile ( ) ;
1116+ try {
1117+ cliBackendsTesting . setDepsForTest ( {
1118+ resolvePluginSetupCliBackend : ( ) => undefined ,
1119+ resolveRuntimeCliBackends : ( ) => [
1120+ {
1121+ id : "claude-cli" ,
1122+ pluginId : "anthropic" ,
1123+ bundleMcp : false ,
1124+ config : {
1125+ command : "claude" ,
1126+ args : [ "--print" ] ,
1127+ resumeArgs : [ "--resume" , "{sessionId}" ] ,
1128+ output : "jsonl" ,
1129+ input : "stdin" ,
1130+ sessionMode : "existing" ,
1131+ } ,
1132+ } ,
1133+ ] ,
1134+ } ) ;
1135+ const transcriptCheck = vi . fn ( async ( ) => true ) ;
1136+ const orphanCheck = vi . fn ( async ( ) => false ) ;
1137+ const fallbackPrelude = vi . fn ( ( ) => "" ) ;
1138+ setCliRunnerPrepareTestDeps ( {
1139+ claudeCliSessionTranscriptHasContent : transcriptCheck ,
1140+ claudeCliSessionTranscriptHasOrphanedToolUse : orphanCheck ,
1141+ buildClaudeCliFallbackContextPrelude : fallbackPrelude ,
1142+ } ) ;
1143+
1144+ const context = await prepareCliRunContext ( {
1145+ sessionId : "session-test" ,
1146+ sessionKey : "agent:main:telegram:direct:peer" ,
1147+ sessionFile,
1148+ workspaceDir : dir ,
1149+ prompt : "next ask" ,
1150+ provider : "claude-cli" ,
1151+ model : "opus" ,
1152+ timeoutMs : 1_000 ,
1153+ runId : "run-reseed-skip" ,
1154+ cliSessionBinding : { sessionId : "live-claude-sid" } ,
1155+ cliSessionId : "live-claude-sid" ,
1156+ config : createCliBackendConfig ( { systemPromptOverride : null } ) ,
1157+ } ) ;
1158+
1159+ expect ( context . reusableCliSession ) . toEqual ( { sessionId : "live-claude-sid" } ) ;
1160+ expect ( fallbackPrelude ) . not . toHaveBeenCalled ( ) ;
1161+ expect ( context . params . prompt ) . not . toContain ( "Prior session context" ) ;
1162+ } finally {
1163+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
1164+ }
1165+ } ) ;
1166+
1167+ it ( "reseeds prior context when invalidator fires for system-prompt change" , async ( ) => {
1168+ // system-prompt invalidation happens when the cli session reuse layer
1169+ // detects that the agent's static system prompt hashed differently
1170+ // than the binding's stored hash. Same end-user pain as the other
1171+ // invalidation paths (fresh CLI session, no memory), so the same
1172+ // recovery applies — read the dead transcript and reseed.
1173+ const { dir, sessionFile } = createSessionFile ( ) ;
1174+ try {
1175+ cliBackendsTesting . setDepsForTest ( {
1176+ resolvePluginSetupCliBackend : ( ) => undefined ,
1177+ resolveRuntimeCliBackends : ( ) => [
1178+ {
1179+ id : "claude-cli" ,
1180+ pluginId : "anthropic" ,
1181+ bundleMcp : false ,
1182+ config : {
1183+ command : "claude" ,
1184+ args : [ "--print" ] ,
1185+ resumeArgs : [ "--resume" , "{sessionId}" ] ,
1186+ output : "jsonl" ,
1187+ input : "stdin" ,
1188+ sessionMode : "existing" ,
1189+ } ,
1190+ } ,
1191+ ] ,
1192+ } ) ;
1193+ const transcriptCheck = vi . fn ( async ( ) => true ) ;
1194+ const orphanCheck = vi . fn ( async ( ) => false ) ;
1195+ const fallbackPrelude = vi . fn (
1196+ ( ) =>
1197+ "## Prior session context (from claude-cli)\n\nuser: prior question\nassistant: prior answer" ,
1198+ ) ;
1199+ setCliRunnerPrepareTestDeps ( {
1200+ claudeCliSessionTranscriptHasContent : transcriptCheck ,
1201+ claudeCliSessionTranscriptHasOrphanedToolUse : orphanCheck ,
1202+ buildClaudeCliFallbackContextPrelude : fallbackPrelude ,
1203+ } ) ;
1204+
1205+ // Drive a system-prompt invalidation by passing a binding whose
1206+ // extraSystemPromptHash doesn't match the current hash. The runtime
1207+ // recomputes the hash from extraSystemPromptStatic/extraSystemPrompt;
1208+ // we set the binding hash to a fixed wrong value.
1209+ const context = await prepareCliRunContext ( {
1210+ sessionId : "session-test" ,
1211+ sessionKey : "agent:main:telegram:direct:peer" ,
1212+ sessionFile,
1213+ workspaceDir : dir ,
1214+ prompt : "follow-up" ,
1215+ provider : "claude-cli" ,
1216+ model : "opus" ,
1217+ timeoutMs : 1_000 ,
1218+ runId : "run-reseed-system-prompt" ,
1219+ extraSystemPrompt : "agent-static-prompt" ,
1220+ extraSystemPromptStatic : "agent-static-prompt" ,
1221+ cliSessionBinding : {
1222+ sessionId : "stale-system-prompt-sid" ,
1223+ extraSystemPromptHash : "deadbeefdeadbeef" ,
1224+ authEpochVersion : CLI_AUTH_EPOCH_VERSION ,
1225+ } ,
1226+ cliSessionId : "stale-system-prompt-sid" ,
1227+ config : createCliBackendConfig ( { systemPromptOverride : null } ) ,
1228+ } ) ;
1229+
1230+ expect ( context . reusableCliSession ) . toEqual ( { invalidatedReason : "system-prompt" } ) ;
1231+ expect ( fallbackPrelude ) . toHaveBeenCalledWith ( { cliSessionId : "stale-system-prompt-sid" } ) ;
1232+ expect ( context . params . prompt ) . toContain ( "Prior session context (from claude-cli)" ) ;
1233+ expect ( context . params . prompt ) . toContain ( "follow-up" ) ;
1234+ } finally {
1235+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
1236+ }
1237+ } ) ;
1238+
1239+ it ( "does not reseed for non-claude-cli providers even on invalidation-shaped paths" , async ( ) => {
1240+ const { dir, sessionFile } = createSessionFile ( ) ;
1241+ try {
1242+ const transcriptCheck = vi . fn ( async ( ) => false ) ;
1243+ const orphanCheck = vi . fn ( async ( ) => false ) ;
1244+ const fallbackPrelude = vi . fn ( ( ) => "should-not-be-called" ) ;
1245+ setCliRunnerPrepareTestDeps ( {
1246+ claudeCliSessionTranscriptHasContent : transcriptCheck ,
1247+ claudeCliSessionTranscriptHasOrphanedToolUse : orphanCheck ,
1248+ buildClaudeCliFallbackContextPrelude : fallbackPrelude ,
1249+ } ) ;
1250+
1251+ const context = await prepareCliRunContext ( {
1252+ sessionId : "session-test" ,
1253+ sessionFile,
1254+ workspaceDir : dir ,
1255+ prompt : "next ask" ,
1256+ provider : "test-cli" ,
1257+ model : "test-model" ,
1258+ timeoutMs : 1_000 ,
1259+ runId : "run-reseed-other-provider" ,
1260+ cliSessionBinding : { sessionId : "test-cli-sid" } ,
1261+ config : createCliBackendConfig ( { systemPromptOverride : null } ) ,
1262+ } ) ;
1263+
1264+ expect ( transcriptCheck ) . not . toHaveBeenCalled ( ) ;
1265+ expect ( orphanCheck ) . not . toHaveBeenCalled ( ) ;
1266+ expect ( fallbackPrelude ) . not . toHaveBeenCalled ( ) ;
1267+ expect ( context . params . prompt ) . not . toContain ( "should-not-be-called" ) ;
1268+ } finally {
1269+ fs . rmSync ( dir , { recursive : true , force : true } ) ;
1270+ }
1271+ } ) ;
9961272} ) ;
0 commit comments