fix: only inject interrupt recovery after real user interrupts (#936)

This commit is contained in:
Charles Packer
2026-02-12 15:22:00 -08:00
committed by GitHub
parent 27217280de
commit 7d09371e5c
10 changed files with 136 additions and 24 deletions

View File

@@ -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<Array<MessageCreate | ApprovalCreate> | 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<string | null>(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 = `<system-alert>Permission mode changed to: ${currentMode}. ${modeDescriptions[currentMode]}</system-alert>\n\n`;
permissionModeAlert = `${SYSTEM_REMINDER_OPEN}Permission mode changed to: ${currentMode}. ${modeDescriptions[currentMode]}${SYSTEM_REMINDER_CLOSE}\n\n`;
lastNotifiedModeRef.current = currentMode;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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") {