chat: persistent Agent SDK session per chat — decouple worker from turn (Phase 1)
Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.
- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
on 'result'), so background events after turn-end still reach the client. First
turn opens the session; later turns push onto it. handleStop → interrupt (keeps
session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.
Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,62 @@ function handleResult(
|
||||
callbacks.onEvent({ type: 'result', cost });
|
||||
}
|
||||
|
||||
function handleSystem(msg: Record<string, unknown>, callbacks: StreamParserCallbacks): void {
|
||||
const subtype = msg.subtype as string | undefined;
|
||||
if (subtype === 'init') {
|
||||
const sessionId = msg.session_id as string | undefined;
|
||||
if (sessionId) callbacks.onSessionId(sessionId);
|
||||
} else if (subtype === 'task_started') {
|
||||
callbacks.onEvent({
|
||||
type: 'task:started',
|
||||
taskId: (msg.task_id as string) ?? '',
|
||||
description: (msg.description as string) ?? '',
|
||||
taskType: msg.task_type as string | undefined,
|
||||
});
|
||||
} else if (subtype === 'task_notification') {
|
||||
callbacks.onEvent({
|
||||
type: 'task:notification',
|
||||
taskId: (msg.task_id as string) ?? '',
|
||||
status: (msg.status as 'completed' | 'failed' | 'stopped') ?? 'completed',
|
||||
summary: (msg.summary as string) ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a single already-parsed message object (a Claude Code stream-json message, or the equivalent
|
||||
* Agent SDK message — same shapes) to ChatEvents. Returns false if the message type was ignored.
|
||||
*/
|
||||
export function processMessage(
|
||||
msg: Record<string, unknown>,
|
||||
state: ParseState,
|
||||
callbacks: StreamParserCallbacks,
|
||||
): boolean {
|
||||
const type = msg.type as string;
|
||||
|
||||
switch (type) {
|
||||
case 'stream_event':
|
||||
handleStreamEvent(msg, state, callbacks.onEvent);
|
||||
break;
|
||||
case 'assistant':
|
||||
handleAssistant(msg, state, callbacks.onEvent);
|
||||
break;
|
||||
case 'user':
|
||||
handleUser(msg, callbacks.onEvent);
|
||||
break;
|
||||
case 'system':
|
||||
handleSystem(msg, callbacks);
|
||||
break;
|
||||
case 'result':
|
||||
handleResult(msg, state, callbacks);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single NDJSON line from Claude Code's stream output.
|
||||
* Returns false if the line was skipped (empty or malformed), true otherwise.
|
||||
@@ -136,32 +192,7 @@ export function processLine(
|
||||
return false;
|
||||
}
|
||||
|
||||
const type = msg.type as string;
|
||||
|
||||
switch (type) {
|
||||
case 'stream_event':
|
||||
handleStreamEvent(msg, state, callbacks.onEvent);
|
||||
break;
|
||||
case 'assistant':
|
||||
handleAssistant(msg, state, callbacks.onEvent);
|
||||
break;
|
||||
case 'user':
|
||||
handleUser(msg, callbacks.onEvent);
|
||||
break;
|
||||
case 'system':
|
||||
if (msg.subtype === 'init') {
|
||||
const sessionId = msg.session_id as string | undefined;
|
||||
if (sessionId) callbacks.onSessionId(sessionId);
|
||||
}
|
||||
break;
|
||||
case 'result':
|
||||
handleResult(msg, state, callbacks);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return processMessage(msg, state, callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user