authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-03-28 21:03:01-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 17:03:01-07:00
log5513845fd56acf7a019d24e72fc4e11bf5654f50
tree0b1cc8fe3deac0b86ca1569d70452c9209a8d117
parent33773ff17eb7dcdb3a020b0d0a1c27ac0df86125
signature Signed by PGP key B5690EEEBB952194

feat(threads): auto-generate first-turn thread titles (#1375)


22 files changed, 738 insertions(+), 65 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+2-1
......@@ -306,7 +306,8 @@ export const makeOrchestrationIntegrationHarness = (
306306 Effect.succeed({ branch: input.newBranch }),
307307 } as unknown as GitCoreShape);
308308 const textGenerationLayer = Layer.succeed(TextGeneration, {
309 generateBranchName: () => Effect.succeed({ branch: null }),
309 generateBranchName: () => Effect.succeed({ branch: "update" }),
310 generateThreadTitle: () => Effect.succeed({ title: "New thread" }),
310311 } as unknown as TextGenerationShape);
311312 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
312313 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+59
......@@ -5,6 +5,7 @@ import { expect } from "vitest";
55
66import { ServerConfig } from "../../config.ts";
77import { TextGeneration } from "../Services/TextGeneration.ts";
8import { sanitizeThreadTitle } from "../Utils.ts";
89import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
910import { ServerSettingsService } from "../../serverSettings.ts";
1011
......@@ -247,4 +248,62 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
247248 }),
248249 ),
249250 );
251
252 it.effect("generates thread titles through the Claude provider", () =>
253 withFakeClaudeEnv(
254 {
255 output: JSON.stringify({
256 structured_output: {
257 title:
258 ' "Reconnect failures after restart because the session state does not recover" ',
259 },
260 }),
261 stdinMustContain: "You write concise thread titles for coding conversations.",
262 },
263 Effect.gen(function* () {
264 const textGeneration = yield* TextGeneration;
265
266 const generated = yield* textGeneration.generateThreadTitle({
267 cwd: process.cwd(),
268 message: "Please investigate reconnect failures after restarting the session.",
269 modelSelection: {
270 provider: "claudeAgent",
271 model: "claude-sonnet-4-6",
272 },
273 });
274
275 expect(generated.title).toBe(
276 sanitizeThreadTitle(
277 '"Reconnect failures after restart because the session state does not recover"',
278 ),
279 );
280 }),
281 ),
282 );
283
284 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
285 withFakeClaudeEnv(
286 {
287 output: JSON.stringify({
288 structured_output: {
289 title: ' """ """ ',
290 },
291 }),
292 },
293 Effect.gen(function* () {
294 const textGeneration = yield* TextGeneration;
295
296 const generated = yield* textGeneration.generateThreadTitle({
297 cwd: process.cwd(),
298 message: "Name this thread.",
299 modelSelection: {
300 provider: "claudeAgent",
301 model: "claude-sonnet-4-6",
302 },
303 });
304
305 expect(generated.title).toBe("New thread");
306 }),
307 ),
308 );
250309});
apps/server/src/git/Layers/ClaudeTextGeneration.ts+36-1
......@@ -20,11 +20,13 @@ import {
2020 buildBranchNamePrompt,
2121 buildCommitMessagePrompt,
2222 buildPrContentPrompt,
23 buildThreadTitlePrompt,
2324} from "../Prompts.ts";
2425import {
2526 normalizeCliError,
2627 sanitizeCommitSubject,
2728 sanitizePrTitle,
29 sanitizeThreadTitle,
2830 toJsonSchemaObject,
2931} from "../Utils.ts";
3032import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts";
......@@ -70,7 +72,11 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
7072 outputSchemaJson,
7173 modelSelection,
7274 }: {
73 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
75 operation:
76 | "generateCommitMessage"
77 | "generatePrContent"
78 | "generateBranchName"
79 | "generateThreadTitle";
7480 cwd: string;
7581 prompt: string;
7682 outputSchemaJson: S;
......@@ -299,10 +305,39 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
299305 };
300306 });
301307
308 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
309 "ClaudeTextGeneration.generateThreadTitle",
310 )(function* (input) {
311 const { prompt, outputSchema } = buildThreadTitlePrompt({
312 message: input.message,
313 attachments: input.attachments,
314 });
315
316 if (input.modelSelection.provider !== "claudeAgent") {
317 return yield* new TextGenerationError({
318 operation: "generateThreadTitle",
319 detail: "Invalid model selection.",
320 });
321 }
322
323 const generated = yield* runClaudeJson({
324 operation: "generateThreadTitle",
325 cwd: input.cwd,
326 prompt,
327 outputSchemaJson: outputSchema,
328 modelSelection: input.modelSelection,
329 });
330
331 return {
332 title: sanitizeThreadTitle(generated.title),
333 };
334 });
335
302336 return {
303337 generateCommitMessage,
304338 generatePrContent,
305339 generateBranchName,
340 generateThreadTitle,
306341 } satisfies TextGenerationShape;
307342});
308343
apps/server/src/git/Layers/CodexTextGeneration.test.ts+64
......@@ -358,6 +358,70 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
358358 ),
359359 );
360360
361 it.effect("generates thread titles and trims them for sidebar use", () =>
362 withFakeCodexEnv(
363 {
364 output: JSON.stringify({
365 title:
366 ' "Investigate websocket reconnect regressions after worktree restore" \nignored line',
367 }),
368 },
369 Effect.gen(function* () {
370 const textGeneration = yield* TextGeneration;
371
372 const generated = yield* textGeneration.generateThreadTitle({
373 cwd: process.cwd(),
374 message: "Please investigate websocket reconnect regressions after a worktree restore.",
375 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
376 });
377
378 expect(generated.title).toBe("Investigate websocket reconnect regressions aft...");
379 }),
380 ),
381 );
382
383 it.effect("falls back when thread title normalization becomes whitespace-only", () =>
384 withFakeCodexEnv(
385 {
386 output: JSON.stringify({
387 title: ' """ """ ',
388 }),
389 },
390 Effect.gen(function* () {
391 const textGeneration = yield* TextGeneration;
392
393 const generated = yield* textGeneration.generateThreadTitle({
394 cwd: process.cwd(),
395 message: "Name this thread.",
396 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
397 });
398
399 expect(generated.title).toBe("New thread");
400 }),
401 ),
402 );
403
404 it.effect("trims whitespace exposed after quote removal in thread titles", () =>
405 withFakeCodexEnv(
406 {
407 output: JSON.stringify({
408 title: ` "' hello world '" `,
409 }),
410 },
411 Effect.gen(function* () {
412 const textGeneration = yield* TextGeneration;
413
414 const generated = yield* textGeneration.generateThreadTitle({
415 cwd: process.cwd(),
416 message: "Name this thread.",
417 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
418 });
419
420 expect(generated.title).toBe("hello world");
421 }),
422 ),
423 );
424
361425 it.effect("omits attachment metadata section when no attachments are provided", () =>
362426 withFakeCodexEnv(
363427 {
apps/server/src/git/Layers/CodexTextGeneration.ts+47-3
......@@ -11,6 +11,7 @@ import { ServerConfig } from "../../config.ts";
1111import { TextGenerationError } from "../Errors.ts";
1212import {
1313 type BranchNameGenerationInput,
14 type ThreadTitleGenerationResult,
1415 type TextGenerationShape,
1516 TextGeneration,
1617} from "../Services/TextGeneration.ts";
......@@ -18,11 +19,13 @@ import {
1819 buildBranchNamePrompt,
1920 buildCommitMessagePrompt,
2021 buildPrContentPrompt,
22 buildThreadTitlePrompt,
2123} from "../Prompts.ts";
2224import {
2325 normalizeCliError,
2426 sanitizeCommitSubject,
2527 sanitizePrTitle,
28 sanitizeThreadTitle,
2629 toJsonSchemaObject,
2730} from "../Utils.ts";
2831import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";
......@@ -30,7 +33,6 @@ import { ServerSettingsService } from "../../serverSettings.ts";
3033
3134const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
3235const CODEX_TIMEOUT_MS = 180_000;
33
3436const makeCodexTextGeneration = Effect.gen(function* () {
3537 const fileSystem = yield* FileSystem.FileSystem;
3638 const path = yield* Path.Path;
......@@ -83,7 +85,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
8385 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
8486
8587 const materializeImageAttachments = (
86 _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
88 _operation:
89 | "generateCommitMessage"
90 | "generatePrContent"
91 | "generateBranchName"
92 | "generateThreadTitle",
8793 attachments: BranchNameGenerationInput["attachments"],
8894 ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> =>
8995 Effect.gen(function* () {
......@@ -124,7 +130,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
124130 cleanupPaths = [],
125131 modelSelection,
126132 }: {
127 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
133 operation:
134 | "generateCommitMessage"
135 | "generatePrContent"
136 | "generateBranchName"
137 | "generateThreadTitle";
128138 cwd: string;
129139 prompt: string;
130140 outputSchemaJson: S;
......@@ -363,10 +373,44 @@ const makeCodexTextGeneration = Effect.gen(function* () {
363373 };
364374 });
365375
376 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
377 "CodexTextGeneration.generateThreadTitle",
378 )(function* (input) {
379 const { imagePaths } = yield* materializeImageAttachments(
380 "generateThreadTitle",
381 input.attachments,
382 );
383 const { prompt, outputSchema } = buildThreadTitlePrompt({
384 message: input.message,
385 attachments: input.attachments,
386 });
387
388 if (input.modelSelection.provider !== "codex") {
389 return yield* new TextGenerationError({
390 operation: "generateThreadTitle",
391 detail: "Invalid model selection.",
392 });
393 }
394
395 const generated = yield* runCodexJson({
396 operation: "generateThreadTitle",
397 cwd: input.cwd,
398 prompt,
399 outputSchemaJson: outputSchema,
400 imagePaths,
401 modelSelection: input.modelSelection,
402 });
403
404 return {
405 title: sanitizeThreadTitle(generated.title),
406 } satisfies ThreadTitleGenerationResult;
407 });
408
366409 return {
367410 generateCommitMessage,
368411 generatePrContent,
369412 generateBranchName,
413 generateThreadTitle,
370414 } satisfies TextGenerationShape;
371415});
372416
apps/server/src/git/Layers/GitManager.test.ts+24-1
......@@ -6,7 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
66import { it } from "@effect/vitest";
77import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
88import { expect } from "vitest";
9import type { GitActionProgressEvent } from "@t3tools/contracts";
9import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";
1010
1111import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
1212import { type GitManagerShape } from "../Services/GitManager.ts";
......@@ -49,6 +49,7 @@ interface FakeGitTextGeneration {
4949 stagedSummary: string;
5050 stagedPatch: string;
5151 includeBranch?: boolean;
52 modelSelection: ModelSelection;
5253 }) => Effect.Effect<
5354 { subject: string; body: string; branch?: string | undefined },
5455 TextGenerationError
......@@ -60,11 +61,18 @@ interface FakeGitTextGeneration {
6061 commitSummary: string;
6162 diffSummary: string;
6263 diffPatch: string;
64 modelSelection: ModelSelection;
6365 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
6466 generateBranchName: (input: {
6567 cwd: string;
6668 message: string;
69 modelSelection: ModelSelection;
6770 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
71 generateThreadTitle: (input: {
72 cwd: string;
73 message: string;
74 modelSelection: ModelSelection;
75 }) => Effect.Effect<{ title: string }, TextGenerationError>;
6876}
6977
7078type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
......@@ -168,6 +176,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
168176 Effect.succeed({
169177 branch: "update-workflow",
170178 }),
179 generateThreadTitle: () =>
180 Effect.succeed({
181 title: "Update workflow",
182 }),
171183 ...overrides,
172184 };
173185
......@@ -205,6 +217,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
205217 }),
206218 ),
207219 ),
220 generateThreadTitle: (input) =>
221 implementation.generateThreadTitle(input).pipe(
222 Effect.mapError(
223 (cause) =>
224 new TextGenerationError({
225 operation: "generateThreadTitle",
226 detail: "fake text generation failed",
227 ...(cause !== undefined ? { cause } : {}),
228 }),
229 ),
230 ),
208231 };
209232}
210233
apps/server/src/git/Layers/RoutingTextGeneration.ts+1
......@@ -47,6 +47,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () {
4747 route(input.modelSelection.provider).generateCommitMessage(input),
4848 generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input),
4949 generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input),
50 generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input),
5051 } satisfies TextGenerationShape;
5152});
5253
apps/server/src/git/Prompts.test.ts+44-1
......@@ -4,8 +4,9 @@ import {
44 buildBranchNamePrompt,
55 buildCommitMessagePrompt,
66 buildPrContentPrompt,
7 buildThreadTitlePrompt,
78} from "./Prompts.ts";
8import { normalizeCliError } from "./Utils.ts";
9import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts";
910import { TextGenerationError } from "./Errors.ts";
1011
1112describe("buildCommitMessagePrompt", () => {
......@@ -103,6 +104,48 @@ describe("buildBranchNamePrompt", () => {
103104 });
104105});
105106
107describe("buildThreadTitlePrompt", () => {
108 it("includes the user message in the prompt", () => {
109 const result = buildThreadTitlePrompt({
110 message: "Investigate reconnect regressions after session restore",
111 });
112
113 expect(result.prompt).toContain("User message:");
114 expect(result.prompt).toContain("Investigate reconnect regressions after session restore");
115 expect(result.prompt).not.toContain("Attachment metadata:");
116 });
117
118 it("includes attachment metadata when attachments are provided", () => {
119 const result = buildThreadTitlePrompt({
120 message: "Name this thread from the screenshot",
121 attachments: [
122 {
123 type: "image" as const,
124 id: "att-456",
125 name: "thread.png",
126 mimeType: "image/png",
127 sizeBytes: 67890,
128 },
129 ],
130 });
131
132 expect(result.prompt).toContain("Attachment metadata:");
133 expect(result.prompt).toContain("thread.png");
134 expect(result.prompt).toContain("image/png");
135 expect(result.prompt).toContain("67890 bytes");
136 });
137});
138
139describe("sanitizeThreadTitle", () => {
140 it("truncates long titles with the shared sidebar-safe limit", () => {
141 expect(
142 sanitizeThreadTitle(
143 ' "Reconnect failures after restart because the session state does not recover" ',
144 ),
145 ).toBe("Reconnect failures after restart because the se...");
146 });
147});
148
106149describe("normalizeCliError", () => {
107150 it("detects 'Command not found' and includes CLI name in the message", () => {
108151 const error = normalizeCliError(
apps/server/src/git/Prompts.ts+57-8
......@@ -119,19 +119,24 @@ export interface BranchNamePromptInput {
119119 attachments?: ReadonlyArray<ChatAttachment> | undefined;
120120}
121121
122export function buildBranchNamePrompt(input: BranchNamePromptInput) {
122interface PromptFromMessageInput {
123 instruction: string;
124 responseShape: string;
125 rules: ReadonlyArray<string>;
126 message: string;
127 attachments?: ReadonlyArray<ChatAttachment> | undefined;
128}
129
130function buildPromptFromMessage(input: PromptFromMessageInput): string {
123131 const attachmentLines = (input.attachments ?? []).map(
124132 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
125133 );
126134
127135 const promptSections = [
128 "You generate concise git branch names.",
129 "Return a JSON object with key: branch.",
136 input.instruction,
137 input.responseShape,
130138 "Rules:",
131 "- Branch should describe the requested work from the user message.",
132 "- Keep it short and specific (2-6 words).",
133 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
134 "- If images are attached, use them as primary context for visual/UI issues.",
139 ...input.rules.map((rule) => `- ${rule}`),
135140 "",
136141 "User message:",
137142 limitSection(input.message, 8_000),
......@@ -144,10 +149,54 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) {
144149 );
145150 }
146151
147 const prompt = promptSections.join("\n");
152 return promptSections.join("\n");
153}
154
155export function buildBranchNamePrompt(input: BranchNamePromptInput) {
156 const prompt = buildPromptFromMessage({
157 instruction: "You generate concise git branch names.",
158 responseShape: "Return a JSON object with key: branch.",
159 rules: [
160 "Branch should describe the requested work from the user message.",
161 "Keep it short and specific (2-6 words).",
162 "Use plain words only, no issue prefixes and no punctuation-heavy text.",
163 "If images are attached, use them as primary context for visual/UI issues.",
164 ],
165 message: input.message,
166 attachments: input.attachments,
167 });
148168 const outputSchema = Schema.Struct({
149169 branch: Schema.String,
150170 });
151171
152172 return { prompt, outputSchema };
153173}
174
175// ---------------------------------------------------------------------------
176// Thread title
177// ---------------------------------------------------------------------------
178
179export interface ThreadTitlePromptInput {
180 message: string;
181 attachments?: ReadonlyArray<ChatAttachment> | undefined;
182}
183
184export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
185 const prompt = buildPromptFromMessage({
186 instruction: "You write concise thread titles for coding conversations.",
187 responseShape: "Return a JSON object with key: title.",
188 rules: [
189 "Title should summarize the user's request, not restate it verbatim.",
190 "Keep it short and specific (3-8 words).",
191 "Avoid quotes, filler, prefixes, and trailing punctuation.",
192 "If images are attached, use them as primary context for visual/UI issues.",
193 ],
194 message: input.message,
195 attachments: input.attachments,
196 });
197 const outputSchema = Schema.Struct({
198 title: Schema.String,
199 });
200
201 return { prompt, outputSchema };
202}
apps/server/src/git/Services/TextGeneration.ts+20
......@@ -61,12 +61,25 @@ export interface BranchNameGenerationResult {
6161 branch: string;
6262}
6363
64export interface ThreadTitleGenerationInput {
65 cwd: string;
66 message: string;
67 attachments?: ReadonlyArray<ChatAttachment> | undefined;
68 /** What model and provider to use for generation. */
69 modelSelection: ModelSelection;
70}
71
72export interface ThreadTitleGenerationResult {
73 title: string;
74}
75
6476export interface TextGenerationService {
6577 generateCommitMessage(
6678 input: CommitMessageGenerationInput,
6779 ): Promise<CommitMessageGenerationResult>;
6880 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
6981 generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
82 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
7083}
7184
7285/**
......@@ -93,6 +106,13 @@ export interface TextGenerationShape {
93106 readonly generateBranchName: (
94107 input: BranchNameGenerationInput,
95108 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
109
110 /**
111 * Generate a concise thread title from a user's first message.
112 */
113 readonly generateThreadTitle: (
114 input: ThreadTitleGenerationInput,
115 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
96116}
97117
98118/**
apps/server/src/git/Utils.ts+21
......@@ -53,6 +53,27 @@ export function sanitizePrTitle(raw: string): string {
5353 return "Update project changes";
5454}
5555
56/** Normalise a raw thread title to a compact single-line sidebar-safe label. */
57export function sanitizeThreadTitle(raw: string): string {
58 const normalized = raw
59 .trim()
60 .split(/\r?\n/g)[0]
61 ?.trim()
62 .replace(/^['"`]+|['"`]+$/g, "")
63 .trim()
64 .replace(/\s+/g, " ");
65
66 if (!normalized || normalized.trim().length === 0) {
67 return "New thread";
68 }
69
70 if (normalized.length <= 50) {
71 return normalized;
72 }
73
74 return `${normalized.slice(0, 47).trimEnd()}...`;
75}
76
5677/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
5778function cliLabel(cliName: string): string {
5879 const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+207-2
......@@ -176,7 +176,7 @@ describe("ProviderCommandReactor", () => {
176176 : "renamed-branch",
177177 }),
178178 );
179 const generateBranchName = vi.fn(() =>
179 const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>((_) =>
180180 Effect.fail(
181181 new TextGenerationError({
182182 operation: "generateBranchName",
......@@ -184,6 +184,14 @@ describe("ProviderCommandReactor", () => {
184184 }),
185185 ),
186186 );
187 const generateThreadTitle = vi.fn<TextGenerationShape["generateThreadTitle"]>((_) =>
188 Effect.fail(
189 new TextGenerationError({
190 operation: "generateThreadTitle",
191 detail: "disabled in test harness",
192 }),
193 ),
194 );
187195
188196 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
189197 const service: ProviderServiceShape = {
......@@ -213,7 +221,10 @@ describe("ProviderCommandReactor", () => {
213221 Layer.provideMerge(Layer.succeed(ProviderService, service)),
214222 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
215223 Layer.provideMerge(
216 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
224 Layer.mock(TextGeneration, {
225 generateBranchName,
226 generateThreadTitle,
227 }),
217228 ),
218229 Layer.provideMerge(ServerSettingsService.layerTest()),
219230 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
......@@ -264,6 +275,7 @@ describe("ProviderCommandReactor", () => {
264275 stopSession,
265276 renameBranch,
266277 generateBranchName,
278 generateThreadTitle,
267279 stateDir,
268280 drain,
269281 };
......@@ -308,6 +320,199 @@ describe("ProviderCommandReactor", () => {
308320 expect(thread?.session?.runtimeMode).toBe("approval-required");
309321 });
310322
323 it("generates a thread title on the first turn", async () => {
324 const harness = await createHarness();
325 const now = new Date().toISOString();
326 const seededTitle = "Please investigate reconnect failures after restar...";
327 harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" }));
328
329 await Effect.runPromise(
330 harness.engine.dispatch({
331 type: "thread.meta.update",
332 commandId: CommandId.makeUnsafe("cmd-thread-title-seed"),
333 threadId: ThreadId.makeUnsafe("thread-1"),
334 title: seededTitle,
335 }),
336 );
337
338 await Effect.runPromise(
339 harness.engine.dispatch({
340 type: "thread.turn.start",
341 commandId: CommandId.makeUnsafe("cmd-turn-start-title"),
342 threadId: ThreadId.makeUnsafe("thread-1"),
343 message: {
344 messageId: asMessageId("user-message-title"),
345 role: "user",
346 text: "Please investigate reconnect failures after restarting the session.",
347 attachments: [],
348 },
349 titleSeed: seededTitle,
350 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
351 runtimeMode: "approval-required",
352 createdAt: now,
353 }),
354 );
355
356 await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);
357 expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({
358 message: "Please investigate reconnect failures after restarting the session.",
359 });
360
361 await waitFor(async () => {
362 const readModel = await Effect.runPromise(harness.engine.getReadModel());
363 return (
364 readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title ===
365 "Generated title"
366 );
367 });
368 const readModel = await Effect.runPromise(harness.engine.getReadModel());
369 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
370 expect(thread?.title).toBe("Generated title");
371 });
372
373 it("does not overwrite an existing custom thread title on the first turn", async () => {
374 const harness = await createHarness();
375 const now = new Date().toISOString();
376 const seededTitle = "Please investigate reconnect failures after restar...";
377
378 await Effect.runPromise(
379 harness.engine.dispatch({
380 type: "thread.meta.update",
381 commandId: CommandId.makeUnsafe("cmd-thread-title-custom"),
382 threadId: ThreadId.makeUnsafe("thread-1"),
383 title: "Keep this custom title",
384 }),
385 );
386
387 await Effect.runPromise(
388 harness.engine.dispatch({
389 type: "thread.turn.start",
390 commandId: CommandId.makeUnsafe("cmd-turn-start-title-preserve"),
391 threadId: ThreadId.makeUnsafe("thread-1"),
392 message: {
393 messageId: asMessageId("user-message-title-preserve"),
394 role: "user",
395 text: "Please investigate reconnect failures after restarting the session.",
396 attachments: [],
397 },
398 titleSeed: seededTitle,
399 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
400 runtimeMode: "approval-required",
401 createdAt: now,
402 }),
403 );
404
405 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
406 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
407
408 const readModel = await Effect.runPromise(harness.engine.getReadModel());
409 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
410 expect(thread?.title).toBe("Keep this custom title");
411 });
412
413 it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => {
414 const harness = await createHarness();
415 const now = new Date().toISOString();
416 const seededTitle = "Fix reconnect spinner on resume";
417 harness.generateThreadTitle.mockReturnValue(
418 Effect.succeed({
419 title: "Reconnect spinner resume bug",
420 }),
421 );
422
423 await Effect.runPromise(
424 harness.engine.dispatch({
425 type: "thread.meta.update",
426 commandId: CommandId.makeUnsafe("cmd-thread-title-formatted-seed"),
427 threadId: ThreadId.makeUnsafe("thread-1"),
428 title: seededTitle,
429 }),
430 );
431
432 await Effect.runPromise(
433 harness.engine.dispatch({
434 type: "thread.turn.start",
435 commandId: CommandId.makeUnsafe("cmd-turn-start-title-formatted"),
436 threadId: ThreadId.makeUnsafe("thread-1"),
437 message: {
438 messageId: asMessageId("user-message-title-formatted"),
439 role: "user",
440 text: "[effort:high]\\n\\nFix reconnect spinner on resume",
441 attachments: [],
442 },
443 titleSeed: seededTitle,
444 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
445 runtimeMode: "approval-required",
446 createdAt: now,
447 }),
448 );
449
450 await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);
451 await waitFor(async () => {
452 const readModel = await Effect.runPromise(harness.engine.getReadModel());
453 return (
454 readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title ===
455 "Reconnect spinner resume bug"
456 );
457 });
458
459 const readModel = await Effect.runPromise(harness.engine.getReadModel());
460 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
461 expect(thread?.title).toBe("Reconnect spinner resume bug");
462 });
463
464 it("generates a worktree branch name for the first turn", async () => {
465 const harness = await createHarness();
466 const now = new Date().toISOString();
467
468 await Effect.runPromise(
469 harness.engine.dispatch({
470 type: "thread.meta.update",
471 commandId: CommandId.makeUnsafe("cmd-thread-branch"),
472 threadId: ThreadId.makeUnsafe("thread-1"),
473 branch: "t3code/1234abcd",
474 worktreePath: "/tmp/provider-project-worktree",
475 }),
476 );
477
478 harness.generateBranchName.mockImplementation((input: unknown) =>
479 Effect.succeed({
480 branch:
481 typeof input === "object" &&
482 input !== null &&
483 "modelSelection" in input &&
484 typeof input.modelSelection === "object" &&
485 input.modelSelection !== null &&
486 "model" in input.modelSelection &&
487 typeof input.modelSelection.model === "string"
488 ? `feature/${input.modelSelection.model}`
489 : "feature/generated",
490 }),
491 );
492
493 await Effect.runPromise(
494 harness.engine.dispatch({
495 type: "thread.turn.start",
496 commandId: CommandId.makeUnsafe("cmd-turn-start-branch-model"),
497 threadId: ThreadId.makeUnsafe("thread-1"),
498 message: {
499 messageId: asMessageId("user-message-branch-model"),
500 role: "user",
501 text: "Add a safer reconnect backoff.",
502 attachments: [],
503 },
504 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
505 runtimeMode: "approval-required",
506 createdAt: now,
507 }),
508 );
509
510 await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
511 expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({
512 message: "Add a safer reconnect backoff.",
513 });
514 });
515
311516 it("forwards codex model options through session start and turn send", async () => {
312517 const harness = await createHarness();
313518 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+85-19
......@@ -73,6 +73,19 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
7373const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
7474const WORKTREE_BRANCH_PREFIX = "t3code";
7575const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
76const DEFAULT_THREAD_TITLE = "New thread";
77
78function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
79 const trimmedCurrentTitle = currentTitle.trim();
80 if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) {
81 return true;
82 }
83
84 const trimmedTitleSeed = titleSeed?.trim();
85 return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0
86 ? trimmedCurrentTitle === trimmedTitleSeed
87 : false;
88}
7689
7790function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
7891 const error = Cause.squash(cause);
......@@ -400,7 +413,6 @@ const make = Effect.gen(function* () {
400413 readonly threadId: ThreadId;
401414 readonly branch: string | null;
402415 readonly worktreePath: string | null;
403 readonly messageId: string;
404416 readonly messageText: string;
405417 readonly attachments?: ReadonlyArray<ChatAttachment>;
406418 }) {
......@@ -411,16 +423,6 @@ const make = Effect.gen(function* () {
411423 return;
412424 }
413425
414 const thread = yield* resolveThread(input.threadId);
415 if (!thread) {
416 return;
417 }
418
419 const userMessages = thread.messages.filter((message) => message.role === "user");
420 if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) {
421 return;
422 }
423
424426 const oldBranch = input.branch;
425427 const cwd = input.worktreePath;
426428 const attachments = input.attachments ?? [];
......@@ -459,6 +461,49 @@ const make = Effect.gen(function* () {
459461 );
460462 });
461463
464 const maybeGenerateThreadTitleForFirstTurn = Effect.fnUntraced(function* (input: {
465 readonly threadId: ThreadId;
466 readonly cwd: string;
467 readonly messageText: string;
468 readonly attachments?: ReadonlyArray<ChatAttachment>;
469 readonly titleSeed?: string;
470 }) {
471 const attachments = input.attachments ?? [];
472 yield* Effect.gen(function* () {
473 const { textGenerationModelSelection: modelSelection } =
474 yield* serverSettingsService.getSettings;
475
476 const generated = yield* textGeneration.generateThreadTitle({
477 cwd: input.cwd,
478 message: input.messageText,
479 ...(attachments.length > 0 ? { attachments } : {}),
480 modelSelection,
481 });
482 if (!generated) return;
483
484 const thread = yield* resolveThread(input.threadId);
485 if (!thread) return;
486 if (!canReplaceThreadTitle(thread.title, input.titleSeed)) {
487 return;
488 }
489
490 yield* orchestrationEngine.dispatch({
491 type: "thread.meta.update",
492 commandId: serverCommandId("thread-title-rename"),
493 threadId: input.threadId,
494 title: generated.title,
495 });
496 }).pipe(
497 Effect.catchCause((cause) =>
498 Effect.logWarning("provider command reactor failed to generate or rename thread title", {
499 threadId: input.threadId,
500 cwd: input.cwd,
501 cause: Cause.pretty(cause),
502 }),
503 ),
504 );
505 });
506
462507 const processTurnStartRequested = Effect.fnUntraced(function* (
463508 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
464509 ) {
......@@ -485,14 +530,35 @@ const make = Effect.gen(function* () {
485530 return;
486531 }
487532
488 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
489 threadId: event.payload.threadId,
490 branch: thread.branch,
491 worktreePath: thread.worktreePath,
492 messageId: message.id,
493 messageText: message.text,
494 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
495 }).pipe(Effect.forkScoped);
533 const isFirstUserMessageTurn =
534 thread.messages.filter((entry) => entry.role === "user").length === 1;
535 if (isFirstUserMessageTurn) {
536 const generationCwd =
537 resolveThreadWorkspaceCwd({
538 thread,
539 projects: (yield* orchestrationEngine.getReadModel()).projects,
540 }) ?? process.cwd();
541 const generationInput = {
542 messageText: message.text,
543 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
544 ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}),
545 };
546
547 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
548 threadId: event.payload.threadId,
549 branch: thread.branch,
550 worktreePath: thread.worktreePath,
551 ...generationInput,
552 }).pipe(Effect.forkScoped);
553
554 if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) {
555 yield* maybeGenerateThreadTitleForFirstTurn({
556 threadId: event.payload.threadId,
557 cwd: generationCwd,
558 ...generationInput,
559 }).pipe(Effect.forkScoped);
560 }
561 }
496562
497563 yield* sendTurnForThread({
498564 threadId: event.payload.threadId,
apps/server/src/orchestration/decider.ts+1-1
......@@ -16,7 +16,6 @@ import {
1616} from "./commandInvariants.ts";
1717
1818const nowIso = () => new Date().toISOString();
19
2019const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {
2120 eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],
2221 aggregateKind: "thread",
......@@ -376,6 +375,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
376375 ...(command.modelSelection !== undefined
377376 ? { modelSelection: command.modelSelection }
378377 : {}),
378 ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}),
379379 runtimeMode: targetThread.runtimeMode,
380380 interactionMode: targetThread.interactionMode,
381381 ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}),
apps/web/src/components/ChatView.tsx+6-3
......@@ -22,6 +22,7 @@ import {
2222 RuntimeMode,
2323} from "@t3tools/contracts";
2424import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
25import { truncate } from "@t3tools/shared/String";
2526import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2627import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2728import { useDebouncedValue } from "@tanstack/react-pacer";
......@@ -69,7 +70,6 @@ import {
6970 proposedPlanTitle,
7071 resolvePlanFollowUpSubmission,
7172} from "../proposedPlan";
72import { truncateTitle } from "../truncateTitle";
7373import {
7474 DEFAULT_INTERACTION_MODE,
7575 DEFAULT_RUNTIME_MODE,
......@@ -2625,7 +2625,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
26252625 titleSeed = "New thread";
26262626 }
26272627 }
2628 const title = truncateTitle(titleSeed);
2628 const title = truncate(titleSeed);
26292629 const threadCreateModelSelection: ModelSelection = {
26302630 provider: selectedProvider,
26312631 model:
......@@ -2710,6 +2710,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27102710 attachments: turnAttachments,
27112711 },
27122712 modelSelection: selectedModelSelection,
2713 titleSeed: title,
27132714 runtimeMode,
27142715 interactionMode,
27152716 createdAt: messageCreatedAt,
......@@ -2991,6 +2992,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29912992 attachments: [],
29922993 },
29932994 modelSelection: selectedModelSelection,
2995 titleSeed: activeThread.title,
29942996 runtimeMode,
29952997 interactionMode: nextInteractionMode,
29962998 ...(nextInteractionMode === "default" && activeProposedPlan
......@@ -3070,7 +3072,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30703072 effort: selectedPromptEffort,
30713073 text: implementationPrompt,
30723074 });
3073 const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown));
3075 const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
30743076 const nextThreadModelSelection: ModelSelection = selectedModelSelection;
30753077
30763078 sendInFlightRef.current = true;
......@@ -3106,6 +3108,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
31063108 attachments: [],
31073109 },
31083110 modelSelection: selectedModelSelection,
3111 titleSeed: nextThreadTitle,
31093112 runtimeMode,
31103113 interactionMode: "default",
31113114 createdAt,
apps/web/src/truncateTitle.test.ts deleted-17
......@@ -1,17 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { truncateTitle } from "./truncateTitle";
4
5describe("truncateTitle", () => {
6 it("trims surrounding whitespace", () => {
7 expect(truncateTitle(" hello world ")).toBe("hello world");
8 });
9
10 it("returns trimmed text when within max length", () => {
11 expect(truncateTitle("alpha", 10)).toBe("alpha");
12 });
13
14 it("appends ellipsis when text exceeds max length", () => {
15 expect(truncateTitle("abcdefghij", 5)).toBe("abcde...");
16 });
17});
apps/web/src/truncateTitle.ts deleted-7
......@@ -1,7 +0,0 @@
1export function truncateTitle(text: string, maxLength = 50): string {
2 const trimmed = text.trim();
3 if (trimmed.length <= maxLength) {
4 return trimmed;
5 }
6 return `${trimmed.slice(0, maxLength)}...`;
7}
packages/contracts/src/orchestration.test.ts+31
......@@ -319,6 +319,25 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
319319 }),
320320);
321321
322it.effect("accepts a title seed in thread.turn.start", () =>
323 Effect.gen(function* () {
324 const parsed = yield* decodeThreadTurnStartCommand({
325 type: "thread.turn.start",
326 commandId: "cmd-turn-title-seed",
327 threadId: "thread-1",
328 message: {
329 messageId: "msg-title-seed",
330 role: "user",
331 text: "hello",
332 attachments: [],
333 },
334 titleSeed: "Investigate reconnect failures",
335 createdAt: "2026-01-01T00:00:00.000Z",
336 });
337 assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures");
338 }),
339);
340
322341it.effect("accepts a source proposed plan reference in thread.turn.start", () =>
323342 Effect.gen(function* () {
324343 const parsed = yield* decodeThreadTurnStartCommand({
......@@ -378,6 +397,18 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe
378397 }),
379398);
380399
400it.effect("decodes thread.turn-start-requested title seed when present", () =>
401 Effect.gen(function* () {
402 const parsed = yield* decodeThreadTurnStartRequestedPayload({
403 threadId: "thread-2",
404 messageId: "msg-2",
405 titleSeed: "Investigate reconnect failures",
406 createdAt: "2026-01-01T00:00:00.000Z",
407 });
408 assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures");
409 }),
410);
411
381412it.effect("decodes latest turn source proposed plan metadata when present", () =>
382413 Effect.gen(function* () {
383414 const parsed = yield* decodeOrchestrationLatestTurn({
packages/contracts/src/orchestration.ts+4-1
......@@ -42,6 +42,7 @@ export const ProviderSandboxMode = Schema.Literals([
4242 "danger-full-access",
4343]);
4444export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
45
4546export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
4647
4748export const CodexModelSelection = Schema.Struct({
......@@ -398,6 +399,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
398399 attachments: Schema.Array(ChatAttachment),
399400 }),
400401 modelSelection: Schema.optional(ModelSelection),
402 titleSeed: Schema.optional(TrimmedNonEmptyString),
401403 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
402404 interactionMode: ProviderInteractionMode.pipe(
403405 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
......@@ -417,6 +419,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
417419 attachments: Schema.Array(UploadChatAttachment),
418420 }),
419421 modelSelection: Schema.optional(ModelSelection),
422 titleSeed: Schema.optional(TrimmedNonEmptyString),
420423 runtimeMode: RuntimeMode,
421424 interactionMode: ProviderInteractionMode,
422425 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
......@@ -711,7 +714,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
711714 threadId: ThreadId,
712715 messageId: MessageId,
713716 modelSelection: Schema.optional(ModelSelection),
714 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
717 titleSeed: Schema.optional(TrimmedNonEmptyString),
715718 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
716719 interactionMode: ProviderInteractionMode.pipe(
717720 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
packages/shared/package.json+4
......@@ -35,6 +35,10 @@
3535 "./Struct": {
3636 "types": "./src/Struct.ts",
3737 "import": "./src/Struct.ts"
38 },
39 "./String": {
40 "types": "./src/String.ts",
41 "import": "./src/String.ts"
3842 }
3943 },
4044 "scripts": {
packages/shared/src/String.test.ts created+17
......@@ -0,0 +1,17 @@
1import { describe, expect, it } from "vitest";
2
3import { truncate } from "./String";
4
5describe("truncate", () => {
6 it("trims surrounding whitespace", () => {
7 expect(truncate(" hello world ")).toBe("hello world");
8 });
9
10 it("returns shorter strings unchanged", () => {
11 expect(truncate("alpha", 10)).toBe("alpha");
12 });
13
14 it("truncates long strings and appends an ellipsis", () => {
15 expect(truncate("abcdefghij", 5)).toBe("abcde...");
16 });
17});
packages/shared/src/String.ts created+8
......@@ -0,0 +1,8 @@
1export function truncate(text: string, maxLength = 50): string {
2 const trimmed = text.trim();
3 if (trimmed.length <= maxLength) {
4 return trimmed;
5 }
6
7 return `${trimmed.slice(0, maxLength)}...`;
8}