11import { assert , describe , it , vi } from "@effect/vitest" ;
22import {
33 CommandId ,
4+ EventId ,
45 MessageId ,
56 ProjectId ,
67 ProviderDriverKind ,
78 ProviderInstanceId ,
9+ ProviderThreadId ,
10+ RunId ,
811 ThreadId ,
912} from "@t3tools/contracts" ;
13+ import * as DateTime from "effect/DateTime" ;
1014import * as Effect from "effect/Effect" ;
1115import * as Layer from "effect/Layer" ;
1216import * as Option from "effect/Option" ;
@@ -18,6 +22,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts";
1822import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts" ;
1923import * as CommandReceiptStore from "./CommandReceiptStore.ts" ;
2024import * as EffectOutbox from "./EffectOutbox.ts" ;
25+ import * as EventSink from "./EventSink.ts" ;
2126import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts" ;
2227import * as ThreadManagement from "./ThreadManagementService.ts" ;
2328import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts" ;
@@ -93,6 +98,7 @@ function makeHarness(
9398 ) ;
9499 return {
95100 layer : Layer . mergeAll (
101+ layerOrchestrator ,
96102 layerThreadManagement ,
97103 layerTitleRegeneration ,
98104 layerOutbox ,
@@ -146,6 +152,95 @@ function dispatchUserMessage(input: {
146152 } ) ;
147153}
148154
155+ /** Records a finished source run with one exchange, as a provider turn would. */
156+ function recordCompletedRun ( input : {
157+ readonly threadId : ThreadId ;
158+ readonly runId : RunId ;
159+ readonly ordinal : number ;
160+ readonly user : string ;
161+ readonly assistant : string ;
162+ } ) {
163+ return Effect . gen ( function * ( ) {
164+ const eventSink = yield * EventSink . EventSinkV2 ;
165+ const now = yield * DateTime . now ;
166+ const providerThreadId = ProviderThreadId . make ( `${ input . threadId } :provider` ) ;
167+ const message = ( role : "user" | "assistant" , text : string ) => ( {
168+ id : EventId . make ( `${ input . runId } :${ role } ` ) ,
169+ type : "message.updated" as const ,
170+ threadId : input . threadId ,
171+ runId : input . runId ,
172+ occurredAt : now ,
173+ payload : {
174+ id : MessageId . make ( `${ input . runId } :${ role } ` ) ,
175+ threadId : input . threadId ,
176+ runId : input . runId ,
177+ nodeId : null ,
178+ role,
179+ text,
180+ attachments : [ ] ,
181+ streaming : false ,
182+ createdBy : "user" as const ,
183+ creationSource : "web" as const ,
184+ createdAt : now ,
185+ updatedAt : now ,
186+ } ,
187+ } ) ;
188+ yield * eventSink . write ( {
189+ events : [
190+ {
191+ id : EventId . make ( `${ input . runId } :created` ) ,
192+ type : "run.created" ,
193+ threadId : input . threadId ,
194+ runId : input . runId ,
195+ occurredAt : now ,
196+ payload : {
197+ id : input . runId ,
198+ threadId : input . threadId ,
199+ ordinal : input . ordinal ,
200+ providerInstanceId : modelSelection . instanceId ,
201+ modelSelection,
202+ providerThreadId,
203+ userMessageId : MessageId . make ( `${ input . runId } :user` ) ,
204+ rootNodeId : null ,
205+ activeAttemptId : null ,
206+ status : "completed" ,
207+ requestedAt : now ,
208+ startedAt : now ,
209+ completedAt : now ,
210+ checkpointId : null ,
211+ contextHandoffId : null ,
212+ } ,
213+ } ,
214+ {
215+ id : EventId . make ( `${ input . runId } :provider-thread` ) ,
216+ type : "provider-thread.updated" ,
217+ threadId : input . threadId ,
218+ occurredAt : now ,
219+ payload : {
220+ id : providerThreadId ,
221+ driver : adapter . driver ,
222+ providerInstanceId : modelSelection . instanceId ,
223+ providerSessionId : null ,
224+ appThreadId : input . threadId ,
225+ ownerNodeId : null ,
226+ nativeThreadRef : { driver : adapter . driver , nativeId : "native" , strength : "strong" } ,
227+ nativeConversationHeadRef : null ,
228+ status : "idle" ,
229+ firstRunOrdinal : 1 ,
230+ lastRunOrdinal : input . ordinal ,
231+ handoffIds : [ ] ,
232+ forkedFrom : null ,
233+ createdAt : now ,
234+ updatedAt : now ,
235+ } ,
236+ } ,
237+ message ( "user" , input . user ) ,
238+ message ( "assistant" , input . assistant ) ,
239+ ] ,
240+ } ) ;
241+ } ) ;
242+ }
243+
149244function armRegeneration ( input : { readonly command : string ; readonly threadId : ThreadId } ) {
150245 return Effect . gen ( function * ( ) {
151246 const threads = yield * ThreadManagement . ThreadManagementService ;
@@ -278,6 +373,75 @@ describe("ThreadTitleRegenerationService", () => {
278373 } ) ,
279374 ) ;
280375
376+ it . effect ( "titles a fork's first message with the source history up to the fork point" , ( ) =>
377+ Effect . gen ( function * ( ) {
378+ const harness = makeHarness ( ) ;
379+ yield * Effect . gen ( function * ( ) {
380+ const threads = yield * ThreadManagement . ThreadManagementService ;
381+ const titleRegeneration = yield * ThreadTitleRegeneration . ThreadTitleRegenerationService ;
382+ const sourceThreadId = yield * createThread ( {
383+ command : "command:title:fork:create" ,
384+ thread : "thread:title:fork:source" ,
385+ } ) ;
386+ const forkRunId = RunId . make ( "run:title:fork:1" ) ;
387+ yield * recordCompletedRun ( {
388+ threadId : sourceThreadId ,
389+ runId : forkRunId ,
390+ ordinal : 1 ,
391+ user : "Move session storage from Redis to Postgres" ,
392+ assistant : "Sessions now persist in the Postgres sessions table." ,
393+ } ) ;
394+ yield * recordCompletedRun ( {
395+ threadId : sourceThreadId ,
396+ runId : RunId . make ( "run:title:fork:2" ) ,
397+ ordinal : 2 ,
398+ user : "Later work the fork never saw" ,
399+ assistant : "Done." ,
400+ } ) ;
401+ const forkThreadId = ThreadId . make ( "thread:title:fork:target" ) ;
402+ yield * threads . dispatch ( {
403+ type : "thread.fork" ,
404+ commandId : CommandId . make ( "command:title:fork:fork" ) ,
405+ sourceThreadId,
406+ targetThreadId : forkThreadId ,
407+ sourcePoint : { type : "run" , runId : forkRunId } ,
408+ createdBy : "user" ,
409+ creationSource : "web" ,
410+ } ) ;
411+ const requestId = CommandId . make ( "command:title:fork:message" ) ;
412+ const messageId = MessageId . make ( "command:title:fork:message:message" ) ;
413+ yield * threads . dispatch ( {
414+ type : "message.dispatch" ,
415+ commandId : requestId ,
416+ threadId : forkThreadId ,
417+ messageId,
418+ text : "Try SQLite instead" ,
419+ titleSeed : "Try SQLite instead" ,
420+ attachments : [ ] ,
421+ modelSelection,
422+ dispatchMode : { type : "defer_start" } ,
423+ createdBy : "user" ,
424+ creationSource : "web" ,
425+ } ) ;
426+
427+ yield * titleRegeneration . execute ( {
428+ threadId : forkThreadId ,
429+ requestId,
430+ kind : { type : "initial" , messageId } ,
431+ } ) ;
432+
433+ const call = harness . generateThreadTitle . mock . calls [ 0 ] ?. [ 0 ] ;
434+ assert . equal ( call ?. message , "Try SQLite instead" ) ;
435+ assert . equal ( call ?. forkSource ?. title , "Seed title" ) ;
436+ assert . include ( call ?. forkSource ?. history , "Move session storage from Redis to Postgres" ) ;
437+ assert . include ( call ?. forkSource ?. history , "Sessions now persist in the Postgres" ) ;
438+ assert . notInclude ( call ?. forkSource ?. history , "Later work the fork never saw" ) ;
439+ const fork = yield * threads . getThreadProjection ( forkThreadId ) ;
440+ assert . equal ( fork . thread . title , "Generated title" ) ;
441+ } ) . pipe ( Effect . provide ( harness . layer ) ) ;
442+ } ) ,
443+ ) ;
444+
281445 it . effect ( "clears the marker and keeps the title when generation fails" , ( ) =>
282446 Effect . gen ( function * ( ) {
283447 const harness = makeHarness ( {
0 commit comments