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:
1 parent
c13ff54638
commit
0c379e5607
5 files changed
+64
-54
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user