From 591907ddeb468668f8ac21900ebc5c494dc3dcf5 Mon Sep 17 00:00:00 2001 From: Jason Tang Date: Fri, 6 Mar 2026 01:03:17 -0600 Subject: [PATCH] fix: prevent stale SDK session and tool-marker parroting after idle timeout After a stream idle timeout (330s), the SDK session ID remained in the database. The next user message attempted to resume this broken session, which failed and fell back to buildPromptWithHistory(). That function converted tool-use blocks into [Used tool: ...] and [Tool result: ...] plain-text markers, which Claude then parroted back as literal text instead of executing real tools. Three changes fix this end-to-end: 1. session PATCH endpoint (route.ts): accept sdk_session_id in the request body so it can be cleared via API. 2. idle timeout handler (stream-session-manager.ts): after emitting the timeout error, fire a PATCH request to clear the stale sdk_session_id from the database. This ensures the next message starts a fresh SDK session instead of attempting a doomed resume. 3. history fallback (claude-client.ts): strip tool_use and tool_result blocks from buildPromptWithHistory() output entirely. Previously these were rendered as [Used tool: X] / [Tool result: ...] text that Claude treated as its own output. Now only the text portions of assistant turns are included, with an explicit instruction that the history is a summary of already-executed turns. Co-Authored-By: Claude Opus 4.6 --- src/app/api/chat/sessions/[id]/route.ts | 5 ++++- src/lib/claude-client.ts | 17 ++++++++--------- src/lib/stream-session-manager.ts | 6 ++++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/app/api/chat/sessions/[id]/route.ts b/src/app/api/chat/sessions/[id]/route.ts index 91d760b6..b44ee0dc 100644 --- a/src/app/api/chat/sessions/[id]/route.ts +++ b/src/app/api/chat/sessions/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode, updateSessionModel, updateSessionProviderId, clearSessionMessages } from '@/lib/db'; +import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode, updateSessionModel, updateSessionProviderId, clearSessionMessages, updateSdkSessionId } from '@/lib/db'; export async function GET( _request: NextRequest, @@ -46,6 +46,9 @@ export async function PATCH( if (body.provider_id !== undefined) { updateSessionProviderId(id, body.provider_id); } + if (body.sdk_session_id !== undefined) { + updateSdkSessionId(id, body.sdk_session_id); + } if (body.clear_messages) { clearSessionMessages(id); } diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 61a813f3..afde9aae 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -235,9 +235,13 @@ function buildPromptWithHistory( ): string { if (!history || history.length === 0) return prompt; - const lines: string[] = ['']; + const lines: string[] = [ + '', + '(This is a summary of earlier conversation turns for context. Tool calls shown here were already executed — do not repeat them or output their markers as text.)', + ]; for (const msg of history) { - // For assistant messages with tool blocks (JSON arrays), summarize + // For assistant messages with tool blocks (JSON arrays), extract only the text portions. + // Tool-use and tool-result blocks are omitted to avoid Claude parroting them as plain text. let content = msg.content; if (msg.role === 'assistant' && content.startsWith('[')) { try { @@ -245,14 +249,9 @@ function buildPromptWithHistory( const parts: string[] = []; for (const b of blocks) { if (b.type === 'text' && b.text) parts.push(b.text); - else if (b.type === 'tool_use') parts.push(`[Used tool: ${b.name}]`); - else if (b.type === 'tool_result') { - const resultStr = typeof b.content === 'string' ? b.content : JSON.stringify(b.content); - // Truncate long tool results - parts.push(`[Tool result: ${resultStr.slice(0, 500)}${resultStr.length > 500 ? '...' : ''}]`); - } + // Skip tool_use and tool_result — they were already executed } - content = parts.join('\n'); + content = parts.length > 0 ? parts.join('\n') : '(assistant used tools)'; } catch { // Not JSON, use as-is } diff --git a/src/lib/stream-session-manager.ts b/src/lib/stream-session-manager.ts index e0548aa7..a041c89c 100644 --- a/src/lib/stream-session-manager.ts +++ b/src/lib/stream-session-manager.ts @@ -392,6 +392,12 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi stream.toolResultsArray = []; stream.toolOutputAccumulated = ''; emit(stream, 'completed'); + // Clear stale SDK session so next message starts fresh + fetch(`/api/chat/sessions/${encodeURIComponent(stream.sessionId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sdk_session_id: '' }), + }).catch(() => {}); scheduleGC(stream); } else if (stream.toolTimeoutInfo) { // Tool timeout — auto-retry