fix: address tool_result dedup, SDK task ID collision, and doc accuracy

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 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-27 13:22:10 +08:00
1 parent c13ff54638
commit 0c379e5607
5 files changed
+64 -54

No files matched your search

+18 -16
View File
@@ -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
+17 -8
View File
@@ -272,15 +272,24 @@ async function collectStreamResponse(stream: ReadableStream<string>, 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
+8 -2
View File
@@ -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;
+11 -22
View File
@@ -451,10 +451,6 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
queryOptions.resume = sdkSessionId;
}
// Track emitted tool_result IDs to prevent duplicates
// (PostToolUse hook and user message handler can both emit for the same tool_use_id)
const emittedToolResultIds = new Set<string>();
// 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<strin
hooks: [async (input) => {
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<strin
return textPrompt;
}
let finalPrompt = buildFinalPrompt(!shouldResume);
const finalPrompt = buildFinalPrompt(!shouldResume);
// Try to start the conversation. If resuming a previous session fails
// (e.g. stale/corrupt session file, CLI version mismatch), automatically
@@ -759,9 +751,6 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result') {
// Dedup: skip if already emitted by PostToolUse hook
if (emittedToolResultIds.has(block.tool_use_id)) continue;
emittedToolResultIds.add(block.tool_use_id);
const resultContent = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
+10 -6
View File
@@ -298,6 +298,9 @@ function migrateDb(db: Database.Database): void {
if (!taskColNames.includes('source')) {
db.exec("ALTER TABLE tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'user'");
}
if (!taskColNames.includes('sort_order')) {
db.exec("ALTER TABLE tasks ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0");
}
// Ensure api_providers table exists for databases created before this migration
db.exec(`
@@ -717,7 +720,7 @@ export function updateSessionStatus(id: string, status: 'active' | 'archived'):
export function getTasksBySession(sessionId: string): TaskItem[] {
const db = getDb();
return db.prepare('SELECT * FROM tasks WHERE session_id = ? ORDER BY created_at ASC').all(sessionId) as TaskItem[];
return db.prepare('SELECT * FROM tasks WHERE session_id = ? ORDER BY sort_order ASC, created_at ASC').all(sessionId) as TaskItem[];
}
export function getTask(id: string): TaskItem | undefined {
@@ -788,13 +791,14 @@ export function syncSdkTasks(
// Delete all SDK-sourced tasks for this session
db.prepare("DELETE FROM tasks WHERE session_id = ? AND source = 'sdk'").run(sessionId);
// Insert new SDK tasks
// Insert new SDK tasks with stable sort_order
const insert = db.prepare(
'INSERT INTO tasks (id, session_id, title, status, description, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO tasks (id, session_id, title, status, description, source, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
for (const todo of todos) {
const taskId = `sdk-${sessionId.slice(0, 8)}-${todo.id}`;
insert.run(taskId, sessionId, todo.content, mapStatus(todo.status), todo.activeForm || null, 'sdk', now, now);
for (let i = 0; i < todos.length; i++) {
const todo = todos[i];
const taskId = `sdk-${sessionId}-${todo.id}`;
insert.run(taskId, sessionId, todo.content, mapStatus(todo.status), todo.activeForm || null, 'sdk', i, now, now);
}
});
txn();