1import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2import {
3 CommandId,
4 type ChatAttachment,
5 type MessageId,
6 type ServerSettingsError,
7 type ThreadId,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Schedule from "effect/Schedule";
15
16import * as ServerSettings from "../serverSettings.ts";
17import * as TextGeneration from "../textGeneration/TextGeneration.ts";
18import type { OrchestratorV2Error } from "./Orchestrator.ts";
19import * as ProjectStore from "./ProjectStore.ts";
20import * as ThreadManagementService from "./ThreadManagementService.ts";
21
22import { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext";
23export { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext";
24
25export class ThreadTitleRegenerationService extends Context.Service<
26 ThreadTitleRegenerationService,
27 {
28 readonly execute: (input: {
29 readonly threadId: ThreadId;
30 readonly requestId: CommandId;
31 readonly kind:
32 | { readonly type: "initial"; readonly messageId: MessageId }
33 | { readonly type: "regenerate" };
34 }) => Effect.Effect<
35 void,
36 | OrchestratorV2Error
37 | ProjectStore.ProjectStoreV2Error
38 | ServerSettingsError
39 // `complete` dispatches thread.title.regeneration.complete, a command
40 // the enablement chokepoint never gates; the type stays accurate anyway.
41 | ThreadManagementService.ThreadManagementProviderInstanceDisabledError
42 >;
43 }
44>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}
45
46const make = Effect.gen(function* () {
47 const threads = yield* ThreadManagementService.ThreadManagementService;
48 const projects = yield* ProjectStore.ProjectStoreV2;
49 const serverSettings = yield* ServerSettings.ServerSettingsService;
50 const textGeneration = yield* TextGeneration.TextGeneration;
51
52 const complete = (input: {
53 readonly threadId: ThreadId;
54 readonly requestId: CommandId;
55 readonly title?: string;
56 }) =>
57 threads
58 .dispatch({
59 type: "thread.title.regeneration.complete",
60 commandId: CommandId.make(`${input.requestId}:title-complete`),
61 threadId: input.threadId,
62 requestId: input.requestId,
63 ...(input.title === undefined ? {} : { title: input.title }),
64 })
65 .pipe(Effect.asVoid);
66
67 const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn(
68 "ThreadTitleRegenerationService.execute",
69 )(function* (input) {
70 const outcome:
71 | { readonly type: "stale" }
72 | { readonly type: "complete"; readonly title?: string } = yield* Effect.gen(function* () {
73 const projection = yield* threads.getThreadRecords(
74 input.threadId,
75 ["messages"],
76 input.kind.type === "initial"
77 ? { messageIds: [input.kind.messageId] }
78 : { messageRoles: ["user", "assistant"] },
79 );
80 if (projection.thread.titleRegeneration?.requestId !== input.requestId) {
81 return { type: "stale" as const };
82 }
83
84 const project = yield* projects.get(projection.thread.projectId);
85 if (Option.isNone(project)) {
86 return { type: "complete" as const };
87 }
88
89 let context: {
90 readonly message: string;
91 readonly attachments: ReadonlyArray<ChatAttachment>;
92 };
93 if (input.kind.type === "initial") {
94 const messageId = input.kind.messageId;
95 const message = projection.messages.find(
96 (candidate) => candidate.id === messageId && !candidate.streaming,
97 );
98 context =
99 message === undefined
100 ? { message: "", attachments: [] }
101 : { message: message.text, attachments: message.attachments };
102 } else {
103 context = formatThreadTitleContext(
104 projection.messages.filter((message) => !message.streaming),
105 );
106 }
107 if (context.message.length === 0 && context.attachments.length === 0) {
108 return { type: "complete" as const };
109 }
110
111 const settings = resolveProjectSettings(
112 yield* serverSettings.getSettings,
113 projection.thread.projectId,
114 ).settings;
115 const result = yield* textGeneration.generateThreadTitle({
116 cwd: projection.thread.worktreePath ?? project.value.workspaceRoot,
117 message: context.message,
118 attachments: context.attachments,
119 ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}),
120 modelSelection: settings.textGenerationModelSelection,
121 });
122 const generatedTitle = result.title.trim();
123 return generatedTitle === "New thread" ||
124 (input.kind.type === "regenerate" && generatedTitle === projection.thread.title.trim())
125 ? { type: "complete" as const }
126 : { type: "complete" as const, title: result.title };
127 }).pipe(
128 Effect.retry({
129 times: input.kind.type === "initial" ? 2 : 0,
130 schedule: Schedule.exponential("2 seconds"),
131 }),
132 Effect.catchCause((cause) =>
133 Cause.hasInterruptsOnly(cause)
134 ? Effect.interrupt
135 : Effect.logWarning("Thread title generation failed", {
136 threadId: input.threadId,
137 requestId: input.requestId,
138 cause,
139 }).pipe(Effect.as({ type: "complete" as const })),
140 ),
141 );
142
143 if (outcome.type === "stale") {
144 return;
145 }
146 yield* complete({
147 ...input,
148 ...(outcome.title === undefined ? {} : { title: outcome.title }),
149 });
150 });
151
152 return ThreadTitleRegenerationService.of({ execute });
153});
154
155export const layer = Layer.effect(ThreadTitleRegenerationService, make);