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", () => {...@@ -785,6 +785,123 @@ describe("ProviderCommandReactor", () => {
785 expect(thread?.titleRegeneration).toBeNull();785 expect(thread?.titleRegeneration).toBeNull();
786 });786 });
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
788 it("clears title regeneration state left pending across reactor startup", async () => {905 it("clears title regeneration state left pending across reactor startup", async () => {
789 const harness = await createHarness({906 const harness = await createHarness({
790 titleRegenerationBeforeStart: "one",907 titleRegenerationBeforeStart: "one",
...@@ -972,10 +1089,14 @@ describe("ProviderCommandReactor", () => {...@@ -972,10 +1089,14 @@ describe("ProviderCommandReactor", () => {
972 expect(thread?.titleRegeneration).toBeNull();1089 expect(thread?.titleRegeneration).toBeNull();
973 });1090 });
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 () => {
976 const harness = await createHarness();1093 const harness = await createHarness();
977 const now = "2026-01-01T00:00:00.000Z";1094 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
980 await harness.runEffect(1101 await harness.runEffect(
981 harness.engine.dispatch({1102 harness.engine.dispatch({
...@@ -1040,9 +1161,14 @@ describe("ProviderCommandReactor", () => {...@@ -1040,9 +1161,14 @@ describe("ProviderCommandReactor", () => {
1040 await harness.drain();1161 await harness.drain();
10411162
1042 expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe(1163 expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe(
1043 `[Earlier content truncated]\n\n${retainedContext}`,1164 `${firstUserContext}\n\n${truncationMarker}${retainedContext}`,
1044 );1165 );
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 ]);
1046 });1172 });
10471173
1048 it("does not overwrite a manual rename while title regeneration is running", async () => {1174 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";...@@ -94,41 +94,61 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
94const DEFAULT_THREAD_TITLE = "New thread";94const DEFAULT_THREAD_TITLE = "New thread";
95const MAX_REGENERATION_ATTACHMENTS = 4;95const MAX_REGENERATION_ATTACHMENTS = 4;
96const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;96const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
97const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000;
97const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";98const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
99const FIRST_USER_CONTEXT_TRUNCATION_MARKER = "\n[First user message truncated]";
98100
99function formatThreadTitleContext(101type ThreadTitleMessage = {
100 messages: ReadonlyArray<{102 readonly role: "user" | "assistant" | "system";
101 readonly role: "user" | "assistant" | "system";103 readonly text: string;
102 readonly text: string;104 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
103 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;105};
104 }>,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,
105): {135): {
106 readonly message: string;136 readonly context: string;
107 readonly attachments: ReadonlyArray<ChatAttachment>;137 readonly attachments: ReadonlyArray<ChatAttachment>;
138 readonly truncated: boolean;
108} {139} {
109 let context = "";140 let context = "";
110 let truncated = false;141 let truncated = false;
111 const retainedAttachments: Array<ChatAttachment> = [];142 const retainedAttachments: Array<ChatAttachment> = [];
112143
113 for (const message of messages.toReversed()) {144 for (const message of messages.toReversed()) {
114 if (message.role === "system") {145 const section = formatThreadTitleSection(message);
115 continue;146 if (section === undefined) {
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) {
126 continue;147 continue;
127 }148 }
128149
129 const section = `${message.role.toUpperCase()}:\n${contents}`;
130 const separator = context.length > 0 ? "\n\n" : "";150 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;
132 if (section.length > available) {152 if (section.length > available) {
133 if (available > 0) {153 if (available > 0) {
134 context = `${section.slice(-available)}${separator}${context}`;154 context = `${section.slice(-available)}${separator}${context}`;
...@@ -141,9 +161,54 @@ function formatThreadTitleContext(...@@ -141,9 +161,54 @@ function formatThreadTitleContext(
141 retainedAttachments.unshift(...(message.attachments ?? []));161 retainedAttachments.unshift(...(message.attachments ?? []));
142 }162 }
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
144 return {204 return {
145 message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context,205 message: `${pinnedSection}\n\n${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${retainedRecent.context}`,
146 attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS),206 attachments: [
207 ...(pinnedAttachment ? [pinnedAttachment] : []),
208 ...recentAttachments.slice(
209 -(MAX_REGENERATION_ATTACHMENTS - (pinnedAttachment === undefined ? 0 : 1)),
210 ),
211 ],
147 };212 };
148}213}
149214
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+8-2
...@@ -195,11 +195,17 @@ describe("buildThreadTitlePrompt", () => {...@@ -195,11 +195,17 @@ describe("buildThreadTitlePrompt", () => {
195 });195 });
196196
197 expect(result.prompt).toContain(197 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.",
199 );199 );
200 expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');200 expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');
201 expect(result.prompt).toContain(201 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,"',
203 );209 );
204 expect(result.prompt).toContain("Thread contents:");210 expect(result.prompt).toContain("Thread contents:");
205 expect(result.prompt).toContain("The remaining issue is stale session state");211 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 {...@@ -153,6 +153,7 @@ interface PromptFromMessageInput {
153 guidance?: ReadonlyArray<string> | undefined;153 guidance?: ReadonlyArray<string> | undefined;
154 rulesLabel?: string | undefined;154 rulesLabel?: string | undefined;
155 rules: ReadonlyArray<string>;155 rules: ReadonlyArray<string>;
156 afterRules?: ReadonlyArray<string> | undefined;
156 message: string;157 message: string;
157 messageLabel?: string | undefined;158 messageLabel?: string | undefined;
158 preserveMessageEnd?: boolean | undefined;159 preserveMessageEnd?: boolean | undefined;
...@@ -182,6 +183,7 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {...@@ -182,6 +183,7 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {
182 ...(input.guidance ?? []),183 ...(input.guidance ?? []),
183 input.rulesLabel ?? "Rules:",184 input.rulesLabel ?? "Rules:",
184 ...input.rules.map((rule) => `- ${rule}`),185 ...input.rules.map((rule) => `- ${rule}`),
186 ...(input.afterRules ?? []),
185 "",187 "",
186 `${input.messageLabel ?? "User message"}:`,188 `${input.messageLabel ?? "User message"}:`,
187 input.preserveMessageEnd189 input.preserveMessageEnd
...@@ -237,42 +239,73 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {...@@ -237,42 +239,73 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
237 const prompt = buildPromptFromMessage({239 const prompt = buildPromptFromMessage({
238 instruction: isRegeneration240 instruction: isRegeneration
239 ? [241 ? [
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.",
241 `The previous title was ${JSON.stringify(input.previousTitle)}.`,243 `The previous title was ${JSON.stringify(input.previousTitle)}.`,
242 ].join("\n")244 ].join("\n")
243 : "Generate a title that will help the user recognize this T3 Code thread weeks later.",245 : "Generate a title that will help the user recognize this T3 Code thread weeks later.",
244 responseShape: "Return JSON with exactly one key: title.",246 responseShape: "Return JSON with exactly one key: title.",
245 guidance: [247 guidance: isRegeneration
246 "",248 ? [
247 "Before answering, silently reduce the request to:",249 "",
248 "- Subject: What system, feature, or problem is this really about?",250 "Determine the title in this order:",
249 "- Outcome: What does the user ultimately want to understand or change?",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.",
250 "- Incidental instructions: What only describes how the agent should do the work?",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.",
251 "",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.",
252 "Title the subject and outcome. Discard incidental instructions.",254 "4. Title the durable subject and desired outcome, not the current workflow state.",
253 "",255 "",
254 ],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 ],
255 rulesLabel: "Editorial rules:",267 rulesLabel: "Editorial rules:",
256 rules: [268 rules: isRegeneration
257 "3-8 words, fewer than 40 characters.",269 ? [
258 "Use a compact noun phrase or clear action phrase.",270 "3-8 words, fewer than 40 characters.",
259 "Capture the umbrella goal when the request lists several symptoms or steps.",271 "Use a compact noun phrase or clear action phrase.",
260 "Name the product change, not the mock, plan, report, branch, or PR used to produce it.",272 "Preserve the umbrella subject when later messages focus on one finding, provider, platform, or implementation detail.",
261 "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.",273 "A thread progressing through research, planning, implementation, review, CI, merge, and monitoring has usually not changed subjects.",
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.',274 "Ignore deliverables and operations such as mocks, plans, HTML, branches, PRs, tests, CI, commits, merging, and monitoring unless they are the actual topic.",
263 "For research, name the question domain rather than the requested research process.",275 "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.",
264 "Do not claim the work is complete.",276 "Treat final operational follow-ups and assistant completion summaries as weak evidence of subject.",
265 "Do not copy and truncate the user's message.",277 "For reviews, name the reviewed feature or system and its durable concern, not one finding from the review.",
266 "Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation.",278 "For research, name the question domain rather than the research process.",
267 "Use attached images as primary context for UI issues.",279 "Do not claim the work is complete.",
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.",280 "Do not copy and truncate a thread message.",
269 ...(isRegeneration281 "Avoid project names already visible in the UI, PR numbers, quotes, labels, filler, and trailing punctuation.",
270 ? [282 "Use attached images as primary context for UI issues.",
271 "Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.",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.",
272 "Return a different title from the previous title.",284 "Return a meaningfully improved title, not a cosmetic paraphrase of the previous title.",
273 ]285 ]
274 : []),286 : [
275 ],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,
276 message: input.message,309 message: input.message,
277 ...(isRegeneration310 ...(isRegeneration
278 ? {311 ? {