Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity
* assertions are the tripwire.
*/
describe("projectActivityPayload", () => {
it("keeps bounded edit details through wire slimming", () => {
const projected = projectActivityPayload(
activity({
itemType: "file_change",
data: { rawInput: { file_path: "src/a.ts", old_string: "before", new_string: "after" } },
}),
);
const data = (projected.payload as { data: Record<string, unknown> }).data;
expect(data.preview).toContain("-before\n");
expect(data.preview).toContain("+after\n");
expect(data.rawInput).toBeUndefined();
expect(projectActivityPayload(projected)).toEqual(projected);
});

it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
const projected = projectActivityPayload(
activity({
Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { projectQuestionToolInput } from "@t3tools/shared/toolActivity";
import { projectQuestionToolInput, summarizeToolActivityInput } from "@t3tools/shared/toolActivity";
import type {
OrchestrationEvent,
OrchestrationThreadActivity,
Expand Down Expand Up @@ -452,7 +452,11 @@ export function projectActivityPayload(
};
}

const projectedData: Record<string, unknown> = { ...questionInput };
const preview = summarizeToolActivityInput(data);
const projectedData: Record<string, unknown> = {
...questionInput,
...(preview ? { preview } : {}),
};
const item = projectCommandData(data);
if (item) {
projectedData.item = item;
Expand Down
75 changes: 75 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4209,6 +4209,81 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("subagent tool calls from assistant snapshots are attributed to the agent", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const itemEventsFiber = yield* adapter.streamEvents.pipe(
Stream.filter((event) => event.type === "item.started" || event.type === "item.completed"),
Stream.take(2),
Stream.runCollect,
Effect.forkChild,
);

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId: session.threadId,
input: "spawn an agent",
attachments: [],
});

harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-edit",
description: "Agent E",
task_type: "local_agent",
tool_use_id: "toolu_agent_e",
uuid: "task-edit-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);
// Subagent tool calls arrive as whole snapshots, never as stream events.
const input = { file_path: "/tmp/a.ts", old_string: "before", new_string: "after" };
harness.query.emit({
type: "assistant",
parent_tool_use_id: "toolu_agent_e",
message: {
model: SYNTHETIC_SUBAGENT_MODEL,
content: [{ type: "tool_use", id: "toolu_sub_edit", name: "Edit", input }],
},
uuid: "subagent-edit-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);
harness.query.emit({
type: "user",
parent_tool_use_id: "toolu_agent_e",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_sub_edit", content: "ok" }],
},
uuid: "subagent-edit-result-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);

const [started, completed] = Array.from(yield* Fiber.join(itemEventsFiber));
assert.equal(started?.type, "item.started");
assert.equal(completed?.type, "item.completed");
for (const event of [started, completed]) {
assert.equal(event?.itemId, "toolu_sub_edit");
if (event?.type === "item.started" || event?.type === "item.completed") {
assert.equal(event.payload.agentId, "task-edit");
}
}
if (started?.type === "item.started") {
assert.equal(started.payload.detail, "Edit: /tmp/a.ts");
assert.deepEqual((started.payload.data as { input: unknown }).input, input);
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("a subagent snapshot that beats task_started still wins over the seed", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
174 changes: 114 additions & 60 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,12 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>):
return `${toolName}: ${command.trim().slice(0, 400)}`;
}

// File, search and fetch tools read best as their target, not their JSON.
for (const key of ["file_path", "notebook_path", "path", "pattern", "url"]) {
const target = trimmedString(input[key]);
if (target) return `${toolName}: ${target.slice(0, 400)}`;
}

// For agent/subagent tools, prefer the human-readable description or prompt
// over raw JSON. The structured subagent_type is carried separately on the
// task.* payloads (role) — the label is display-only.
Expand Down Expand Up @@ -2960,6 +2966,81 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
yield* updateResumeCursor(context);
});

/** Registers a tool_use block as in flight and emits its item.started. */
const startInFlightTool = Effect.fn("startInFlightTool")(function* (
context: ClaudeSessionContext,
input: {
readonly index: number;
readonly block: { readonly id: string; readonly name: string; readonly input: unknown };
readonly parentToolUseId: string | undefined;
readonly rawMethod: string;
readonly rawPayload: unknown;
},
) {
const { index, block, parentToolUseId } = input;
const toolName = block.name;
const toolInput =
typeof block.input === "object" && block.input !== null
? (block.input as Record<string, unknown>)
: {};
const itemType = classifyToolItemType(toolName, toolInput);
const itemId = block.id;
const detail = summarizeToolRequest(toolName, toolInput);
const inputFingerprint =
Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined;

// Attribute tools that ran inside a subagent to their owning agent so
// clients can re-home them out of the main timeline (quiet-timeline
// guarantee): the SDK tags subagent tool_use blocks with the spawning
// Task tool's id as parent_tool_use_id.
const owningAgentId = agentIdForParentToolUse(context.taskAgents, parentToolUseId);

const tool: ToolInFlight = {
itemId,
itemType,
toolName,
title: titleForTool(itemType),
detail,
input: toolInput,
partialInputJson: "",
...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}),
...(owningAgentId ? { agentId: owningAgentId } : {}),
...(parentToolUseId ? { parentToolUseId } : {}),
};
context.inFlightTools.set(index, tool);

const stamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "item.started",
eventId: stamp.eventId,
provider: PROVIDER,
createdAt: stamp.createdAt,
threadId: context.session.threadId,
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
itemId: asRuntimeItemId(tool.itemId),
payload: {
itemType: tool.itemType,
status: "inProgress",
title: tool.title,
...(tool.detail ? { detail: tool.detail } : {}),
...(tool.agentId ? { agentId: tool.agentId } : {}),
...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
data: {
toolName: tool.toolName,
input: toolInput,
},
},
providerRefs: nativeProviderRefs(context, {
providerItemId: tool.itemId,
}),
raw: {
source: "claude.sdk.message",
method: input.rawMethod,
payload: input.rawPayload,
},
});
});

const handleStreamEvent = Effect.fn("handleStreamEvent")(function* (
context: ClaudeSessionContext,
message: SDKMessage,
Expand Down Expand Up @@ -3191,68 +3272,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return;
}

const toolName = block.name;
const toolInput =
typeof block.input === "object" && block.input !== null
? (block.input as Record<string, unknown>)
: {};
const itemType = classifyToolItemType(toolName, toolInput);
const itemId = block.id;
const detail = summarizeToolRequest(toolName, toolInput);
const inputFingerprint =
Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined;

// Attribute tools that ran inside a subagent to their owning agent so
// clients can re-home them out of the main timeline (quiet-timeline
// guarantee): the SDK forwards subagent tool_use blocks tagged with the
// spawning Task tool's id as parent_tool_use_id.
const parentToolUseId =
(message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? undefined;
const owningAgentId = agentIdForParentToolUse(context.taskAgents, parentToolUseId);

const tool: ToolInFlight = {
itemId,
itemType,
toolName,
title: titleForTool(itemType),
detail,
input: toolInput,
partialInputJson: "",
...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}),
...(owningAgentId ? { agentId: owningAgentId } : {}),
...(parentToolUseId ? { parentToolUseId } : {}),
};
context.inFlightTools.set(index, tool);

const stamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "item.started",
eventId: stamp.eventId,
provider: PROVIDER,
createdAt: stamp.createdAt,
threadId: context.session.threadId,
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
itemId: asRuntimeItemId(tool.itemId),
payload: {
itemType: tool.itemType,
status: "inProgress",
title: tool.title,
...(tool.detail ? { detail: tool.detail } : {}),
...(tool.agentId ? { agentId: tool.agentId } : {}),
...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
data: {
toolName: tool.toolName,
input: toolInput,
},
},
providerRefs: nativeProviderRefs(context, {
providerItemId: tool.itemId,
}),
raw: {
source: "claude.sdk.message",
method: "claude/stream_event/content_block_start",
payload: message,
},
yield* startInFlightTool(context, {
index,
block,
parentToolUseId,
rawMethod: "claude/stream_event/content_block_start",
rawPayload: message,
});
return;
}
Expand Down Expand Up @@ -3467,6 +3494,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
);
}
}
// The SDK forwards subagent tool calls as complete assistant snapshots
// rather than stream events, so register any tool_use it has not already
// streamed. The matching user tool_result then completes it.
const content: unknown = message.message.content;
for (const block of Array.isArray(content) ? content : []) {
if (
!block ||
typeof block !== "object" ||
(block.type !== "tool_use" &&
block.type !== "server_tool_use" &&
block.type !== "mcp_tool_use") ||
typeof block.id !== "string" ||
typeof block.name !== "string" ||
Array.from(context.inFlightTools.values()).some((tool) => tool.itemId === block.id)
) {
continue;
}
// Stream indexes are non-negative; snapshot tools take unused negative keys.
const index = Math.min(0, ...context.inFlightTools.keys()) - 1;
yield* startInFlightTool(context, {
index,
block,
parentToolUseId: assistantParentToolUseId,
rawMethod: "claude/assistant",
rawPayload: message,
});
}
context.lastAssistantUuid = message.uuid;
yield* updateResumeCursor(context);
return;
Expand Down
16 changes: 7 additions & 9 deletions apps/web/src/components/AgentActivityParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import { cn } from "~/lib/utils";
import { formatSecondsTimestamp } from "~/timestampFormat";

