From 0ce647dfe81e73338e28766ab9bfc3bd36960eec Mon Sep 17 00:00:00 2001 From: Sam Jandris Date: Tue, 24 Mar 2026 18:00:34 -0400 Subject: [PATCH 1/4] Map Claude TodoWrite updates to plan events - Classify TodoWrite tool calls as plan items - Emit turn.plan.updated events from successful TodoWrite results --- .../src/provider/Layers/ClaudeAdapter.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index bddda8895e..92bd0988e0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -398,6 +398,9 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef function classifyToolItemType(toolName: string): CanonicalItemType { const normalized = toolName.toLowerCase(); + if (normalized.includes("todowrite")) { + return "plan"; + } if (normalized.includes("agent")) { return "collab_agent_tool_call"; } @@ -1757,6 +1760,43 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { result: toolResult.block, }; + if (tool.toolName === "TodoWrite" && !toolResult.isError && context.turnState) { + const steps = Array.isArray(tool.input["todos"]) ? tool.input["todos"] : []; + const planStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.plan.updated", + eventId: planStamp.eventId, + provider: PROVIDER, + createdAt: planStamp.createdAt, + threadId: context.session.threadId, + turnId: asCanonicalTurnId(context.turnState.turnId), + payload: { + plan: steps + .filter( + (entry): entry is Record => + typeof entry === "object" && entry !== null, + ) + .map((entry) => ({ + step: typeof entry.content === "string" ? entry.content : "step", + status: + entry.status === "completed" + ? "completed" + : entry.status === "in_progress" + ? "inProgress" + : "pending", + })), + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + context.inFlightTools.delete(index); + continue; + } + const updatedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "item.updated", From bdc5a4b337412e736ae2b4575ca23565597bb65b Mon Sep 17 00:00:00 2001 From: Sam Jandris Date: Tue, 24 Mar 2026 19:55:06 -0400 Subject: [PATCH 2/4] Handle lowercase TodoWrite plan updates - Emit plan updates for TodoWrite tool results regardless of casing - Add coverage for lowercase todowrite tool events --- .../src/provider/Layers/ClaudeAdapter.test.ts | 101 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 78 +++++++------- 2 files changed, 141 insertions(+), 38 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 48055c88a5..19dfdd4829 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1029,6 +1029,107 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("emits plan updates for TodoWrite tool results regardless of tool name casing", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "make a plan", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-todowrite", + uuid: "stream-todowrite-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-todowrite-1", + name: "todowrite", + input: { + todos: [ + { + content: "Inspect provider events", + status: "completed", + }, + { + content: "Emit runtime plan update", + status: "in_progress", + }, + ], + }, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "user", + session_id: "sdk-session-todowrite", + uuid: "user-todowrite-result", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-todowrite-1", + content: "Plan updated", + }, + ], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-todowrite", + uuid: "result-todowrite", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + const toolCompleted = runtimeEvents.find( + (event) => + event.type === "item.completed" && + event.payload.itemType === "plan" && + (event.payload.data as { toolName?: string } | undefined)?.toolName === "todowrite", + ); + + assert.equal(planUpdated?.type, "turn.plan.updated"); + if (planUpdated?.type === "turn.plan.updated") { + assert.equal(String(planUpdated.turnId), String(turn.turnId)); + assert.deepEqual(planUpdated.payload.plan, [ + { step: "Inspect provider events", status: "completed" }, + { step: "Emit runtime plan update", status: "inProgress" }, + ]); + } + + assert.equal(toolCompleted?.type, "item.completed"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("treats user-aborted Claude results as interrupted without a runtime error", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 92bd0988e0..aefa0a655c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1760,7 +1760,11 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { result: toolResult.block, }; - if (tool.toolName === "TodoWrite" && !toolResult.isError && context.turnState) { + if ( + tool.toolName.toLowerCase() === "todowrite" && + !toolResult.isError && + context.turnState + ) { const steps = Array.isArray(tool.input["todos"]) ? tool.input["todos"] : []; const planStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ @@ -1793,48 +1797,22 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { payload: message, }, }); - context.inFlightTools.delete(index); - continue; - } - - const updatedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "item.updated", - eventId: updatedStamp.eventId, - provider: PROVIDER, - createdAt: updatedStamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - itemId: asRuntimeItemId(tool.itemId), - payload: { - itemType: tool.itemType, - status: toolResult.isError ? "failed" : "inProgress", - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), - data: toolData, - }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), - raw: { - source: "claude.sdk.message", - method: "claude/user", - payload: message, - }, - }); - - const streamKind = toolResultStreamKind(tool.itemType); - if (streamKind && toolResult.text.length > 0 && context.turnState) { - const deltaStamp = yield* makeEventStamp(); + } else { + const updatedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "content.delta", - eventId: deltaStamp.eventId, + type: "item.updated", + eventId: updatedStamp.eventId, provider: PROVIDER, - createdAt: deltaStamp.createdAt, + createdAt: updatedStamp.createdAt, threadId: context.session.threadId, - turnId: context.turnState.turnId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), itemId: asRuntimeItemId(tool.itemId), payload: { - streamKind, - delta: toolResult.text, + itemType: tool.itemType, + status: toolResult.isError ? "failed" : "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: toolData, }, providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), raw: { @@ -1843,6 +1821,30 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { payload: message, }, }); + + const streamKind = toolResultStreamKind(tool.itemType); + if (streamKind && toolResult.text.length > 0 && context.turnState) { + const deltaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + eventId: deltaStamp.eventId, + provider: PROVIDER, + createdAt: deltaStamp.createdAt, + threadId: context.session.threadId, + turnId: context.turnState.turnId, + itemId: asRuntimeItemId(tool.itemId), + payload: { + streamKind, + delta: toolResult.text, + }, + providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + } } const completedStamp = yield* makeEventStamp(); From 606fbe68d0ebda102087518efd1cf96bf59befe5 Mon Sep 17 00:00:00 2001 From: Sam Jandris Date: Tue, 24 Mar 2026 20:08:16 -0400 Subject: [PATCH 3/4] Match Claude todo-write tools by name substring - Treat any tool name containing `todowrite` as a completed todo-write action - Preserve turn state updates for renamed Claude tool variants --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index aefa0a655c..51ff047e3c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1761,7 +1761,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }; if ( - tool.toolName.toLowerCase() === "todowrite" && + tool.toolName.toLowerCase().includes("todowrite") && !toolResult.isError && context.turnState ) { From 38e14ceb48994ed3d16c3e19b66925035468e8b0 Mon Sep 17 00:00:00 2001 From: Sam Jandris Date: Tue, 24 Mar 2026 20:27:02 -0400 Subject: [PATCH 4/4] Stop classifying Claude todo tools as plan items - Treat `todowrite` as a normal tool call instead of a plan item - Update the Claude adapter test expectations accordingly --- apps/server/src/provider/Layers/ClaudeAdapter.test.ts | 8 -------- apps/server/src/provider/Layers/ClaudeAdapter.ts | 3 --- 2 files changed, 11 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 19dfdd4829..0f2dc0cf36 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1107,12 +1107,6 @@ describe("ClaudeAdapterLive", () => { const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated"); - const toolCompleted = runtimeEvents.find( - (event) => - event.type === "item.completed" && - event.payload.itemType === "plan" && - (event.payload.data as { toolName?: string } | undefined)?.toolName === "todowrite", - ); assert.equal(planUpdated?.type, "turn.plan.updated"); if (planUpdated?.type === "turn.plan.updated") { @@ -1122,8 +1116,6 @@ describe("ClaudeAdapterLive", () => { { step: "Emit runtime plan update", status: "inProgress" }, ]); } - - assert.equal(toolCompleted?.type, "item.completed"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 51ff047e3c..f1243406ae 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -398,9 +398,6 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef function classifyToolItemType(toolName: string): CanonicalItemType { const normalized = toolName.toLowerCase(); - if (normalized.includes("todowrite")) { - return "plan"; - } if (normalized.includes("agent")) { return "collab_agent_tool_call"; }