From 7d09371e5cb68099a65eb32d52d1deda56e94d74 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 12 Feb 2026 15:22:00 -0800 Subject: [PATCH] fix: only inject interrupt recovery after real user interrupts (#936) --- src/agent/prompts/approval_recovery_alert.txt | 2 +- .../prompts/interrupt_recovery_alert.txt | 2 +- src/cli/App.tsx | 40 ++++++++++++++++--- src/cli/components/ConversationSelector.tsx | 9 ++++- src/cli/components/UserMessageRich.tsx | 33 ++++++++++----- src/cli/helpers/backfill.ts | 17 ++++++-- src/constants.ts | 4 ++ .../cli/backfill-system-reminder.test.ts | 23 ++++++++++- .../cli/interrupt-recovery-wiring.test.ts | 21 ++++++++++ src/tests/cli/userMessageRich.test.ts | 9 +++++ 10 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 src/tests/cli/interrupt-recovery-wiring.test.ts diff --git a/src/agent/prompts/approval_recovery_alert.txt b/src/agent/prompts/approval_recovery_alert.txt index a62df6d..22748df 100644 --- a/src/agent/prompts/approval_recovery_alert.txt +++ b/src/agent/prompts/approval_recovery_alert.txt @@ -1 +1 @@ -Automated keep-alive ping. Ignore this message and continue from where you stopped. +Automated keep-alive ping. Ignore this message and continue from where you stopped. diff --git a/src/agent/prompts/interrupt_recovery_alert.txt b/src/agent/prompts/interrupt_recovery_alert.txt index 4dbae8e..479b694 100644 --- a/src/agent/prompts/interrupt_recovery_alert.txt +++ b/src/agent/prompts/interrupt_recovery_alert.txt @@ -1 +1 @@ -The user interrupted the active stream. +The user interrupted the active stream. diff --git a/src/cli/App.tsx b/src/cli/App.tsx index f222d72..aca9ea3 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -53,6 +53,8 @@ import { SessionStats } from "../agent/stats"; import { INTERRUPTED_BY_USER, MEMFS_CONFLICT_CHECK_INTERVAL, + SYSTEM_ALERT_CLOSE, + SYSTEM_ALERT_OPEN, SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN, } from "../constants"; @@ -716,6 +718,10 @@ function stripSystemReminders(text: string): string { ), "", ) + .replace( + new RegExp(`${SYSTEM_ALERT_OPEN}[\\s\\S]*?${SYSTEM_ALERT_CLOSE}`, "g"), + "", + ) .trim(); } @@ -1537,6 +1543,9 @@ export default function App({ const lastSentInputRef = useRef | null>( null, ); + // Non-null only when the previous turn was explicitly interrupted by the user. + // Used to gate recovery alert injection to true user-interrupt retries. + const pendingInterruptRecoveryConversationIdRef = useRef(null); // Epoch counter to force dequeue effect re-run when refs change but state doesn't // Incremented when userCancelledRef is reset while messages are queued @@ -3008,13 +3017,22 @@ export default function App({ setNetworkPhase("upload"); abortControllerRef.current = new AbortController(); - // Recover interrupted message: if cache contains ONLY user messages, prepend them + // Recover interrupted message only after explicit user interrupt: + // if cache contains ONLY user messages, prepend them. // Note: type="message" is a local discriminator (not in SDK types) to distinguish from approvals const originalInput = currentInput; const cacheIsAllUserMsgs = lastSentInputRef.current?.every( (m) => m.type === "message" && m.role === "user", ); - if (cacheIsAllUserMsgs && lastSentInputRef.current) { + const canInjectInterruptRecovery = + pendingInterruptRecoveryConversationIdRef.current !== null && + pendingInterruptRecoveryConversationIdRef.current === + conversationIdRef.current; + if ( + cacheIsAllUserMsgs && + lastSentInputRef.current && + canInjectInterruptRecovery + ) { currentInput = [ ...lastSentInputRef.current, ...currentInput.map((m) => @@ -3031,12 +3049,14 @@ export default function App({ : m, ), ]; + pendingInterruptRecoveryConversationIdRef.current = null; // Cache old + new for chained recovery lastSentInputRef.current = [ ...lastSentInputRef.current, ...originalInput, ]; } else { + pendingInterruptRecoveryConversationIdRef.current = null; lastSentInputRef.current = originalInput; } @@ -3242,7 +3262,8 @@ export default function App({ c.type === "text" && "text" in c && typeof c.text === "string" && - !c.text.includes(SYSTEM_REMINDER_OPEN), + !c.text.includes(SYSTEM_REMINDER_OPEN) && + !c.text.includes(SYSTEM_ALERT_OPEN), ) .map((c) => c.text) .join("\n"); @@ -3483,6 +3504,7 @@ export default function App({ conversationBusyRetriesRef.current = 0; lastDequeuedMessageRef.current = null; // Clear - message was processed successfully lastSentInputRef.current = null; // Clear - no recovery needed + pendingInterruptRecoveryConversationIdRef.current = null; // Get last assistant message, user message, and reasoning for Stop hook const lastAssistant = Array.from( @@ -3696,6 +3718,7 @@ export default function App({ setAutoHandledResults([]); setAutoDeniedApprovals([]); lastSentInputRef.current = null; // Clear - message was received by server + pendingInterruptRecoveryConversationIdRef.current = null; // Use new approvals array, fallback to legacy approval for backward compat const approvalsToProcess = @@ -4219,7 +4242,8 @@ export default function App({ c.type === "text" && "text" in c && typeof c.text === "string" && - !c.text.includes(SYSTEM_REMINDER_OPEN), + !c.text.includes(SYSTEM_REMINDER_OPEN) && + !c.text.includes(SYSTEM_ALERT_OPEN), ) .map((c) => c.text) .join("\n"); @@ -4735,6 +4759,8 @@ export default function App({ abortControllerRef.current = null; } + pendingInterruptRecoveryConversationIdRef.current = + conversationIdRef.current; userCancelledRef.current = true; // Prevent dequeue setStreaming(false); setIsExecutingTool(false); @@ -4786,6 +4812,8 @@ export default function App({ } // Set cancellation flag to prevent processConversation from starting + pendingInterruptRecoveryConversationIdRef.current = + conversationIdRef.current; userCancelledRef.current = true; // Increment generation to mark any in-flight processConversation as stale. @@ -4880,6 +4908,8 @@ export default function App({ if (abortControllerRef.current) { abortControllerRef.current.abort(); } + pendingInterruptRecoveryConversationIdRef.current = + conversationIdRef.current; } catch (e) { const errorDetails = formatErrorDetails(e, agentId); appendError(`Failed to interrupt stream: ${errorDetails}`); @@ -8003,7 +8033,7 @@ ${SYSTEM_REMINDER_CLOSE} plan: "Read-only mode. Focus on exploration and planning.", bypassPermissions: "All tools auto-approved. Bias toward action.", }; - permissionModeAlert = `Permission mode changed to: ${currentMode}. ${modeDescriptions[currentMode]}\n\n`; + permissionModeAlert = `${SYSTEM_REMINDER_OPEN}Permission mode changed to: ${currentMode}. ${modeDescriptions[currentMode]}${SYSTEM_REMINDER_CLOSE}\n\n`; lastNotifiedModeRef.current = currentMode; } diff --git a/src/cli/components/ConversationSelector.tsx b/src/cli/components/ConversationSelector.tsx index fe5c805..35167de 100644 --- a/src/cli/components/ConversationSelector.tsx +++ b/src/cli/components/ConversationSelector.tsx @@ -4,7 +4,7 @@ import type { Conversation } from "@letta-ai/letta-client/resources/conversation import { Box, useInput } from "ink"; import { useCallback, useEffect, useRef, useState } from "react"; import { getClient } from "../../agent/client"; -import { SYSTEM_REMINDER_OPEN } from "../../constants"; +import { SYSTEM_ALERT_OPEN, SYSTEM_REMINDER_OPEN } from "../../constants"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { MarkdownDisplay } from "./MarkdownDisplay"; @@ -93,7 +93,12 @@ function extractUserMessagePreview(message: Message): string | null { const part = content[i]; if (part?.type === "text" && part.text) { // Skip system-reminder blocks - if (part.text.startsWith(SYSTEM_REMINDER_OPEN)) continue; + if ( + part.text.startsWith(SYSTEM_REMINDER_OPEN) || + part.text.startsWith(SYSTEM_ALERT_OPEN) + ) { + continue; + } textToShow = part.text; break; } diff --git a/src/cli/components/UserMessageRich.tsx b/src/cli/components/UserMessageRich.tsx index d11d80f..f7d2bac 100644 --- a/src/cli/components/UserMessageRich.tsx +++ b/src/cli/components/UserMessageRich.tsx @@ -1,6 +1,11 @@ import { memo } from "react"; import stringWidth from "string-width"; -import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; +import { + SYSTEM_ALERT_CLOSE, + SYSTEM_ALERT_OPEN, + SYSTEM_REMINDER_CLOSE, + SYSTEM_REMINDER_OPEN, +} from "../../constants"; import { extractTaskNotificationsForDisplay } from "../helpers/taskNotifications"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors, hexToBgAnsi, hexToFgAnsi } from "./colors"; @@ -53,15 +58,20 @@ export function splitSystemReminderBlocks( text: string, ): Array<{ text: string; isSystemReminder: boolean }> { const blocks: Array<{ text: string; isSystemReminder: boolean }> = []; - const tagOpen = SYSTEM_REMINDER_OPEN; - const tagClose = SYSTEM_REMINDER_CLOSE; + const tags = [ + { open: SYSTEM_REMINDER_OPEN, close: SYSTEM_REMINDER_CLOSE }, + { open: SYSTEM_ALERT_OPEN, close: SYSTEM_ALERT_CLOSE }, // legacy + ]; let remaining = text; while (remaining.length > 0) { - const openIdx = remaining.indexOf(tagOpen); + const nextTag = tags + .map((tag) => ({ ...tag, idx: remaining.indexOf(tag.open) })) + .filter((tag) => tag.idx >= 0) + .sort((a, b) => a.idx - b.idx)[0]; - if (openIdx === -1) { + if (!nextTag) { // No more system-reminder tags, rest is user content if (remaining.trim()) { blocks.push({ text: remaining.trim(), isSystemReminder: false }); @@ -70,7 +80,7 @@ export function splitSystemReminderBlocks( } // Find the closing tag - const closeIdx = remaining.indexOf(tagClose, openIdx); + const closeIdx = remaining.indexOf(nextTag.close, nextTag.idx); if (closeIdx === -1) { // Malformed/incomplete tag - treat the whole remainder as literal user text. const literal = remaining.trim(); @@ -81,18 +91,21 @@ export function splitSystemReminderBlocks( } // Content before the tag is user content - if (openIdx > 0) { - const before = remaining.slice(0, openIdx).trim(); + if (nextTag.idx > 0) { + const before = remaining.slice(0, nextTag.idx).trim(); if (before) { blocks.push({ text: before, isSystemReminder: false }); } } // Extract the full system-reminder block (including tags) - const sysBlock = remaining.slice(openIdx, closeIdx + tagClose.length); + const sysBlock = remaining.slice( + nextTag.idx, + closeIdx + nextTag.close.length, + ); blocks.push({ text: sysBlock, isSystemReminder: true }); - remaining = remaining.slice(closeIdx + tagClose.length); + remaining = remaining.slice(closeIdx + nextTag.close.length); } return blocks; diff --git a/src/cli/helpers/backfill.ts b/src/cli/helpers/backfill.ts index 8186949..42dce32 100644 --- a/src/cli/helpers/backfill.ts +++ b/src/cli/helpers/backfill.ts @@ -5,7 +5,12 @@ import type { Message, TextContent, } from "@letta-ai/letta-client/resources/agents/messages"; -import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; +import { + SYSTEM_ALERT_CLOSE, + SYSTEM_ALERT_OPEN, + SYSTEM_REMINDER_CLOSE, + SYSTEM_REMINDER_OPEN, +} from "../../constants"; import type { Buffers } from "./accumulator"; import { extractTaskNotificationsForDisplay } from "./taskNotifications"; @@ -41,7 +46,7 @@ function normalizeLineEndings(s: string): string { return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } -function removeSystemReminderBlocks(text: string): string { +function removeSystemContextBlocks(text: string): string { return text .replace( new RegExp( @@ -50,6 +55,10 @@ function removeSystemReminderBlocks(text: string): string { ), "", ) + .replace( + new RegExp(`${SYSTEM_ALERT_OPEN}[\\s\\S]*?${SYSTEM_ALERT_CLOSE}`, "g"), + "", + ) .trim(); } @@ -102,7 +111,7 @@ function renderUserContentParts( // Parts are joined with newlines so each appears as a separate line if (typeof parts === "string") { const normalized = normalizeLineEndings(parts); - return clip(removeSystemReminderBlocks(normalized), CLIP_CHAR_LIMIT_TEXT); + return clip(removeSystemContextBlocks(normalized), CLIP_CHAR_LIMIT_TEXT); } const rendered: string[] = []; @@ -111,7 +120,7 @@ function renderUserContentParts( const text = p.text || ""; // Normalize line endings (\r\n and \r -> \n) to prevent terminal garbling const normalized = normalizeLineEndings(text); - const withoutSystemReminders = removeSystemReminderBlocks(normalized); + const withoutSystemReminders = removeSystemContextBlocks(normalized); if (!withoutSystemReminders) continue; rendered.push(clip(withoutSystemReminders, CLIP_CHAR_LIMIT_TEXT)); } else if (p.type === "image") { diff --git a/src/constants.ts b/src/constants.ts index fb72e99..e8133aa 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,6 +23,10 @@ export const INTERRUPTED_BY_USER = "Interrupted by user"; export const SYSTEM_REMINDER_TAG = "system-reminder"; export const SYSTEM_REMINDER_OPEN = `<${SYSTEM_REMINDER_TAG}>`; export const SYSTEM_REMINDER_CLOSE = ``; +// Legacy tag kept for parsing/backward compatibility with older saved messages. +export const SYSTEM_ALERT_TAG = "system-alert"; +export const SYSTEM_ALERT_OPEN = `<${SYSTEM_ALERT_TAG}>`; +export const SYSTEM_ALERT_CLOSE = ``; /** * How often (in turns) to check for memfs sync conflicts, even without diff --git a/src/tests/cli/backfill-system-reminder.test.ts b/src/tests/cli/backfill-system-reminder.test.ts index d3c6796..9b0880a 100644 --- a/src/tests/cli/backfill-system-reminder.test.ts +++ b/src/tests/cli/backfill-system-reminder.test.ts @@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test"; import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; import { createBuffers } from "../../cli/helpers/accumulator"; import { backfillBuffers } from "../../cli/helpers/backfill"; -import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "../../constants"; +import { + SYSTEM_ALERT_CLOSE, + SYSTEM_ALERT_OPEN, + SYSTEM_REMINDER_CLOSE, + SYSTEM_REMINDER_OPEN, +} from "../../constants"; function userMessage( id: string, @@ -65,4 +70,20 @@ describe("backfill system-reminder handling", () => { expect(buffers.byId.get("u3")).toBeUndefined(); expect(buffers.order).toHaveLength(0); }); + + test("hides legacy system-alert blocks from backfill", () => { + const buffers = createBuffers(); + const history = [ + userMessage( + "u4", + `${SYSTEM_ALERT_OPEN}The user interrupted the active stream.${SYSTEM_ALERT_CLOSE}\n\nhello :D`, + ), + ]; + + backfillBuffers(buffers, history); + + const line = buffers.byId.get("u4"); + expect(line?.kind).toBe("user"); + expect(line && "text" in line ? line.text : "").toBe("hello :D"); + }); }); diff --git a/src/tests/cli/interrupt-recovery-wiring.test.ts b/src/tests/cli/interrupt-recovery-wiring.test.ts new file mode 100644 index 0000000..17eeade --- /dev/null +++ b/src/tests/cli/interrupt-recovery-wiring.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +describe("interrupt recovery alert wiring", () => { + test("gates alert injection on explicit user interrupt state", () => { + const appPath = fileURLToPath( + new URL("../../cli/App.tsx", import.meta.url), + ); + const source = readFileSync(appPath, "utf-8"); + + expect(source).toContain("pendingInterruptRecoveryConversationIdRef"); + expect(source).toContain("canInjectInterruptRecovery"); + expect(source).toContain( + "pendingInterruptRecoveryConversationIdRef.current ===", + ); + expect(source).toContain( + "pendingInterruptRecoveryConversationIdRef.current = null;", + ); + }); +}); diff --git a/src/tests/cli/userMessageRich.test.ts b/src/tests/cli/userMessageRich.test.ts index 75df921..06bc212 100644 --- a/src/tests/cli/userMessageRich.test.ts +++ b/src/tests/cli/userMessageRich.test.ts @@ -18,4 +18,13 @@ describe("splitSystemReminderBlocks", () => { expect(blocks.some((b) => b.text.includes("before"))).toBe(true); expect(blocks.some((b) => b.text.includes("after"))).toBe(true); }); + + test("detects legacy system-alert blocks as system context", () => { + const blocks = splitSystemReminderBlocks( + "before\nalert\nafter", + ); + + expect(blocks.some((b) => b.isSystemReminder)).toBe(true); + expect(blocks.some((b) => b.text.includes(""))).toBe(true); + }); });