import { ToolCallBody } from "./ToolCallBody";
import { elapsedBetween } from "./AgentStatus";
import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "./ui/preview-card";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
Expand All @@ -49,14 +50,13 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
function ExpandedBody(props: { text: string; meta?: string; tone?: "default" | "error" }) {
return (
<div className="cursor-default rounded-md bg-muted/40 px-3 py-2">
<pre
<ToolCallBody
text={props.text}
className={cn(
"max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-2xs leading-relaxed select-text",
"max-h-64 cursor-text",
props.tone === "error" ? "text-destructive-foreground" : "text-secondary-label",
)}
>
{props.text}
</pre>
/>
{props.meta ? (
<p className="mt-1.5 font-mono text-3xs text-muted-foreground">{props.meta}</p>
) : null}
Expand Down Expand Up @@ -189,9 +189,7 @@ export const CallRow = memo(function CallRow(props: {
<Icon aria-hidden className="size-3.5 text-icon-muted" />
{props.title}
</p>
<pre className="max-h-[50vh] overflow-auto whitespace-pre-wrap break-words font-mono text-2xs leading-relaxed select-text">
{props.body}
</pre>
<ToolCallBody text={props.body} className="max-h-[50vh]" />
<p className="font-mono text-3xs text-muted-foreground">{props.meta}</p>
</div>
</PreviewCardPopup>
Expand Down Expand Up @@ -233,7 +231,7 @@ export function ToolLogList(props: {
status={entry.status}
time={time}
duration={duration}
body={subagentToolCallText(entry)}
body={subagentToolCallText(entry, false)}
meta={[time, TOOL_STATUS_LABELS[entry.status].toLowerCase(), duration].join(" · ")}
defaultExpanded={entry.id === props.expandedId}
{...(onActivate ? { onActivate: () => onActivate(entry.id) } : {})}
Expand Down
Loading