From 75b241099f5e6f3df35846c939a4970536e822d2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Oct 2025 13:01:03 -0500 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20Inject=20mode=20transition?= =?UTF-8?q?=20context=20to=20improve=20reliability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'mode' field to CmuxMetadata to track mode per message - Detect mode switches by comparing with last assistant message - Inject explicit transition note when mode changes mid-conversation - Helps models understand they should follow new mode instructions Addresses #224 - flaky mode-specific instructions test --- src/services/aiService.ts | 22 +++++++++++++++++++++- src/types/message.ts | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index d3e23050d..b902c948b 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -485,11 +485,30 @@ export class AIService extends EventEmitter { return Err({ type: "unknown", raw: metadataResult.error }); } + // Detect mode transitions by checking the last assistant message's mode + // If mode changed, inject transition context to help the model understand the switch + let enhancedSystemInstructions = additionalSystemInstructions; + if (mode) { + // Find the last assistant message to check its mode + // We look in the original messages (not filtered) to preserve mode state + const lastAssistantMessage = [...messages].reverse().find((m) => m.role === "assistant"); + const lastMode = lastAssistantMessage?.metadata?.mode; + + // If we have a previous mode and it's different from current mode, inject transition context + if (lastMode && lastMode !== mode) { + const transitionNote = `\n\nIMPORTANT: The user has switched from ${lastMode} mode to ${mode} mode. Ignore any previous mode state from conversation history and follow the current mode instructions.`; + enhancedSystemInstructions = enhancedSystemInstructions + ? enhancedSystemInstructions + transitionNote + : transitionNote; + log.info(`Mode transition detected: ${lastMode} → ${mode}`, { workspaceId }); + } + } + // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadataResult.data, mode, - additionalSystemInstructions + enhancedSystemInstructions ); // Count system message tokens for cost tracking @@ -525,6 +544,7 @@ export class AIService extends EventEmitter { timestamp: Date.now(), model: modelString, systemMessageTokens, + mode, // Track the mode for this assistant response }); // Append to history to get historySequence assigned diff --git a/src/types/message.ts b/src/types/message.ts index c5a81daea..9af3b7d5a 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -18,6 +18,7 @@ export interface CmuxMetadata { errorType?: StreamErrorType; // Error type/category if stream failed compacted?: boolean; // Whether this message is a compacted summary of previous history toolPolicy?: ToolPolicy; // Tool policy active when this message was sent (user messages only) + mode?: string; // The mode (plan/exec/etc) active when this message was sent (assistant messages only) } // Extended tool part type that supports interrupted tool calls (input-available state) From 8a17bf8436920496719abcbf990f2b80324ae546 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Oct 2025 13:07:56 -0500 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20Inject=20mode=20transition?= =?UTF-8?q?=20as=20temporal=20user=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed approach: inject mode switch as synthetic user message instead of system instruction enhancement. Benefits: - More temporal - transitions happen in conversation flow - Models handle in-message context better than system changes - Simpler - no need to persist mode across stream lifecycle - Avoids metadata persistence issue caught by Codex The synthetic message is inserted before the last user message when mode changes, providing natural context for the model. Implementation: - Added injectModeTransition() to modelMessageTransform.ts - Operates on CmuxMessage[] where metadata is available - Inserts synthetic user message: '[Mode switched from X to Y]' - Called after addInterruptedSentinel, before conversion to ModelMessage Co-authored-by: Codex (review feedback) --- src/services/aiService.ts | 27 ++------ src/utils/messages/modelMessageTransform.ts | 74 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index b902c948b..c4dc61f85 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -22,6 +22,7 @@ import { validateAnthropicCompliance, addInterruptedSentinel, filterEmptyAssistantMessages, + injectModeTransition, } from "@/utils/messages/modelMessageTransform"; import { applyCacheControl } from "@/utils/ai/cacheStrategy"; import type { HistoryService } from "./historyService"; @@ -450,9 +451,12 @@ export class AIService extends EventEmitter { // Add [CONTINUE] sentinel to partial messages (for model context) const messagesWithSentinel = addInterruptedSentinel(filteredMessages); + // Inject mode transition context if mode changed from last assistant message + const messagesWithModeContext = injectModeTransition(messagesWithSentinel, mode); + // Apply centralized tool-output redaction BEFORE converting to provider ModelMessages // This keeps the persisted/UI history intact while trimming heavy fields for the request - const redactedForProvider = applyToolOutputRedaction(messagesWithSentinel); + const redactedForProvider = applyToolOutputRedaction(messagesWithModeContext); log.debug_obj(`${workspaceId}/2a_redacted_messages.json`, redactedForProvider); // Convert CmuxMessage to ModelMessage format using Vercel AI SDK utility @@ -485,30 +489,11 @@ export class AIService extends EventEmitter { return Err({ type: "unknown", raw: metadataResult.error }); } - // Detect mode transitions by checking the last assistant message's mode - // If mode changed, inject transition context to help the model understand the switch - let enhancedSystemInstructions = additionalSystemInstructions; - if (mode) { - // Find the last assistant message to check its mode - // We look in the original messages (not filtered) to preserve mode state - const lastAssistantMessage = [...messages].reverse().find((m) => m.role === "assistant"); - const lastMode = lastAssistantMessage?.metadata?.mode; - - // If we have a previous mode and it's different from current mode, inject transition context - if (lastMode && lastMode !== mode) { - const transitionNote = `\n\nIMPORTANT: The user has switched from ${lastMode} mode to ${mode} mode. Ignore any previous mode state from conversation history and follow the current mode instructions.`; - enhancedSystemInstructions = enhancedSystemInstructions - ? enhancedSystemInstructions + transitionNote - : transitionNote; - log.info(`Mode transition detected: ${lastMode} → ${mode}`, { workspaceId }); - } - } - // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadataResult.data, mode, - enhancedSystemInstructions + additionalSystemInstructions ); // Count system message tokens for cost tracking diff --git a/src/utils/messages/modelMessageTransform.ts b/src/utils/messages/modelMessageTransform.ts index 6f6805ab8..812f163fa 100644 --- a/src/utils/messages/modelMessageTransform.ts +++ b/src/utils/messages/modelMessageTransform.ts @@ -106,6 +106,80 @@ export function addInterruptedSentinel(messages: CmuxMessage[]): CmuxMessage[] { return result; } +/** + * Inject mode transition context when mode changes mid-conversation. + * Inserts a synthetic user message before the final user message to signal the mode switch. + * This provides temporal context that helps models understand they should follow new mode instructions. + * + * @param messages The conversation history + * @param currentMode The mode for the upcoming assistant response (e.g., "plan", "exec") + * @returns Messages with mode transition context injected if needed + */ +export function injectModeTransition(messages: CmuxMessage[], currentMode?: string): CmuxMessage[] { + // No mode specified, nothing to do + if (!currentMode) { + return messages; + } + + // Need at least one message to have a conversation + if (messages.length === 0) { + return messages; + } + + // Find the last assistant message to check its mode + const lastAssistantMessage = [...messages].reverse().find((m) => m.role === "assistant"); + const lastMode = lastAssistantMessage?.metadata?.mode; + + // No mode transition if no previous mode or same mode + if (!lastMode || lastMode === currentMode) { + return messages; + } + + // Mode transition detected! Inject a synthetic user message before the last user message + // This provides temporal context: user says "switch modes" before their actual request + const result: CmuxMessage[] = []; + + // Find the index of the last user message + let lastUserIndex = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + lastUserIndex = i; + break; + } + } + + // Add all messages up to (but not including) the last user message + for (let i = 0; i < lastUserIndex; i++) { + result.push(messages[i]); + } + + // Inject mode transition message right before the last user message + const transitionMessage: CmuxMessage = { + id: `mode-transition-${Date.now()}`, + role: "user", + parts: [ + { + type: "text", + text: `[Mode switched from ${lastMode} to ${currentMode}. Follow ${currentMode} mode instructions.]`, + }, + ], + metadata: { + timestamp: Date.now(), + synthetic: true, + }, + }; + result.push(transitionMessage); + + // Add the last user message and any remaining messages + for (let i = lastUserIndex; i < messages.length; i++) { + result.push(messages[i]); + } + + return result; +} + + + /** * Split assistant messages with mixed text and tool calls into separate messages * to comply with Anthropic's requirement that tool_use blocks must be immediately From ce725bcd4bd8de57e033e4f4d4e482adc833d7d0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Oct 2025 13:10:57 -0500 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20Add=20unit=20tests=20and=20p?= =?UTF-8?q?ersist=20mode=20in=20final=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added 5 unit tests for injectModeTransition() - Fixed edge case: don't inject when no user messages exist - Pass mode to StreamManager so it persists in final history - Updated PR description to be clearer and more concise Co-authored-by: Codex (persistence fix) --- src/services/aiService.ts | 1 + .../messages/modelMessageTransform.test.ts | 137 ++++++++++++++++++ src/utils/messages/modelMessageTransform.ts | 8 +- 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index c4dc61f85..cedc98f31 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -681,6 +681,7 @@ export class AIService extends EventEmitter { { systemMessageTokens, timestamp: Date.now(), + mode, // Pass mode so it persists in final history entry }, providerOptions, maxOutputTokens, diff --git a/src/utils/messages/modelMessageTransform.test.ts b/src/utils/messages/modelMessageTransform.test.ts index 9020960a2..79439cdba 100644 --- a/src/utils/messages/modelMessageTransform.test.ts +++ b/src/utils/messages/modelMessageTransform.test.ts @@ -4,6 +4,7 @@ import { transformModelMessages, validateAnthropicCompliance, addInterruptedSentinel, + injectModeTransition, } from "./modelMessageTransform"; import type { CmuxMessage } from "@/types/message"; @@ -712,3 +713,139 @@ describe("modelMessageTransform", () => { }); }); }); + + describe("injectModeTransition", () => { + it("should inject transition message when mode changes", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Let's plan a feature" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Here's the plan..." }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Now execute it" }], + metadata: { timestamp: 3000 }, + }, + ]; + + const result = injectModeTransition(messages, "exec"); + + // Should have 4 messages: user, assistant, mode-transition, user + expect(result.length).toBe(4); + + // Third message should be mode transition + expect(result[2].role).toBe("user"); + expect(result[2].metadata?.synthetic).toBe(true); + expect(result[2].parts[0]).toMatchObject({ + type: "text", + text: "[Mode switched from plan to exec. Follow exec mode instructions.]", + }); + + // Original messages should be preserved + expect(result[0]).toEqual(messages[0]); + expect(result[1]).toEqual(messages[1]); + expect(result[3]).toEqual(messages[2]); // Last user message shifted + }); + + it("should not inject transition when mode is the same", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Let's plan" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Planning..." }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue planning" }], + metadata: { timestamp: 3000 }, + }, + ]; + + const result = injectModeTransition(messages, "plan"); + + // Should be unchanged + expect(result.length).toBe(3); + expect(result).toEqual(messages); + }); + + it("should not inject transition when no previous mode exists", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + ]; + + const result = injectModeTransition(messages, "exec"); + + // Should be unchanged (no assistant message to compare) + expect(result.length).toBe(1); + expect(result).toEqual(messages); + }); + + it("should not inject transition when no mode specified", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Hi" }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 3000 }, + }, + ]; + + const result = injectModeTransition(messages, undefined); + + // Should be unchanged + expect(result.length).toBe(3); + expect(result).toEqual(messages); + }); + + it("should handle conversation with no user messages", () => { + const messages: CmuxMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Hi" }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + ]; + + const result = injectModeTransition(messages, "exec"); + + // Should be unchanged (no user message to inject before) + expect(result.length).toBe(1); + expect(result).toEqual(messages); + }); + }); + diff --git a/src/utils/messages/modelMessageTransform.ts b/src/utils/messages/modelMessageTransform.ts index 812f163fa..a7b3f62e8 100644 --- a/src/utils/messages/modelMessageTransform.ts +++ b/src/utils/messages/modelMessageTransform.ts @@ -137,7 +137,6 @@ export function injectModeTransition(messages: CmuxMessage[], currentMode?: stri // Mode transition detected! Inject a synthetic user message before the last user message // This provides temporal context: user says "switch modes" before their actual request - const result: CmuxMessage[] = []; // Find the index of the last user message let lastUserIndex = -1; @@ -148,6 +147,13 @@ export function injectModeTransition(messages: CmuxMessage[], currentMode?: stri } } + // If there's no user message, can't inject transition (nothing to inject before) + if (lastUserIndex === -1) { + return messages; + } + + const result: CmuxMessage[] = []; + // Add all messages up to (but not including) the last user message for (let i = 0; i < lastUserIndex; i++) { result.push(messages[i]); From cbfc8a8c6cc2a200cc766f6db6d204aee6167f47 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Oct 2025 13:13:37 -0500 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messages/modelMessageTransform.test.ts | 251 +++++++++--------- src/utils/messages/modelMessageTransform.ts | 4 +- 2 files changed, 126 insertions(+), 129 deletions(-) diff --git a/src/utils/messages/modelMessageTransform.test.ts b/src/utils/messages/modelMessageTransform.test.ts index 79439cdba..669113e30 100644 --- a/src/utils/messages/modelMessageTransform.test.ts +++ b/src/utils/messages/modelMessageTransform.test.ts @@ -714,138 +714,137 @@ describe("modelMessageTransform", () => { }); }); - describe("injectModeTransition", () => { - it("should inject transition message when mode changes", () => { - const messages: CmuxMessage[] = [ - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Let's plan a feature" }], - metadata: { timestamp: 1000 }, - }, - { - id: "assistant-1", - role: "assistant", - parts: [{ type: "text", text: "Here's the plan..." }], - metadata: { timestamp: 2000, mode: "plan" }, - }, - { - id: "user-2", - role: "user", - parts: [{ type: "text", text: "Now execute it" }], - metadata: { timestamp: 3000 }, - }, - ]; - - const result = injectModeTransition(messages, "exec"); - - // Should have 4 messages: user, assistant, mode-transition, user - expect(result.length).toBe(4); - - // Third message should be mode transition - expect(result[2].role).toBe("user"); - expect(result[2].metadata?.synthetic).toBe(true); - expect(result[2].parts[0]).toMatchObject({ - type: "text", - text: "[Mode switched from plan to exec. Follow exec mode instructions.]", - }); - - // Original messages should be preserved - expect(result[0]).toEqual(messages[0]); - expect(result[1]).toEqual(messages[1]); - expect(result[3]).toEqual(messages[2]); // Last user message shifted - }); - - it("should not inject transition when mode is the same", () => { - const messages: CmuxMessage[] = [ - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Let's plan" }], - metadata: { timestamp: 1000 }, - }, - { - id: "assistant-1", - role: "assistant", - parts: [{ type: "text", text: "Planning..." }], - metadata: { timestamp: 2000, mode: "plan" }, - }, - { - id: "user-2", - role: "user", - parts: [{ type: "text", text: "Continue planning" }], - metadata: { timestamp: 3000 }, - }, - ]; - - const result = injectModeTransition(messages, "plan"); - - // Should be unchanged - expect(result.length).toBe(3); - expect(result).toEqual(messages); +describe("injectModeTransition", () => { + it("should inject transition message when mode changes", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Let's plan a feature" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Here's the plan..." }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Now execute it" }], + metadata: { timestamp: 3000 }, + }, + ]; + + const result = injectModeTransition(messages, "exec"); + + // Should have 4 messages: user, assistant, mode-transition, user + expect(result.length).toBe(4); + + // Third message should be mode transition + expect(result[2].role).toBe("user"); + expect(result[2].metadata?.synthetic).toBe(true); + expect(result[2].parts[0]).toMatchObject({ + type: "text", + text: "[Mode switched from plan to exec. Follow exec mode instructions.]", }); - it("should not inject transition when no previous mode exists", () => { - const messages: CmuxMessage[] = [ - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Hello" }], - metadata: { timestamp: 1000 }, - }, - ]; - - const result = injectModeTransition(messages, "exec"); - - // Should be unchanged (no assistant message to compare) - expect(result.length).toBe(1); - expect(result).toEqual(messages); - }); + // Original messages should be preserved + expect(result[0]).toEqual(messages[0]); + expect(result[1]).toEqual(messages[1]); + expect(result[3]).toEqual(messages[2]); // Last user message shifted + }); - it("should not inject transition when no mode specified", () => { - const messages: CmuxMessage[] = [ - { - id: "user-1", - role: "user", - parts: [{ type: "text", text: "Hello" }], - metadata: { timestamp: 1000 }, - }, - { - id: "assistant-1", - role: "assistant", - parts: [{ type: "text", text: "Hi" }], - metadata: { timestamp: 2000, mode: "plan" }, - }, - { - id: "user-2", - role: "user", - parts: [{ type: "text", text: "Continue" }], - metadata: { timestamp: 3000 }, - }, - ]; + it("should not inject transition when mode is the same", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Let's plan" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Planning..." }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue planning" }], + metadata: { timestamp: 3000 }, + }, + ]; + + const result = injectModeTransition(messages, "plan"); + + // Should be unchanged + expect(result.length).toBe(3); + expect(result).toEqual(messages); + }); - const result = injectModeTransition(messages, undefined); + it("should not inject transition when no previous mode exists", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + ]; + + const result = injectModeTransition(messages, "exec"); + + // Should be unchanged (no assistant message to compare) + expect(result.length).toBe(1); + expect(result).toEqual(messages); + }); - // Should be unchanged - expect(result.length).toBe(3); - expect(result).toEqual(messages); - }); + it("should not inject transition when no mode specified", () => { + const messages: CmuxMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Hello" }], + metadata: { timestamp: 1000 }, + }, + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Hi" }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + { + id: "user-2", + role: "user", + parts: [{ type: "text", text: "Continue" }], + metadata: { timestamp: 3000 }, + }, + ]; + + const result = injectModeTransition(messages, undefined); + + // Should be unchanged + expect(result.length).toBe(3); + expect(result).toEqual(messages); + }); - it("should handle conversation with no user messages", () => { - const messages: CmuxMessage[] = [ - { - id: "assistant-1", - role: "assistant", - parts: [{ type: "text", text: "Hi" }], - metadata: { timestamp: 2000, mode: "plan" }, - }, - ]; + it("should handle conversation with no user messages", () => { + const messages: CmuxMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [{ type: "text", text: "Hi" }], + metadata: { timestamp: 2000, mode: "plan" }, + }, + ]; - const result = injectModeTransition(messages, "exec"); + const result = injectModeTransition(messages, "exec"); - // Should be unchanged (no user message to inject before) - expect(result.length).toBe(1); - expect(result).toEqual(messages); - }); + // Should be unchanged (no user message to inject before) + expect(result.length).toBe(1); + expect(result).toEqual(messages); }); - +}); diff --git a/src/utils/messages/modelMessageTransform.ts b/src/utils/messages/modelMessageTransform.ts index a7b3f62e8..fbfd5bc2a 100644 --- a/src/utils/messages/modelMessageTransform.ts +++ b/src/utils/messages/modelMessageTransform.ts @@ -137,7 +137,7 @@ export function injectModeTransition(messages: CmuxMessage[], currentMode?: stri // Mode transition detected! Inject a synthetic user message before the last user message // This provides temporal context: user says "switch modes" before their actual request - + // Find the index of the last user message let lastUserIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { @@ -184,8 +184,6 @@ export function injectModeTransition(messages: CmuxMessage[], currentMode?: stri return result; } - - /** * Split assistant messages with mixed text and tool calls into separate messages * to comply with Anthropic's requirement that tool_use blocks must be immediately