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 = (...@@ -306,7 +306,8 @@ export const makeOrchestrationIntegrationHarness = (
306 Effect.succeed({ branch: input.newBranch }),306 Effect.succeed({ branch: input.newBranch }),
307 } as unknown as GitCoreShape);307 } as unknown as GitCoreShape);
308 const textGenerationLayer = Layer.succeed(TextGeneration, {308 const textGenerationLayer = Layer.succeed(TextGeneration, {
309 generateBranchName: () => Effect.succeed({ branch: null }),309 generateBranchName: () => Effect.succeed({ branch: "update" }),
310 generateThreadTitle: () => Effect.succeed({ title: "New thread" }),
310 } as unknown as TextGenerationShape);311 } as unknown as TextGenerationShape);
311 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(312 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
312 Layer.provideMerge(runtimeServicesLayer),313 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts+59
...@@ -5,6 +5,7 @@ import { expect } from "vitest";...@@ -5,6 +5,7 @@ import { expect } from "vitest";
55
6import { ServerConfig } from "../../config.ts";6import { ServerConfig } from "../../config.ts";
7import { TextGeneration } from "../Services/TextGeneration.ts";7import { TextGeneration } from "../Services/TextGeneration.ts";
8import { sanitizeThreadTitle } from "../Utils.ts";
8import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";9import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";10import { ServerSettingsService } from "../../serverSettings.ts";
1011
...@@ -247,4 +248,62 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {...@@ -247,4 +248,62 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
247 }),248 }),
248 ),249 ),
249 );250 );
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 );
250});309});
apps/server/src/git/Layers/ClaudeTextGeneration.ts+36-1
...@@ -20,11 +20,13 @@ import {...@@ -20,11 +20,13 @@ import {
20 buildBranchNamePrompt,20 buildBranchNamePrompt,
21 buildCommitMessagePrompt,21 buildCommitMessagePrompt,
22 buildPrContentPrompt,22 buildPrContentPrompt,
23 buildThreadTitlePrompt,
23} from "../Prompts.ts";24} from "../Prompts.ts";
24import {25import {
25 normalizeCliError,26 normalizeCliError,
26 sanitizeCommitSubject,27 sanitizeCommitSubject,
27 sanitizePrTitle,28 sanitizePrTitle,
29 sanitizeThreadTitle,
28 toJsonSchemaObject,30 toJsonSchemaObject,
29} from "../Utils.ts";31} from "../Utils.ts";
30import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts";32import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts";
...@@ -70,7 +72,11 @@ const makeClaudeTextGeneration = Effect.gen(function* () {...@@ -70,7 +72,11 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
70 outputSchemaJson,72 outputSchemaJson,
71 modelSelection,73 modelSelection,
72 }: {74 }: {
73 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";75 operation:
76 | "generateCommitMessage"
77 | "generatePrContent"
78 | "generateBranchName"
79 | "generateThreadTitle";
74 cwd: string;80 cwd: string;
75 prompt: string;81 prompt: string;
76 outputSchemaJson: S;82 outputSchemaJson: S;
...@@ -299,10 +305,39 @@ const makeClaudeTextGeneration = Effect.gen(function* () {...@@ -299,10 +305,39 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
299 };305 };
300 });306 });
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
302 return {336 return {
303 generateCommitMessage,337 generateCommitMessage,
304 generatePrContent,338 generatePrContent,
305 generateBranchName,339 generateBranchName,
340 generateThreadTitle,
306 } satisfies TextGenerationShape;341 } satisfies TextGenerationShape;
307});342});
308343
apps/server/src/git/Layers/CodexTextGeneration.test.ts+64
...@@ -358,6 +358,70 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -358,6 +358,70 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
358 ),358 ),
359 );359 );
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
361 it.effect("omits attachment metadata section when no attachments are provided", () =>425 it.effect("omits attachment metadata section when no attachments are provided", () =>
362 withFakeCodexEnv(426 withFakeCodexEnv(
363 {427 {
apps/server/src/git/Layers/CodexTextGeneration.ts+47-3
...@@ -11,6 +11,7 @@ import { ServerConfig } from "../../config.ts";...@@ -11,6 +11,7 @@ import { ServerConfig } from "../../config.ts";
11import { TextGenerationError } from "../Errors.ts";11import { TextGenerationError } from "../Errors.ts";
12import {12import {
13 type BranchNameGenerationInput,13 type BranchNameGenerationInput,
14 type ThreadTitleGenerationResult,
14 type TextGenerationShape,15 type TextGenerationShape,
15 TextGeneration,16 TextGeneration,
16} from "../Services/TextGeneration.ts";17} from "../Services/TextGeneration.ts";
...@@ -18,11 +19,13 @@ import {...@@ -18,11 +19,13 @@ import {
18 buildBranchNamePrompt,19 buildBranchNamePrompt,
19 buildCommitMessagePrompt,20 buildCommitMessagePrompt,
20 buildPrContentPrompt,21 buildPrContentPrompt,
22 buildThreadTitlePrompt,
21} from "../Prompts.ts";23} from "../Prompts.ts";
22import {24import {
23 normalizeCliError,25 normalizeCliError,
24 sanitizeCommitSubject,26 sanitizeCommitSubject,
25 sanitizePrTitle,27 sanitizePrTitle,
28 sanitizeThreadTitle,
26 toJsonSchemaObject,29 toJsonSchemaObject,
27} from "../Utils.ts";30} from "../Utils.ts";
28import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";31import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";
...@@ -30,7 +33,6 @@ import { ServerSettingsService } from "../../serverSettings.ts";...@@ -30,7 +33,6 @@ import { ServerSettingsService } from "../../serverSettings.ts";
3033
31const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";34const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
32const CODEX_TIMEOUT_MS = 180_000;35const CODEX_TIMEOUT_MS = 180_000;
33
34const makeCodexTextGeneration = Effect.gen(function* () {36const makeCodexTextGeneration = Effect.gen(function* () {
35 const fileSystem = yield* FileSystem.FileSystem;37 const fileSystem = yield* FileSystem.FileSystem;
36 const path = yield* Path.Path;38 const path = yield* Path.Path;
...@@ -83,7 +85,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -83,7 +85,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
83 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));85 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
8486
85 const materializeImageAttachments = (87 const materializeImageAttachments = (
86 _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",88 _operation:
89 | "generateCommitMessage"
90 | "generatePrContent"
91 | "generateBranchName"
92 | "generateThreadTitle",
87 attachments: BranchNameGenerationInput["attachments"],93 attachments: BranchNameGenerationInput["attachments"],
88 ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> =>94 ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> =>
89 Effect.gen(function* () {95 Effect.gen(function* () {
...@@ -124,7 +130,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -124,7 +130,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
124 cleanupPaths = [],130 cleanupPaths = [],
125 modelSelection,131 modelSelection,
126 }: {132 }: {
127 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";133 operation:
134 | "generateCommitMessage"
135 | "generatePrContent"
136 | "generateBranchName"
137 | "generateThreadTitle";
128 cwd: string;138 cwd: string;
129 prompt: string;139 prompt: string;
130 outputSchemaJson: S;140 outputSchemaJson: S;
...@@ -363,10 +373,44 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -363,10 +373,44 @@ const makeCodexTextGeneration = Effect.gen(function* () {
363 };373 };
364 });374 });
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
366 return {409 return {
367 generateCommitMessage,410 generateCommitMessage,
368 generatePrContent,411 generatePrContent,
369 generateBranchName,412 generateBranchName,
413 generateThreadTitle,
370 } satisfies TextGenerationShape;414 } satisfies TextGenerationShape;
371});415});
372416
apps/server/src/git/Layers/GitManager.test.ts+24-1
...@@ -6,7 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -6,7 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";6import { it } from "@effect/vitest";
7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
8import { expect } from "vitest";8import { expect } from "vitest";
9import type { GitActionProgressEvent } from "@t3tools/contracts";9import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";
1010
11import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";11import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
12import { type GitManagerShape } from "../Services/GitManager.ts";12import { type GitManagerShape } from "../Services/GitManager.ts";
...@@ -49,6 +49,7 @@ interface FakeGitTextGeneration {...@@ -49,6 +49,7 @@ interface FakeGitTextGeneration {
49 stagedSummary: string;49 stagedSummary: string;
50 stagedPatch: string;50 stagedPatch: string;
51 includeBranch?: boolean;51 includeBranch?: boolean;
52 modelSelection: ModelSelection;
52 }) => Effect.Effect<53 }) => Effect.Effect<
53 { subject: string; body: string; branch?: string | undefined },54 { subject: string; body: string; branch?: string | undefined },
54 TextGenerationError55 TextGenerationError
...@@ -60,11 +61,18 @@ interface FakeGitTextGeneration {...@@ -60,11 +61,18 @@ interface FakeGitTextGeneration {
60 commitSummary: string;61 commitSummary: string;
61 diffSummary: string;62 diffSummary: string;
62 diffPatch: string;63 diffPatch: string;
64 modelSelection: ModelSelection;
63 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;65 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
64 generateBranchName: (input: {66 generateBranchName: (input: {
65 cwd: string;67 cwd: string;
66 message: string;68 message: string;
69 modelSelection: ModelSelection;
67 }) => Effect.Effect<{ branch: string }, TextGenerationError>;70 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
71 generateThreadTitle: (input: {
72 cwd: string;
73 message: string;
74 modelSelection: ModelSelection;
75 }) => Effect.Effect<{ title: string }, TextGenerationError>;
68}76}
6977
70type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;78type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
...@@ -168,6 +176,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T...@@ -168,6 +176,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
168 Effect.succeed({176 Effect.succeed({
169 branch: "update-workflow",177 branch: "update-workflow",
170 }),178 }),
179 generateThreadTitle: () =>
180 Effect.succeed({
181 title: "Update workflow",
182 }),
171 ...overrides,183 ...overrides,
172 };184 };
173185
...@@ -205,6 +217,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T...@@ -205,6 +217,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
205 }),217 }),
206 ),218 ),
207 ),219 ),
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 ),
208 };231 };
209}232}
210233
apps/server/src/git/Layers/RoutingTextGeneration.ts+1
...@@ -47,6 +47,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () {...@@ -47,6 +47,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () {
47 route(input.modelSelection.provider).generateCommitMessage(input),47 route(input.modelSelection.provider).generateCommitMessage(input),
48 generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input),48 generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input),
49 generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input),49 generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input),
50 generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input),
50 } satisfies TextGenerationShape;51 } satisfies TextGenerationShape;
51});52});
5253
apps/server/src/git/Prompts.test.ts+44-1
...@@ -4,8 +4,9 @@ import {...@@ -4,8 +4,9 @@ import {
4 buildBranchNamePrompt,4 buildBranchNamePrompt,
5 buildCommitMessagePrompt,5 buildCommitMessagePrompt,
6 buildPrContentPrompt,6 buildPrContentPrompt,
7 buildThreadTitlePrompt,
7} from "./Prompts.ts";8} from "./Prompts.ts";
8import { normalizeCliError } from "./Utils.ts";9import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts";
9import { TextGenerationError } from "./Errors.ts";10import { TextGenerationError } from "./Errors.ts";
1011
11describe("buildCommitMessagePrompt", () => {12describe("buildCommitMessagePrompt", () => {
...@@ -103,6 +104,48 @@ describe("buildBranchNamePrompt", () => {...@@ -103,6 +104,48 @@ describe("buildBranchNamePrompt", () => {
103 });104 });
104});105});
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
106describe("normalizeCliError", () => {149describe("normalizeCliError", () => {
107 it("detects 'Command not found' and includes CLI name in the message", () => {150 it("detects 'Command not found' and includes CLI name in the message", () => {
108 const error = normalizeCliError(151 const error = normalizeCliError(
apps/server/src/git/Prompts.ts+57-8
...@@ -119,19 +119,24 @@ export interface BranchNamePromptInput {...@@ -119,19 +119,24 @@ export interface BranchNamePromptInput {
119 attachments?: ReadonlyArray<ChatAttachment> | undefined;119 attachments?: ReadonlyArray<ChatAttachment> | undefined;
120}120}
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 {
123 const attachmentLines = (input.attachments ?? []).map(131 const attachmentLines = (input.attachments ?? []).map(
124 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,132 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
125 );133 );
126134
127 const promptSections = [135 const promptSections = [
128 "You generate concise git branch names.",136 input.instruction,
129 "Return a JSON object with key: branch.",137 input.responseShape,
130 "Rules:",138 "Rules:",
131 "- Branch should describe the requested work from the user message.",139 ...input.rules.map((rule) => `- ${rule}`),
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.",
135 "",140 "",
136 "User message:",141 "User message:",
137 limitSection(input.message, 8_000),142 limitSection(input.message, 8_000),
...@@ -144,10 +149,54 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) {...@@ -144,10 +149,54 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) {
144 );149 );
145 }150 }
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 });
148 const outputSchema = Schema.Struct({168 const outputSchema = Schema.Struct({
149 branch: Schema.String,169 branch: Schema.String,
150 });170 });
151171
152 return { prompt, outputSchema };172 return { prompt, outputSchema };
153}173}
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 {...@@ -61,12 +61,25 @@ export interface BranchNameGenerationResult {
61 branch: string;61 branch: string;
62}62}
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
64export interface TextGenerationService {76export interface TextGenerationService {
65 generateCommitMessage(77 generateCommitMessage(
66 input: CommitMessageGenerationInput,78 input: CommitMessageGenerationInput,
67 ): Promise<CommitMessageGenerationResult>;79 ): Promise<CommitMessageGenerationResult>;
68 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;80 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
69 generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;81 generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
82 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
70}83}
7184
72/**85/**
...@@ -93,6 +106,13 @@ export interface TextGenerationShape {...@@ -93,6 +106,13 @@ export interface TextGenerationShape {
93 readonly generateBranchName: (106 readonly generateBranchName: (
94 input: BranchNameGenerationInput,107 input: BranchNameGenerationInput,
95 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;108 ) => 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>;
96}116}
97117
98/**118/**
apps/server/src/git/Utils.ts+21
...@@ -53,6 +53,27 @@ export function sanitizePrTitle(raw: string): string {...@@ -53,6 +53,27 @@ export function sanitizePrTitle(raw: string): string {
53 return "Update project changes";53 return "Update project changes";
54}54}
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
56/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */77/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
57function cliLabel(cliName: string): string {78function cliLabel(cliName: string): string {
58 const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);79 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", () => {...@@ -176,7 +176,7 @@ describe("ProviderCommandReactor", () => {
176 : "renamed-branch",176 : "renamed-branch",
177 }),177 }),
178 );178 );
179 const generateBranchName = vi.fn(() =>179 const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>((_) =>
180 Effect.fail(180 Effect.fail(
181 new TextGenerationError({181 new TextGenerationError({
182 operation: "generateBranchName",182 operation: "generateBranchName",
...@@ -184,6 +184,14 @@ describe("ProviderCommandReactor", () => {...@@ -184,6 +184,14 @@ describe("ProviderCommandReactor", () => {
184 }),184 }),
185 ),185 ),
186 );186 );
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
188 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;196 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
189 const service: ProviderServiceShape = {197 const service: ProviderServiceShape = {
...@@ -213,7 +221,10 @@ describe("ProviderCommandReactor", () => {...@@ -213,7 +221,10 @@ describe("ProviderCommandReactor", () => {
213 Layer.provideMerge(Layer.succeed(ProviderService, service)),221 Layer.provideMerge(Layer.succeed(ProviderService, service)),
214 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),222 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
215 Layer.provideMerge(223 Layer.provideMerge(
216 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),224 Layer.mock(TextGeneration, {
225 generateBranchName,
226 generateThreadTitle,
227 }),
217 ),228 ),
218 Layer.provideMerge(ServerSettingsService.layerTest()),229 Layer.provideMerge(ServerSettingsService.layerTest()),
219 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),230 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
...@@ -264,6 +275,7 @@ describe("ProviderCommandReactor", () => {...@@ -264,6 +275,7 @@ describe("ProviderCommandReactor", () => {
264 stopSession,275 stopSession,
265 renameBranch,276 renameBranch,
266 generateBranchName,277 generateBranchName,
278 generateThreadTitle,
267 stateDir,279 stateDir,
268 drain,280 drain,
269 };281 };
...@@ -308,6 +320,199 @@ describe("ProviderCommandReactor", () => {...@@ -308,6 +320,199 @@ describe("ProviderCommandReactor", () => {
308 expect(thread?.session?.runtimeMode).toBe("approval-required");320 expect(thread?.session?.runtimeMode).toBe("approval-required");
309 });321 });
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
311 it("forwards codex model options through session start and turn send", async () => {516 it("forwards codex model options through session start and turn send", async () => {
312 const harness = await createHarness();517 const harness = await createHarness();
313 const now = new Date().toISOString();518 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);...@@ -73,6 +73,19 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
73const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";73const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
74const WORKTREE_BRANCH_PREFIX = "t3code";74const WORKTREE_BRANCH_PREFIX = "t3code";
75const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);75const 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
77function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {90function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
78 const error = Cause.squash(cause);91 const error = Cause.squash(cause);
...@@ -400,7 +413,6 @@ const make = Effect.gen(function* () {...@@ -400,7 +413,6 @@ const make = Effect.gen(function* () {
400 readonly threadId: ThreadId;413 readonly threadId: ThreadId;
401 readonly branch: string | null;414 readonly branch: string | null;
402 readonly worktreePath: string | null;415 readonly worktreePath: string | null;
403 readonly messageId: string;
404 readonly messageText: string;416 readonly messageText: string;
405 readonly attachments?: ReadonlyArray<ChatAttachment>;417 readonly attachments?: ReadonlyArray<ChatAttachment>;
406 }) {418 }) {
...@@ -411,16 +423,6 @@ const make = Effect.gen(function* () {...@@ -411,16 +423,6 @@ const make = Effect.gen(function* () {
411 return;423 return;
412 }424 }
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
424 const oldBranch = input.branch;426 const oldBranch = input.branch;
425 const cwd = input.worktreePath;427 const cwd = input.worktreePath;
426 const attachments = input.attachments ?? [];428 const attachments = input.attachments ?? [];
...@@ -459,6 +461,49 @@ const make = Effect.gen(function* () {...@@ -459,6 +461,49 @@ const make = Effect.gen(function* () {
459 );461 );
460 });462 });
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
462 const processTurnStartRequested = Effect.fnUntraced(function* (507 const processTurnStartRequested = Effect.fnUntraced(function* (
463 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,508 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
464 ) {509 ) {
...@@ -485,14 +530,35 @@ const make = Effect.gen(function* () {...@@ -485,14 +530,35 @@ const make = Effect.gen(function* () {
485 return;530 return;
486 }531 }
487532
488 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({533 const isFirstUserMessageTurn =
489 threadId: event.payload.threadId,534 thread.messages.filter((entry) => entry.role === "user").length === 1;
490 branch: thread.branch,535 if (isFirstUserMessageTurn) {
491 worktreePath: thread.worktreePath,536 const generationCwd =
492 messageId: message.id,537 resolveThreadWorkspaceCwd({
493 messageText: message.text,538 thread,
494 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),539 projects: (yield* orchestrationEngine.getReadModel()).projects,
495 }).pipe(Effect.forkScoped);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
497 yield* sendTurnForThread({563 yield* sendTurnForThread({
498 threadId: event.payload.threadId,564 threadId: event.payload.threadId,
apps/server/src/orchestration/decider.ts+1-1
...@@ -16,7 +16,6 @@ import {...@@ -16,7 +16,6 @@ import {
16} from "./commandInvariants.ts";16} from "./commandInvariants.ts";
1717
18const nowIso = () => new Date().toISOString();18const nowIso = () => new Date().toISOString();
19
20const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {19const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {
21 eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],20 eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],
22 aggregateKind: "thread",21 aggregateKind: "thread",
...@@ -376,6 +375,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -376,6 +375,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
376 ...(command.modelSelection !== undefined375 ...(command.modelSelection !== undefined
377 ? { modelSelection: command.modelSelection }376 ? { modelSelection: command.modelSelection }
378 : {}),377 : {}),
378 ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}),
379 runtimeMode: targetThread.runtimeMode,379 runtimeMode: targetThread.runtimeMode,
380 interactionMode: targetThread.interactionMode,380 interactionMode: targetThread.interactionMode,
381 ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}),381 ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}),
apps/web/src/components/ChatView.tsx+6-3
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 RuntimeMode,22 RuntimeMode,
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";24import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
25import { truncate } from "@t3tools/shared/String";
25import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";26import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
26import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";27import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
27import { useDebouncedValue } from "@tanstack/react-pacer";28import { useDebouncedValue } from "@tanstack/react-pacer";
...@@ -69,7 +70,6 @@ import {...@@ -69,7 +70,6 @@ import {
69 proposedPlanTitle,70 proposedPlanTitle,
70 resolvePlanFollowUpSubmission,71 resolvePlanFollowUpSubmission,
71} from "../proposedPlan";72} from "../proposedPlan";
72import { truncateTitle } from "../truncateTitle";
73import {73import {
74 DEFAULT_INTERACTION_MODE,74 DEFAULT_INTERACTION_MODE,
75 DEFAULT_RUNTIME_MODE,75 DEFAULT_RUNTIME_MODE,
...@@ -2625,7 +2625,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2625,7 +2625,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2625 titleSeed = "New thread";2625 titleSeed = "New thread";
2626 }2626 }
2627 }2627 }
2628 const title = truncateTitle(titleSeed);2628 const title = truncate(titleSeed);
2629 const threadCreateModelSelection: ModelSelection = {2629 const threadCreateModelSelection: ModelSelection = {
2630 provider: selectedProvider,2630 provider: selectedProvider,
2631 model:2631 model:
...@@ -2710,6 +2710,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2710,6 +2710,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2710 attachments: turnAttachments,2710 attachments: turnAttachments,
2711 },2711 },
2712 modelSelection: selectedModelSelection,2712 modelSelection: selectedModelSelection,
2713 titleSeed: title,
2713 runtimeMode,2714 runtimeMode,
2714 interactionMode,2715 interactionMode,
2715 createdAt: messageCreatedAt,2716 createdAt: messageCreatedAt,
...@@ -2991,6 +2992,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2991,6 +2992,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2991 attachments: [],2992 attachments: [],
2992 },2993 },
2993 modelSelection: selectedModelSelection,2994 modelSelection: selectedModelSelection,
2995 titleSeed: activeThread.title,
2994 runtimeMode,2996 runtimeMode,
2995 interactionMode: nextInteractionMode,2997 interactionMode: nextInteractionMode,
2996 ...(nextInteractionMode === "default" && activeProposedPlan2998 ...(nextInteractionMode === "default" && activeProposedPlan
...@@ -3070,7 +3072,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3070,7 +3072,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3070 effort: selectedPromptEffort,3072 effort: selectedPromptEffort,
3071 text: implementationPrompt,3073 text: implementationPrompt,
3072 });3074 });
3073 const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown));3075 const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
3074 const nextThreadModelSelection: ModelSelection = selectedModelSelection;3076 const nextThreadModelSelection: ModelSelection = selectedModelSelection;
30753077
3076 sendInFlightRef.current = true;3078 sendInFlightRef.current = true;
...@@ -3106,6 +3108,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3106,6 +3108,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3106 attachments: [],3108 attachments: [],
3107 },3109 },
3108 modelSelection: selectedModelSelection,3110 modelSelection: selectedModelSelection,
3111 titleSeed: nextThreadTitle,
3109 runtimeMode,3112 runtimeMode,
3110 interactionMode: "default",3113 interactionMode: "default",
3111 createdAt,3114 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", () =>...@@ -319,6 +319,25 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
319 }),319 }),
320);320);
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
322it.effect("accepts a source proposed plan reference in thread.turn.start", () =>341it.effect("accepts a source proposed plan reference in thread.turn.start", () =>
323 Effect.gen(function* () {342 Effect.gen(function* () {
324 const parsed = yield* decodeThreadTurnStartCommand({343 const parsed = yield* decodeThreadTurnStartCommand({
...@@ -378,6 +397,18 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe...@@ -378,6 +397,18 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe
378 }),397 }),
379);398);
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
381it.effect("decodes latest turn source proposed plan metadata when present", () =>412it.effect("decodes latest turn source proposed plan metadata when present", () =>
382 Effect.gen(function* () {413 Effect.gen(function* () {
383 const parsed = yield* decodeOrchestrationLatestTurn({414 const parsed = yield* decodeOrchestrationLatestTurn({
packages/contracts/src/orchestration.ts+4-1
...@@ -42,6 +42,7 @@ export const ProviderSandboxMode = Schema.Literals([...@@ -42,6 +42,7 @@ export const ProviderSandboxMode = Schema.Literals([
42 "danger-full-access",42 "danger-full-access",
43]);43]);
44export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;44export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
45
45export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";46export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
4647
47export const CodexModelSelection = Schema.Struct({48export const CodexModelSelection = Schema.Struct({
...@@ -398,6 +399,7 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -398,6 +399,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
398 attachments: Schema.Array(ChatAttachment),399 attachments: Schema.Array(ChatAttachment),
399 }),400 }),
400 modelSelection: Schema.optional(ModelSelection),401 modelSelection: Schema.optional(ModelSelection),
402 titleSeed: Schema.optional(TrimmedNonEmptyString),
401 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),403 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
402 interactionMode: ProviderInteractionMode.pipe(404 interactionMode: ProviderInteractionMode.pipe(
403 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),405 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
...@@ -417,6 +419,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({...@@ -417,6 +419,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
417 attachments: Schema.Array(UploadChatAttachment),419 attachments: Schema.Array(UploadChatAttachment),
418 }),420 }),
419 modelSelection: Schema.optional(ModelSelection),421 modelSelection: Schema.optional(ModelSelection),
422 titleSeed: Schema.optional(TrimmedNonEmptyString),
420 runtimeMode: RuntimeMode,423 runtimeMode: RuntimeMode,
421 interactionMode: ProviderInteractionMode,424 interactionMode: ProviderInteractionMode,
422 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),425 sourceProposedPlan: Schema.optional(SourceProposedPlanReference),
...@@ -711,7 +714,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({...@@ -711,7 +714,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
711 threadId: ThreadId,714 threadId: ThreadId,
712 messageId: MessageId,715 messageId: MessageId,
713 modelSelection: Schema.optional(ModelSelection),716 modelSelection: Schema.optional(ModelSelection),
714 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),717 titleSeed: Schema.optional(TrimmedNonEmptyString),
715 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),718 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
716 interactionMode: ProviderInteractionMode.pipe(719 interactionMode: ProviderInteractionMode.pipe(
717 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),720 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
packages/shared/package.json+4
...@@ -35,6 +35,10 @@...@@ -35,6 +35,10 @@
35 "./Struct": {35 "./Struct": {
36 "types": "./src/Struct.ts",36 "types": "./src/Struct.ts",
37 "import": "./src/Struct.ts"37 "import": "./src/Struct.ts"
38 },
39 "./String": {
40 "types": "./src/String.ts",
41 "import": "./src/String.ts"
38 }42 }
39 },43 },
40 "scripts": {44 "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}