diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index e4661061b23..97379b94b88 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -785,6 +785,123 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); + it("pins the first user message when regeneration context is truncated", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const firstUserMessage = `Review subagent monitoring risks. ${"Opening context. ".repeat(200)}`; + const recentUserMessage = `LATEST FINDING: ${"implementation detail ".repeat(320)}`; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ title: "Review subagent monitoring risks" }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-existing-long"), + threadId: ThreadId.make("thread-1"), + title: "Generic PR review", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-before-long-title-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-before-long-title-regeneration"), + role: "user", + text: firstUserMessage, + attachments: [ + { + type: "image", + id: "opening-context-image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-middle-turn-before-long-title-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("middle-message-before-long-title-regeneration"), + role: "user", + text: "Temporary handoff details.", + attachments: [ + { + type: "image", + id: "middle-context-image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-recent-turn-before-long-title-regeneration"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("recent-message-before-long-title-regeneration"), + role: "user", + text: recentUserMessage, + attachments: [ + { + type: "image", + id: "recent-context-image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:02.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-title-regenerate-long"), + threadId: ThreadId.make("thread-1"), + regenerateTitle: true, + }), + ); + + await harness.drain(); + + expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1); + const input = harness.generateThreadTitle.mock.calls[0]?.[0]; + if (!input) { + throw new Error("Expected a title generation input"); + } + const message = input.message; + expect(message.startsWith("USER:\nReview subagent monitoring risks.")).toBe(true); + expect(message).toContain("[First user message truncated]"); + expect(message).toContain("[Earlier content truncated]"); + expect(message).toContain("image.png"); + expect(message).toHaveLength(8_000); + expect(input.attachments?.map((attachment) => attachment.id)).toEqual([ + "opening-context-image", + "recent-context-image", + ]); + }); + it("clears title regeneration state left pending across reactor startup", async () => { const harness = await createHarness({ titleRegenerationBeforeStart: "one", @@ -972,10 +1089,14 @@ describe("ProviderCommandReactor", () => { expect(thread?.titleRegeneration).toBeNull(); }); - it("keeps the full retained context and excludes attachments outside it", async () => { + it("pins the first user context and attachment before the retained tail", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - const retainedContext = "x".repeat(8_000); + const firstUserContext = "USER:\nOld visual issue\n[Attachments: old-issue.png]"; + const truncationMarker = "[Earlier content truncated]\n\n"; + const retainedContext = "x".repeat( + 8_000 - firstUserContext.length - "\n\n".length - truncationMarker.length, + ); await harness.runEffect( harness.engine.dispatch({ @@ -1040,9 +1161,14 @@ describe("ProviderCommandReactor", () => { await harness.drain(); expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe( - `[Earlier content truncated]\n\n${retainedContext}`, + `${firstUserContext}\n\n${truncationMarker}${retainedContext}`, ); - expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toBeUndefined(); + expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toEqual([ + expect.objectContaining({ + id: "old-title-context-image", + name: "old-issue.png", + }), + ]); }); it("does not overwrite a manual rename while title regeneration is running", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1135fd579e7..ff639797179 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -94,41 +94,61 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; +const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; +const FIRST_USER_CONTEXT_TRUNCATION_MARKER = "\n[First user message truncated]"; -function formatThreadTitleContext( - messages: ReadonlyArray<{ - readonly role: "user" | "assistant" | "system"; - readonly text: string; - readonly attachments?: ReadonlyArray | undefined; - }>, +type ThreadTitleMessage = { + readonly role: "user" | "assistant" | "system"; + readonly text: string; + readonly attachments?: ReadonlyArray | undefined; +}; + +function formatThreadTitleSection(message: ThreadTitleMessage): string | undefined { + if (message.role === "system") { + return undefined; + } + const text = message.text.trim(); + const attachmentSummary = (message.attachments ?? []) + .map((attachment) => attachment.name) + .join(", "); + const contents = [ + ...(text.length > 0 ? [text] : []), + ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []), + ].join("\n"); + return contents.length > 0 ? `${message.role.toUpperCase()}:\n${contents}` : undefined; +} + +function limitFirstUserSection(section: string): string { + if (section.length <= MAX_FIRST_USER_TITLE_CONTEXT_CHARS) { + return section; + } + return `${section.slice( + 0, + MAX_FIRST_USER_TITLE_CONTEXT_CHARS - FIRST_USER_CONTEXT_TRUNCATION_MARKER.length, + )}${FIRST_USER_CONTEXT_TRUNCATION_MARKER}`; +} + +function collectRecentThreadTitleContext( + messages: ReadonlyArray, + maxChars: number, ): { - readonly message: string; + readonly context: string; readonly attachments: ReadonlyArray; + readonly truncated: boolean; } { let context = ""; let truncated = false; const retainedAttachments: Array = []; for (const message of messages.toReversed()) { - if (message.role === "system") { - continue; - } - const text = message.text.trim(); - const attachmentSummary = (message.attachments ?? []) - .map((attachment) => attachment.name) - .join(", "); - const contents = [ - ...(text.length > 0 ? [text] : []), - ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []), - ].join("\n"); - if (contents.length === 0) { + const section = formatThreadTitleSection(message); + if (section === undefined) { continue; } - const section = `${message.role.toUpperCase()}:\n${contents}`; const separator = context.length > 0 ? "\n\n" : ""; - const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length; + const available = maxChars - context.length - separator.length; if (section.length > available) { if (available > 0) { context = `${section.slice(-available)}${separator}${context}`; @@ -141,9 +161,54 @@ function formatThreadTitleContext( retainedAttachments.unshift(...(message.attachments ?? [])); } + return { context, attachments: retainedAttachments, truncated }; +} + +function formatThreadTitleContext(messages: ReadonlyArray): { + readonly message: string; + readonly attachments: ReadonlyArray; +} { + const recent = collectRecentThreadTitleContext(messages, MAX_THREAD_TITLE_CONTEXT_CHARS); + if (!recent.truncated) { + return { + message: recent.context, + attachments: recent.attachments.slice(-MAX_REGENERATION_ATTACHMENTS), + }; + } + + const firstUserMessage = messages.find( + (message) => message.role === "user" && formatThreadTitleSection(message), + ); + const firstUserSection = firstUserMessage + ? formatThreadTitleSection(firstUserMessage) + : undefined; + if (!firstUserMessage || !firstUserSection) { + return { + message: `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${recent.context}`, + attachments: recent.attachments.slice(-MAX_REGENERATION_ATTACHMENTS), + }; + } + + const pinnedSection = limitFirstUserSection(firstUserSection); + const recentContextBudget = + MAX_THREAD_TITLE_CONTEXT_CHARS - + pinnedSection.length - + "\n\n".length - + THREAD_TITLE_CONTEXT_TRUNCATION_MARKER.length; + const retainedRecent = collectRecentThreadTitleContext(messages, recentContextBudget); + const pinnedAttachment = firstUserMessage.attachments?.[0]; + const recentAttachments = retainedRecent.attachments.filter( + (attachment) => attachment.id !== pinnedAttachment?.id, + ); + return { - message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context, - attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS), + message: `${pinnedSection}\n\n${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${retainedRecent.context}`, + attachments: [ + ...(pinnedAttachment ? [pinnedAttachment] : []), + ...recentAttachments.slice( + -(MAX_REGENERATION_ATTACHMENTS - (pinnedAttachment === undefined ? 0 : 1)), + ), + ], }; } diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index e93be422a3c..7614cc9e00f 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -195,11 +195,17 @@ describe("buildThreadTitlePrompt", () => { }); expect(result.prompt).toContain( - "Generate a new title that will help the user recognize this T3 Code thread weeks later.", + "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); expect(result.prompt).toContain( - "Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.", + "Read the USER messages first. Identify the latest explicit durable goal.", + ); + expect(result.prompt).toContain( + "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", + ); + expect(result.prompt).toContain( + 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 155c9b91427..a6a00d8218e 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -153,6 +153,7 @@ interface PromptFromMessageInput { guidance?: ReadonlyArray | undefined; rulesLabel?: string | undefined; rules: ReadonlyArray; + afterRules?: ReadonlyArray | undefined; message: string; messageLabel?: string | undefined; preserveMessageEnd?: boolean | undefined; @@ -182,6 +183,7 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { ...(input.guidance ?? []), input.rulesLabel ?? "Rules:", ...input.rules.map((rule) => `- ${rule}`), + ...(input.afterRules ?? []), "", `${input.messageLabel ?? "User message"}:`, input.preserveMessageEnd @@ -237,42 +239,73 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { const prompt = buildPromptFromMessage({ instruction: isRegeneration ? [ - "Generate a new title that will help the user recognize this T3 Code thread weeks later.", + "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", `The previous title was ${JSON.stringify(input.previousTitle)}.`, ].join("\n") : "Generate a title that will help the user recognize this T3 Code thread weeks later.", responseShape: "Return JSON with exactly one key: title.", - guidance: [ - "", - "Before answering, silently reduce the request to:", - "- Subject: What system, feature, or problem is this really about?", - "- Outcome: What does the user ultimately want to understand or change?", - "- Incidental instructions: What only describes how the agent should do the work?", - "", - "Title the subject and outcome. Discard incidental instructions.", - "", - ], + guidance: isRegeneration + ? [ + "", + "Determine the title in this order:", + "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.", + "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.", + "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.", + "4. Title the durable subject and desired outcome, not the current workflow state.", + "", + ] + : [ + "", + "Before answering, silently reduce the request to:", + "- Subject: What system, feature, or problem is this really about?", + "- Outcome: What does the user ultimately want to understand or change?", + "- Incidental instructions: What only describes how the agent should do the work?", + "", + "Title the subject and outcome. Discard incidental instructions.", + "", + ], rulesLabel: "Editorial rules:", - rules: [ - "3-8 words, fewer than 40 characters.", - "Use a compact noun phrase or clear action phrase.", - "Capture the umbrella goal when the request lists several symptoms or steps.", - "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", - "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.", - '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.', - "For research, name the question domain rather than the requested research process.", - "Do not claim the work is complete.", - "Do not copy and truncate the user's message.", - "Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation.", - "Use attached images as primary context for UI issues.", - "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.", - ...(isRegeneration - ? [ - "Capture the current durable subject and outcome across the whole thread, not merely its initial request or latest step.", - "Return a different title from the previous title.", - ] - : []), - ], + rules: isRegeneration + ? [ + "3-8 words, fewer than 40 characters.", + "Use a compact noun phrase or clear action phrase.", + "Preserve the umbrella subject when later messages focus on one finding, provider, platform, or implementation detail.", + "A thread progressing through research, planning, implementation, review, CI, merge, and monitoring has usually not changed subjects.", + "Ignore deliverables and operations such as mocks, plans, HTML, branches, PRs, tests, CI, commits, merging, and monitoring unless they are the actual topic.", + "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.", + "Treat final operational follow-ups and assistant completion summaries as weak evidence of subject.", + "For reviews, name the reviewed feature or system and its durable concern, not one finding from the review.", + "For research, name the question domain rather than the research process.", + "Do not claim the work is complete.", + "Do not copy and truncate a thread message.", + "Avoid project names already visible in the UI, PR numbers, quotes, labels, filler, and trailing punctuation.", + "Use attached images as primary context for UI issues.", + "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.", + "Return a meaningfully improved title, not a cosmetic paraphrase of the previous title.", + ] + : [ + "3-8 words, fewer than 40 characters.", + "Use a compact noun phrase or clear action phrase.", + "Capture the umbrella goal when the request lists several symptoms or steps.", + "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", + "Models, subagents, tools, output formats, and monitoring instructions do not belong in the title unless they are themselves the topic.", + '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.', + "For research, name the question domain rather than the requested research process.", + "Do not claim the work is complete.", + "Do not copy and truncate the user's message.", + "Avoid project names already visible in the UI, quotes, labels, filler, and trailing punctuation.", + "Use attached images as primary context for UI issues.", + "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.", + ], + afterRules: isRegeneration + ? [ + "", + "Examples of the distinction:", + '- A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks," not "Codex Roster Bug Review."', + '- A vague failing-test request later identified as a lazy thread-feed mismatch becomes "Fix Lazy Thread Feed Test," not "Prevent Mobile Feed Regressions."', + "- A QR-sharing overhaul that ends with CI and merge work remains about QR sharing, not the PR lifecycle.", + ] + : undefined, message: input.message, ...(isRegeneration ? {