authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-04 15:32:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 15:32:05-07:00
log9bd2a4c6886a4c20e0a0a937dc98dd118c645c8f
treea042d1db723b3d7513ac915881fa6e9eead147e2
parent4fb03aff0bcddf3379e9f9277a87e8eae994a2ae
signature Signed by PGP key B5690EEEBB952194

fix(server): keep regenerated titles on topic (#5365)


4 files changed, 290 insertions(+), 60 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+130-4
......@@ -785,6 +785,123 @@ describe("ProviderCommandReactor", () => {
785785 expect(thread?.titleRegeneration).toBeNull();
786786 });
787787
788 it("pins the first user message when regeneration context is truncated", async () => {
789 const harness = await createHarness();
790 const now = "2026-01-01T00:00:00.000Z";
791 const firstUserMessage = `Review subagent monitoring risks. ${"Opening context. ".repeat(200)}`;
792 const recentUserMessage = `LATEST FINDING: ${"implementation detail ".repeat(320)}`;
793 harness.generateThreadTitle.mockReturnValue(
794 Effect.succeed({ title: "Review subagent monitoring risks" }),
795 );
796
797 await harness.runEffect(
798 harness.engine.dispatch({
799 type: "thread.meta.update",
800 commandId: CommandId.make("cmd-thread-title-existing-long"),
801 threadId: ThreadId.make("thread-1"),
802 title: "Generic PR review",
803 }),
804 );
805 await harness.runEffect(
806 harness.engine.dispatch({
807 type: "thread.turn.start",
808 commandId: CommandId.make("cmd-turn-start-before-long-title-regeneration"),
809 threadId: ThreadId.make("thread-1"),
810 message: {
811 messageId: asMessageId("user-message-before-long-title-regeneration"),
812 role: "user",
813 text: firstUserMessage,
814 attachments: [
815 {
816 type: "image",
817 id: "opening-context-image",
818 name: "image.png",
819 mimeType: "image/png",
820 sizeBytes: 5,
821 },
822 ],
823 },
824 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
825 runtimeMode: "approval-required",
826 createdAt: now,
827 }),
828 );
829 await harness.runEffect(
830 harness.engine.dispatch({
831 type: "thread.turn.start",
832 commandId: CommandId.make("cmd-middle-turn-before-long-title-regeneration"),
833 threadId: ThreadId.make("thread-1"),
834 message: {
835 messageId: asMessageId("middle-message-before-long-title-regeneration"),
836 role: "user",
837 text: "Temporary handoff details.",
838 attachments: [
839 {
840 type: "image",
841 id: "middle-context-image",
842 name: "image.png",
843 mimeType: "image/png",
844 sizeBytes: 5,
845 },
846 ],
847 },
848 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
849 runtimeMode: "approval-required",
850 createdAt: "2026-01-01T00:00:01.000Z",
851 }),
852 );
853 await harness.runEffect(
854 harness.engine.dispatch({
855 type: "thread.turn.start",
856 commandId: CommandId.make("cmd-recent-turn-before-long-title-regeneration"),
857 threadId: ThreadId.make("thread-1"),
858 message: {
859 messageId: asMessageId("recent-message-before-long-title-regeneration"),
860 role: "user",
861 text: recentUserMessage,
862 attachments: [
863 {
864 type: "image",
865 id: "recent-context-image",
866 name: "image.png",
867 mimeType: "image/png",
868 sizeBytes: 5,
869 },
870 ],
871 },
872 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
873 runtimeMode: "approval-required",
874 createdAt: "2026-01-01T00:00:02.000Z",
875 }),
876 );
877 await harness.runEffect(
878 harness.engine.dispatch({
879 type: "thread.meta.update",
880 commandId: CommandId.make("cmd-thread-title-regenerate-long"),
881 threadId: ThreadId.make("thread-1"),
882 regenerateTitle: true,
883 }),
884 );
885
886 await harness.drain();
887
888 expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1);
889 const input = harness.generateThreadTitle.mock.calls[0]?.[0];
890 if (!input) {
891 throw new Error("Expected a title generation input");
892 }
893 const message = input.message;
894 expect(message.startsWith("USER:\nReview subagent monitoring risks.")).toBe(true);
895 expect(message).toContain("[First user message truncated]");
896 expect(message).toContain("[Earlier content truncated]");
897 expect(message).toContain("image.png");
898 expect(message).toHaveLength(8_000);
899 expect(input.attachments?.map((attachment) => attachment.id)).toEqual([
900 "opening-context-image",
901 "recent-context-image",
902 ]);
903 });
904
788905 it("clears title regeneration state left pending across reactor startup", async () => {
789906 const harness = await createHarness({
790907 titleRegenerationBeforeStart: "one",
......@@ -972,10 +1089,14 @@ describe("ProviderCommandReactor", () => {
9721089 expect(thread?.titleRegeneration).toBeNull();
9731090 });
9741091
975 it("keeps the full retained context and excludes attachments outside it", async () => {
1092 it("pins the first user context and attachment before the retained tail", async () => {
9761093 const harness = await createHarness();
9771094 const now = "2026-01-01T00:00:00.000Z";
978 const retainedContext = "x".repeat(8_000);
1095 const firstUserContext = "USER:\nOld visual issue\n[Attachments: old-issue.png]";
1096 const truncationMarker = "[Earlier content truncated]\n\n";
1097 const retainedContext = "x".repeat(
1098 8_000 - firstUserContext.length - "\n\n".length - truncationMarker.length,
1099 );
9791100
9801101 await harness.runEffect(
9811102 harness.engine.dispatch({
......@@ -1040,9 +1161,14 @@ describe("ProviderCommandReactor", () => {
10401161 await harness.drain();
10411162
10421163 expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe(
1043 `[Earlier content truncated]\n\n${retainedContext}`,
1164 `${firstUserContext}\n\n${truncationMarker}${retainedContext}`,
10441165 );
1045 expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toBeUndefined();
1166 expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toEqual([
1167 expect.objectContaining({
1168 id: "old-title-context-image",
1169 name: "old-issue.png",
1170 }),
1171 ]);
10461172 });
10471173
10481174 it("does not overwrite a manual rename while title regeneration is running", async () => {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+88-23
......@@ -94,41 +94,61 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
9494const DEFAULT_THREAD_TITLE = "New thread";
9595const MAX_REGENERATION_ATTACHMENTS = 4;
9696const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
97const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000;
9798const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
99const FIRST_USER_CONTEXT_TRUNCATION_MARKER = "\n[First user message truncated]";
98100
99function formatThreadTitleContext(
100 messages: ReadonlyArray<{
101 readonly role: "user" | "assistant" | "system";
102 readonly text: string;
103 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
104 }>,
101type ThreadTitleMessage = {
102 readonly role: "user" | "assistant" | "system";
103 readonly text: string;
104 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
105};
106
107function formatThreadTitleSection(message: ThreadTitleMessage): string | undefined {
108 if (message.role === "system") {
109 return undefined;
110 }
111 const text = message.text.trim();
112 const attachmentSummary = (message.attachments ?? [])
113 .map((attachment) => attachment.name)
114 .join(", ");
115 const contents = [
116 ...(text.length > 0 ? [text] : []),
117 ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []),
118 ].join("\n");
119 return contents.length > 0 ? `${message.role.toUpperCase()}:\n${contents}` : undefined;
120}
121
122function limitFirstUserSection(section: string): string {
123 if (section.length <= MAX_FIRST_USER_TITLE_CONTEXT_CHARS) {
124 return section;
125 }
126 return `${section.slice(
127 0,
128 MAX_FIRST_USER_TITLE_CONTEXT_CHARS - FIRST_USER_CONTEXT_TRUNCATION_MARKER.length,
129 )}${FIRST_USER_CONTEXT_TRUNCATION_MARKER}`;
130}
131
132function collectRecentThreadTitleContext(
133 messages: ReadonlyArray<ThreadTitleMessage>,
134 maxChars: number,
105135): {
106 readonly message: string;
136 readonly context: string;
107137 readonly attachments: ReadonlyArray<ChatAttachment>;
138 readonly truncated: boolean;
108139} {
109140 let context = "";
110141 let truncated = false;
111142 const retainedAttachments: Array<ChatAttachment> = [];
112143
113144 for (const message of messages.toReversed()) {
114 if (message.role === "system") {
115 continue;
116 }
117 const text = message.text.trim();
118 const attachmentSummary = (message.attachments ?? [])
119 .map((attachment) => attachment.name)
120 .join(", ");
121 const contents = [
122 ...(text.length > 0 ? [text] : []),
123 ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []),
124 ].join("\n");
125 if (contents.length === 0) {
145 const section = formatThreadTitleSection(message);
146 if (section === undefined) {
126147 continue;
127148 }
128149
129 const section = `${message.role.toUpperCase()}:\n${contents}`;
130150 const separator = context.length > 0 ? "\n\n" : "";
131 const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length;
151 const available = maxChars - context.length - separator.length;
132152 if (section.length > available) {
133153 if (available > 0) {
134154 context = `${section.slice(-available)}${separator}${context}`;
......@@ -141,9 +161,54 @@ function formatThreadTitleContext(
141161 retainedAttachments.unshift(...(message.attachments ?? []));
142162 }
143163
164 return { context, attachments: retainedAttachments, truncated };
165}
166
167function formatThreadTitleContext(messages: ReadonlyArray<ThreadTitleMessage>): {
168 readonly message: string;
169 readonly attachments: ReadonlyArray<ChatAttachment>;
170} {
171 const recent = collectRecentThreadTitleContext(messages, MAX_THREAD_TITLE_CONTEXT_CHARS);
172 if (!recent.truncated) {
173 return {
174 message: recent.context,
175 attachments: recent.attachments.slice(-MAX_REGENERATION_ATTACHMENTS),
176 };
177 }
178
179 const firstUserMessage = messages.find(
180 (message) => message.role === "user" && formatThreadTitleSection(message),
181 );
182 const firstUserSection = firstUserMessage
183 ? formatThreadTitleSection(firstUserMessage)
184 : undefined;
185 if (!firstUserMessage || !firstUserSection) {
186 return {
187 message: `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${recent.context}`,
188 attachments: recent.attachments.slice(-MAX_REGENERATION_ATTACHMENTS),
189 };
190 }
191
192 const pinnedSection = limitFirstUserSection(firstUserSection);
193 const recentContextBudget =
194 MAX_THREAD_TITLE_CONTEXT_CHARS -
195 pinnedSection.length -
196 "\n\n".length -
197 THREAD_TITLE_CONTEXT_TRUNCATION_MARKER.length;
198 const retainedRecent = collectRecentThreadTitleContext(messages, recentContextBudget);
199 const pinnedAttachment = firstUserMessage.attachments?.[0];
200 const recentAttachments = retainedRecent.attachments.filter(
201 (attachment) => attachment.id !== pinnedAttachment?.id,
202 );
203
144204 return {
145 message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context,
146 attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS),
205 message: `${pinnedSection}\n\n${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${retainedRecent.context}`,
206 attachments: [
207 ...(pinnedAttachment ? [pinnedAttachment] : []),
208 ...recentAttachments.slice(
209 -(MAX_REGENERATION_ATTACHMENTS - (pinnedAttachment === undefined ? 0 : 1)),
210 ),
211 ],
147212 };
148213}
149214
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+8-2
......@@ -195,11 +195,17 @@ describe("buildThreadTitlePrompt", () => {
195195 });
196196
197197 expect(result.prompt).toContain(
198 "Generate a new title that will help the user recognize this T3 Code thread weeks later.",
198 "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.",
199199 );
200200 expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');
201201 expect(result.prompt).toContain(
202 "Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.",
202 "Read the USER messages first. Identify the latest explicit durable goal.",
203 );
204 expect(result.prompt).toContain(
205 "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.",
206 );
207 expect(result.prompt).toContain(
208 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"',
203209 );
204210 expect(result.prompt).toContain("Thread contents:");
205211 expect(result.prompt).toContain("The remaining issue is stale session state");
apps/server/src/textGeneration/TextGenerationPrompts.ts+64-31
......@@ -153,6 +153,7 @@ interface PromptFromMessageInput {
153153 guidance?: ReadonlyArray<string> | undefined;
154154 rulesLabel?: string | undefined;
155155 rules: ReadonlyArray<string>;
156 afterRules?: ReadonlyArray<string> | undefined;
156157 message: string;
157158 messageLabel?: string | undefined;
158159 preserveMessageEnd?: boolean | undefined;
......@@ -182,6 +183,7 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {
182183 ...(input.guidance ?? []),
183184 input.rulesLabel ?? "Rules:",
184185 ...input.rules.map((rule) => `- ${rule}`),
186 ...(input.afterRules ?? []),
185187 "",
186188 `${input.messageLabel ?? "User message"}:`,
187189 input.preserveMessageEnd
......@@ -237,42 +239,73 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
237239 const prompt = buildPromptFromMessage({
238240 instruction: isRegeneration
239241 ? [
240 "Generate a new title that will help the user recognize this T3 Code thread weeks later.",
242 "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.",
241243 `The previous title was ${JSON.stringify(input.previousTitle)}.`,
242244 ].join("\n")
243245 : "Generate a title that will help the user recognize this T3 Code thread weeks later.",
244246 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 ],
247 guidance: isRegeneration
248 ? [
249 "",
250 "Determine the title in this order:",
251 "1. Read the USER messages first. Identify the latest explicit durable goal. The original subject remains the subject until the user clearly changes what the thread is about.",
252 "2. Use ASSISTANT messages to resolve vague links, unnamed code, and discovered product nouns. Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.",
253 "3. Compare that subject with the previous title. Preserve accurate scope words, especially when earlier content is truncated. Replace the previous title when it is generic, artifact-based, a completion update, or contradicted by the thread.",
254 "4. Title the durable subject and desired outcome, not the current workflow state.",
255 "",
256 ]
257 : [
258 "",
259 "Before answering, silently reduce the request to:",
260 "- Subject: What system, feature, or problem is this really about?",
261 "- Outcome: What does the user ultimately want to understand or change?",
262 "- Incidental instructions: What only describes how the agent should do the work?",
263 "",
264 "Title the subject and outcome. Discard incidental instructions.",
265 "",
266 ],
255267 rulesLabel: "Editorial rules:",
256 rules: [
257 "3-8 words, fewer than 40 characters.",
258 "Use a compact noun phrase or clear action phrase.",
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.",
269 ...(isRegeneration
270 ? [
271 "Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.",
272 "Return a different title from the previous title.",
273 ]
274 : []),
275 ],
268 rules: isRegeneration
269 ? [
270 "3-8 words, fewer than 40 characters.",
271 "Use a compact noun phrase or clear action phrase.",
272 "Preserve the umbrella subject when later messages focus on one finding, provider, platform, or implementation detail.",
273 "A thread progressing through research, planning, implementation, review, CI, merge, and monitoring has usually not changed subjects.",
274 "Ignore deliverables and operations such as mocks, plans, HTML, branches, PRs, tests, CI, commits, merging, and monitoring unless they are the actual topic.",
275 "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.",
276 "Treat final operational follow-ups and assistant completion summaries as weak evidence of subject.",
277 "For reviews, name the reviewed feature or system and its durable concern, not one finding from the review.",
278 "For research, name the question domain rather than the research process.",
279 "Do not claim the work is complete.",
280 "Do not copy and truncate a thread message.",
281 "Avoid project names already visible in the UI, PR numbers, quotes, labels, filler, and trailing punctuation.",
282 "Use attached images as primary context for UI issues.",
283 "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.",
284 "Return a meaningfully improved title, not a cosmetic paraphrase of the previous title.",
285 ]
286 : [
287 "3-8 words, fewer than 40 characters.",
288 "Use a compact noun phrase or clear action phrase.",
289 "Capture the umbrella goal when the request lists several symptoms or steps.",
290 "Name the product change, not the mock, plan, report, branch, or PR used to produce it.",
291 "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.",
292 '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.',
293 "For research, name the question domain rather than the requested research process.",
294 "Do not claim the work is complete.",
295 "Do not copy and truncate the user's message.",
296 "Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation.",
297 "Use attached images as primary context for UI issues.",
298 "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.",
299 ],
300 afterRules: isRegeneration
301 ? [
302 "",
303 "Examples of the distinction:",
304 '- A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks," not "Codex Roster Bug Review."',
305 '- A vague failing-test request later identified as a lazy thread-feed mismatch becomes "Fix Lazy Thread Feed Test," not "Prevent Mobile Feed Regressions."',
306 "- A QR-sharing overhaul that ends with CI and merge work remains about QR sharing, not the PR lifecycle.",
307 ]
308 : undefined,
276309 message: input.message,
277310 ...(isRegeneration
278311 ? {