@@ -7,7 +7,13 @@ import {
77 type AgentBootstrapHookContext ,
88} from "../hooks/internal-hooks.js" ;
99import { makeTempWorkspace } from "../test-helpers/workspace.js" ;
10- import { resolveBootstrapContextForRun , resolveBootstrapFilesForRun } from "./bootstrap-files.js" ;
10+ import {
11+ FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE ,
12+ hasCompletedBootstrapTurn ,
13+ resolveBootstrapContextForRun ,
14+ resolveBootstrapFilesForRun ,
15+ resolveContextInjectionMode ,
16+ } from "./bootstrap-files.js" ;
1117import type { WorkspaceBootstrapFile } from "./workspace.js" ;
1218
1319function registerExtraBootstrapFileHook ( ) {
@@ -127,3 +133,181 @@ describe("resolveBootstrapContextForRun", () => {
127133 expect ( files ) . toEqual ( [ ] ) ;
128134 } ) ;
129135} ) ;
136+
137+ describe ( "hasCompletedBootstrapTurn" , ( ) => {
138+ let tmpDir : string ;
139+
140+ beforeEach ( async ( ) => {
141+ tmpDir = await fs . mkdtemp ( path . join ( await fs . realpath ( "/tmp" ) , "openclaw-bootstrap-turn-" ) ) ;
142+ } ) ;
143+
144+ afterEach ( async ( ) => {
145+ await fs . rm ( tmpDir , { recursive : true , force : true } ) ;
146+ } ) ;
147+
148+ it ( "returns false when session file does not exist" , async ( ) => {
149+ expect ( await hasCompletedBootstrapTurn ( path . join ( tmpDir , "missing.jsonl" ) ) ) . toBe ( false ) ;
150+ } ) ;
151+
152+ it ( "returns false for empty session files" , async ( ) => {
153+ const sessionFile = path . join ( tmpDir , "empty.jsonl" ) ;
154+ await fs . writeFile ( sessionFile , "" , "utf8" ) ;
155+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( false ) ;
156+ } ) ;
157+
158+ it ( "returns false for header-only session files" , async ( ) => {
159+ const sessionFile = path . join ( tmpDir , "header-only.jsonl" ) ;
160+ await fs . writeFile ( sessionFile , `${ JSON . stringify ( { type : "session" , id : "s1" } ) } \n` , "utf8" ) ;
161+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( false ) ;
162+ } ) ;
163+
164+ it ( "returns false when no assistant turn has been flushed yet" , async ( ) => {
165+ const sessionFile = path . join ( tmpDir , "user-only.jsonl" ) ;
166+ await fs . writeFile (
167+ sessionFile ,
168+ [
169+ JSON . stringify ( { type : "session" , id : "s1" } ) ,
170+ JSON . stringify ( { type : "message" , message : { role : "user" , content : "hello" } } ) ,
171+ ] . join ( "\n" ) + "\n" ,
172+ "utf8" ,
173+ ) ;
174+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( false ) ;
175+ } ) ;
176+
177+ it ( "returns false for assistant turns without a recorded full bootstrap marker" , async ( ) => {
178+ const sessionFile = path . join ( tmpDir , "assistant-no-marker.jsonl" ) ;
179+ await fs . writeFile (
180+ sessionFile ,
181+ [
182+ JSON . stringify ( { type : "session" , id : "s1" } ) ,
183+ JSON . stringify ( { type : "message" , message : { role : "user" , content : "hello" } } ) ,
184+ JSON . stringify ( { type : "message" , message : { role : "assistant" , content : "hi" } } ) ,
185+ ] . join ( "\n" ) + "\n" ,
186+ "utf8" ,
187+ ) ;
188+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( false ) ;
189+ } ) ;
190+
191+ it ( "returns true when a full bootstrap completion marker exists" , async ( ) => {
192+ const sessionFile = path . join ( tmpDir , "full-bootstrap.jsonl" ) ;
193+ await fs . writeFile (
194+ sessionFile ,
195+ [
196+ JSON . stringify ( { type : "message" , message : { role : "assistant" , content : "hi" } } ) ,
197+ JSON . stringify ( {
198+ type : "custom" ,
199+ customType : FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE ,
200+ data : { timestamp : 1 } ,
201+ } ) ,
202+ ] . join ( "\n" ) + "\n" ,
203+ "utf8" ,
204+ ) ;
205+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( true ) ;
206+ } ) ;
207+
208+ it ( "returns false when compaction happened after the last assistant turn" , async ( ) => {
209+ const sessionFile = path . join ( tmpDir , "post-compaction.jsonl" ) ;
210+ await fs . writeFile (
211+ sessionFile ,
212+ [
213+ JSON . stringify ( {
214+ type : "custom" ,
215+ customType : FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE ,
216+ data : { timestamp : 1 } ,
217+ } ) ,
218+ JSON . stringify ( { type : "compaction" , summary : "trimmed" } ) ,
219+ ] . join ( "\n" ) + "\n" ,
220+ "utf8" ,
221+ ) ;
222+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( false ) ;
223+ } ) ;
224+
225+ it ( "returns true when a later full bootstrap marker happens after compaction" , async ( ) => {
226+ const sessionFile = path . join ( tmpDir , "assistant-after-compaction.jsonl" ) ;
227+ await fs . writeFile (
228+ sessionFile ,
229+ [
230+ JSON . stringify ( {
231+ type : "custom" ,
232+ customType : FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE ,
233+ data : { timestamp : 1 } ,
234+ } ) ,
235+ JSON . stringify ( { type : "compaction" , summary : "trimmed" } ) ,
236+ JSON . stringify ( { type : "message" , message : { role : "user" , content : "new ask" } } ) ,
237+ JSON . stringify ( { type : "message" , message : { role : "assistant" , content : "new reply" } } ) ,
238+ JSON . stringify ( {
239+ type : "custom" ,
240+ customType : FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE ,
241+ data : { timestamp : 2 } ,
242+ } ) ,
243+ ] . join ( "\n" ) + "\n" ,
244+ "utf8" ,
245+ ) ;
246+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( true ) ;
247+ } ) ;
248+
249+ it ( "ignores malformed JSON lines" , async ( ) => {
250+ const sessionFile = path . join ( tmpDir , "malformed.jsonl" ) ;
251+ await fs . writeFile (
252+ sessionFile ,
253+ [
254+ "{broken" ,
255+ JSON . stringify ( {
256+ type : "custom" ,
257+ customType : FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE ,
258+ data : { timestamp : 1 } ,
259+ } ) ,
260+ ] . join ( "\n" ) + "\n" ,
261+ "utf8" ,
262+ ) ;
263+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( true ) ;
264+ } ) ;
265+
266+ it ( "finds a recent full bootstrap marker even when the scan starts mid-file" , async ( ) => {
267+ const sessionFile = path . join ( tmpDir , "large-prefix.jsonl" ) ;
268+ const hugePrefix = "x" . repeat ( 300 * 1024 ) ;
269+ await fs . writeFile (
270+ sessionFile ,
271+ [
272+ JSON . stringify ( { type : "message" , message : { role : "user" , content : hugePrefix } } ) ,
273+ JSON . stringify ( {
274+ type : "custom" ,
275+ customType : FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE ,
276+ data : { timestamp : 1 } ,
277+ } ) ,
278+ ] . join ( "\n" ) + "\n" ,
279+ "utf8" ,
280+ ) ;
281+ expect ( await hasCompletedBootstrapTurn ( sessionFile ) ) . toBe ( true ) ;
282+ } ) ;
283+
284+ it ( "returns false for symbolic links" , async ( ) => {
285+ const realFile = path . join ( tmpDir , "real.jsonl" ) ;
286+ const linkFile = path . join ( tmpDir , "link.jsonl" ) ;
287+ await fs . writeFile (
288+ realFile ,
289+ `${ JSON . stringify ( { type : "custom" , customType : FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE , data : { timestamp : 1 } } ) } \n` ,
290+ "utf8" ,
291+ ) ;
292+ await fs . symlink ( realFile , linkFile ) ;
293+ expect ( await hasCompletedBootstrapTurn ( linkFile ) ) . toBe ( false ) ;
294+ } ) ;
295+ } ) ;
296+
297+ describe ( "resolveContextInjectionMode" , ( ) => {
298+ it ( "defaults to always when config is missing" , ( ) => {
299+ expect ( resolveContextInjectionMode ( undefined ) ) . toBe ( "always" ) ;
300+ } ) ;
301+
302+ it ( "defaults to always when the setting is omitted" , ( ) => {
303+ expect ( resolveContextInjectionMode ( { agents : { defaults : { } } } as never ) ) . toBe ( "always" ) ;
304+ } ) ;
305+
306+ it ( "returns the configured continuation-skip mode" , ( ) => {
307+ expect (
308+ resolveContextInjectionMode ( {
309+ agents : { defaults : { contextInjection : "continuation-skip" } } ,
310+ } as never ) ,
311+ ) . toBe ( "continuation-skip" ) ;
312+ } ) ;
313+ } ) ;
0 commit comments