interrupted by user, not an error
pressing stop ended the turn with "Claude Code returned an error" — the agent sdk reports interrupt() as an ordinary failed result, indistinguishable from a real fault downstream. the sidecar now flags the session it interrupted and rewrites that event to the existing durable 'stopped', which opencode already emitted. escape stops the turn (bound to the chat subtree, not the document), and the prompt comes back to the composer verbatim unless you've started typing something else. history parity: claude files [Request interrupted by user] as a user message, so the transcript reader maps those exact strings to the same role instead of replaying them as something you typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -344,7 +344,17 @@ export type ClaudeChatMessage =
|
||||
// virtualised list can key on. It has to be visible, not smoothed over: the parts read as one
|
||||
// conversation to you, but the agent's context was emptied at this line and nothing above it is in
|
||||
// its memory.
|
||||
| { role: 'divider'; sessionId: string };
|
||||
| { role: 'divider'; sessionId: string }
|
||||
/** A turn you stopped. See `INTERRUPTION_MARKERS`. */
|
||||
| { role: 'interrupted' };
|
||||
|
||||
/**
|
||||
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
|
||||
* model is told, on the next turn, that it was cut off. Replayed literally it reads as something you
|
||||
* typed, so the transcript showed a message you never sent. Matched whole-string only: this text appears
|
||||
* inside real messages too (this file's own conversation being one), and those are genuinely yours.
|
||||
*/
|
||||
const INTERRUPTION_MARKERS = new Set(['[Request interrupted by user]', '[Request interrupted by user for tool use]']);
|
||||
|
||||
type ContentBlock =
|
||||
| { type: 'text'; text?: string }
|
||||
@@ -366,6 +376,9 @@ function blockText(content: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
const userOrInterruption = (text: string): ClaudeChatMessage =>
|
||||
INTERRUPTION_MARKERS.has(text.trim()) ? { role: 'interrupted' } : { role: 'user', text };
|
||||
|
||||
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
|
||||
|
||||
/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
|
||||
@@ -397,13 +410,13 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
|
||||
|
||||
if (entry.type === 'user' && !entry.isMeta) {
|
||||
if (typeof content === 'string') {
|
||||
if (content.trim()) messages.push({ role: 'user', text: content });
|
||||
if (content.trim()) messages.push(userOrInterruption(content));
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content as ContentBlock[]) {
|
||||
if (block.type === 'text' && block.text?.trim()) {
|
||||
messages.push({ role: 'user', text: block.text });
|
||||
messages.push(userOrInterruption(block.text));
|
||||
} else if (block.type === 'tool_result') {
|
||||
const tool = toolById.get(block.tool_use_id);
|
||||
if (tool) {
|
||||
|
||||
@@ -163,6 +163,13 @@ type PersistentSession = {
|
||||
emit: (event: ChatEvent) => void;
|
||||
isGenerating: boolean;
|
||||
pendingTasks: Set<string>; // background tasks started but not yet notified; suppress idle-GC while non-empty
|
||||
/**
|
||||
* The user pressed stop and we are waiting for the turn to fall over. The SDK reports an interrupt as
|
||||
* an ordinary failed `result` — `is_error` with no text — which is indistinguishable downstream from
|
||||
* the harness actually breaking, and reached the user as "Claude Code returned an error". Only the
|
||||
* side that called `interrupt()` knows better, so it says so here.
|
||||
*/
|
||||
interrupted: boolean;
|
||||
idleTimer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
@@ -227,6 +234,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
emit: onEvent,
|
||||
isGenerating: false,
|
||||
pendingTasks: new Set<string>(),
|
||||
interrupted: false,
|
||||
};
|
||||
|
||||
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
|
||||
@@ -259,7 +267,12 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
session.pushTurn = (prompt: string) => {
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.isGenerating = true;
|
||||
input.push({ type: 'user', message: { role: 'user', content: prompt }, parent_tool_use_id: null, session_id: sessionKey });
|
||||
input.push({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: prompt },
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionKey,
|
||||
});
|
||||
};
|
||||
|
||||
sessions.set(sessionKey, session);
|
||||
@@ -268,7 +281,9 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
// and (re)arm the idle timer; the session process stays alive so later task_notifications still flow.
|
||||
void (async () => {
|
||||
const state = createParseState();
|
||||
const emit = (event: ChatEvent) => {
|
||||
const emit = (raw: ChatEvent) => {
|
||||
// A turn we interrupted ends in a failed `result`. That is the stop landing, not a fault.
|
||||
const event: ChatEvent = raw.type === 'error' && session.interrupted ? { type: 'stopped' } : raw;
|
||||
if (event.type === 'task:started') {
|
||||
// Work is running — hold off idle-GC until it finishes.
|
||||
session.pendingTasks.add(event.taskId);
|
||||
@@ -281,6 +296,8 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
if (!session.isGenerating && session.pendingTasks.size === 0) armIdle(session);
|
||||
} else if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') {
|
||||
session.isGenerating = false;
|
||||
// Whatever ended the turn, the interrupt is spent — a later genuine error must not wear it.
|
||||
session.interrupted = false;
|
||||
if (session.pendingTasks.size === 0) armIdle(session);
|
||||
}
|
||||
session.emit(event);
|
||||
@@ -323,11 +340,15 @@ export async function spawnClaudeStreaming(
|
||||
export async function interruptClaudeSession(sessionKey: string): Promise<boolean> {
|
||||
const session = sessions.get(sessionKey);
|
||||
if (!session) return false;
|
||||
// Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the
|
||||
// consumer loop reads this flag to tell a stop from a fault.
|
||||
session.interrupted = true;
|
||||
try {
|
||||
await session.query.interrupt();
|
||||
session.isGenerating = false;
|
||||
return true;
|
||||
} catch {
|
||||
session.interrupted = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user