Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
105 changes: 72 additions & 33 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> =>
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,
},
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TodoWrite tools misclassified as file_change in lifecycle events

Medium Severity

The new code emits turn.plan.updated for todowrite tools, but classifyToolItemType still classifies "todowrite" as "file_change" because the name contains "write" (matching at line 422). This means the item.started and item.completed lifecycle events report itemType: "file_change" and title: "File change" for what is actually a plan/todo tool. A check for "todowrite" returning "plan" needs to be added to classifyToolItemType before the "write" check so the full item lifecycle is consistent with the new plan event.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review is valid. TodoWrite already emits turn.plan.updated, but its lifecycle events are still classified as file_change. So, the work log currently shows both a file_change tool event and a separate Plan updated event for the same action.

I can fix that if desired, though it slightly widens the scope because it affects lifecycle classification and not just event emission. If so, should TodoWrite still appear as its own tool event, or should we only show Plan updated?

} 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: {
Expand All @@ -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();
Expand Down