From 0c379e56079d30befebf128806bc7ffb998a1baa Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 27 Feb 2026 13:22:10 +0800 Subject: [PATCH] fix: address tool_result dedup, SDK task ID collision, and doc accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: tool_result dedup changed from "first wins" to "last wins" - claude-client.ts: remove emittedToolResultIds Set, both PostToolUse and user message handler emit freely. PostToolUse provides immediate UI feedback; user handler's potentially more complete result arrives second. - route.ts: collectStreamResponse replaces existing contentBlock entry when duplicate tool_use_id arrives (last wins for DB persistence). - ChatView.tsx: onToolResult replaces existing state entry by tool_use_id instead of skipping, ensuring most complete result is displayed. P2: SDK task ID collision risk eliminated - db.ts: syncSdkTasks now uses full sessionId in task ID (`sdk-{fullSessionId}-{index}`) instead of truncated 8-char prefix, eliminating cross-session primary key collisions. P2: Todo ordering stabilized - db.ts: add sort_order column migration, syncSdkTasks writes array index as sort_order. getTasksBySession sorts by sort_order ASC, created_at ASC so tasks with identical timestamps maintain insertion order. P3: Documentation field mapping corrected - agent-tooling-todo-bridge.md: fix field mapping table — actual implementation maps activeForm (not priority) to description column. Updated dedup strategy docs from "three-layer skip" to "two-layer last-wins replace". Lint: fix prefer-const for finalPrompt in claude-client.ts. Co-Authored-By: Claude Opus 4.6 --- docs/agent-tooling-todo-bridge.md | 34 ++++++++++++++++--------------- src/app/api/chat/route.ts | 25 +++++++++++++++-------- src/components/chat/ChatView.tsx | 10 +++++++-- src/lib/claude-client.ts | 33 ++++++++++-------------------- src/lib/db.ts | 16 +++++++++------ 5 files changed, 64 insertions(+), 54 deletions(-) diff --git a/docs/agent-tooling-todo-bridge.md b/docs/agent-tooling-todo-bridge.md index 1470299..037813d 100644 --- a/docs/agent-tooling-todo-bridge.md +++ b/docs/agent-tooling-todo-bridge.md @@ -16,12 +16,13 @@ SDK TodoWrite tool_use ## TodoWrite Field Mapping -| SDK TodoWrite field | Local tasks table column | Notes | -|---------------------|-------------------------|-------| -| `id` | `id` (prefixed `sdk-`) | Prefixed to avoid collision with user tasks | -| `content` | `title` | Main display text | -| `status` | `status` | Mapped via `mapStatus()` | -| `priority` | `description` | Optional context | +| SDK TodoWriteInput field | Local tasks table column | Notes | +|--------------------------|-------------------------|-------| +| (array index) | `id` (prefixed `sdk-{sessionId}-{index}`) | Full sessionId avoids collision | +| `content` | `title` | Main display text | +| `status` | `status` | Mapped via `mapStatus()` | +| `activeForm` | `description` | Present-continuous form label | +| (array index) | `sort_order` | Preserves original order | ### Status Mapping @@ -40,19 +41,20 @@ SDK TodoWrite tool_use This is naturally idempotent — calling it multiple times with the same data produces identical results. User-created tasks (`source = 'user'`) are never affected. -## tool_result Three-Layer Dedup +## tool_result Two-Layer Last-Wins Dedup -Both `PostToolUse` hook and `user` message handler in the SDK can emit the same `tool_result`. Three layers prevent duplicates: +Both `PostToolUse` hook and `user` message handler in the SDK can emit the same `tool_result`. +PostToolUse fires first (for immediate UI feedback); the user handler's result may be more +complete/canonical. Strategy: **both sources emit freely; consumption layers use "last wins" +(replace, not skip)** so the most complete result is always kept. -### Layer 1: claude-client.ts (SSE emission) -- `emittedToolResultIds` Set — checks before enqueueing any `tool_result` SSE event -- Both PostToolUse and user message handler check this Set +### Layer 1: route.ts (DB persistence) +- `seenToolResultIds` Set in `collectStreamResponse()` — when a duplicate `tool_use_id` + arrives, the existing `contentBlock` entry is **replaced** with the newer result. -### Layer 2: route.ts (DB persistence) -- `seenToolResultIds` Set in `collectStreamResponse()` — skips duplicate `tool_use_id` before writing to `contentBlocks` - -### Layer 3: ChatView.tsx (UI state) -- `onToolResult` checks `prev.some(r => r.tool_use_id === id)` before adding to state +### Layer 2: ChatView.tsx (UI state) +- `onToolResult` finds existing entry by `tool_use_id` and **replaces** it instead of + appending a duplicate. ## SSE Event Types diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index fb6ac85..1311003 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -272,15 +272,24 @@ async function collectStreamResponse(stream: ReadableStream, sessionId: } else if (event.type === 'tool_result') { try { const resultData = JSON.parse(event.data); - // Dedup: skip if already seen - if (!seenToolResultIds.has(resultData.tool_use_id)) { + const newBlock = { + type: 'tool_result' as const, + tool_use_id: resultData.tool_use_id, + content: resultData.content, + is_error: resultData.is_error || false, + }; + // Last-wins: if same tool_use_id already exists, replace it + // (user handler's result may be more complete than PostToolUse's) + if (seenToolResultIds.has(resultData.tool_use_id)) { + const idx = contentBlocks.findIndex( + (b) => b.type === 'tool_result' && 'tool_use_id' in b && b.tool_use_id === resultData.tool_use_id + ); + if (idx >= 0) { + contentBlocks[idx] = newBlock; + } + } else { seenToolResultIds.add(resultData.tool_use_id); - contentBlocks.push({ - type: 'tool_result', - tool_use_id: resultData.tool_use_id, - content: resultData.content, - is_error: resultData.is_error || false, - }); + contentBlocks.push(newBlock); } } catch { // skip malformed tool_result data diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 97c0c76..46f3a50 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -326,8 +326,14 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal markActive(); setStreamingToolOutput(''); setToolResults((prev) => { - // Dedup: skip if already received for this tool_use_id - if (prev.some(r => r.tool_use_id === res.tool_use_id)) return prev; + // Last-wins: if same tool_use_id exists, replace with newer (potentially more complete) result + const existingIdx = prev.findIndex(r => r.tool_use_id === res.tool_use_id); + if (existingIdx >= 0) { + const next = [...prev]; + next[existingIdx] = res; + toolResultsRef.current = next; + return next; + } const next = [...prev, res]; toolResultsRef.current = next; return next; diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 8e78987..5ab38af 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -451,10 +451,6 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream(); - // Permission handler: sends SSE event and waits for user response queryOptions.canUseTool = async (toolName, input, opts) => { const permissionRequestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -525,20 +521,16 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { const toolEvent = input as PostToolUseHookInput; console.log('[claude-client] PostToolUse:', toolEvent.tool_name, 'id:', toolEvent.tool_use_id); - // Dedup: only emit if not already seen - if (!emittedToolResultIds.has(toolEvent.tool_use_id)) { - emittedToolResultIds.add(toolEvent.tool_use_id); - controller.enqueue(formatSSE({ - type: 'tool_result', - data: JSON.stringify({ - tool_use_id: toolEvent.tool_use_id, - content: typeof toolEvent.tool_response === 'string' - ? toolEvent.tool_response - : JSON.stringify(toolEvent.tool_response), - is_error: false, - }), - })); - } + controller.enqueue(formatSSE({ + type: 'tool_result', + data: JSON.stringify({ + tool_use_id: toolEvent.tool_use_id, + content: typeof toolEvent.tool_response === 'string' + ? toolEvent.tool_response + : JSON.stringify(toolEvent.tool_response), + is_error: false, + }), + })); // Detect TodoWrite tool and emit task_update SSE for frontend sync if (toolEvent.tool_name === 'TodoWrite') { @@ -665,7 +657,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream