@@ -73,6 +73,104 @@ describe("TaskHistoryStore cross-instance safety", () => {
7373 await fs . rm ( tmpDir , { recursive : true , force : true } ) . catch ( ( ) => { } )
7474 } )
7575
76+ describe ( "setTitleIfUnset" , ( ) => {
77+ it ( "keeps a manual title that another instance wrote after this cache was loaded" , async ( ) => {
78+ await storeA . initialize ( )
79+ await storeB . initialize ( )
80+ await storeA . upsert ( makeHistoryItem ( { id : "title-task" , task : "Original prompt" } ) )
81+ await storeB . reconcile ( )
82+ expect ( storeB . get ( "title-task" ) ?. title ) . toBeUndefined ( )
83+
84+ // Instance A still caches no title when B renames the task on disk.
85+ await storeB . atomicReadAndUpdate ( "title-task" , ( item ) => ( { ...item , title : "Manual title" } ) )
86+ expect ( storeA . get ( "title-task" ) ?. title ) . toBeUndefined ( )
87+
88+ await expect ( storeA . setTitleIfUnset ( "title-task" , "Auto title" ) ) . resolves . toBe ( "conflict" )
89+
90+ expect ( storeA . get ( "title-task" ) ?. title ) . toBe ( "Manual title" )
91+ await storeB . reconcile ( )
92+ expect ( storeB . get ( "title-task" ) ?. title ) . toBe ( "Manual title" )
93+ } )
94+
95+ it ( "does not recreate a task that another instance deleted" , async ( ) => {
96+ await storeA . initialize ( )
97+ await storeB . initialize ( )
98+ await storeA . upsert ( makeHistoryItem ( { id : "title-deleted" , task : "Original prompt" } ) )
99+ await storeB . reconcile ( )
100+
101+ await storeB . delete ( "title-deleted" )
102+ expect ( storeA . get ( "title-deleted" ) ) . toBeDefined ( )
103+
104+ await expect ( storeA . setTitleIfUnset ( "title-deleted" , "Auto title" ) ) . resolves . toBe ( "conflict" )
105+
106+ expect ( storeA . get ( "title-deleted" ) ) . toBeUndefined ( )
107+ await storeB . reconcile ( )
108+ expect ( storeB . get ( "title-deleted" ) ) . toBeUndefined ( )
109+ } )
110+
111+ it ( "applies the title when no instance has set one" , async ( ) => {
112+ await storeA . initialize ( )
113+ await storeA . upsert ( makeHistoryItem ( { id : "title-free" , task : "Original prompt" } ) )
114+
115+ await expect ( storeA . setTitleIfUnset ( "title-free" , "Auto title" ) ) . resolves . toBe ( "applied" )
116+
117+ expect ( storeA . get ( "title-free" ) ) . toMatchObject ( { title : "Auto title" , task : "Original prompt" } )
118+ } )
119+
120+ it ( "persists the title to disk so reloading from storage preserves it" , async ( ) => {
121+ await storeA . initialize ( )
122+ await storeA . upsert ( makeHistoryItem ( { id : "persist-check" , task : "Original task prompt" } ) )
123+
124+ await expect ( storeA . setTitleIfUnset ( "persist-check" , "Persisted title" ) ) . resolves . toBe ( "applied" )
125+
126+ // Reload a fresh store instance pointing to the same disk directory
127+ const storeReloaded = new TaskHistoryStore ( tmpDir )
128+ await storeReloaded . initialize ( )
129+
130+ const diskItem = storeReloaded . get ( "persist-check" )
131+ expect ( diskItem ) . toBeDefined ( )
132+ expect ( diskItem ?. title ) . toBe ( "Persisted title" )
133+ expect ( diskItem ?. task ) . toBe ( "Original task prompt" )
134+ storeReloaded . dispose ( )
135+ } )
136+
137+ it ( "throws when the task is not in the cache" , async ( ) => {
138+ await storeA . initialize ( )
139+ await expect ( storeA . setTitleIfUnset ( "missing-id" , "Auto title" ) ) . rejects . toThrow ( "not found in cache" )
140+ } )
141+
142+ it ( "returns conflict immediately when cached item already has a title" , async ( ) => {
143+ await storeA . initialize ( )
144+ await storeA . upsert ( makeHistoryItem ( { id : "already-titled" , title : "Existing" , task : "Prompt" } ) )
145+ await expect ( storeA . setTitleIfUnset ( "already-titled" , "New title" ) ) . resolves . toBe ( "conflict" )
146+ } )
147+
148+ it ( "notifies onWrite callback when title is successfully applied" , async ( ) => {
149+ const onWriteSpy = vi . fn ( ) . mockResolvedValue ( undefined )
150+ const storeWithOnWrite = new TaskHistoryStore ( tmpDir , { onWrite : onWriteSpy } )
151+ await storeWithOnWrite . initialize ( )
152+ await storeWithOnWrite . upsert ( makeHistoryItem ( { id : "title-onwrite" , task : "Prompt" } ) )
153+ onWriteSpy . mockClear ( )
154+
155+ await expect ( storeWithOnWrite . setTitleIfUnset ( "title-onwrite" , "Notified title" ) ) . resolves . toBe ( "applied" )
156+ expect ( onWriteSpy ) . toHaveBeenCalledWith (
157+ expect . arrayContaining ( [ expect . objectContaining ( { id : "title-onwrite" , title : "Notified title" } ) ] ) ,
158+ )
159+ storeWithOnWrite . dispose ( )
160+ } )
161+
162+ it ( "re-throws unexpected errors from safeWriteJson" , async ( ) => {
163+ await storeA . initialize ( )
164+ await storeA . upsert ( makeHistoryItem ( { id : "title-io-err" , task : "Prompt" } ) )
165+
166+ const { safeWriteJson } = await import ( "../../../utils/safeWriteJson" )
167+ const mockedSafeWrite = vi . mocked ( safeWriteJson )
168+ mockedSafeWrite . mockRejectedValueOnce ( new Error ( "disk IO error" ) )
169+
170+ await expect ( storeA . setTitleIfUnset ( "title-io-err" , "Auto title" ) ) . rejects . toThrow ( "disk IO error" )
171+ } )
172+ } )
173+
76174 it ( "two instances can write different tasks without conflict" , async ( ) => {
77175 await storeA . initialize ( )
78176 await storeB . initialize ( )
0 commit comments