diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index a973e24fce..20b047de99 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -1870,9 +1870,11 @@ const ChatInputInner: React.FC = (props) => { api, workspaceId: props.workspaceId, followUpContent: { - text: messageTextForSend, - fileParts, - reviews: reviewsData, + message: { + content: messageTextForSend, + fileParts, + reviews: reviewsData, + }, muxMetadata: skillMuxMetadata, }, sendMessageOptions: compactionSendMessageOptions, @@ -1949,9 +1951,11 @@ const ChatInputInner: React.FC = (props) => { followUpContent: parsed.continueMessage || sendFileParts?.length || reviewsData?.length ? { - text: parsed.continueMessage ?? "", - fileParts: sendFileParts, - reviews: reviewsData, + message: { + content: parsed.continueMessage ?? "", + fileParts: sendFileParts, + reviews: reviewsData, + }, } : undefined, model: parsed.model, @@ -1964,7 +1968,7 @@ const ChatInputInner: React.FC = (props) => { } const { finalText: finalMessageText, metadata: reviewMetadata } = prepareUserMessageForSend( - { text: actualMessageText, reviews: reviewsData }, + { content: actualMessageText, reviews: reviewsData }, muxMetadata ); // When editing /compact, compactionOptions already includes the base sendMessageOptions. diff --git a/src/browser/components/ChatPane.tsx b/src/browser/components/ChatPane.tsx index 05b09d664f..f5d757f4dc 100644 --- a/src/browser/components/ChatPane.tsx +++ b/src/browser/components/ChatPane.tsx @@ -254,7 +254,7 @@ export const ChatPane: React.FC = (props) => { api, workspaceId, sendMessageOptions: pendingSendOptions, - followUpContent: { text: "Continue" }, + followUpContent: { message: { content: "Continue" } }, }); }, [api, workspaceId, pendingSendOptions, autoBackgroundOnSend]); diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts index 9351902e68..b51668be78 100644 --- a/src/browser/hooks/useCompactAndRetry.ts +++ b/src/browser/hooks/useCompactAndRetry.ts @@ -53,9 +53,11 @@ function buildFollowUpFromSource( source: Extract ): CompactionFollowUpInput { return { - text: source.content, - fileParts: source.fileParts, - reviews: source.reviews, + message: { + content: source.content, + fileParts: source.fileParts, + reviews: source.reviews, + }, muxMetadata: source.agentSkill ? buildAgentSkillMetadata({ rawCommand: source.content, @@ -249,7 +251,7 @@ export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRe insertIntoChatInput( fallbackText + (shouldAppendNewline ? "\n" : ""), - nestedFollowUp?.fileParts + nestedFollowUp?.message.fileParts ); } diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index 014bf0e571..71e5e9daea 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -269,7 +269,7 @@ describe("prepareCompactionMessage", () => { const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", maxOutputTokens: 4096, - followUpContent: { text: "Keep building" }, + followUpContent: { message: { content: "Keep building" } }, model: "anthropic:claude-3-5-haiku", sendMessageOptions, }); @@ -280,9 +280,9 @@ describe("prepareCompactionMessage", () => { } // followUpContent includes model/agentId from sendMessageOptions (captured for follow-up) - expect(metadata.parsed.followUpContent?.text).toBe("Keep building"); - expect(metadata.parsed.followUpContent?.model).toBe("anthropic:claude-3-5-sonnet"); - expect(metadata.parsed.followUpContent?.agentId).toBe("exec"); + expect(metadata.parsed.followUpContent?.message.content).toBe("Keep building"); + expect(metadata.parsed.followUpContent?.sendOptions.model).toBe("anthropic:claude-3-5-sonnet"); + expect(metadata.parsed.followUpContent?.sendOptions.agentId).toBe("exec"); }); test("does not create followUpContent when no text or images provided", () => { @@ -312,7 +312,7 @@ describe("prepareCompactionMessage", () => { const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", - followUpContent: { text: "Continue" }, + followUpContent: { message: { content: "Continue" } }, sendMessageOptions, }); @@ -321,8 +321,8 @@ describe("prepareCompactionMessage", () => { } // Follow-up should use the user's original model/agentId - expect(metadata.parsed.followUpContent?.model).toBe("openai:gpt-4o"); - expect(metadata.parsed.followUpContent?.agentId).toBe("code"); + expect(metadata.parsed.followUpContent?.sendOptions.model).toBe("openai:gpt-4o"); + expect(metadata.parsed.followUpContent?.sendOptions.agentId).toBe("code"); }); test("uses agentId from sendMessageOptions in followUpContent", () => { @@ -335,7 +335,7 @@ describe("prepareCompactionMessage", () => { const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", - followUpContent: { text: "Continue" }, + followUpContent: { message: { content: "Continue" } }, sendMessageOptions, }); @@ -343,14 +343,14 @@ describe("prepareCompactionMessage", () => { throw new Error("Expected compaction metadata"); } - expect(metadata.parsed.followUpContent?.agentId).toBe("exec"); + expect(metadata.parsed.followUpContent?.sendOptions.agentId).toBe("exec"); }); test("creates followUpContent when text is provided", () => { const sendMessageOptions = createBaseOptions(); const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", - followUpContent: { text: "Continue with this" }, + followUpContent: { message: { content: "Continue with this" } }, sendMessageOptions, }); @@ -359,7 +359,7 @@ describe("prepareCompactionMessage", () => { } expect(metadata.parsed.followUpContent).toBeDefined(); - expect(metadata.parsed.followUpContent?.text).toBe("Continue with this"); + expect(metadata.parsed.followUpContent?.message.content).toBe("Continue with this"); }); test("rawCommand includes multiline continue payload", () => { @@ -368,7 +368,7 @@ describe("prepareCompactionMessage", () => { workspaceId: "ws-1", maxOutputTokens: 2048, model: "anthropic:claude-3-5-haiku", - followUpContent: { text: "Line 1\nLine 2" }, + followUpContent: { message: { content: "Line 1\nLine 2" } }, sendMessageOptions, }); @@ -385,7 +385,7 @@ describe("prepareCompactionMessage", () => { const sendMessageOptions = createBaseOptions(); const { messageText, metadata } = prepareCompactionMessage({ workspaceId: "ws-1", - followUpContent: { text: "Continue" }, + followUpContent: { message: { content: "Continue" } }, sendMessageOptions, }); @@ -396,14 +396,14 @@ describe("prepareCompactionMessage", () => { } // Still queued for auto-send after compaction - expect(metadata.parsed.followUpContent?.text).toBe("Continue"); + expect(metadata.parsed.followUpContent?.message.content).toBe("Continue"); }); test("includes non-default continue text in compaction prompt", () => { const sendMessageOptions = createBaseOptions(); const { messageText } = prepareCompactionMessage({ workspaceId: "ws-1", - followUpContent: { text: "fix tests" }, + followUpContent: { message: { content: "fix tests" } }, sendMessageOptions, }); @@ -415,8 +415,10 @@ describe("prepareCompactionMessage", () => { const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", followUpContent: { - text: "", - fileParts: [{ url: "data:image/png;base64,abc", mediaType: "image/png" }], + message: { + content: "", + fileParts: [{ url: "data:image/png;base64,abc", mediaType: "image/png" }], + }, }, sendMessageOptions, }); @@ -426,7 +428,7 @@ describe("prepareCompactionMessage", () => { } expect(metadata.parsed.followUpContent).toBeDefined(); - expect(metadata.parsed.followUpContent?.fileParts).toHaveLength(1); + expect(metadata.parsed.followUpContent?.message.fileParts).toHaveLength(1); }); test("creates followUpContent when reviews are provided without text", () => { @@ -434,15 +436,17 @@ describe("prepareCompactionMessage", () => { const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", followUpContent: { - text: "", - reviews: [ - { - filePath: "src/test.ts", - lineRange: "10-15", - selectedCode: "const x = 1;", - userNote: "Please fix this", - }, - ], + message: { + content: "", + reviews: [ + { + filePath: "src/test.ts", + lineRange: "10-15", + selectedCode: "const x = 1;", + userNote: "Please fix this", + }, + ], + }, }, sendMessageOptions, }); @@ -452,8 +456,8 @@ describe("prepareCompactionMessage", () => { } expect(metadata.parsed.followUpContent).toBeDefined(); - expect(metadata.parsed.followUpContent?.reviews).toHaveLength(1); - expect(metadata.parsed.followUpContent?.reviews?.[0].userNote).toBe("Please fix this"); + expect(metadata.parsed.followUpContent?.message.reviews).toHaveLength(1); + expect(metadata.parsed.followUpContent?.message.reviews?.[0].userNote).toBe("Please fix this"); }); test("creates followUpContent with reviews and text combined", () => { @@ -461,15 +465,17 @@ describe("prepareCompactionMessage", () => { const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", followUpContent: { - text: "Also check the tests", - reviews: [ - { - filePath: "src/test.ts", - lineRange: "10-15", - selectedCode: "const x = 1;", - userNote: "Fix this bug", - }, - ], + message: { + content: "Also check the tests", + reviews: [ + { + filePath: "src/test.ts", + lineRange: "10-15", + selectedCode: "const x = 1;", + userNote: "Fix this bug", + }, + ], + }, }, sendMessageOptions, }); @@ -479,8 +485,8 @@ describe("prepareCompactionMessage", () => { } expect(metadata.parsed.followUpContent).toBeDefined(); - expect(metadata.parsed.followUpContent?.text).toBe("Also check the tests"); - expect(metadata.parsed.followUpContent?.reviews).toHaveLength(1); + expect(metadata.parsed.followUpContent?.message.content).toBe("Also check the tests"); + expect(metadata.parsed.followUpContent?.message.reviews).toHaveLength(1); }); test("builds followUpContent from sourceContent with skill metadata", () => { @@ -489,7 +495,9 @@ describe("prepareCompactionMessage", () => { const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", followUpContent: { - text: "/tests run all tests", + message: { + content: "/tests run all tests", + }, muxMetadata: { type: "agent-skill", rawCommand: "/tests run all tests", @@ -506,7 +514,7 @@ describe("prepareCompactionMessage", () => { // ContinueMessage should be built from sourceContent expect(metadata.parsed.followUpContent).toBeDefined(); - expect(metadata.parsed.followUpContent?.text).toBe("/tests run all tests"); + expect(metadata.parsed.followUpContent?.message.content).toBe("/tests run all tests"); // Skill metadata should be preserved in muxMetadata expect(metadata.parsed.followUpContent?.muxMetadata).toEqual({ @@ -522,15 +530,17 @@ describe("prepareCompactionMessage", () => { const { messageText, metadata } = prepareCompactionMessage({ workspaceId: "ws-1", followUpContent: { - text: "Continue", - reviews: [ - { - filePath: "src/test.ts", - lineRange: "10", - selectedCode: "x = 1", - userNote: "Check this", - }, - ], + message: { + content: "Continue", + reviews: [ + { + filePath: "src/test.ts", + lineRange: "10", + selectedCode: "x = 1", + userNote: "Check this", + }, + ], + }, }, sendMessageOptions, }); @@ -543,7 +553,7 @@ describe("prepareCompactionMessage", () => { throw new Error("Expected compaction metadata"); } - expect(metadata.parsed.followUpContent?.reviews).toHaveLength(1); + expect(metadata.parsed.followUpContent?.message.reviews).toHaveLength(1); }); }); @@ -745,13 +755,19 @@ describe("handleCompactCommand", () => { expect(sendMessageMock).toHaveBeenCalled(); const callArgs = sendMessageMock.mock.calls[0][0] as { - options?: { muxMetadata?: { parsed?: { followUpContent?: { reviews?: ReviewNoteData[] } } } }; + options?: { + muxMetadata?: { + parsed?: { + followUpContent?: { message: { reviews?: ReviewNoteData[] } }; + }; + }; + }; }; const followUpContent = callArgs?.options?.muxMetadata?.parsed?.followUpContent; expect(followUpContent).toBeDefined(); - expect(followUpContent?.reviews).toHaveLength(1); - expect(followUpContent?.reviews?.[0].userNote).toBe("Please fix this bug"); + expect(followUpContent?.message.reviews).toHaveLength(1); + expect(followUpContent?.message.reviews?.[0].userNote).toBe("Please fix this bug"); }); test("creates followUpContent with only reviews (no text)", async () => { @@ -773,12 +789,18 @@ describe("handleCompactCommand", () => { expect(sendMessageMock).toHaveBeenCalled(); const callArgs = sendMessageMock.mock.calls[0][0] as { - options?: { muxMetadata?: { parsed?: { followUpContent?: { reviews?: ReviewNoteData[] } } } }; + options?: { + muxMetadata?: { + parsed?: { + followUpContent?: { message: { reviews?: ReviewNoteData[] } }; + }; + }; + }; }; const followUpContent = callArgs?.options?.muxMetadata?.parsed?.followUpContent; // Should have followUpContent even without text, because reviews are present expect(followUpContent).toBeDefined(); - expect(followUpContent?.reviews).toHaveLength(1); + expect(followUpContent?.message.reviews).toHaveLength(1); }); }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 7c35d43f57..eb0f57c11a 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -14,6 +14,7 @@ import { type CompactionRequestData, type CompactionFollowUpRequest, type CompactionFollowUpInput, + type CompactionFollowUpSendOptions, isDefaultSourceContent, pickPreservedSendOptions, } from "@/common/types/message"; @@ -788,40 +789,36 @@ export function prepareCompactionMessage(options: CompactionOptions): { // misread it as a competing instruction. We still keep it in metadata so the backend resumes. // Only treat it as the default resume when there's no other queued content (images/reviews). // - // Convert CompactionFollowUpInput to CompactionFollowUpRequest by adding model/agentId. + // Convert CompactionFollowUpInput to CompactionFollowUpRequest by adding sendOptions. // Compaction uses its own agentId ("compact") and potentially a different model for // summarization, so we capture the user's original settings for the follow-up message. // // In compaction recovery (retrying a failed /compact), followUpContent may already be - // a CompactionFollowUpRequest with preserved model/agentId. Only fill in missing fields + // a CompactionFollowUpRequest with preserved sendOptions. Only fill in missing fields // to avoid overwriting the original settings when the user changes model/agent before retry. let fc: CompactionFollowUpRequest | undefined; if (options.followUpContent) { - // Check if already a CompactionFollowUpRequest (has model/agentId from previous compaction) - const existingModel = - "model" in options.followUpContent && - typeof options.followUpContent.model === "string" && - options.followUpContent.model - ? options.followUpContent.model - : undefined; - const existingAgentId = - "agentId" in options.followUpContent && - typeof options.followUpContent.agentId === "string" && - options.followUpContent.agentId - ? options.followUpContent.agentId + // Check if already a CompactionFollowUpRequest (has sendOptions from previous compaction) + const existingSendOptions = + "sendOptions" in options.followUpContent + ? (options.followUpContent as CompactionFollowUpRequest).sendOptions : undefined; + const sendOpts: CompactionFollowUpSendOptions = { + model: existingSendOptions?.model ?? options.sendMessageOptions.model, + agentId: existingSendOptions?.agentId ?? options.sendMessageOptions.agentId ?? "exec", + ...pickPreservedSendOptions(options.sendMessageOptions), + }; + fc = { ...options.followUpContent, - model: existingModel ?? options.sendMessageOptions.model, - agentId: existingAgentId ?? options.sendMessageOptions.agentId ?? "exec", - ...pickPreservedSendOptions(options.sendMessageOptions), + sendOptions: sendOpts, }; } - const isDefaultResume = isDefaultSourceContent(fc); + const isDefaultResume = isDefaultSourceContent(fc?.message); if (fc && !isDefaultResume) { - messageText += `\n\nThe user wants to continue with: ${fc.text}`; + messageText += `\n\nThe user wants to continue with: ${fc.message.content}`; } // Handle model preference (sticky globally) @@ -1052,9 +1049,11 @@ export async function handleCompactCommand( parsed.continueMessage ?? context.fileParts?.length ?? context.reviews?.length; const followUpContent: CompactionFollowUpInput | undefined = hasContent ? { - text: parsed.continueMessage ?? "", - fileParts: context.fileParts, - reviews: context.reviews, + message: { + content: parsed.continueMessage ?? "", + fileParts: context.fileParts, + reviews: context.reviews, + }, } : undefined; diff --git a/src/browser/utils/chatEditing.ts b/src/browser/utils/chatEditing.ts index 7f64102863..f7230df858 100644 --- a/src/browser/utils/chatEditing.ts +++ b/src/browser/utils/chatEditing.ts @@ -63,7 +63,7 @@ export const buildEditingStateFromCompaction = ( id: messageId, pending: { content: command, - fileParts: followUp?.fileParts ?? [], - reviews: followUp?.reviews ?? [], + fileParts: followUp?.message.fileParts ?? [], + reviews: followUp?.message.reviews ?? [], }, }); diff --git a/src/browser/utils/compaction/format.ts b/src/browser/utils/compaction/format.ts index f8ea3bbdb4..bb88f20290 100644 --- a/src/browser/utils/compaction/format.ts +++ b/src/browser/utils/compaction/format.ts @@ -26,8 +26,8 @@ export function getFollowUpContentText( followUpContent?: CompactionRequestData["followUpContent"] ): string | null { if (!followUpContent) return null; - if (isDefaultSourceContent(followUpContent)) return null; - const text = followUpContent.text; + if (isDefaultSourceContent(followUpContent.message)) return null; + const text = followUpContent.message.content; if (typeof text !== "string" || text.trim().length === 0) { return null; } diff --git a/src/browser/utils/compaction/handler.test.ts b/src/browser/utils/compaction/handler.test.ts index d21880e550..570a91f64f 100644 --- a/src/browser/utils/compaction/handler.test.ts +++ b/src/browser/utils/compaction/handler.test.ts @@ -26,7 +26,12 @@ describe("cancelCompaction", () => { muxMetadata: { type: "compaction-request", rawCommand: "/compact -t 100", - parsed: { followUpContent: { text: "Do the thing" } }, + parsed: { + followUpContent: { + message: { content: "Do the thing" }, + sendOptions: { model: "m", agentId: "exec" }, + }, + }, }, }, }, @@ -89,9 +94,12 @@ describe("cancelCompaction", () => { rawCommand: "/compact", parsed: { followUpContent: { - text: "Continue work", - fileParts: [mockFilePart], - reviews: [mockReview], + message: { + content: "Continue work", + fileParts: [mockFilePart], + reviews: [mockReview], + }, + sendOptions: { model: "m", agentId: "exec" }, }, }, }, diff --git a/src/common/types/message.test.ts b/src/common/types/message.test.ts index 424349beee..62037655d4 100644 --- a/src/common/types/message.test.ts +++ b/src/common/types/message.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { buildContinueMessage, rebuildContinueMessage } from "./message"; +import { + buildContinueMessage, + normalizeCompactionFollowUpRequest, + rebuildContinueMessage, + type CompactionFollowUpRequest, +} from "./message"; import type { ReviewNoteData } from "./review"; // Helper to create valid ReviewNoteData for tests @@ -163,3 +168,102 @@ describe("rebuildContinueMessage", () => { expect(result?.fileParts?.length).toBe(1); }); }); + +describe("normalizeCompactionFollowUpRequest", () => { + const defaults = { model: "default:model", agentId: "exec" }; + + test("preserves modern envelope shape unchanged", () => { + const modern: CompactionFollowUpRequest = { + message: { content: "fix tests", fileParts: [], reviews: [] }, + muxMetadata: undefined, + sendOptions: { + model: "openai:gpt-4o", + agentId: "code", + thinkingLevel: "high", + }, + }; + const result = normalizeCompactionFollowUpRequest(modern, defaults); + expect(result.message.content).toBe("fix tests"); + expect(result.sendOptions.model).toBe("openai:gpt-4o"); + expect(result.sendOptions.agentId).toBe("code"); + expect(result.sendOptions.thinkingLevel).toBe("high"); + }); + + test("normalizes legacy flattened follow-up to envelope shape", () => { + // Simulate a persisted legacy follow-up (flat fields, no message/sendOptions) + const legacy = { + text: "Continue working", + model: "anthropic:claude-3-5-sonnet", + agentId: "exec", + fileParts: [{ url: "data:image/png;base64,abc", mediaType: "image/png" }], + reviews: [makeReview("fix-this.ts")], + thinkingLevel: "medium" as const, + } as unknown as CompactionFollowUpRequest; + + const result = normalizeCompactionFollowUpRequest(legacy, defaults); + + expect(result.message.content).toBe("Continue working"); + expect(result.message.fileParts).toHaveLength(1); + expect(result.message.reviews).toHaveLength(1); + expect(result.sendOptions.model).toBe("anthropic:claude-3-5-sonnet"); + expect(result.sendOptions.agentId).toBe("exec"); + expect(result.sendOptions.thinkingLevel).toBe("medium"); + }); + + test("migrates legacy mode to agentId", () => { + const legacy = { + text: "hello", + mode: "plan", + } as unknown as CompactionFollowUpRequest; + + const result = normalizeCompactionFollowUpRequest(legacy, defaults); + expect(result.sendOptions.agentId).toBe("plan"); + }); + + test("migrates legacy imageParts to fileParts", () => { + const legacy = { + text: "look at this", + imageParts: [{ url: "data:image/png;base64,xyz", mediaType: "image/png" }], + } as unknown as CompactionFollowUpRequest; + + const result = normalizeCompactionFollowUpRequest(legacy, defaults); + expect(result.message.fileParts).toHaveLength(1); + }); + + test("uses defaults when legacy fields are missing", () => { + const legacy = { + text: "hello", + } as unknown as CompactionFollowUpRequest; + + const result = normalizeCompactionFollowUpRequest(legacy, defaults); + expect(result.sendOptions.model).toBe("default:model"); + expect(result.sendOptions.agentId).toBe("exec"); + expect(result.message.content).toBe("hello"); + }); + + test("preserves muxMetadata from legacy data", () => { + const legacy = { + text: "/tests run all", + muxMetadata: { + type: "agent-skill" as const, + rawCommand: "/tests run all", + skillName: "tests", + scope: "project" as const, + }, + } as unknown as CompactionFollowUpRequest; + + const result = normalizeCompactionFollowUpRequest(legacy, defaults); + expect(result.muxMetadata?.type).toBe("agent-skill"); + }); + + test("fills sendOptions defaults when modern shape is missing sendOptions", () => { + // Edge case: somehow persisted with message but without sendOptions + const partial = { + message: { content: "hello" }, + } as unknown as CompactionFollowUpRequest; + + const result = normalizeCompactionFollowUpRequest(partial, defaults); + expect(result.sendOptions.model).toBe("default:model"); + expect(result.sendOptions.agentId).toBe("exec"); + }); +}); diff --git a/src/common/types/message.ts b/src/common/types/message.ts index efbe19be17..2399550bac 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -17,9 +17,8 @@ import { type ReviewNoteData, formatReviewForModel } from "./review"; export type ReviewNoteDataForDisplay = ReviewNoteData; /** - * Content that a user wants to send in a message. - * Shared between normal send and continue-after-compaction to ensure - * both paths handle the same fields (text, attachments, reviews). + * Content that a user wants to send in a message (legacy shape with `text` field). + * Used by the ContinueMessage branded type. New code should prefer ChatInputMessageDraft. */ export interface UserMessageContent { text: string; @@ -28,11 +27,23 @@ export interface UserMessageContent { reviews?: ReviewNoteDataForDisplay[]; } +/** + * Canonical message draft shared between chat input and compaction follow-up. + * Uses `content` (matching the chat-input field name) instead of `text`. + */ +export interface ChatInputMessageDraft { + content: string; + fileParts?: FilePart[]; + reviews?: ReviewNoteDataForDisplay[]; +} + /** * Input for follow-up content - what call sites provide when triggering compaction. - * Does not include model/agentId since those come from sendMessageOptions. + * The message is a plain chat-input draft; model/agentId come from sendMessageOptions. */ -export interface CompactionFollowUpInput extends UserMessageContent { +export interface CompactionFollowUpInput { + /** The message to send after compaction completes */ + message: ChatInputMessageDraft; /** Frontend metadata to apply to the queued follow-up user message (e.g., preserve /skill display) */ muxMetadata?: MuxFrontendMetadata; } @@ -66,23 +77,92 @@ export function pickPreservedSendOptions(options: SendMessageOptions): Preserved } /** - * Content to send after compaction completes. - * Extends CompactionFollowUpInput with model/agentId for the follow-up message, - * plus preserved send options so the follow-up uses the same settings as the - * original user message. - * - * These fields are required because compaction uses its own agentId ("compact") - * and potentially a different model for summarization. The follow-up message - * should use the user's original model, agentId, and send options. - * - * Call sites provide CompactionFollowUpInput; prepareCompactionMessage converts - * it to CompactionFollowUpRequest by adding model/agentId/options from sendMessageOptions. + * Compaction-specific execution policy: model, agentId, and preserved send options. + * Isolated from the message content so callers don't mix chat-draft fields with send policy. */ -export interface CompactionFollowUpRequest extends CompactionFollowUpInput, PreservedSendOptions { +export type CompactionFollowUpSendOptions = { /** Model to use for the follow-up message (user's original model, not compaction model) */ model: string; /** Agent ID for the follow-up message (user's original agentId, not "compact") */ agentId: string; +} & PreservedSendOptions; + +/** + * Full request to send after compaction completes. + * Separates the message draft from execution policy (sendOptions). + * + * Call sites provide CompactionFollowUpInput (message + optional metadata); + * prepareCompactionMessage converts it to CompactionFollowUpRequest by + * adding sendOptions from the active sendMessageOptions. + */ +export interface CompactionFollowUpRequest extends CompactionFollowUpInput { + sendOptions: CompactionFollowUpSendOptions; +} + +/** + * Legacy flattened follow-up shape from older persisted requests. + * Used only by normalizeCompactionFollowUpRequest for backward compatibility. + */ +interface LegacyCompactionFollowUpRequest { + text?: string; + fileParts?: FilePart[]; + /** @deprecated Older entries stored file attachments as `imageParts`. */ + imageParts?: FilePart[]; + reviews?: ReviewNoteDataForDisplay[]; + muxMetadata?: MuxFrontendMetadata; + model?: string; + agentId?: string; + /** @deprecated Older entries stored agent mode instead of agentId. */ + mode?: "exec" | "plan"; + thinkingLevel?: SendMessageOptions["thinkingLevel"]; + additionalSystemInstructions?: string; + providerOptions?: SendMessageOptions["providerOptions"]; + experiments?: SendMessageOptions["experiments"]; + disableWorkspaceAgents?: boolean; +} + +/** + * Normalize a persisted follow-up request into the current envelope shape. + * Legacy requests stored all fields flat (text, model, agentId, thinkingLevel, ...); + * the new shape nests message content in `message` and send policy in `sendOptions`. + * + * Detection: if `raw` already has a `message` object, assume it's the new shape. + */ +export function normalizeCompactionFollowUpRequest( + raw: CompactionFollowUpRequest | LegacyCompactionFollowUpRequest, + defaults: { model: string; agentId: string } +): CompactionFollowUpRequest { + // New shape: has nested `message` object with `content` field + if ("message" in raw && raw.message != null && typeof raw.message === "object") { + // Already new shape — fill in sendOptions defaults if somehow missing + return { + ...raw, + sendOptions: raw.sendOptions ?? { + model: defaults.model, + agentId: defaults.agentId, + }, + }; + } + + // Legacy flattened shape — migrate to envelope + const legacy = raw as LegacyCompactionFollowUpRequest; + return { + message: { + content: legacy.text ?? "", + fileParts: legacy.fileParts ?? legacy.imageParts, + reviews: legacy.reviews, + }, + muxMetadata: legacy.muxMetadata, + sendOptions: { + model: legacy.model ?? defaults.model, + agentId: legacy.agentId ?? legacy.mode ?? defaults.agentId, + thinkingLevel: legacy.thinkingLevel, + additionalSystemInstructions: legacy.additionalSystemInstructions, + providerOptions: legacy.providerOptions, + experiments: legacy.experiments, + disableWorkspaceAgents: legacy.disableWorkspaceAgents, + }, + }; } /** @@ -161,9 +241,9 @@ export type PersistedContinueMessage = Partial< * True when the content is the default resume sentinel ("Continue") * with no attachments. */ -export function isDefaultSourceContent(content?: Partial): boolean { +export function isDefaultSourceContent(content?: Partial): boolean { if (!content) return false; - const text = typeof content.text === "string" ? content.text.trim() : ""; + const text = typeof content.content === "string" ? content.content.trim() : ""; const hasFiles = (content.fileParts?.length ?? 0) > 0; const hasReviews = (content.reviews?.length ?? 0) > 0; return text === "Continue" && !hasFiles && !hasReviews; @@ -211,21 +291,21 @@ export interface CompactionRequestData { } /** - * Process UserMessageContent into final message text and metadata. - * Used by both normal send path and backend continue message processing. + * Process a chat-input message draft into final message text and metadata. + * Used by both normal send path and backend follow-up message processing. * - * @param content - The user message content (text, attachments, reviews) + * @param message - The message draft (content text, attachments, reviews) * @param existingMetadata - Optional existing metadata to merge with (e.g., for compaction messages) * @returns Object with finalText (reviews prepended) and metadata (reviews for display) */ export function prepareUserMessageForSend( - content: UserMessageContent, + message: ChatInputMessageDraft, existingMetadata?: MuxFrontendMetadata ): { finalText: string; metadata: MuxFrontendMetadata | undefined; } { - const { text, reviews } = content; + const { content: text, reviews } = message; // Format reviews into message text const reviewsText = reviews?.length ? reviews.map(formatReviewForModel).join("\n\n") : ""; diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index b036c799ba..43b4aa8b17 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, mock } from "bun:test"; -import { buildContinueMessage } from "@/common/types/message"; +import { buildContinueMessage, type CompactionFollowUpRequest } from "@/common/types/message"; import type { FilePart, SendMessageOptions } from "@/common/orpc/types"; import { AgentSession } from "./agentSession"; import type { Config } from "@/node/config"; @@ -48,13 +48,14 @@ describe("AgentSession continue-message agentId fallback", () => { throw new Error("Expected base continue message to be built"); } - // Simulate legacy format: no agentId, but has mode instead + // Simulate legacy format: flat fields with mode instead of agentId. + // Cast through unknown because persisted data can be any shape — the normalizer handles it. const legacyFollowUp = { text: baseContinueMessage.text, model: "openai:gpt-4o", agentId: undefined as unknown as string, // Legacy: missing agentId mode: "plan" as const, // Legacy: mode field instead of agentId - }; + } as unknown as CompactionFollowUpRequest; // Mock history service to return a compaction summary with pending follow-up const mockSummaryMessage = { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index ca7aa86d48..88d600c8a5 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -47,12 +47,11 @@ import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { createMuxMessage, isCompactionSummaryMetadata, + normalizeCompactionFollowUpRequest, prepareUserMessageForSend, - type CompactionFollowUpRequest, type MuxFrontendMetadata, type MuxFilePart, type MuxMessage, - type ReviewNoteDataForDisplay, } from "@/common/types/message"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; @@ -83,24 +82,8 @@ import { materializeFileAtMentions } from "@/node/services/fileAtMentions"; // Re-export types from FileChangeTracker for backward compatibility export type { FileState, EditedFileAttachment } from "@/node/services/utils/fileChangeTracker"; -// Type guard for compaction request metadata -// Supports both new `followUpContent` and legacy `continueMessage` for backwards compatibility -interface CompactionRequestMetadata { - type: "compaction-request"; - parsed: { - followUpContent?: CompactionFollowUpRequest; - // Legacy field - older persisted requests may use this instead of followUpContent - continueMessage?: { - text?: string; - imageParts?: FilePart[]; - reviews?: ReviewNoteDataForDisplay[]; - muxMetadata?: MuxFrontendMetadata; - model?: string; - agentId?: string; - mode?: "exec" | "plan"; // Legacy: older versions stored mode instead of agentId - }; - }; -} +// Note: Legacy compaction request metadata (flat follow-up fields, `continueMessage`) +// is handled by normalizeCompactionFollowUpRequest in @/common/types/message. const PDF_MEDIA_TYPE = "application/pdf"; @@ -121,7 +104,9 @@ function estimateBase64DataUrlBytes(dataUrl: string): number | null { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; } -function isCompactionRequestMetadata(meta: unknown): meta is CompactionRequestMetadata { +function isCompactionRequestMetadata( + meta: unknown +): meta is Extract { if (typeof meta !== "object" || meta === null) return false; const obj = meta as Record; if (obj.type !== "compaction-request") return false; @@ -1903,57 +1888,41 @@ export class AgentSession { return; } - // Handle legacy formats: older persisted requests may have `mode` instead of `agentId`, - // and `imageParts` instead of `fileParts`. - const followUp = muxMeta.pendingFollowUp as typeof muxMeta.pendingFollowUp & { - mode?: "exec" | "plan"; - imageParts?: FilePart[]; - }; - - // Derive agentId: new field has it directly, legacy may use `mode` field. - // Legacy `mode` was "exec" | "plan" and maps directly to agentId. - const effectiveAgentId = followUp.agentId ?? followUp.mode ?? "exec"; - - // Normalize attachments: newer metadata uses `fileParts`, older persisted entries used `imageParts`. - const effectiveFileParts = followUp.fileParts ?? followUp.imageParts; - - // Model fallback for legacy follow-ups that may lack the model field. - // DEFAULT_MODEL is a safe fallback that's always available. - const effectiveModel = followUp.model ?? DEFAULT_MODEL; + // Normalize the follow-up into the current envelope shape. + // Handles legacy flattened fields (text, model, agentId, imageParts, mode, etc.) + // and the new nested shape (message, sendOptions) uniformly. + const followUp = normalizeCompactionFollowUpRequest(muxMeta.pendingFollowUp, { + model: DEFAULT_MODEL, + agentId: "exec", + }); log.debug("Dispatching pending follow-up from compaction summary", { workspaceId: this.workspaceId, - hasText: Boolean(followUp.text), - hasFileParts: Boolean(effectiveFileParts?.length), - hasReviews: Boolean(followUp.reviews?.length), - model: effectiveModel, - agentId: effectiveAgentId, + hasContent: Boolean(followUp.message.content), + hasFileParts: Boolean(followUp.message.fileParts?.length), + hasReviews: Boolean(followUp.message.reviews?.length), + model: followUp.sendOptions.model, + agentId: followUp.sendOptions.agentId, }); // Process the follow-up content (handles reviews -> text formatting + metadata) const { finalText, metadata } = prepareUserMessageForSend( - { - text: followUp.text, - fileParts: effectiveFileParts, - reviews: followUp.reviews, - }, + followUp.message, followUp.muxMetadata ); // Build options for the follow-up message. - // Spread the followUp to include preserved send options (thinkingLevel, providerOptions, etc.) + // sendOptions contains the preserved send options (thinkingLevel, providerOptions, etc.) // that were captured from the original user message in prepareCompactionMessage(). const options: SendMessageOptions & { fileParts?: FilePart[]; muxMetadata?: MuxFrontendMetadata; } = { - ...followUp, - model: effectiveModel, - agentId: effectiveAgentId, + ...followUp.sendOptions, }; - if (effectiveFileParts && effectiveFileParts.length > 0) { - options.fileParts = effectiveFileParts; + if (followUp.message.fileParts && followUp.message.fileParts.length > 0) { + options.fileParts = followUp.message.fileParts; } if (metadata) { diff --git a/src/node/services/mock/mockAiRouter.ts b/src/node/services/mock/mockAiRouter.ts index e7fb4c7c1b..0542f08200 100644 --- a/src/node/services/mock/mockAiRouter.ts +++ b/src/node/services/mock/mockAiRouter.ts @@ -112,7 +112,7 @@ function buildMockCompactionSummary(options: { const assistantCount = options.preCompactionMessages.filter((m) => m.role === "assistant").length; const totalCount = options.preCompactionMessages.length; - const followUpText = options.followUpContent?.text?.trim(); + const followUpText = options.followUpContent?.message.content?.trim(); return [ "Mock compaction summary:",