diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 48055c88a5..0f2dc0cf36 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1029,6 +1029,99 @@ 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"); + + 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" }, + ]); + } + }).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 bddda8895e..f1243406ae 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1757,44 +1757,59 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { result: toolResult.block, }; - 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(); + if ( + tool.toolName.toLowerCase().includes("todowrite") && + !toolResult.isError && + context.turnState + ) { + const steps = Array.isArray(tool.input["todos"]) ? tool.input["todos"] : []; + const planStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "content.delta", - eventId: deltaStamp.eventId, + type: "turn.plan.updated", + eventId: planStamp.eventId, provider: PROVIDER, - createdAt: deltaStamp.createdAt, + createdAt: planStamp.createdAt, threadId: context.session.threadId, - turnId: context.turnState.turnId, + 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, + }, + }); + } else { + 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: { - 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: { @@ -1803,6 +1818,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();