diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index e6468ff8f789..cd9cc1efac9e 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -21,6 +21,20 @@ function activity(payload: Record): 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 }).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({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index adb6306efc5e..41f3b0bb384a 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -1,4 +1,4 @@ -import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; +import { projectQuestionToolInput, summarizeToolActivityInput } from "@t3tools/shared/toolActivity"; import type { OrchestrationEvent, OrchestrationThreadActivity, @@ -452,7 +452,11 @@ export function projectActivityPayload( }; } - const projectedData: Record = { ...questionInput }; + const preview = summarizeToolActivityInput(data); + const projectedData: Record = { + ...questionInput, + ...(preview ? { preview } : {}), + }; const item = projectCommandData(data); if (item) { projectedData.item = item; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index cab1d1bdf620..58eeb88d1ad7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -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* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9f9914040da9..4d25d6d46fa0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1509,6 +1509,12 @@ function summarizeToolRequest(toolName: string, input: Record): 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. @@ -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) + : {}; + 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, @@ -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) - : {}; - 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; } @@ -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; diff --git a/apps/web/src/components/AgentActivityParts.tsx b/apps/web/src/components/AgentActivityParts.tsx index bb3c45863a18..d098c6bc6ea4 100644 --- a/apps/web/src/components/AgentActivityParts.tsx +++ b/apps/web/src/components/AgentActivityParts.tsx @@ -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"; @@ -49,14 +50,13 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; function ExpandedBody(props: { text: string; meta?: string; tone?: "default" | "error" }) { return (
-
-        {props.text}
-      
+ /> {props.meta ? (

{props.meta}

) : null} @@ -189,9 +189,7 @@ export const CallRow = memo(function CallRow(props: { {props.title}

-
-                {props.body}
-              
+

{props.meta}

@@ -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) } : {})} diff --git a/apps/web/src/components/ToolCallBody.tsx b/apps/web/src/components/ToolCallBody.tsx new file mode 100644 index 000000000000..a1a10dba4b3c --- /dev/null +++ b/apps/web/src/components/ToolCallBody.tsx @@ -0,0 +1,70 @@ +import type { FileDiffMetadata, FileDiffOptions } from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; +import { useMemo, useState } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { cn } from "~/lib/utils"; +import { parseToolCallBody } from "~/lib/toolCallBody"; + +import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; + +/** Render tool patches with the same unified diff view as the rest of the app. */ +export function ToolCallBody({ text, className }: { text: string; className?: string }) { + const blocks = useMemo(() => parseToolCallBody(text), [text]); + return ( +
+ {blocks.map((block) => + block.kind === "diff" ? ( + + ) : ( +
+            {block.text}
+          
+ ), + )} +
+ ); +} + +/** A tool patch that falls back to its plain text if the diff renderer fails. */ +function ToolDiff(props: { patch: string; files: ReadonlyArray }) { + const { resolvedTheme } = useTheme(); + const [failed, setFailed] = useState(false); + const options = useMemo>( + () => ({ + diffStyle: "unified", + diffIndicators: "classic", + theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, + disableFileHeader: true, + disableLineNumbers: true, + overflow: "wrap", + // The renderer reports failures by drawing its own error and stack trace. + onPostRender: (node) => { + if (node.shadowRoot?.querySelector("[data-error-wrapper]")) setFailed(true); + }, + }), + [resolvedTheme], + ); + if (failed) { + // Show the hunks without the patch's file headers. + const hunks = props.patch.slice(Math.max(props.patch.indexOf("\n@@"), 0)).trim(); + return
{hunks}
; + } + return ( + +
+ {props.files.map((file) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d547086c6bc7..24fe6a298e95 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import { ToolCallBody } from "../ToolCallBody"; import { ArrowUpIcon, ClockIcon } from "lucide-react"; import { LinearIcon } from "../Icons"; import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; @@ -5041,7 +5042,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > -
{expandedBody}
+ ) : null} diff --git a/apps/web/src/lib/toolCallBody.test.ts b/apps/web/src/lib/toolCallBody.test.ts new file mode 100644 index 000000000000..29725dfc125d --- /dev/null +++ b/apps/web/src/lib/toolCallBody.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; +import { summarizeToolActivityInput } from "@t3tools/shared/toolActivity"; +import { parseToolCallBody } from "./toolCallBody"; + +describe("parseToolCallBody", () => { + it("renders generated replacements as unified diffs with unchanged context", () => { + const summary = summarizeToolActivityInput({ + rawInput: { + file_path: "test.py", + old_string: "unchanged\nbefore\n", + new_string: "unchanged\nafter\nextra\n", + }, + })!; + const blocks = parseToolCallBody(summary); + const diff = blocks.find((block) => block.kind === "diff"); + expect(diff?.files[0]?.hunks[0]).toMatchObject({ additionLines: 2, deletionLines: 1 }); + expect(blocks.some((block) => block.kind === "text" && block.text.includes("Before\n"))).toBe( + false, + ); + }); + + it("keeps malformed provider patches readable", () => { + expect(parseToolCallBody("Diff\nnot a patch")).toEqual([ + { kind: "text", text: "Diff\nnot a patch" }, + ]); + }); + + it("keys each patch by its content so worker highlights match their diff", () => { + const key = (old_string: string, new_string: string) => { + const summary = summarizeToolActivityInput({ + rawInput: { file_path: "src/a.ts", old_string, new_string }, + })!; + const diff = parseToolCallBody(summary).find((block) => block.kind === "diff"); + return diff?.kind === "diff" ? diff.files[0]?.cacheKey : undefined; + }; + expect(key("a", "b")).toBeDefined(); + expect(key("a", "b")).toBe(key("a", "b")); + expect(key("a", "b")).not.toBe(key("a", "c")); + }); +}); diff --git a/apps/web/src/lib/toolCallBody.ts b/apps/web/src/lib/toolCallBody.ts new file mode 100644 index 000000000000..4bd3bd4db098 --- /dev/null +++ b/apps/web/src/lib/toolCallBody.ts @@ -0,0 +1,27 @@ +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; + +import { buildPatchCacheKey } from "./diffRendering"; + +/** Split bounded tool summaries into ordinary text and parseable diff blocks. */ +export function parseToolCallBody(text: string) { + return text + .split("\n\n") + .filter(Boolean) + .map((block) => { + if (block.startsWith("Diff\n")) { + try { + const patch = `${block.slice(5).trimEnd()}\n`; + // Worker-highlighted diffs are matched to their results by cache key. + const files = parsePatchFiles( + patch, + buildPatchCacheKey(patch, "tool-call"), + true, + ).flatMap((patch) => patch.files); + if (files.length > 0) return { kind: "diff" as const, text: block, files }; + } catch { + // Legacy/provider patches may be incomplete. Keep their readable text. + } + } + return { kind: "text" as const, text: block }; + }); +} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7da6567dbbed..09f535b4afd2 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -19,7 +19,10 @@ import { workLogEntryIsToolLike, type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; -import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; +import { + extractToolActivityData, + extractToolActivityPresentation, +} from "@t3tools/client-runtime/work-log/tool-presentation"; import { isToolLifecycleItemType, type AssetResource, @@ -632,13 +635,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolPresentation.toolSource) { entry.toolSource = toolPresentation.toolSource; } - if (itemType === "mcp_tool_call") { - const data = asRecord(payload?.data); - const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; - if (toolData !== undefined) { - entry.toolData = toolData; - } - } + const toolData = extractToolActivityData(payload); + if (toolData !== undefined) entry.toolData = toolData; if (itemType) { entry.itemType = itemType; } diff --git a/docs/fork-differences.md b/docs/fork-differences.md index f38304a5f588..b031e984c1d1 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -106,7 +106,9 @@ Code: `apps/server/src/provider/acp/CursorSubagents.ts`, the subagent handlers i The Agents panel has search, a status filter, sorting, compact rows that show a working agent's latest tool call, and hover previews of each agent. Each agent opens a detail view with its launch prompt, full result or error, searchable and filterable tool calls or an on-demand -transcript, and a usage footer. On web and desktop, **Open in new tab** keeps an agent in its own +transcript, and a usage footer. Tool previews include bounded unified edit diffs and line counts, +read ranges, search arguments, working directories, and exit codes when the provider supplies them. +The same details appear in chat tool expansions on web, desktop, and mobile. On web and desktop, **Open in new tab** keeps an agent in its own thread-scoped sidebar tab alongside the fleet and other agents. Agent tabs can be closed and reopened from the detail view, and are restored when the app restarts. Upstream's panel is a fixed list with one summary line per agent. To feed it: diff --git a/packages/client-runtime/src/state/agentPanelView.test.ts b/packages/client-runtime/src/state/agentPanelView.test.ts index d3f98272af83..764d0c84ef41 100644 --- a/packages/client-runtime/src/state/agentPanelView.test.ts +++ b/packages/client-runtime/src/state/agentPanelView.test.ts @@ -133,6 +133,27 @@ describe("applyAgentPanelView", () => { }); describe("deriveSubagentToolLog", () => { + it("preserves edit previews across lifecycle updates and searches their contents", () => { + const log = deriveSubagentToolLog( + [ + activity("tool.started", { + agentId: "alpha", + toolCallId: "edit", + title: "Edit", + data: { + preview: "src/a.ts\n\nBefore\nbefore\n\nAfter\nfixed", + }, + }), + activity("tool.completed", { agentId: "alpha", toolCallId: "edit" }), + ], + "alpha", + ); + expect(subagentToolCallText(log[0]!)).toContain("After\nfixed"); + expect( + applySubagentToolLogView(log, { query: "fixed", statuses: [], kinds: [], sort: "oldest" }), + ).toHaveLength(1); + }); + it("collects one entry per tool call owned by the agent", () => { const rows = [ activity("tool.started", { agentId: "alpha", toolCallId: "t1", title: "Read file" }), @@ -150,6 +171,33 @@ describe("deriveSubagentToolLog", () => { ]); expect(log[0]?.completedAt).not.toBeNull(); }); + + it("names rows after the tool when the detail carries its name", () => { + const log = deriveSubagentToolLog( + [ + activity("tool.started", { + agentId: "alpha", + toolCallId: "w1", + itemType: "file_change", + title: "File change", + detail: "Write: /tmp/NOTES.md", + data: { toolName: "Write", input: { file_path: "/tmp/NOTES.md", content: "hi" } }, + }), + activity("tool.completed", { + agentId: "alpha", + toolCallId: "w1", + itemType: "file_change", + title: "File change", + status: "completed", + data: { toolName: "Write" }, + }), + ], + "alpha", + ); + expect(log.map(({ title, detail, kind }) => ({ title, detail, kind }))).toEqual([ + { title: "Write", detail: "/tmp/NOTES.md", kind: "edit" }, + ]); + }); }); describe("deriveSubagentToolLog kinds and commands", () => { diff --git a/packages/client-runtime/src/state/agentPanelView.ts b/packages/client-runtime/src/state/agentPanelView.ts index 64a19397e9d7..4b7e9b749655 100644 --- a/packages/client-runtime/src/state/agentPanelView.ts +++ b/packages/client-runtime/src/state/agentPanelView.ts @@ -1,3 +1,4 @@ +import { summarizeToolActivityInput } from "@t3tools/shared/toolActivity"; /** * Agents-panel presentation over the source-neutral AgentPanelModel: the * user's filter/sort view, and the per-agent tool log derived from the @@ -203,6 +204,7 @@ export interface SubagentToolLogEntry { readonly detail: string | null; /** The full command when the provider recorded one beyond the detail line. */ readonly command: string | null; + readonly preview?: string | null; readonly itemType: string | null; readonly kind: SubagentToolKind; readonly status: "running" | "completed" | "failed"; @@ -248,7 +250,12 @@ function toolKindFor(title: string, itemType: string | null, data: unknown): Sub function commandFrom(data: unknown): string | null { const record = asRecord(data); - return asText(record?.command) ?? asText(asRecord(record?.rawInput)?.command); + return ( + asText(record?.command) ?? + asText(asRecord(record?.rawInput)?.command) ?? + asText(asRecord(record?.input)?.command) ?? + asText(asRecord(record?.item)?.command) + ); } /** @@ -280,15 +287,29 @@ export function deriveSubagentToolLogs( : activity.kind === "tool.completed" || nativeStatus === "completed" ? "completed" : "running"; - const title = asText(payload.title) ?? existing?.title ?? activity.summary; + const payloadTitle = asText(payload.title); const itemType = asText(payload.itemType) ?? existing?.itemType ?? null; + // Adapters title tools by category ("File change", "Tool call") with a + // "Write: " detail; name the row after the tool instead. + const toolName = asText(asRecord(payload.data)?.toolName); + const rawDetail = asText(payload.detail); + const namedDetail = + toolName !== null && rawDetail?.startsWith(`${toolName}: `) + ? rawDetail.slice(toolName.length + 2) + : null; + const title = + namedDetail !== null || (toolName !== null && existing?.title === toolName) + ? (toolName ?? activity.summary) + : (payloadTitle ?? existing?.title ?? activity.summary); + const detail = namedDetail ?? rawDetail; entries.set(id, { id, title, - detail: asText(payload.detail) ?? existing?.detail ?? null, + detail: detail ?? existing?.detail ?? null, command: commandFrom(payload.data) ?? existing?.command ?? null, + preview: summarizeToolActivityInput(payload.data) ?? existing?.preview ?? null, itemType, - kind: toolKindFor(title, itemType, payload.data), + kind: toolKindFor(payloadTitle ?? activity.summary, itemType, payload.data), status: existing && existing.status !== "running" ? existing.status : status, startedAt: existing?.startedAt ?? activity.createdAt, completedAt: existing?.completedAt ?? (status === "running" ? null : activity.createdAt), @@ -308,9 +329,17 @@ export function deriveSubagentToolLog( } /** Everything a call recorded, deduplicated, for previews, expansion and search. */ -export function subagentToolCallText(entry: SubagentToolLogEntry): string { - return [...new Set([entry.title, entry.detail, entry.command].filter((v) => v !== null))].join( - "\n\n", +export function subagentToolCallText(entry: SubagentToolLogEntry, includeTitle = true): string { + const preview = entry.preview ?? null; + const detail = entry.detail && preview?.split("\n").includes(entry.detail) ? null : entry.detail; + return ( + [ + ...new Set( + [includeTitle ? entry.title : null, detail, entry.command, preview].filter( + (v) => v != null, + ), + ), + ].join("\n\n") || entry.title ); } diff --git a/packages/client-runtime/src/work-log/toolPresentation.test.ts b/packages/client-runtime/src/work-log/toolPresentation.test.ts index d25befe8fd46..5ac12403d704 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.test.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.test.ts @@ -33,6 +33,14 @@ const devinResourceToolEvent = { } as const; describe("extractToolActivityPresentation", () => { + it("makes bounded edit previews available to web and mobile expansion", () => { + const data = { preview: "src/a.ts\n\nBefore\nbefore\n\nAfter\nfixed" }; + const extracted = extractToolActivityData({ itemType: "file_change", data }); + const entry = { itemType: "file_change", toolData: extracted }; + expect(hasToolActivityData(entry)).toBe(true); + expect(toolActivityDataBody(entry)).toBe(data.preview); + }); + it("expands generic resource URI and embedded text without serializing collapsed rows", () => { const resource = { uri: "urn:notes", text: "Resource notes" }; const entry = { itemType: "dynamic_tool_call", toolData: { resource } }; diff --git a/packages/client-runtime/src/work-log/toolPresentation.ts b/packages/client-runtime/src/work-log/toolPresentation.ts index d85534e59e41..84e13c4c288f 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.ts @@ -1,3 +1,4 @@ +import { summarizeToolActivityInput } from "@t3tools/shared/toolActivity"; import type { ToolActivityIcon, ToolActivityNativeAppReference, @@ -12,7 +13,7 @@ export interface ExtractedToolActivityPresentation { } /** - * Keeps the bounded resource metadata that providers attach to an otherwise + * Keeps bounded tool previews and resource metadata attached to an otherwise * generic tool row. MCP rows retain their existing focused payload shape. */ export function extractToolActivityData(payloadValue: unknown): unknown { @@ -22,7 +23,7 @@ export function extractToolActivityData(payloadValue: unknown): unknown { if (payload?.itemType === "mcp_tool_call") { return typeof data.toolName === "string" ? (data.item ?? data) : data.item; } - return "resource" in data ? data : undefined; + return "resource" in data || summarizeToolActivityInput(data) ? data : undefined; } type ToolActivityDataEntry = { readonly itemType?: string; readonly toolData?: unknown }; @@ -31,16 +32,21 @@ type ToolActivityDataEntry = { readonly itemType?: string; readonly toolData?: u export function hasToolActivityData(entry: ToolActivityDataEntry): boolean { return entry.itemType === "mcp_tool_call" ? entry.toolData !== undefined - : asRecord(asRecord(entry.toolData)?.resource) !== undefined; + : asRecord(asRecord(entry.toolData)?.resource) !== undefined || + summarizeToolActivityInput(entry.toolData) !== undefined; } -/** Shared expanded detail for MCP calls and bounded generic resource metadata. */ +/** Shared expanded detail for MCP calls, tool previews, and resource metadata. */ export function toolActivityDataBody(entry: ToolActivityDataEntry): string | undefined { if (!hasToolActivityData(entry)) return undefined; if (entry.itemType === "mcp_tool_call") { return `MCP call\n${JSON.stringify(entry.toolData, null, 2)}`; } - return `Resource\n${JSON.stringify(asRecord(entry.toolData)?.resource, null, 2)}`; + const preview = summarizeToolActivityInput(entry.toolData); + const resource = asRecord(entry.toolData)?.resource; + return [preview, resource ? `Resource\n${JSON.stringify(resource, null, 2)}` : undefined] + .filter(Boolean) + .join("\n\n"); } function asRecord(value: unknown): Record | undefined { diff --git a/packages/shared/package.json b/packages/shared/package.json index d2ed8e86b397..009a21b4dc11 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -376,6 +376,7 @@ "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@t3tools/contracts": "workspace:*", + "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", "smol-toml": "1.8.0", diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 8c1c65565f51..e433867054f1 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -292,3 +292,5 @@ export function projectQuestionToolInput(data: Record, title: u }, }; } + +export { summarizeToolActivityInput } from "./toolActivityPreview.ts"; diff --git a/packages/shared/src/toolActivityPreview.test.ts b/packages/shared/src/toolActivityPreview.test.ts new file mode 100644 index 000000000000..49a702cbdecd --- /dev/null +++ b/packages/shared/src/toolActivityPreview.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; +import { summarizeToolActivityInput } from "./toolActivityPreview.ts"; + +describe("summarizeToolActivityInput", () => { + it("shows Claude and ACP edit replacements without sending full file bodies", () => { + for (const input of [ + { file_path: "src/a.ts", old_string: "before", new_string: "after" }, + { path: "src/a.ts", oldText: "before", newText: "after" }, + ]) { + const preview = summarizeToolActivityInput({ rawInput: input }); + expect(preview).toContain("+1, −1 lines"); + expect(preview).toContain("-before\n"); + expect(preview).toContain("+after\n"); + expect(preview).not.toContain("Before\n"); + } + expect( + summarizeToolActivityInput({ + input: { old_string: "a".repeat(100000), new_string: "b".repeat(100000) }, + })!.length, + ).toBeLessThanOrEqual(2400); + }); + + it("counts Codex diff lines without counting file headers", () => { + expect( + summarizeToolActivityInput({ + item: { + changes: [ + { + path: "src/a.ts", + diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-before\n+after", + }, + { path: "src/b.ts", linesAdded: 3, linesRemoved: 1 }, + ], + }, + }), + ).toContain("src/a.ts\n\n+1, −1 lines\n\nDiff\n"); + expect( + summarizeToolActivityInput({ + item: { changes: [{ path: "src/b.ts", linesAdded: 3, linesRemoved: 1 }] }, + }), + ).toBe("src/b.ts\n\n+3, −1 lines"); + }); + + it("keeps read ranges, search arguments and command failures", () => { + expect( + summarizeToolActivityInput({ input: { file_path: "a.ts", offset: 20, limit: 10 } }), + ).toBe("a.ts\n\nStart line: 20\n\nLimit: 10"); + expect( + summarizeToolActivityInput({ + rawInput: { pattern: "TODO", glob: "*.ts", cwd: "/repo" }, + rawOutput: { exitCode: 2, error: "failed" }, + }), + ).toBe( + "Pattern: TODO\n\nGlob: *.ts\n\nWorking directory: /repo\n\nExit code: 2\n\nError: failed", + ); + expect( + summarizeToolActivityInput({ unknown: "secret", content: "full output" }), + ).toBeUndefined(); + }); + + it("renders whole-file writes as an all-addition diff, keeping large bodies as excerpts", () => { + const small = summarizeToolActivityInput({ + rawInput: { file_path: "src/new.ts", content: "export const a = 1;\nexport const b = 2;" }, + }); + expect(small).toContain("+2, −0 lines"); + expect(small).toContain("Diff\n"); + expect(small).toContain("+export const b = 2;\n"); + const large = summarizeToolActivityInput({ + rawInput: { file_path: "src/big.ts", content: "x".repeat(4000) }, + }); + expect(large).toContain("Content\n"); + expect(large).not.toContain("Diff\n"); + }); +}); diff --git a/packages/shared/src/toolActivityPreview.ts b/packages/shared/src/toolActivityPreview.ts new file mode 100644 index 000000000000..15e8c591277d --- /dev/null +++ b/packages/shared/src/toolActivityPreview.ts @@ -0,0 +1,126 @@ +import { createPatch } from "diff"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +const MAX_PREVIEW = 2400; + +/** Summarize provider inputs before wire slimming, without retaining full file bodies. */ +export function summarizeToolActivityInput(data: unknown): string | undefined { + const root = record(data); + if (!root) return undefined; + if (typeof root.preview === "string") return root.preview.slice(0, MAX_PREVIEW); + const parts = new Set(); + const excerpt = (text: string) => (text.length > 500 ? `${text.slice(0, 499)}…` : text); + let visited = 0; + const add = (text: string) => { + if (text.startsWith("Diff\n")) { + const used = [...parts].reduce((length, part) => length + part.length + 2, 0); + parts.add(used + text.length <= MAX_PREVIEW ? text : "Additional diff omitted from preview"); + } else { + parts.add(text.length > 600 ? `${text.slice(0, 599)}…` : text); + } + }; + const visit = (value: unknown, depth: number) => { + if (depth > 4 || visited++ >= 40) return; + if (Array.isArray(value)) { + for (const entry of value.slice(0, 12)) visit(entry, depth + 1); + return; + } + const row = record(value); + if (!row) return; + const string = (...keys: string[]) => { + for (const key of keys) if (typeof row[key] === "string") return row[key] as string; + return undefined; + }; + const path = string("file_path", "filePath", "path", "TargetFile"); + if (path) add(path); + let diff = string("diff", "diffString", "unifiedDiff", "patch"); + const before = string("old_string", "oldText", "old_text", "oldString"); + const after = string("new_string", "newText", "new_text", "newString", "ReplacementContent"); + if (!diff && before !== undefined && after !== undefined) { + // Tool replacements may be fragments, rather than complete files. Bound + // both input size and diff work; never diff independently clipped text. + const oldLines = before && !before.endsWith("\n") ? `${before}\n` : before; + const newLines = after && !after.endsWith("\n") ? `${after}\n` : after; + if (oldLines === newLines) { + add(before === after ? "No changes" : "Trailing newline changed"); + } else { + diff = + before.length + after.length <= 32_000 + ? createPatch(path ?? "edit", oldLines, newLines, undefined, undefined, { + context: 2, + maxEditLength: 200, + }) + : undefined; + if (!diff) add("Edit is too large to preview"); + } + } + // Whole-file writes render as an all-addition patch; large bodies keep the + // plain excerpt below. + const written = !diff && before === undefined && path ? string("content") : undefined; + if (written && written.length <= 1600) { + diff = createPatch(path ?? "file", "", written.endsWith("\n") ? written : `${written}\n`); + } + const added = row.linesAdded ?? row.lines_added; + const removed = row.linesRemoved ?? row.lines_removed; + if (typeof added === "number" && typeof removed === "number") { + add(`+${added}, −${removed} lines`); + } else if (diff) { + let plus = 0; + let minus = 0; + for (let offset = 0; offset < diff.length;) { + if (diff[offset] === "+" && !diff.startsWith("+++", offset)) plus++; + if (diff[offset] === "-" && !diff.startsWith("---", offset)) minus++; + const newline = diff.indexOf("\n", offset); + if (newline === -1) break; + offset = newline + 1; + } + add(`+${plus}, −${minus} lines`); + } + if (diff) { + // Keep complete patches so the standard renderer can parse hunk counts. + add(diff.length <= 1800 ? `Diff\n${diff}` : "Diff is too large to preview"); + } else if (before === undefined && path && typeof row.content === "string") { + add(`Content\n${excerpt(row.content)}`); + } + for (const [label, keys] of [ + ["Start line", ["start_line", "startLine", "line", "offset", "StartLine"]], + ["End line", ["end_line", "endLine", "EndLine"]], + ["Limit", ["limit", "max_results"]], + ["Pattern", ["pattern", "query", "search_term", "regex"]], + ["Glob", ["glob", "include"]], + ["URL", ["url"]], + ["Working directory", ["cwd", "workdir", "workingDirectory", "Cwd"]], + ["Exit code", ["exitCode", "exit_code"]], + ["Error", ["error", "errorMessage"]], + ] as const) { + const key = keys.find((key) => typeof row[key] === "string" || typeof row[key] === "number"); + if (key !== undefined) add(`${label}: ${row[key]}`); + } + for (const key of [ + "rawInput", + "input", + "arguments", + "item", + "state", + "result", + "rawOutput", + "changes", + "edits", + "content", + "locations", + ]) { + if (key in row) visit(row[key], depth + 1); + } + }; + visit(root, 0); + const text = [...parts].join("\n\n"); + if (!text) return undefined; + const bounded = text.length > MAX_PREVIEW ? `${text.slice(0, MAX_PREVIEW - 1)}…` : text; + // A small sliced excerpt must not keep a provider's entire file body alive. + return Array.from(bounded).join(""); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b00c42b5f73c..b80cb69a675c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -980,6 +980,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)