1import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences";
3import {
4 CommandId,
5 type ChatAttachment,
6 type MessageId,
7 type OrchestrationV2AppThread,
8 type OrchestrationV2ConversationMessage,
9 type RunId,
10 type ServerSettingsError,
11 TextGenerationError,
12 type ThreadId,
13} from "@t3tools/contracts";
14import * as Cause from "effect/Cause";
15import * as Context from "effect/Context";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Schedule from "effect/Schedule";
21import * as Schema from "effect/Schema";
22
23import * as ServerSettings from "../serverSettings.ts";
24import * as TextGeneration from "../textGeneration/TextGeneration.ts";
25import type { OrchestratorV2Error } from "./Orchestrator.ts";
26import * as ProjectStore from "./ProjectStore.ts";
27import * as ThreadManagementService from "./ThreadManagementService.ts";
28
29import { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext";
30export { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext";
31
32export class ThreadTitleRegenerationService extends Context.Service<
33 ThreadTitleRegenerationService,
34 {
35 readonly execute: (input: {
36 readonly threadId: ThreadId;
37 readonly requestId: CommandId;
38 readonly kind:
39 | { readonly type: "initial"; readonly messageId: MessageId }
40 | { readonly type: "regenerate" };
41 }) => Effect.Effect<
42 void,
43 | OrchestratorV2Error
44 | ProjectStore.ProjectStoreV2Error
45 | ServerSettingsError
46 | ThreadManagementService.ThreadManagementProviderInstanceDisabledError
47 >;
48 }
49>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}
50
51type Outcome =
52 | { readonly status: "renamed"; readonly title: string }
53 | { readonly status: "unchanged" }
54 | { readonly status: "failed"; readonly message: string };
55
56const MAX_FAILURE_MESSAGE_LENGTH = 300;
57const isTextGenerationError = Schema.is(TextGenerationError);
58
59function failureMessage(cause: Cause.Cause<unknown>): string {
60 const error = Cause.squash(cause);
61 const lines = isTextGenerationError(error)
62 ? error.detail
63 .split("\n")
64 .map((line) => line.trim())
65 .filter((line) => line.length > 0)
66 : [];
67 const detail = lines.at(-1);
68 if (detail === undefined) return "Title generation failed.";
69 return detail.length > MAX_FAILURE_MESSAGE_LENGTH
70 ? `${detail.slice(0, MAX_FAILURE_MESSAGE_LENGTH - 1)}…`
71 : detail;
72}
73
74const make = Effect.gen(function* () {
75 const threads = yield* ThreadManagementService.ThreadManagementService;
76 const projects = yield* ProjectStore.ProjectStoreV2;
77 const serverSettings = yield* ServerSettings.ServerSettingsService;
78 const textGeneration = yield* TextGeneration.TextGeneration;
79 const fileSystem = yield* FileSystem.FileSystem;
80
81 const complete = (input: {
82 readonly threadId: ThreadId;
83 readonly requestId: CommandId;
84 readonly kind: { readonly type: "initial" | "regenerate" };
85 readonly outcome: Outcome;
86 }) =>
87 threads
88 .dispatch({
89 type: "thread.title.regeneration.complete",
90 commandId: CommandId.make(`${input.requestId}:title-complete`),
91 threadId: input.threadId,
92 requestId: input.requestId,
93 ...(input.outcome.status === "renamed" ? { title: input.outcome.title } : {}),
94 ...(input.kind.type === "regenerate"
95 ? {
96 outcome: {
97 status: input.outcome.status,
98 ...(input.outcome.status === "failed" ? { message: input.outcome.message } : {}),
99 },
100 }
101 : {}),
102 })
103 .pipe(Effect.asVoid);
104
105 const messagesThroughRun = Effect.fn("ThreadTitleRegenerationService.messagesThroughRun")(
106 function* (threadId: ThreadId, runId: RunId) {
107 const source = yield* threads.getThreadRecords(threadId, ["messages", "runs"], {
108 messageRoles: ["user", "assistant"],
109 });
110 const ordinals = new Map(
111 source.runs
112 .filter((run) => run.status !== "rolled_back")
113 .map((run) => [run.id, run.ordinal]),
114 );
115 const forkOrdinal = ordinals.get(runId) ?? -1;
116 const messages = source.messages.filter((message) => {
117 if (message.streaming) return false;
118 if (message.runId === null) return source.thread.historyOrigin === "v1_import";
119 return (ordinals.get(message.runId) ?? Infinity) <= forkOrdinal;
120 });
121 return { thread: source.thread, messages };
122 },
123 );
124
125 // A fork starts with no messages of its own, so its subject lives in the history it inherited.
126 const forkSource = Effect.fn("ThreadTitleRegenerationService.forkSource")(function* (
127 thread: OrchestrationV2AppThread,
128 ) {
129 let title: string | undefined;
130 const history: Array<ReadonlyArray<OrchestrationV2ConversationMessage>> = [];
131 let forkedFrom = thread.forkedFrom;
132 while (forkedFrom?.type === "run") {
133 const source = yield* messagesThroughRun(forkedFrom.threadId, forkedFrom.runId);
134 history.push(source.messages);
135 title ??= source.thread.title;
136 forkedFrom = source.thread.forkedFrom;
137 }
138 const context = formatThreadTitleContext(history.reverse().flat());
139 return title === undefined || context.message.length === 0
140 ? undefined
141 : { title, history: context.message };
142 });
143
144 const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn(
145 "ThreadTitleRegenerationService.execute",
146 )(function* (input) {
147 const outcome: Outcome | { readonly status: "stale" } = yield* Effect.gen(function* () {
148 const projection = yield* threads.getThreadRecords(
149 input.threadId,
150 ["messages"],
151 input.kind.type === "initial"
152 ? { messageIds: [input.kind.messageId] }
153 : { messageRoles: ["user", "assistant"] },
154 );
155 if (projection.thread.titleRegeneration?.requestId !== input.requestId) {
156 return { status: "stale" as const };
157 }
158
159 const project = yield* projects.get(projection.thread.projectId);
160 if (Option.isNone(project)) {
161 return { status: "failed" as const, message: "This thread's project no longer exists." };
162 }
163
164 let context: {
165 readonly message: string;
166 readonly attachments: ReadonlyArray<ChatAttachment>;
167 };
168 if (input.kind.type === "initial") {
169 const messageId = input.kind.messageId;
170 const message = projection.messages.find(
171 (candidate) => candidate.id === messageId && !candidate.streaming,
172 );
173 context =
174 message === undefined
175 ? { message: "", attachments: [] }
176 : {
177 message: isAttachmentOnlyBootstrapPrompt(message.text) ? "" : message.text,
178 attachments: message.attachments,
179 };
180 } else {
181 context = formatThreadTitleContext(
182 projection.messages.filter((message) => !message.streaming),
183 );
184 }
185 if (context.message.length === 0 && context.attachments.length === 0) {
186 return {
187 status: "failed" as const,
188 message: "This thread has no messages to title yet.",
189 };
190 }
191
192 // A deleted source should not cost the fork its title.
193 const inherited = yield* forkSource(projection.thread).pipe(
194 Effect.orElseSucceed(() => undefined),
195 );
196 const settings = resolveProjectSettings(
197 yield* serverSettings.getSettings,
198 projection.thread.projectId,
199 ).settings;
200 // Storage cleanup removes worktrees but keeps the path; CLIs cannot spawn in a missing cwd.
201 const worktreePath = projection.thread.worktreePath;
202 const worktreeExists =
203 worktreePath !== null &&
204 (yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => false)));
205 const result = yield* textGeneration.generateThreadTitle({
206 cwd: worktreeExists ? worktreePath : project.value.workspaceRoot,
207 message: context.message,
208 projectTitle: project.value.title,
209 attachments: context.attachments,
210 ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}),
211 ...(inherited === undefined ? {} : { forkSource: inherited }),
212 instructions: settings.threadTitleInstructions,
213 modelSelection: settings.textGenerationModelSelection,
214 projectId: projection.thread.projectId,
215 });
216 const generatedTitle = result.title.trim();
217 return generatedTitle === "New thread" ||
218 (input.kind.type === "regenerate" && generatedTitle === projection.thread.title.trim())
219 ? { status: "unchanged" as const }
220 : { status: "renamed" as const, title: result.title };
221 }).pipe(
222 Effect.retry({
223 times: input.kind.type === "initial" ? 2 : 0,
224 schedule: Schedule.exponential("2 seconds"),
225 }),
226 Effect.catchCause((cause) =>
227 Cause.hasInterruptsOnly(cause)
228 ? Effect.interrupt
229 : Effect.logWarning("Thread title generation failed", {
230 threadId: input.threadId,
231 requestId: input.requestId,
232 cause,
233 }).pipe(Effect.as({ status: "failed" as const, message: failureMessage(cause) })),
234 ),
235 );
236
237 if (outcome.status === "stale") {
238 return;
239 }
240 yield* complete({ ...input, outcome });
241 });
242
243 return ThreadTitleRegenerationService.of({ execute });
244});
245
246export const layer = Layer.effect(ThreadTitleRegenerationService, make);