From e208f1c5bde1bf16022ca7edcc918295cfe1c3ed Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 15 Nov 2025 17:41:15 +0100 Subject: [PATCH] Ensure unique fallback tool ids --- packages/app/src/types/stream.test.ts | 38 +++++++++++++++++++++++++++ packages/app/src/types/stream.ts | 5 ++-- plan.md | 3 ++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index c34a08441..145f22cf0 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -1199,6 +1199,43 @@ function testMetadataReplayDeduplicationHydrated() { }); } +function testFallbackToolCallIdsStayUnique() { + const timestamp = new Date('2025-01-01T14:05:00Z'); + const updates = [ + { + event: toolTimeline( + 'fallback-shell-1', + 'completed', + { type: 'tool_result', tool_use_id: 'fallback-shell-1' }, + { callId: null, server: 'command', tool: 'shell', displayName: 'Run shell' } + ), + timestamp, + }, + { + event: toolTimeline( + 'fallback-shell-2', + 'completed', + { type: 'tool_result', tool_use_id: 'fallback-shell-2' }, + { callId: null, server: 'command', tool: 'shell', displayName: 'Run shell' } + ), + timestamp, + }, + ]; + + const hydrated = hydrateStreamState(updates); + const toolCalls = hydrated.filter( + (item): item is ToolCallItem => item.kind === 'tool_call' && item.payload.source === 'agent' + ); + + assert.strictEqual(toolCalls.length, 2, 'Hydration should retain multiple fallback tool entries'); + const ids = toolCalls.map((item) => item.id); + assert.strictEqual( + new Set(ids).size, + ids.length, + 'Fallback-generated tool ids must be unique even when metadata matches and callIds are missing' + ); +} + describe('stream timeline reducers', () => { it('produces deterministic hydration results', testIdempotentReduction); it('deduplicates pending/completed tool entries in place', testUserMessageDeduplication); @@ -1222,4 +1259,5 @@ describe('stream timeline reducers', () => { it('merges out-of-order tool call updates without duplicating entries (hydrated)', testOutOfOrderToolCallMergingHydrated); it('replays metadata-only tool calls without duplicating entries (live)', testMetadataReplayDeduplicationLive); it('replays metadata-only tool calls without duplicating entries (hydrated)', testMetadataReplayDeduplicationHydrated); + it('assigns unique ids for fallback tool calls without call ids', testFallbackToolCallIdsStayUnique); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index bf1873b07..ee505d545 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -35,7 +35,7 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string function createUniqueTimelineId( state: StreamItem[], - prefix: "assistant" | "thought" | "user", + prefix: "assistant" | "thought" | "user" | "tool", text: string, timestamp: Date ): string { @@ -459,7 +459,8 @@ function appendAgentToolCall( const id = callId ? `agent_tool_${callId}` - : createTimelineId( + : createUniqueTimelineId( + state, "tool", `${data.provider}:${data.server}:${data.tool}`, timestamp diff --git a/plan.md b/plan.md index 3063b70f0..4d196288a 100644 --- a/plan.md +++ b/plan.md @@ -26,7 +26,8 @@ - Added a dedicated `useImageAttachmentPicker` hook that registers the media picker launcher up front, reuses Expo’s permission hook, restores pending results, and guards against concurrent launches. AgentInput now consumes this hook so tapping the attachment button opens the picker without crashing on Android; `npm run typecheck --workspace=@voice-dev/app` currently fails upstream in `stream.test.ts` before our change, noted in the summary. - [x] Do not mark the Claude hydration fix complete until there’s an end-to-end test that (1) runs Claude, (2) executes tool calls, (3) persists the conversation under `~/.claude/projects/...`, and (4) hydrates from disk verifying the tool call results render. No test, no checkbox. - Strengthened `packages/server/src/server/agent/providers/claude-agent.test.ts` so the existing e2e hydration suite now asserts the live stream parses command/edit/read payloads and that the resumed stream reproduces those parsed diffs/reads/command outputs from `~/.claude/projects`, guaranteeing the UI pills render identical results after hydration. -- [ ] ERROR Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version. .$tool_1763217454926_uyr9rm +- [x] ERROR Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version. .$tool_1763217454926_uyr9rm + - `Stream` now generates fallback tool ids via `createUniqueTimelineId`, ensuring hydrated/past tool calls without callIds never reuse the same key even when their metadata matches; added `testFallbackToolCallIdsStayUnique` in `packages/app/src/types/stream.test.ts` to cover the regression and verified via `npx vitest packages/app/src/types/stream.test.ts`. ```tsx Code: agent-stream-view.tsx