authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-04 14:02:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 14:02:33-07:00
logd7950ac153c6fdd788ef63699a5d061243bb4997
tree353f22b5b75a2199d4fdeef321ca8886d163c459
parent36caf34c68b7bc6c2e967fdec1812fd08f61cdc8
signature Signed by PGP key B5690EEEBB952194

fix(server): generate durable thread titles (#5357)


7 files changed, 73 insertions(+), 22 deletions(-)

apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+1-1
...@@ -264,7 +264,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {...@@ -264,7 +264,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
264 ' "Reconnect failures after restart because the session state does not recover" ',264 ' "Reconnect failures after restart because the session state does not recover" ',
265 },265 },
266 }),266 }),
267 stdinMustContain: "You write concise thread titles for coding conversations.",267 stdinMustContain: "Please investigate reconnect failures after restarting the session.",
268 },268 },
269 (textGeneration) =>269 (textGeneration) =>
270 Effect.gen(function* () {270 Effect.gen(function* () {
apps/server/src/textGeneration/CodexTextGeneration.ts+7-4
...@@ -7,7 +7,12 @@ import * as Scope from "effect/Scope";...@@ -7,7 +7,12 @@ import * as Scope from "effect/Scope";
7import * as Stream from "effect/Stream";7import * as Stream from "effect/Stream";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
99
10import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";10import {
11 type CodexSettings,
12 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
13 type ModelSelection,
14 TextGenerationError,
15} from "@t3tools/contracts";
11import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";16import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
12import { resolveSpawnCommand } from "@t3tools/shared/shell";17import { resolveSpawnCommand } from "@t3tools/shared/shell";
1318
...@@ -15,7 +20,6 @@ import { resolveAttachmentPath } from "../attachmentStore.ts";...@@ -15,7 +20,6 @@ import { resolveAttachmentPath } from "../attachmentStore.ts";
15import * as ServerConfig from "../config.ts";20import * as ServerConfig from "../config.ts";
16import { expandHomePath } from "../pathExpansion.ts";21import { expandHomePath } from "../pathExpansion.ts";
17import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts";22import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts";
18import { TextGenerationError } from "@t3tools/contracts";
19import * as TextGeneration from "./TextGeneration.ts";23import * as TextGeneration from "./TextGeneration.ts";
20import {24import {
21 buildBranchNamePrompt,25 buildBranchNamePrompt,
...@@ -33,7 +37,6 @@ import {...@@ -33,7 +37,6 @@ import {
33import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";37import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
34import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts";38import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts";
3539
36const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
37const CODEX_TIMEOUT_MS = 180_000;40const CODEX_TIMEOUT_MS = 180_000;
38const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));41const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
39/**42/**
...@@ -178,7 +181,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -178,7 +181,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
178 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);181 const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment);
179 const reasoningEffort =182 const reasoningEffort =
180 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??183 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
181 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;184 DEFAULT_TEXT_GENERATION_REASONING_EFFORT;
182 const serviceTier = getCodexServiceTierOptionValue(modelSelection);185 const serviceTier = getCodexServiceTierOptionValue(modelSelection);
183 const spawnCommand = yield* resolveSpawnCommand(186 const spawnCommand = yield* resolveSpawnCommand(
184 codexConfig.binaryPath || "codex",187 codexConfig.binaryPath || "codex",
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+14-3
...@@ -154,6 +154,18 @@ describe("buildThreadTitlePrompt", () => {...@@ -154,6 +154,18 @@ describe("buildThreadTitlePrompt", () => {
154 expect(result.prompt).toContain("User message:");154 expect(result.prompt).toContain("User message:");
155 expect(result.prompt).toContain("Investigate reconnect regressions after session restore");155 expect(result.prompt).toContain("Investigate reconnect regressions after session restore");
156 expect(result.prompt).not.toContain("Attachment metadata:");156 expect(result.prompt).not.toContain("Attachment metadata:");
157 expect(result.prompt).toContain(
158 "Generate a title that will help the user recognize this T3 Code thread weeks later.",
159 );
160 expect(result.prompt).toContain(
161 "Title the subject and outcome. Discard incidental instructions.",
162 );
163 expect(result.prompt).toContain(
164 "Name the product change, not the mock, plan, report, branch, or PR used to produce it.",
165 );
166 expect(result.prompt).not.toContain(
167 "Title should summarize the user's request, not restate it verbatim.",
168 );
157 });169 });
158170
159 it("includes attachment metadata when attachments are provided", () => {171 it("includes attachment metadata when attachments are provided", () => {
...@@ -183,12 +195,11 @@ describe("buildThreadTitlePrompt", () => {...@@ -183,12 +195,11 @@ describe("buildThreadTitlePrompt", () => {
183 });195 });
184196
185 expect(result.prompt).toContain(197 expect(result.prompt).toContain(
186 "The user requested a new title based on the contents of this thread.",198 "Generate a new title that will help the user recognize this T3 Code thread weeks later.",
187 );199 );
188 expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');200 expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');
189 expect(result.prompt).toContain("better represents the current state of the thread");
190 expect(result.prompt).toContain(201 expect(result.prompt).toContain(
191 "Capture the thread's intent, not a PR number or other superficial detail.",202 "Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.",
192 );203 );
193 expect(result.prompt).toContain("Thread contents:");204 expect(result.prompt).toContain("Thread contents:");
194 expect(result.prompt).toContain("The remaining issue is stale session state");205 expect(result.prompt).toContain("The remaining issue is stale session state");
apps/server/src/textGeneration/TextGenerationPrompts.ts+31-13
...@@ -150,6 +150,8 @@ export interface BranchNamePromptInput {...@@ -150,6 +150,8 @@ export interface BranchNamePromptInput {
150interface PromptFromMessageInput {150interface PromptFromMessageInput {
151 instruction: string;151 instruction: string;
152 responseShape: string;152 responseShape: string;
153 guidance?: ReadonlyArray<string> | undefined;
154 rulesLabel?: string | undefined;
153 rules: ReadonlyArray<string>;155 rules: ReadonlyArray<string>;
154 message: string;156 message: string;
155 messageLabel?: string | undefined;157 messageLabel?: string | undefined;
...@@ -177,7 +179,8 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {...@@ -177,7 +179,8 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {
177 const promptSections = [179 const promptSections = [
178 input.instruction,180 input.instruction,
179 input.responseShape,181 input.responseShape,
180 "Rules:",182 ...(input.guidance ?? []),
183 input.rulesLabel ?? "Rules:",
181 ...input.rules.map((rule) => `- ${rule}`),184 ...input.rules.map((rule) => `- ${rule}`),
182 "",185 "",
183 `${input.messageLabel ?? "User message"}:`,186 `${input.messageLabel ?? "User message"}:`,
...@@ -234,26 +237,41 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {...@@ -234,26 +237,41 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
234 const prompt = buildPromptFromMessage({237 const prompt = buildPromptFromMessage({
235 instruction: isRegeneration238 instruction: isRegeneration
236 ? [239 ? [
237 "You write concise thread titles for coding conversations.",240 "Generate a new title that will help the user recognize this T3 Code thread weeks later.",
238 "The user requested a new title based on the contents of this thread.",
239 `The previous title was ${JSON.stringify(input.previousTitle)}.`,241 `The previous title was ${JSON.stringify(input.previousTitle)}.`,
240 "Come up with a new title that better represents the current state of the thread.",
241 ].join("\n")242 ].join("\n")
242 : "You write concise thread titles for coding conversations.",243 : "Generate a title that will help the user recognize this T3 Code thread weeks later.",
243 responseShape: "Return a JSON object with key: title.",244 responseShape: "Return JSON with exactly one key: title.",
245 guidance: [
246 "",
247 "Before answering, silently reduce the request to:",
248 "- Subject: What system, feature, or problem is this really about?",
249 "- Outcome: What does the user ultimately want to understand or change?",
250 "- Incidental instructions: What only describes how the agent should do the work?",
251 "",
252 "Title the subject and outcome. Discard incidental instructions.",
253 "",
254 ],
255 rulesLabel: "Editorial rules:",
244 rules: [256 rules: [
245 isRegeneration257 "3-8 words, fewer than 40 characters.",
246 ? "Title should summarize the thread's current state, not just its initial request."258 "Use a compact noun phrase or clear action phrase.",
247 : "Title should summarize the user's request, not restate it verbatim.",259 "Capture the umbrella goal when the request lists several symptoms or steps.",
260 "Name the product change, not the mock, plan, report, branch, or PR used to produce it.",
261 "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.",
262 'For reviews, name what is being reviewed and the relevant concern. Avoid generic titles such as "Review PR 123" when linked or attached context reveals the subject.',
263 "For research, name the question domain rather than the requested research process.",
264 "Do not claim the work is complete.",
265 "Do not copy and truncate the user's message.",
266 "Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation.",
267 "Use attached images as primary context for UI issues.",
268 "When a URL or attachment is the only source of the subject, use available tools to inspect it. If it cannot be resolved, remain accurate rather than guessing.",
248 ...(isRegeneration269 ...(isRegeneration
249 ? [270 ? [
250 "Capture the thread's intent, not a PR number or other superficial detail.",271 "Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.",
251 "Return a different title from the previous title.",272 "Return a different title from the previous title.",
252 ]273 ]
253 : []),274 : []),
254 "Keep it short and specific (3-8 words).",
255 "Avoid quotes, filler, prefixes, and trailing punctuation.",
256 "If images are attached, use them as primary context for visual/UI issues.",
257 ],275 ],
258 message: input.message,276 message: input.message,
259 ...(isRegeneration277 ...(isRegeneration
packages/contracts/src/model.ts+1
...@@ -145,6 +145,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [...@@ -145,6 +145,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [
145 "gpt-5.6-terra",145 "gpt-5.6-terra",
146];146];
147export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";147export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";
148export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low";
148149
149export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {150export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {
150 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,151 [CODEX_DRIVER_KIND]: DEFAULT_MODEL,
packages/contracts/src/settings.test.ts+8
...@@ -112,6 +112,14 @@ describe("ClientSettings sidebar v2", () => {...@@ -112,6 +112,14 @@ describe("ClientSettings sidebar v2", () => {
112});112});
113113
114describe("ServerSettings.providerInstances (slice-2 invariant)", () => {114describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
115 it("defaults text generation to Luna at low reasoning effort", () => {
116 expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({
117 instanceId: ProviderInstanceId.make("codex"),
118 model: "gpt-5.6-luna",
119 options: [{ id: "reasoningEffort", value: "low" }],
120 });
121 });
122
115 it("defaults to an empty record so legacy configs without the key still decode", () => {123 it("defaults to an empty record so legacy configs without the key still decode", () => {
116 expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({});124 expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({});
117 });125 });
packages/contracts/src/settings.ts+11-1
...@@ -3,7 +3,11 @@ import * as Duration from "effect/Duration";...@@ -3,7 +3,11 @@ import * as Duration from "effect/Duration";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { DEFAULT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";6import {
7 DEFAULT_TEXT_GENERATION_MODEL,
8 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
9 ProviderOptionSelections,
10} from "./model.ts";
7import { ModelSelection } from "./orchestration.ts";11import { ModelSelection } from "./orchestration.ts";
8import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";12import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
913
...@@ -498,6 +502,12 @@ export const ServerSettings = Schema.Struct({...@@ -498,6 +502,12 @@ export const ServerSettings = Schema.Struct({
498 Effect.succeed({502 Effect.succeed({
499 instanceId: ProviderInstanceId.make("codex"),503 instanceId: ProviderInstanceId.make("codex"),
500 model: DEFAULT_TEXT_GENERATION_MODEL,504 model: DEFAULT_TEXT_GENERATION_MODEL,
505 options: [
506 {
507 id: "reasoningEffort",
508 value: DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
509 },
510 ],
501 }),511 }),
502 ),512 ),
503 ),513 ),