1import { assert, describe, it, vi } from "@effect/vitest";
2import {
3 type ChatAttachment,
4 CommandId,
5 MessageId,
6 ProjectId,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 ThreadId,
10 TextGenerationError,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as Deferred from "effect/Deferred";
14import * as Fiber from "effect/Fiber";
15import * as TestClock from "effect/testing/TestClock";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18
19import * as SqlitePersistence from "../persistence/Sqlite.ts";
20import * as ProjectStore from "./ProjectStore.ts";
21import * as ServerSettings from "../serverSettings.ts";
22import * as TextGeneration from "../textGeneration/TextGeneration.ts";
23import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
24import * as CommandReceiptStore from "./CommandReceiptStore.ts";
25import * as EffectOutbox from "./EffectOutbox.ts";
26import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
27import * as ThreadManagement from "./ThreadManagementService.ts";
28import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts";
29import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
30import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
31
32const projectId = ProjectId.make("project:title-regeneration");
33const modelSelection = {
34 instanceId: ProviderInstanceId.make("codex"),
35 model: "gpt-5.1-codex",
36} as const;
37
38const adapter = {
39 instanceId: modelSelection.instanceId,
40 driver: ProviderDriverKind.make("codex"),
41 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
42 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
43 openSession: () => Effect.die("provider execution is disabled in title regeneration tests"),
44} as ProviderAdapter.ProviderAdapterV2["Service"];
45
46function makeHarness(
47 options: {
48 readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"];
49 } = {},
50) {
51 const layerDatabase = SqlitePersistence.layerMemory;
52 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([adapter]);
53 const layerOrchestrator = ProviderReplayHarness.layerWithRegistry(
54 { name: "thread-title-regeneration" },
55 layerRegistry,
56 { databaseLayer: layerDatabase, runEffectWorker: false },
57 );
58 const layerServerSettings = ServerSettings.layerTest({});
59 const layerThreadManagement = ThreadManagement.layer.pipe(
60 Layer.provide(layerOrchestrator),
61 Layer.provide(layerServerSettings),
62 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
63 );
64 const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
65 const generateThreadTitle = vi.fn(
66 options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })),
67 );
68 const layerProjectedProjects = Layer.mock(ProjectStore.ProjectStoreV2)({
69 get: (requestedProjectId) =>
70 Effect.succeed(
71 requestedProjectId === projectId
72 ? Option.some({
73 projectId,
74 title: "Project",
75 workspaceRoot: "/repo",
76 defaultModelSelection: modelSelection,
77 defaultThreadEnvMode: null,
78 autoPull: false,
79 faviconPath: null,
80 projectIcon: null,
81 scripts: [],
82 createdAt: "2026-06-20T00:00:00.000Z",
83 updatedAt: "2026-06-20T00:00:00.000Z",
84 deletedAt: null,
85 })
86 : Option.none(),
87 ),
88 });
89 const layerTitleRegeneration = ThreadTitleRegeneration.layer.pipe(
90 Layer.provide(
91 Layer.mergeAll(
92 layerThreadManagement,
93 layerProjectedProjects,
94 Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }),
95 layerServerSettings,
96 ),
97 ),
98 );
99 return {
100 layer: Layer.mergeAll(
101 layerThreadManagement,
102 layerTitleRegeneration,
103 layerOutbox,
104 layerDatabase,
105 ),
106 generateThreadTitle,
107 };
108}
109
110function createThread(input: { readonly command: string; readonly thread: string }) {
111 return Effect.gen(function* () {
112 const threads = yield* ThreadManagement.ThreadManagementService;
113 const threadId = ThreadId.make(input.thread);
114 yield* threads.dispatch({
115 type: "thread.create",
116 commandId: CommandId.make(input.command),
117 threadId,
118 projectId,
119 title: "Seed title",
120 modelSelection,
121 runtimeMode: "full-access",
122 interactionMode: "default",
123 branch: null,
124 worktreePath: null,
125 createdBy: "user",
126 creationSource: "web",
127 });
128 return threadId;
129 });
130}
131
132function dispatchUserMessage(input: {
133 readonly command: string;
134 readonly threadId: ThreadId;
135 readonly text: string;
136}) {
137 return Effect.gen(function* () {
138 const threads = yield* ThreadManagement.ThreadManagementService;
139 yield* threads.dispatch({
140 type: "message.dispatch",
141 commandId: CommandId.make(input.command),
142 threadId: input.threadId,
143 messageId: MessageId.make(`${input.command}:message`),
144 text: input.text,
145 attachments: [],
146 modelSelection,
147 dispatchMode: { type: "defer_start" },
148 createdBy: "user",
149 creationSource: "web",
150 });
151 });
152}
153
154function armRegeneration(input: { readonly command: string; readonly threadId: ThreadId }) {
155 return Effect.gen(function* () {
156 const threads = yield* ThreadManagement.ThreadManagementService;
157 const requestId = CommandId.make(input.command);
158 yield* threads.dispatch({
159 type: "thread.metadata.update",
160 commandId: requestId,
161 threadId: input.threadId,
162 regenerateTitle: true,
163 });
164 return requestId;
165 });
166}
167
168describe("formatThreadTitleContext", () => {
169 const attachment = (id: string): ChatAttachment => ({
170 type: "image",
171 id,
172 name: `${id}.png`,
173 mimeType: "image/png",
174 sizeBytes: 1,
175 });
176
177 it("builds a newest-first digest, skipping system messages and empty sections", () => {
178 const context = ThreadTitleRegeneration.formatThreadTitleContext([
179 { role: "user", text: "First question" },
180 { role: "system", text: "Hidden instructions" },
181 { role: "assistant", text: "" },
182 { role: "assistant", text: "Second answer", attachments: [attachment("shot")] },
183 ]);
184 assert.equal(
185 context.message,
186 "USER:\nFirst question\n\nASSISTANT:\nSecond answer\n[Attachments: shot.png]",
187 );
188 assert.deepEqual(
189 context.attachments.map((entry) => entry.name),
190 ["shot.png"],
191 );
192 });
193
194 it("pins the first user message ahead of the retained tail once content stops fitting", () => {
195 const context = ThreadTitleRegeneration.formatThreadTitleContext([
196 { role: "user", text: `Ancient context that anchors the topic ${"x".repeat(600)}` },
197 { role: "assistant", text: "y".repeat(6_000) },
198 { role: "user", text: "z".repeat(1_500) },
199 ]);
200 assert.isTrue(context.message.includes("USER:\nAncient context that anchors the topic"));
201 assert.isTrue(context.message.includes("[Earlier content truncated]\n\n"));
202 assert.isTrue(context.message.includes("y".repeat(100)));
203 });
204
205 it("truncates an oversized pinned first user message", () => {
206 const context = ThreadTitleRegeneration.formatThreadTitleContext([
207 { role: "user", text: `Topic anchor ${"a".repeat(4_000)}` },
208 { role: "assistant", text: "y".repeat(9_000) },
209 { role: "user", text: "z".repeat(1_500) },
210 ]);
211 assert.isTrue(context.message.includes("USER:\nTopic anchor"));
212 assert.isTrue(context.message.includes("[Content truncated]"));
213 assert.isTrue(context.message.includes("[Earlier content truncated]\n\n"));
214 });
215
216 it("retains at most four attachments from the newest messages", () => {
217 const context = ThreadTitleRegeneration.formatThreadTitleContext([
218 { role: "user", text: "older", attachments: [attachment("a"), attachment("b")] },
219 {
220 role: "user",
221 text: "newer",
222 attachments: [attachment("c"), attachment("d"), attachment("e")],
223 },
224 ]);
225 assert.deepEqual(
226 context.attachments.map((entry) => entry.name),
227 ["a.png", "c.png", "d.png", "e.png"],
228 );
229 });
230});
231
232describe("ThreadTitleRegenerationService", () => {
233 it.effect("arms and clears the regeneration marker through metadata commands", () =>
234 Effect.gen(function* () {
235 const harness = makeHarness();
236 yield* Effect.gen(function* () {
237 const threads = yield* ThreadManagement.ThreadManagementService;
238 const outbox = yield* EffectOutbox.EffectOutboxV2;
239 const threadId = yield* createThread({
240 command: "command:title:arm:create",
241 thread: "thread:title:arm",
242 });
243
244 const firstRequest = yield* armRegeneration({ command: "command:title:arm:1", threadId });
245 const armed = yield* threads.getThreadProjection(threadId);
246 assert.equal(armed.thread.titleRegeneration?.requestId, firstRequest);
247 assert.deepEqual(
248 (yield* outbox.listByCommandId(firstRequest)).map((effect) => effect.request),
249 [{ type: "thread-title.generate", kind: { type: "regenerate" } }],
250 );
251
252 yield* threads.dispatch({
253 type: "thread.metadata.update",
254 commandId: CommandId.make("command:title:arm:abandon"),
255 threadId,
256 regenerateTitle: false,
257 });
258 const abandoned = yield* threads.getThreadProjection(threadId);
259 assert.isNotOk(abandoned.thread.titleRegeneration);
260
261 yield* armRegeneration({ command: "command:title:arm:2", threadId });
262 yield* threads.dispatch({
263 type: "thread.metadata.update",
264 commandId: CommandId.make("command:title:arm:rename"),
265 threadId,
266 title: "Manual title",
267 });
268 const renamed = yield* threads.getThreadProjection(threadId);
269 assert.equal(renamed.thread.title, "Manual title");
270 assert.isNotOk(renamed.thread.titleRegeneration);
271 }).pipe(Effect.provide(harness.layer));
272 }),
273 );
274
275 it.effect("skips execution when the marker was superseded by a newer request", () =>
276 Effect.gen(function* () {
277 const harness = makeHarness();
278 yield* Effect.gen(function* () {
279 const threads = yield* ThreadManagement.ThreadManagementService;
280 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
281 const threadId = yield* createThread({
282 command: "command:title:stale:create",
283 thread: "thread:title:stale",
284 });
285 const staleRequest = yield* armRegeneration({ command: "command:title:stale:1", threadId });
286 const currentRequest = yield* armRegeneration({
287 command: "command:title:stale:2",
288 threadId,
289 });
290
291 yield* titleRegeneration.execute({
292 threadId,
293 requestId: staleRequest,
294 kind: { type: "regenerate" },
295 });
296
297 assert.equal(harness.generateThreadTitle.mock.calls.length, 0);
298 const projection = yield* threads.getThreadProjection(threadId);
299 assert.equal(projection.thread.title, "Seed title");
300 assert.equal(projection.thread.titleRegeneration?.requestId, currentRequest);
301 }).pipe(Effect.provide(harness.layer));
302 }),
303 );
304
305 it.effect("lands the regenerated title from the conversation digest", () =>
306 Effect.gen(function* () {
307 const harness = makeHarness({
308 generateTitle: () => Effect.succeed({ title: "Fresh title" }),
309 });
310 yield* Effect.gen(function* () {
311 const threads = yield* ThreadManagement.ThreadManagementService;
312 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
313 const threadId = yield* createThread({
314 command: "command:title:landing:create",
315 thread: "thread:title:landing",
316 });
317 yield* dispatchUserMessage({
318 command: "command:title:landing:message",
319 threadId,
320 text: "Investigate the flaky login test",
321 });
322 const requestId = yield* armRegeneration({ command: "command:title:landing:1", threadId });
323
324 yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
325
326 const call = harness.generateThreadTitle.mock.calls[0]?.[0];
327 assert.equal(call?.previousTitle, "Seed title");
328 assert.equal(call?.cwd, "/repo");
329 assert.include(call?.message, "USER:\nInvestigate the flaky login test");
330 const projection = yield* threads.getThreadProjection(threadId);
331 assert.equal(projection.thread.title, "Fresh title");
332 assert.isNotOk(projection.thread.titleRegeneration);
333 }).pipe(Effect.provide(harness.layer));
334 }),
335 );
336
337 it.effect("keeps the current title when generation returns the fallback", () =>
338 Effect.gen(function* () {
339 const harness = makeHarness({
340 generateTitle: () => Effect.succeed({ title: "New thread" }),
341 });
342 yield* Effect.gen(function* () {
343 const threads = yield* ThreadManagement.ThreadManagementService;
344 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
345 const threadId = yield* createThread({
346 command: "command:title:fallback:create",
347 thread: "thread:title:fallback",
348 });
349 yield* dispatchUserMessage({
350 command: "command:title:fallback:message",
351 threadId,
352 text: "Some conversation",
353 });
354 const requestId = yield* armRegeneration({ command: "command:title:fallback:1", threadId });
355
356 yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
357
358 const projection = yield* threads.getThreadProjection(threadId);
359 assert.equal(projection.thread.title, "Seed title");
360 assert.isNotOk(projection.thread.titleRegeneration);
361 }).pipe(Effect.provide(harness.layer));
362 }),
363 );
364
365 it.effect("keeps the current title when regeneration reproduces it", () =>
366 Effect.gen(function* () {
367 const harness = makeHarness({
368 generateTitle: () => Effect.succeed({ title: "Seed title" }),
369 });
370 yield* Effect.gen(function* () {
371 const threads = yield* ThreadManagement.ThreadManagementService;
372 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
373 const threadId = yield* createThread({
374 command: "command:title:unchanged:create",
375 thread: "thread:title:unchanged",
376 });
377 yield* dispatchUserMessage({
378 command: "command:title:unchanged:message",
379 threadId,
380 text: "Some conversation",
381 });
382 const requestId = yield* armRegeneration({
383 command: "command:title:unchanged:1",
384 threadId,
385 });
386
387 yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
388
389 const projection = yield* threads.getThreadProjection(threadId);
390 assert.equal(projection.thread.title, "Seed title");
391 assert.isNotOk(projection.thread.titleRegeneration);
392 }).pipe(Effect.provide(harness.layer));
393 }),
394 );
395
396 it.effect("clears the marker and keeps the title when generation fails", () =>
397 Effect.gen(function* () {
398 const harness = makeHarness({
399 generateTitle: () => Effect.die(new Error("model unavailable")),
400 });
401 yield* Effect.gen(function* () {
402 const threads = yield* ThreadManagement.ThreadManagementService;
403 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
404 const threadId = yield* createThread({
405 command: "command:title:failure:create",
406 thread: "thread:title:failure",
407 });
408 yield* dispatchUserMessage({
409 command: "command:title:failure:message",
410 threadId,
411 text: "Some conversation",
412 });
413 const requestId = yield* armRegeneration({ command: "command:title:failure:1", threadId });
414
415 yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
416
417 assert.equal(harness.generateThreadTitle.mock.calls.length, 1);
418 const projection = yield* threads.getThreadProjection(threadId);
419 assert.equal(projection.thread.title, "Seed title");
420 assert.isNotOk(projection.thread.titleRegeneration);
421 }).pipe(Effect.provide(harness.layer));
422 }),
423 );
424
425 it.effect("completes without generating when the initial message is unavailable", () =>
426 Effect.gen(function* () {
427 const harness = makeHarness();
428 yield* Effect.gen(function* () {
429 const threads = yield* ThreadManagement.ThreadManagementService;
430 const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
431 const threadId = yield* createThread({
432 command: "command:title:missing:create",
433 thread: "thread:title:missing",
434 });
435 const requestId = yield* armRegeneration({ command: "command:title:missing:1", threadId });
436
437 yield* titleRegeneration.execute({
438 threadId,
439 requestId,
440 kind: { type: "initial", messageId: MessageId.make("message:title:missing") },
441 });
442
443 assert.equal(harness.generateThreadTitle.mock.calls.length, 0);
444 const projection = yield* threads.getThreadProjection(threadId);
445 assert.equal(projection.thread.title, "Seed title");
446 assert.isNotOk(projection.thread.titleRegeneration);
447 }).pipe(Effect.provide(harness.layer));
448 }),
449 );
450});
451
452it.effect.each(["success", "exhausted", "stale", "interrupted"] as const)(
453 "initial title retry: %s",
454 (outcome) =>
455 Effect.gen(function* () {
456 const attempted = yield* Deferred.make<void>();
457 let attempts = 0;
458 const harness = makeHarness({
459 generateTitle: () =>
460 Effect.gen(function* () {
461 attempts += 1;
462 yield* Deferred.succeed(attempted, undefined);
463 if (outcome === "success" && attempts === 3) return { title: "Recovered title" };
464 return yield* new TextGenerationError({
465 operation: "generateThreadTitle",
466 detail: "Temporary failure",
467 });
468 }),
469 });
470 yield* Effect.gen(function* () {
471 const threads = yield* ThreadManagement.ThreadManagementService;
472 const service = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
473 const threadId = yield* createThread({
474 command: `create:${outcome}`,
475 thread: `thread:${outcome}`,
476 });
477 const messageCommand = `message:${outcome}`;
478 yield* dispatchUserMessage({ command: messageCommand, threadId, text: "Fix the title" });
479 const requestId = yield* armRegeneration({ command: `title:${outcome}`, threadId });
480 const fiber = yield* service
481 .execute({
482 threadId,
483 requestId,
484 kind: { type: "initial", messageId: MessageId.make(`${messageCommand}:message`) },
485 })
486 .pipe(Effect.forkChild);
487 yield* Deferred.await(attempted);
488 if (outcome === "interrupted") {
489 yield* Fiber.interrupt(fiber);
490 } else {
491 if (outcome === "stale")
492 yield* threads.dispatch({
493 type: "thread.metadata.update",
494 commandId: CommandId.make("manual-title"),
495 threadId,
496 title: "Manual title",
497 });
498 yield* TestClock.adjust("2 seconds");
499 if (outcome !== "stale") yield* TestClock.adjust("4 seconds");
500 yield* Fiber.join(fiber);
501 }
502 assert.equal(attempts, outcome === "success" || outcome === "exhausted" ? 3 : 1);
503 const projection = yield* threads.getThreadProjection(threadId);
504 assert.equal(
505 projection.thread.title,
506 outcome === "success"
507 ? "Recovered title"
508 : outcome === "stale"
509 ? "Manual title"
510 : "Seed title",
511 );
512 if (outcome === "interrupted")
513 assert.equal(projection.thread.titleRegeneration?.requestId, requestId);
514 else assert.isNotOk(projection.thread.titleRegeneration);
515 }).pipe(Effect.provide(harness.layer));
516 }),
517);