fix: only inject interrupt recovery after real user interrupts (#936)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user