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 = `${SYSTEM_REMINDER_TAG}>`;
+// 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 = `${SYSTEM_ALERT_TAG}>`;
/**
* 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);
+ });
});