fix: handle SDK keep_alive messages to prevent idle timeout during long operations (#163)

The stream idle timer (330s) fires during long-running Claude Code tool
executions because the SDK's keep_alive heartbeat messages are silently
dropped. This causes a false "Stream idle timeout" error even though
Claude is still actively working.

Root cause: claude-client.ts only handles 6 SDK message types in its
switch statement and has no default case, so keep_alive messages from
the SDK are never forwarded to the frontend. The idle timer in
stream-session-manager.ts never gets reset by markActive().

Fix: thread keep_alive through the existing 3-layer event pipeline:
- claude-client.ts: forward keep_alive as SSE event (via default case)
- useSSEStream.ts: add onKeepAlive callback to SSECallbacks interface
- stream-session-manager.ts: call markActive() on keep_alive to reset
  the idle timer

No visible UI change — keep_alive only resets the timer silently.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jasonjaclyn2017andClaude Opus 4.6 authored and GitHub committed 2026-03-05 10:59:43 +08:00
1 parent 315aac0f04
commit a906172807
4 files changed
+18

No files matched your search

+7
View File
@@ -24,6 +24,7 @@ export interface SSECallbacks {
onToolTimeout: (toolName: string, elapsedSeconds: number) => void;
onModeChanged: (mode: string) => void;
onTaskUpdate: (sessionId: string) => void;
onKeepAlive: () => void;
onError: (accumulated: string) => void;
}
@@ -146,6 +147,11 @@ function handleSSEEvent(
return accumulated;
}
case 'keep_alive': {
callbacks.onKeepAlive();
return accumulated;
}
case 'error': {
const next = accumulated + '\n\n**Error:** ' + event.data;
callbacks.onError(next);
@@ -232,6 +238,7 @@ export function useSSEStream() {
onToolTimeout: (n, s) => callbacksRef.current?.onToolTimeout(n, s),
onModeChanged: (m) => callbacksRef.current?.onModeChanged(m),
onTaskUpdate: (s) => callbacksRef.current?.onTaskUpdate(s),
onKeepAlive: () => callbacksRef.current?.onKeepAlive(),
onError: (a) => callbacksRef.current?.onError(a),
};
+7
View File
@@ -873,6 +873,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
}));
break;
}
default: {
if ((message as { type: string }).type === 'keep_alive') {
controller.enqueue(formatSSE({ type: 'keep_alive', data: '' }));
}
break;
}
}
}
+3
View File
@@ -311,6 +311,9 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi
markActive();
window.dispatchEvent(new CustomEvent('tasks-updated'));
},
onKeepAlive: () => {
markActive();
},
onError: (acc) => {
markActive();
stream.accumulatedText = acc;
+1
View File
@@ -356,6 +356,7 @@ export type SSEEventType =
| 'permission_request' // permission approval needed
| 'mode_changed' // SDK permission mode changed (e.g. plan → code)
| 'task_update' // SDK TodoWrite task sync
| 'keep_alive' // SDK keep-alive heartbeat (resets idle timer)
| 'done'; // stream complete
export interface SSEEvent {