1import { assert, describe, it, vi } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 CommandId,
5 ChatAttachmentId,
6 type ChatAttachment,
7 EventId,
8 MessageId,
9 ProjectId,
10 ProviderDriverKind,
11 ProviderInstanceId,
12 ProviderThreadId,
13 RunId,
14 TextGenerationError,
15 ThreadId,
16} from "@t3tools/contracts";
17import * as DateTime from "effect/DateTime";
18import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Option from "effect/Option";
22import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences";
23
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as ProjectStore from "./ProjectStore.ts";
26import * as ServerSettings from "../serverSettings.ts";
27import * as TextGeneration from "../textGeneration/TextGeneration.ts";
28import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
29import * as CommandReceiptStore from "./CommandReceiptStore.ts";
30import * as EffectOutbox from "./EffectOutbox.ts";
31import * as EventSink from "./EventSink.ts";
32import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
33import * as ThreadManagement from "./ThreadManagementService.ts";
34import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts";
35import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
36import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
37
38const projectId = ProjectId.make("project:title-regeneration");
39const modelSelection = {
40 instanceId: ProviderInstanceId.make("codex"),
41 model: "gpt-5.1-codex",
42} as const;
43
44const adapter = {
45 instanceId: modelSelection.instanceId,
46 driver: ProviderDriverKind.make("codex"),
47 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
48 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
49 openSession: () => Effect.die("provider execution is disabled in title regeneration tests"),
50} as ProviderAdapter.ProviderAdapterV2["Service"];
51
52function makeHarness(
53 options: {
54 readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"];
55 } = {},
56) {
57 const layerDatabase = SqlitePersistence.layerMemory;
58 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]);
59 const layerOrchestrator = ProviderReplayHarness.layerWithRegistry(
60 { name: "thread-title-regeneration" },
61 layerRegistry,
62 { databaseLayer: layerDatabase, runEffectWorker: false },
63 );
64 const layerServerSettings = ServerSettings.layerTest({});
65 const layerThreadManagement = ThreadManagement.layer.pipe(
66 Layer.provide(layerOrchestrator),
67 Layer.provide(layerServerSettings),
68 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
69 );
70 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
71 const generateThreadTitle = vi.fn(
72 options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })),
73 );
74 const layerProjectedProjects = Layer.mock(ProjectStore.ProjectStoreV2)({
75 get: (requestedProjectId) =>
76 Effect.succeed(
77 requestedProjectId === projectId
78 ? Option.some({
79 projectId,
80 title: "Project",
81 workspaceRoot: "/repo",
82 defaultModelSelection: modelSelection,
83 defaultThreadEnvMode: null,
84 hasCustomTitle: false,
85 autoPull: false,
86 faviconPath: null,
87 projectIcon: null,
88 scripts: [],
89 pinnedAt: null,
90 archivedAt: null,
91 createdAt: "2026-06-20T00:00:00.000Z",
92 updatedAt: "2026-06-20T00:00:00.000Z",
93 deletedAt: null,
94 })
95 : Option.none(),
96 ),
97 });
98 const layerTitleRegeneration = ThreadTitleRegeneration.layer.pipe(
99 Layer.provide(
100 Layer.mergeAll(
101 layerThreadManagement,
102 layerProjectedProjects,
103 Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }),
104 layerServerSettings,
105 NodeServices.layer,
106 ),
107 ),
108 );
109 return {
110 layer: Layer.mergeAll(
111 layerOrchestrator,
112 layerThreadManagement,
113 layerTitleRegeneration,
114 layerOutbox,
115 layerDatabase,
116 ),
117 generateThreadTitle,
118 };
119}
120
121function createThread(input: {
122 readonly command: string;
123 readonly thread: string;
124 readonly worktreePath?: string;
125}) {
126 return Effect.gen(function* () {
127 const threads = yield* ThreadManagement.ThreadManagementService;
128 const threadId = ThreadId.make(input.thread);
129 yield* threads.dispatch({
130 type: "thread.create",
131 commandId: CommandId.make(input.command),
132 threadId,
133 projectId,
134 title: "Seed title",
135 modelSelection,
136 runtimeMode: "full-access",
137 interactionMode: "default",
138 branch: null,
139 worktreePath: input.worktreePath ?? null,
140 createdBy: "user",
141 creationSource: "web",
142 });
143 return threadId;
144 });
145}
146
147function dispatchUserMessage(input: {
148 readonly command: string;
149 readonly threadId: ThreadId;
150 readonly text: string;
151 readonly attachments?: ReadonlyArray<ChatAttachment>;
152}) {
153 return Effect.gen(function* () {
154 const threads = yield* ThreadManagement.ThreadManagementService;
155 yield* threads.dispatch({
156 type: "message.dispatch",
157 commandId: CommandId.make(input.command),
158 threadId: input.threadId,
159 messageId: MessageId.make(`${input.command}:message`),
160 text: input.text,
161 attachments: input.attachments ?? [],
162 modelSelection,
163 dispatchMode: { type: "defer_start" },
164 createdBy: "user",
165 creationSource: "web",
166 });
167 });
168}
169
170/** Records a settled source run with one exchange, as a provider turn would. */
171function recordRun(input: {
172 readonly threadId: ThreadId;
173 readonly runId: RunId;
174 readonly ordinal: number;
175 readonly user: string;
176 readonly assistant: string;
177 readonly status?: "completed" | "rolled_back";
178}) {
179 return Effect.gen(function* () {
180 const eventSink = yield* EventSink.EventSinkV2;
181 const now = yield* DateTime.now;
182 const providerThreadId = ProviderThreadId.make(`${input.threadId}:provider`);
183 const message = (role: "user" | "assistant", text: string) => ({
184 id: EventId.make(`${input.runId}:${role}`),
185 type: "message.updated" as const,
186 threadId: input.threadId,
187 runId: input.runId,
188 occurredAt: now,
189 payload: {
190 id: MessageId.make(`${input.runId}:${role}`),
191 threadId: input.threadId,
192 runId: input.runId,
193 nodeId: null,
194 role,
195 text,
196 attachments: [],
197 streaming: false,
198 createdBy: "user" as const,
199 creationSource: "web" as const,
200 createdAt: now,
201 updatedAt: now,
202 },
203 });
204 yield* eventSink.write({
205 events: [
206 {
207 id: EventId.make(`${input.runId}:created`),
208 type: "run.created",
209 threadId: input.threadId,
210 runId: input.runId,
211 occurredAt: now,
212 payload: {
213 id: input.runId,
214 threadId: input.threadId,
215 ordinal: input.ordinal,
216 providerInstanceId: modelSelection.instanceId,
217 modelSelection,
218 providerThreadId,
219 userMessageId: MessageId.make(`${input.runId}:user`),
220 rootNodeId: null,
221 activeAttemptId: null,
222 status: input.status ?? "completed",
223 requestedAt: now,
224 startedAt: now,
225 completedAt: now,
226 checkpointId: null,
227 contextHandoffId: null,
228 },
229 },
230 {
231 id: EventId.make(`${input.runId}:provider-thread`),
232 type: "provider-thread.updated",
233 threadId: input.threadId,
234 occurredAt: now,
235 payload: {
236 id: providerThreadId,
237 driver: adapter.driver,
238 providerInstanceId: modelSelection.instanceId,
239 providerSessionId: null,
240 appThreadId: input.threadId,
241 ownerNodeId: null,
242 nativeThreadRef: { driver: adapter.driver, nativeId: "native", strength: "strong" },
243 nativeConversationHeadRef: null,
244 status: "idle",
245 firstRunOrdinal: 1,
246 lastRunOrdinal: input.ordinal,
247 handoffIds: [],
248 forkedFrom: null,
249 createdAt: now,
250 updatedAt: now,
251 },
252 },
253 message("user", input.user),
254 message("assistant", input.assistant),
255 ],
256 });
257 });
258}
259
260function armRegeneration(input: { readonly command: string; readonly threadId: ThreadId }) {
261 return Effect.gen(function* () {
262 const threads = yield* ThreadManagement.ThreadManagementService;
263 const requestId = CommandId.make(input.command);
264 yield* threads.dispatch({
265 type: "thread.metadata.update",
266 commandId: requestId,
267 threadId: input.threadId,
268 regenerateTitle: true,
269 });
270 return requestId;
271 });
272}
273
274describe("formatThreadTitleContext", () => {
275 it("pins the first user message ahead of the retained tail once content stops fitting", () => {
276 const context = ThreadTitleRegeneration.formatThreadTitleContext([
277 { role: "user", text: `Ancient context that anchors the topic ${"x".repeat(600)}` },
278 { role: "assistant", text: "y".repeat(6_000) },
279 { role: "user", text: "z".repeat(1_500) },
280 ]);
281 assert.isTrue(context.message.includes("USER:\nAncient context that anchors the topic"));
282 assert.isTrue(context.message.includes("[Earlier content truncated]\n\n"));
283 assert.isTrue(context.message.includes("y".repeat(100)));
284 });
285});
286
287describe("ThreadTitleRegenerationService", () => {
288 it.effect(
289 "titles an image-only first message without sending bootstrap text to the title model",
290 () =>
291 Effect.gen(function* () {
292 for (const prefix of ["", "Ultrathink:\n"]) {
293 const harness = makeHarness();
294 yield* Effect.gen(function* () {
295 const threads = yield* ThreadManagement.ThreadManagementService;
296 const service = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
297 const threadId = yield* createThread({
298 command: "image:create",
299 thread: "image:thread",
300 });
301 const text = prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT;
302 const attachments = [
303 {
304 type: "image" as const,
305 id: ChatAttachmentId.make("diagram"),
306 name: "diagram.png",
307 mimeType: "image/png",
308 sizeBytes: 10,
309 },
310 ];
311 yield* dispatchUserMessage({ command: "image:message", threadId, text, attachments });
312 const requestId = yield* armRegeneration({ command: "image:title", threadId });
313 const messageId = MessageId.make("image:message:message");
314 yield* service.execute({ threadId, requestId, kind: { type: "initial", messageId } });
315 const call = harness.generateThreadTitle.mock.calls[0]?.[0];
316 assert.equal(call?.message, "");
317 assert.deepEqual(call?.attachments, attachments);
318 const projection = yield* threads.getThreadProjection(threadId);
319 assert.equal(projection.messages[0]?.text, text);
320 assert.equal(projection.thread.title, "Generated title");
321 }).pipe(Effect.provide(harness.layer));
322 }
323 }),
324 );
325
326 it.effect("arms and clears the regeneration marker through metadata commands", () =>
327 Effect.gen(function* () {
328 const harness = makeHarness();
329 yield* Effect.gen(function* () {
330 const threads = yield* ThreadManagement.ThreadManagementService;
331 const outbox = yield* EffectOutbox.EffectOutboxV2;
332 const threadId = yield* createThread({
333 command: "command:title:arm:create",
334 thread: "thread:title:arm",
335 });
336
337 const firstRequest = yield* armRegeneration({ command: "command:title:arm:1", threadId });
338 const armed = yield* threads.getThreadProjection(threadId);
339 assert.equal(armed.thread.titleRegeneration?.requestId, firstRequest);
340 assert.deepEqual(
341 (yield* outbox.listByCommandId(firstRequest)).map((effect) => effect.request),
342 [{ type: "thread-title.generate", kind: { type: "regenerate" } }],
343 );
344
345 yield* threads.dispatch({
346 type: "thread.metadata.update",
347 commandId: CommandId.make("command:title:arm:abandon"),
348 threadId,
349 regenerateTitle: false,
350 });
351 const abandoned = yield* threads.getThreadProjection(threadId);
352 assert.isNotOk(abandoned.thread.titleRegeneration);
353
354 yield* armRegeneration({ command: "command:title:arm:2", threadId });
355 yield* threads.dispatch({
356 type: "thread.metadata.update",
357 commandId: CommandId.make("command:title:arm:rename"),
358 threadId,
359 title: "Manual title",
360 });
361 const renamed = yield* threads.getThreadProjection(threadId);
362 assert.equal(renamed.thread.title, "Manual title");
363 assert.isNotOk(renamed.thread.titleRegeneration);
364 }).pipe(Effect.provide(harness.layer));
365 }),
366 );
367
368 it.effect("skips execution when the marker was superseded by a newer request", () =>
369 Effect.gen(function* () {
370 const harness = makeHarness();
371 yield* Effect.gen(function* () {
372 const threads = yield* ThreadManagement.ThreadManagementService;
373 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
374 const threadId = yield* createThread({
375 command: "command:title:stale:create",
376 thread: "thread:title:stale",
377 });
378 const staleRequest = yield* armRegeneration({ command: "command:title:stale:1", threadId });
379 const currentRequest = yield* armRegeneration({
380 command: "command:title:stale:2",
381 threadId,
382 });
383
384 yield* titleRegeneration.execute({
385 threadId,
386 requestId: staleRequest,
387 kind: { type: "regenerate" },
388 });
389
390 assert.equal(harness.generateThreadTitle.mock.calls.length, 0);
391 const projection = yield* threads.getThreadProjection(threadId);
392 assert.equal(projection.thread.title, "Seed title");
393 assert.equal(projection.thread.titleRegeneration?.requestId, currentRequest);
394 }).pipe(Effect.provide(harness.layer));
395 }),
396 );
397
398 it.effect("lands the regenerated title from the conversation digest", () =>
399 Effect.gen(function* () {
400 const harness = makeHarness({
401 generateTitle: () => Effect.succeed({ title: "Fresh title" }),
402 });
403 const fs = yield* FileSystem.FileSystem;
404 const existingWorktree = yield* fs.makeTempDirectoryScoped({ prefix: "title-worktree-" });
405 yield* Effect.gen(function* () {
406 const threads = yield* ThreadManagement.ThreadManagementService;
407 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
408 const cases = [
409 { name: "removed", worktreePath: "/repo-worktrees/removed-by-cleanup", cwd: "/repo" },
410 { name: "existing", worktreePath: existingWorktree, cwd: existingWorktree },
411 ];
412 for (const [index, testCase] of cases.entries()) {
413 const threadId = yield* createThread({
414 command: `command:title:landing:${testCase.name}:create`,
415 thread: `thread:title:landing:${testCase.name}`,
416 worktreePath: testCase.worktreePath,
417 });
418 yield* dispatchUserMessage({
419 command: `command:title:landing:${testCase.name}:message`,
420 threadId,
421 text: "Investigate the flaky login test",
422 });
423 const requestId = yield* armRegeneration({
424 command: `command:title:landing:${testCase.name}:1`,
425 threadId,
426 });
427
428 yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
429
430 const call = harness.generateThreadTitle.mock.calls[index]?.[0];
431 assert.equal(call?.previousTitle, "Seed title");
432 assert.equal(call?.cwd, testCase.cwd);
433 assert.include(call?.message, "USER:\nInvestigate the flaky login test");
434 const projection = yield* threads.getThreadProjection(threadId);
435 assert.equal(projection.thread.title, "Fresh title");
436 assert.isNotOk(projection.thread.titleRegeneration);
437 assert.deepInclude(projection.thread.lastTitleRegeneration, {
438 requestId,
439 status: "renamed",
440 });
441 }
442 }).pipe(Effect.provide(harness.layer));
443 }).pipe(Effect.provide(NodeServices.layer)),
444 );
445
446 it.effect("titles a fork's first message with the source history up to the fork point", () =>
447 Effect.gen(function* () {
448 const harness = makeHarness();
449 yield* Effect.gen(function* () {
450 const threads = yield* ThreadManagement.ThreadManagementService;
451 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
452 const sourceThreadId = yield* createThread({
453 command: "command:title:fork:create",
454 thread: "thread:title:fork:source",
455 });
456 yield* recordRun({
457 threadId: sourceThreadId,
458 runId: RunId.make("run:title:fork:0"),
459 ordinal: 1,
460 user: "Rolled back attempt",
461 assistant: "Discarded.",
462 status: "rolled_back",
463 });
464 const forkRunId = RunId.make("run:title:fork:1");
465 yield* recordRun({
466 threadId: sourceThreadId,
467 runId: forkRunId,
468 ordinal: 2,
469 user: "Move session storage from Redis to Postgres",
470 assistant: "Sessions now persist in the Postgres sessions table.",
471 });
472 yield* recordRun({
473 threadId: sourceThreadId,
474 runId: RunId.make("run:title:fork:2"),
475 ordinal: 3,
476 user: "Later work the fork never saw",
477 assistant: "Done.",
478 });
479 const forkThreadId = ThreadId.make("thread:title:fork:target");
480 yield* threads.dispatch({
481 type: "thread.fork",
482 commandId: CommandId.make("command:title:fork:fork"),
483 sourceThreadId,
484 targetThreadId: forkThreadId,
485 sourcePoint: { type: "run", runId: forkRunId },
486 createdBy: "user",
487 creationSource: "web",
488 });
489 const requestId = CommandId.make("command:title:fork:message");
490 const messageId = MessageId.make("command:title:fork:message:message");
491 yield* threads.dispatch({
492 type: "message.dispatch",
493 commandId: requestId,
494 threadId: forkThreadId,
495 messageId,
496 text: "Try SQLite instead",
497 titleSeed: "Try SQLite instead",
498 attachments: [],
499 modelSelection,
500 dispatchMode: { type: "defer_start" },
501 createdBy: "user",
502 creationSource: "web",
503 });
504
505 yield* titleRegeneration.execute({
506 threadId: forkThreadId,
507 requestId,
508 kind: { type: "initial", messageId },
509 });
510
511 const call = harness.generateThreadTitle.mock.calls[0]?.[0];
512 assert.equal(call?.message, "Try SQLite instead");
513 assert.equal(call?.forkSource?.title, "Seed title");
514 assert.include(call?.forkSource?.history, "Move session storage from Redis to Postgres");
515 assert.include(call?.forkSource?.history, "Sessions now persist in the Postgres");
516 assert.notInclude(call?.forkSource?.history, "Later work the fork never saw");
517 assert.notInclude(call?.forkSource?.history, "Rolled back attempt");
518 const fork = yield* threads.getThreadProjection(forkThreadId);
519 assert.equal(fork.thread.title, "Generated title");
520 }).pipe(Effect.provide(harness.layer));
521 }),
522 );
523
524 it.effect("clears the marker and keeps the title when generation fails", () =>
525 Effect.gen(function* () {
526 const harness = makeHarness({
527 generateTitle: () =>
528 Effect.fail(
529 new TextGenerationError({
530 operation: "generateThreadTitle",
531 detail:
532 "Codex CLI command failed: OpenAI Codex\nuser\nConversation text\nmodel unavailable\n",
533 }),
534 ),
535 });
536 yield* Effect.gen(function* () {
537 const threads = yield* ThreadManagement.ThreadManagementService;
538 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
539 const threadId = yield* createThread({
540 command: "command:title:failure:create",
541 thread: "thread:title:failure",
542 });
543 yield* dispatchUserMessage({
544 command: "command:title:failure:message",
545 threadId,
546 text: "Some conversation",
547 });
548 const requestId = yield* armRegeneration({ command: "command:title:failure:1", threadId });
549
550 yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
551
552 assert.equal(harness.generateThreadTitle.mock.calls.length, 1);
553 const projection = yield* threads.getThreadProjection(threadId);
554 assert.equal(projection.thread.title, "Seed title");
555 assert.isNotOk(projection.thread.titleRegeneration);
556 assert.deepInclude(projection.thread.lastTitleRegeneration, {
557 requestId,
558 status: "failed",
559 message: "model unavailable",
560 });
561 }).pipe(Effect.provide(harness.layer));
562 }),
563 );
564
565 it.effect("records an unchanged outcome when the model keeps the current title", () =>
566 Effect.gen(function* () {
567 const harness = makeHarness({
568 generateTitle: () => Effect.succeed({ title: "Seed title" }),
569 });
570 yield* Effect.gen(function* () {
571 const threads = yield* ThreadManagement.ThreadManagementService;
572 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
573 const threadId = yield* createThread({
574 command: "command:title:unchanged:create",
575 thread: "thread:title:unchanged",
576 });
577 yield* dispatchUserMessage({
578 command: "command:title:unchanged:message",
579 threadId,
580 text: "Some conversation",
581 });
582 const requestId = yield* armRegeneration({
583 command: "command:title:unchanged:1",
584 threadId,
585 });
586
587 yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
588
589 const projection = yield* threads.getThreadProjection(threadId);
590 assert.deepInclude(projection.thread.lastTitleRegeneration, {
591 requestId,
592 status: "unchanged",
593 });
594 }).pipe(Effect.provide(harness.layer));
595 }),
596 );
597});