diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 988048823c5b..3ea856a7b854 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -370,6 +370,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { return { ios: "eye", android: "visibility" }; case "globe": return { ios: "globe", android: "public" }; + case "search": + return "magnifyingglass"; case "hammer": return { ios: "hammer", android: "construction" }; case "lock": diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ea8a2e0de438..69a730915149 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -20,6 +20,7 @@ import { type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; +import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; @@ -59,6 +60,79 @@ it("keeps historical plan detail accessible from its paged turn item", () => { expect(activity?.getFullDetail()).toContain("Full historical plan text"); }); +it("shows only the structured path in expanded mobile read details", () => { + const item: OrchestrationV2TurnItem = { + ...base("read-detail", "2026-06-20T00:00:03.000Z", 2), + type: "dynamic_tool", + toolName: "Read", + title: "Read src/env.ts", + input: { path: "src/env.ts" }, + output: "---\nname: env\n---\nsecret content", + }; + const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + )[0]; + + expect(activity?.getFullDetail()).toBe("src/env.ts"); + expect(activity?.canExpand).toBe(true); + expect(activity?.getCopyText()).not.toContain("secret content"); + expect(activity?.getFullDetail()).not.toContain("sourceThreadId"); + + const withoutPath = buildThreadFeed([ + projected({ ...item, id: TurnItemId.make("read-without-path"), input: {} }, 0), + ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0]; + expect(withoutPath?.getFullDetail()).toBeNull(); + expect(withoutPath?.canExpand).toBe(false); +}); + +it("labels file searches with the adapter title and its search target", () => { + const item: OrchestrationV2TurnItem = { + ...base("file-search", "2026-06-20T00:00:03.000Z", 2), + type: "file_search", + title: "Searched TODO in web", + pattern: "TODO", + }; + const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + )[0]; + + expect(activity?.summary).toBe("Searched TODO in web"); + expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("Searched TODO in web"); +}); + +it("keeps approval prompts rather than presenting them as tool work", () => { + const approval = ( + id: string, + requestKind: "file-read" | "command" | "file-change", + ordinal: number, + ) => + ({ + ...base(id, `2026-06-20T00:00:0${ordinal}.000Z`, ordinal), + type: "approval_request", + requestId: RuntimeRequestId.make(`request-${id}`), + requestKind, + prompt: `Allow ${requestKind}?`, + }) satisfies OrchestrationV2TurnItem; + const feed = buildThreadFeed([ + projected(approval("approve-read", "file-read", 1), 0), + projected(approval("approve-command", "command", 2), 1), + projected(approval("approve-edit", "file-change", 3), 2), + ]); + const activities = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activities.map((activity) => workEntryRowLabel(activity.workEntry))).toEqual([ + "Allow file-read?", + "Allow command?", + "Allow file-change?", + ]); + expect(activities[0]?.canExpand).toBe(true); + expect( + summarizeToolGroup(activities.slice(1).map((activity) => activity.workEntry)).summary, + ).not.toMatch(/Ran|changed/); +}); + function base(id: string, updatedAt: string, ordinal: number) { const timestamp = DateTime.makeUnsafe(updatedAt); return { @@ -2049,6 +2123,21 @@ it("uses a compact reasoning preview and a short expanded heading", () => { expect(workEntryRowLabel({ ...entry, toolLifecycleStatus: "completed" }, true)).toBe("Thought"); }); +it("keeps search output in expanded details rather than the compact label", () => { + const entry = { + id: "search", + label: "Grep", + toolTitle: "Grep", + createdAt: "2026-09-17T12:00:00Z", + itemType: "dynamic_tool" as const, + tone: "tool" as const, + detail: "---\nfile body", + toolData: {}, + }; + expect(workEntryRowLabel(entry)).toBe("Grep"); + expect(workEntryRowLabel(entry, true)).toBe("---\nfile body"); +}); + it.each(["First paragraph.\n\nSecond paragraph.", ""])( "previews live reasoning text %j", (text) => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 653331a9dc35..b5e93a9016c9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -45,9 +45,15 @@ import type { ScheduledTaskId, } from "@t3tools/contracts"; import { RunId, ThreadId } from "@t3tools/contracts"; +import { + classifyToolActivity, + collectToolFilePaths, + computerUseToolTitle, + formatReadToolLabel, + formatSearchToolLabel, +} from "@t3tools/shared/toolActivity"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; -import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; import * as DateTime from "effect/DateTime"; export type PendingApproval = ThreadPendingApproval; @@ -80,6 +86,7 @@ export interface ThreadFeedActivity { | "edit" | "eye" | "globe" + | "search" | "hammer" | "lock" | "message" @@ -206,9 +213,32 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa const presentation = resolveWorkEntryToolPresentation(entry); if (presentation) return presentation.displayName; if (entry.command?.trim()) return compactWorkEntryText(commandDisplayText(entry.command)); + const action = toolGroupAction(entry); + const isToolRead = action === "read" && entry.itemType === "dynamic_tool"; + if (action === "code-search" || action === "search") { + const toolData = + entry.toolData !== null && + typeof entry.toolData === "object" && + !Array.isArray(entry.toolData) + ? (entry.toolData as Record) + : undefined; + // Adapters title file searches with their target; the item keeps only the pattern. + const searchLabel = + entry.itemType === "file_search" ? entry.label : formatSearchToolLabel(toolData); + if (searchLabel) return searchLabel; + } + if (isToolRead) { + const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData); + if (firstPath) { + return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1)); + } + if (!expanded) return "Read file"; + } const preview = entry.command ?? - entry.detail ?? + (isToolRead || (!expanded && (action === "code-search" || action === "search")) + ? null + : entry.detail) ?? (entry.changedFiles?.length ? entry.changedFiles.length === 1 ? entry.changedFiles[0]! @@ -414,6 +444,16 @@ function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntr function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { if (item.type === "notification") return "zap"; + if (item.type === "dynamic_tool") { + const classified = classifyToolActivity({ + itemType: "dynamic_tool_call", + data: { toolName: item.toolName ?? undefined, input: item.input }, + }); + if (classified === "read") { + return "eye"; + } + if (classified === "search") return "search"; + } switch (item.type) { case "reasoning": return "agent"; @@ -422,7 +462,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { case "file_change": return "edit"; case "file_search": - return "eye"; + return "search"; case "web_search": return "globe"; case "approval_request": @@ -486,7 +526,7 @@ function itemSummary( ? `Changed ${item.changes.length} files` : `Changed ${item.fileName}`; case "file_search": - return "Searched files"; + return item.title?.trim() || formatSearchToolLabel(item) || "Searched files"; case "web_search": return "Searched the web"; case "approval_request": @@ -507,8 +547,20 @@ function itemSummary( return "Thread forked"; case "thread_created": return "Thread created"; - case "dynamic_tool": + case "dynamic_tool": { + const classified = classifyToolActivity({ + itemType: "dynamic_tool_call", + data: { toolName: item.toolName ?? undefined, input: item.input }, + }); + if (classified === "read") { + const [path] = collectToolFilePaths({ input: item.input }); + return formatReadToolLabel(path ?? ""); + } + if (classified === "search") { + return formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call"; + } return toolPresentation?.displayName ?? item.toolName ?? "Tool call"; + } case "proposed_plan": return "Proposed plan"; case "todo_list": @@ -651,8 +703,15 @@ function toFeedActivity( const detail = item.type === "notification" ? null : itemPreview(item); const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); const workEntry = toWorkLogEntry(item, createdAt, summary, detail); - const getFullDetail = memoizeValue(() => - JSON.stringify( + const readPaths = + item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read" + ? collectToolFilePaths(item) + : null; + const getFullDetail = memoizeValue(() => { + if (readPaths) { + return readPaths.join("\n") || null; + } + return JSON.stringify( { visibility: row.visibility, sourceThreadId: row.sourceThreadId, @@ -661,8 +720,8 @@ function toFeedActivity( }, null, 2, - ), - ); + ); + }); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] .filter( @@ -678,7 +737,7 @@ function toFeedActivity( attemptId, summary, detail, - canExpand: !(item.type === "error" && item.status === "failed"), + canExpand: !(item.type === "error" && item.status === "failed") && (readPaths?.length ?? 1) > 0, getFullDetail, getCopyText, icon: workEntry.toolSurface ?? itemIcon(item), diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 3f9db76ad0fa..ac297cc077bb 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1054,6 +1054,23 @@ const program = Effect.gen(function* () { }, ], }, + { + sessionUpdate: "tool_call_update", + toolCallId: "structured-read", + title: "Read `src/env.ts`", + kind: "read", + status: "completed", + rawInput: { path: "src/env.ts" }, + locations: [{ path: "src/env.ts" }], + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "structured-search", + title: "Grep", + kind: "search", + status: "completed", + rawInput: { query: "TODO", path: "apps/web" }, + }, { sessionUpdate: "compaction_update", compactionId: "compact-1", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index ee79510f58fc..28c241ba82f8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1335,6 +1335,16 @@ describe("AcpAdapterV2", () => { item.changes[0]?.oldPath === "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/workspace/old.ts", ), ); + const read = items.find((item) => item.type === "dynamic_tool" && item.toolName === "Read"); + assert.deepEqual( + read?.type === "dynamic_tool" ? { title: read.title, input: read.input } : null, + { title: "Read src/env.ts", input: { path: "src/env.ts" } }, + ); + const search = items.find((item) => item.type === "file_search"); + assert.deepEqual( + search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null, + { title: "Searched TODO in web", pattern: "apps/web" }, + ); const completedCompaction = items.find( (item) => item.type === "compaction" && diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index b448ba13e007..d53a5f33039f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -55,6 +55,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; +import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -3251,9 +3252,28 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV } else { switch (toolCall.kind) { case "read": + turnItem = { + ...base, + title: path ? formatReadToolLabel(path) : (title ?? "Read file"), + type: "dynamic_tool", + toolName: "Read", + input: + path === undefined || + ["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path) + ? (rawInputRecord ?? {}) + : { ...rawInputRecord, path }, + ...(rawOutput === undefined ? {} : { output: rawOutput }), + }; + break; case "search": turnItem = { ...base, + title: + formatSearchToolLabel({ + rawInput: rawInputRecord, + input: rawInputRecord, + ...(path === undefined ? {} : { pattern: path }), + }) ?? title, type: "file_search", ...(path === undefined ? {} : { pattern: path }), ...(path === undefined diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 01318f945ec8..0d4027a5ae76 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2762,6 +2762,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const tools = [ { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } }, { id: "text", name: "Read", input: { file_path: "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/workspace/README.md" } }, + { id: "search", name: "Grep", input: { pattern: "TODO", path: "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/workspace/src" } }, { id: "write", name: "Write", @@ -2823,6 +2824,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(image?.type, "dynamic_tool"); if (image?.type === "dynamic_tool") assert.equal(image.viewedImagePath, "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/workspace/reference.png"); + assert.equal(image?.title, "Read /workspace/reference.png"); + assert.equal( + items.find((item) => item.nativeItemRef?.nativeId === "text")?.title, + "Read /workspace/README.md", + ); + assert.equal( + items.find((item) => item.nativeItemRef?.nativeId === "search")?.title, + "Searched TODO in src", + ); for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) assert.notProperty(item, "viewedImagePath"); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, idAllocatorLayer))), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b7cc51ac67ba..fc6803e46561 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,6 +1,7 @@ import * as NodeCrypto from "node:crypto"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; +import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; import { @@ -3367,6 +3368,20 @@ export function makeClaudeAdapterV2( startedAt: input.startedAt, completedAt, }; + const readPath = ["read", "read file"].includes(input.classification.normalizedName) + ? firstStringInputField(input.toolInput, ["file_path", "path"])?.trim() + : undefined; + const nativeToolInput = claudeNativeToolInputValue(input.toolInput); + const searchTitle = ["grep", "glob", "ls"].includes(input.classification.normalizedName) + ? formatSearchToolLabel({ + input: + nativeToolInput !== null && + typeof nativeToolInput === "object" && + !Array.isArray(nativeToolInput) + ? (nativeToolInput as Record) + : undefined, + }) + : undefined; const itemBase = { id: turnItemId, threadId: input.threadId, @@ -3378,7 +3393,7 @@ export function makeClaudeAdapterV2( parentItemId: null, ordinal: input.ordinal, status: input.status, - title: null, + title: readPath !== undefined ? formatReadToolLabel(readPath) : (searchTitle ?? null), startedAt: input.startedAt, completedAt, updatedAt: input.updatedAt, @@ -3399,9 +3414,6 @@ export function makeClaudeAdapterV2( | "completedAt" | "updatedAt" >; - const readPath = ["read", "read file"].includes(input.classification.normalizedName) - ? firstStringInputField(input.toolInput, ["file_path", "path"])?.trim() - : undefined; const viewedImagePath = readPath && readPath.length <= 4096 && diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 7a4732b159d5..7d51f3793a7e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -359,6 +359,19 @@ describe("CursorAdapterV2", () => { numFiles: 0, }; const updates: ReadonlyArray = [ + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "read-file", + toolCall: { + type: "read", + args: { path: "src/env.ts" }, + result: { + status: "success", + value: { fileSize: 12, content: "---\nfile body", totalLines: 2 }, + }, + }, + }, { type: "tool-call-completed", modelCallId: "native-model-call", @@ -599,6 +612,18 @@ describe("CursorAdapterV2", () => { ? [event.turnItem] : [], ); + const readItems = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "Read" && + event.turnItem.status === "completed" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + readItems.map((item) => ({ title: item.title, input: item.input })), + [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }], + ); assert.deepEqual( fileSearchItems.map((item) => ({ pattern: item.pattern, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 69ef6d899c7a..4829fb0cb3c7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -8,6 +8,7 @@ import type { SettingSource, ToolCall, } from "@cursor/sdk"; +import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { CursorSettings, @@ -442,7 +443,6 @@ function cursorToolSearchPattern(toolCall: ToolCall): string | undefined { return toolCall.args.pattern; case "semSearch": return toolCall.args.query; - case "read": case "ls": return toolCall.args.path; case "readLints": @@ -480,13 +480,6 @@ function cursorToolSearchResults( return []; } switch (toolCall.type) { - case "read": - return [ - { - fileName: toolCall.args.path, - preview: toolCall.result.value.content, - }, - ]; case "glob": return toolCall.result.value.files.map((fileName) => ({ fileName })); case "grep": @@ -1265,19 +1258,34 @@ export function makeCursorAdapterV2( ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), }; break; + case "read": + turnItem = { + ...base, + title: formatReadToolLabel(toolCall.args.path), + type: "dynamic_tool", + toolName: "Read", + input: toolCall.args, + ...(cursorToolOutput(toolCall) === undefined + ? {} + : { output: cursorToolOutput(toolCall) }), + }; + break; case "glob": case "grep": - case "read": case "ls": case "readLints": case "semSearch": { const results = cursorToolSearchResults(toolCall, path); + const pattern = cursorToolSearchPattern(toolCall); turnItem = { ...base, + title: + formatSearchToolLabel({ + input: toolCall.args, + ...(pattern === undefined ? {} : { pattern }), + }) ?? null, type: "file_search", - ...(cursorToolSearchPattern(toolCall) === undefined - ? {} - : { pattern: cursorToolSearchPattern(toolCall) }), + ...(pattern === undefined ? {} : { pattern }), ...(results.length === 0 ? {} : { results: [...results] }), }; break; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 4b4ef583d52a..3715005de1fc 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -711,6 +711,83 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(idAllocatorLayer), Effect.scoped), ); + it.effect("titles OpenCode reads and searches from their input", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const nativeSessionId = "native-opencode-search"; + const harness = yield* makeOpenCodeRuntimeHarness("search-projection", nativeSessionId, { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: nativeSessionId, time: { created: 1, updated: 1 } }, + }), + promptAsync: async () => ({ data: true }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "compaction", + ), + Stream.runCollect, + Effect.forkScoped, + ); + for (const [tool, input] of [ + ["read", { filePath: "src/env.ts" }], + ["grep", { pattern: "TODO", path: "apps/web" }], + ["websearch", { query: "OpenCode documentation" }], + ] as const) { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: `part-${tool}`, + sessionID: nativeSessionId, + messageID: "assistant-search", + type: "tool", + callID: `call-${tool}`, + tool, + state: { + status: "completed", + input, + output: "---\nfile body", + title: tool, + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + }, + }), + ); + } + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.compacted", + properties: { sessionID: nativeSessionId }, + }), + ); + const items = (yield* Fiber.join(received)).flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + const read = items.find((item) => item.type === "dynamic_tool"); + assert.equal(read?.title, "Read src/env.ts"); + const grep = items.find((item) => item.type === "file_search"); + assert.equal(grep?.title, "Searched TODO in web"); + assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); + const webSearch = items.find((item) => item.type === "web_search"); + assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ + "OpenCode documentation", + ]); + }).pipe(Effect.provide(idAllocatorLayer), Effect.scoped), + ); + it.effect("admits a native command on its user receipt before generation completes", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); @@ -2073,7 +2150,7 @@ describe("OpenCodeAdapterV2", () => { it("maps OpenCode tools to semantic turn-item families", () => { assert.equal(openCodeToolProjectionKind("bash"), "command_execution"); assert.equal(openCodeToolProjectionKind("edit"), "file_change"); - assert.equal(openCodeToolProjectionKind("read"), "file_search"); + assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 403208d03725..62fa2f8316a8 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -9,6 +9,7 @@ import type { Todo as OpenCodeTodo, ToolPart, } from "@opencode-ai/sdk/v2"; +import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -648,8 +649,10 @@ export function openCodeToolProjectionKind( if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { return "web_search"; } + if (normalized === "read") { + return "dynamic_tool"; + } if ( - normalized === "read" || normalized.includes("glob") || normalized.includes("grep") || normalized.includes("search") || @@ -1707,12 +1710,14 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }), }; } else if (projectionKind === "file_search") { + const pattern = recordString(input, "pattern", "query", "path", "filePath"); turnItem = { ...base, + title: + formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? + base.title, type: "file_search", - ...(recordString(input, "pattern", "query", "path", "filePath") === undefined - ? {} - : { pattern: recordString(input, "pattern", "query", "path", "filePath")! }), + ...(pattern === undefined ? {} : { pattern }), }; } else if (projectionKind === "web_search") { const pattern = recordString(input, "query", "url", "pattern"); @@ -1722,8 +1727,13 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid ...(pattern === undefined ? {} : { patterns: [pattern] }), }; } else { + const readPath = recordString(input, "filePath", "path", "file"); turnItem = { ...base, + title: + part.tool.toLowerCase() === "read" && readPath !== undefined + ? formatReadToolLabel(readPath) + : base.title, type: "dynamic_tool", toolName: part.tool, input, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/cursor_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/cursor_output.ts index a2a9b3374cde..773ca0b4b0ec 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent/cursor_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent/cursor_output.ts @@ -82,7 +82,7 @@ export function assertCursorSubagentOutput( assert.lengthOf(childProjection.providerThreads, 0); assert.lengthOf(childProjection.providerTurns, 0); assertExecutionNodeKinds(childProjection, ["root_turn", "tool_call"]); - assertTurnItemTypes(childProjection, ["user_message", "file_search", "assistant_message"]); + assertTurnItemTypes(childProjection, ["user_message", "dynamic_tool", "assistant_message"]); assertUserMessagesInclude(childProjection, [subagent.prompt]); assert.isTrue( childProjection.turnItems.some( diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_output.ts index 174cdf2420bf..af2acd72cf41 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/todo_list/grok_output.ts @@ -28,7 +28,7 @@ export function assertTodoListGrokOutput( assertTurnItemTypes(projection, [ "user_message", "todo_list", - "file_search", + "dynamic_tool", "assistant_message", ]); assertUserMessagesInclude(projection, [TODO_LIST_PROMPT]); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only/cursor_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only/cursor_output.ts index 53747b94f8bd..2bc377adbd34 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only/cursor_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only/cursor_output.ts @@ -23,7 +23,7 @@ export function assertToolCallReadOnlyCursorOutput( const projection = projectionFor(result, transcript.scenario); assertSemanticProjectionIntegrity(projection); assertVisibleTurnItemsMirrorLocalTurnItems(projection); - assertTurnItemTypes(projection, ["user_message", "file_search", "assistant_message"]); + assertTurnItemTypes(projection, ["user_message", "dynamic_tool", "assistant_message"]); assertUserMessagesInclude(projection, [TOOL_CALL_READ_ONLY_PROMPT]); assertAssistantTextIncludes(projection, "read only tool fixture complete"); assertRuntimeRequestCounts(projection, { total: 0 }); @@ -38,28 +38,21 @@ export function assertToolCallReadOnlyCursorOutput( "Cursor progress text and the final response must be separate messages", ); - const fileSearches = projection.turnItems.filter((item) => item.type === "file_search"); - assert.lengthOf(fileSearches, 2); - assert.isBelow(assistantMessages[0]?.ordinal ?? Infinity, fileSearches[0]?.ordinal ?? -Infinity); - assert.isBelow(fileSearches[1]?.ordinal ?? Infinity, assistantMessages[1]?.ordinal ?? -Infinity); + const reads = projection.turnItems.filter((item) => item.type === "dynamic_tool"); + assert.lengthOf(reads, 2); + assert.isBelow(assistantMessages[0]?.ordinal ?? Infinity, reads[0]?.ordinal ?? -Infinity); + assert.isBelow(reads[1]?.ordinal ?? Infinity, assistantMessages[1]?.ordinal ?? -Infinity); assert.isTrue( - fileSearches.some((item) => - JSON.stringify(item.results ?? []).includes("cursor-read-only-fixture"), - ), + reads.some((item) => JSON.stringify(item.output ?? []).includes("cursor-read-only-fixture")), ); - assert.isTrue(fileSearches.some((item) => JSON.stringify(item.results ?? []).includes("ES2022"))); + assert.isTrue(reads.some((item) => JSON.stringify(item.output ?? []).includes("ES2022"))); const expectedPaths = [ "/tmp/claude-replay-tool_call_read_only/package.json", "/tmp/claude-replay-tool_call_read_only/tsconfig.json", ]; assert.deepEqual( - fileSearches.map((item) => item.pattern).toSorted(), + reads.map((item) => (item.input as { path: string }).path).toSorted(), expectedPaths, - "Cursor file_search patterns must match the files named in the recorded prompt", - ); - assert.deepEqual( - fileSearches.flatMap((item) => item.results?.map((result) => result.fileName) ?? []).toSorted(), - expectedPaths, - "Cursor file_search paths must match the files named in the recorded prompt", + "Cursor read paths must match the files named in the recorded prompt", ); } diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 4afdf85ee1e5..e69c851b258e 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -322,6 +322,39 @@ describe("AcpRuntimeModel", () => { } }); + it("clears prior ACP locations when an update explicitly replaces them", () => { + const toolCall = (locations?: EffectAcpSchema.ToolCallLocation[] | null) => { + const parsed = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "read-1", + kind: "read", + ...(locations === undefined ? {} : { locations }), + }, + } satisfies EffectAcpSchema.SessionNotification); + const event = parsed.events.find((candidate) => candidate._tag === "ToolCallUpdated"); + if (!event || event._tag !== "ToolCallUpdated") { + throw new Error("expected a tool call update"); + } + return event.toolCall; + }; + + const created = toolCall([{ path: "README" }]); + expect(mergeToolCallState(created, toolCall()).data.locations).toEqual([{ path: "README" }]); + const cleared = mergeToolCallState(created, toolCall([])); + expect(cleared.data.locations).toEqual([]); + expect(mergeToolCallState(created, toolCall(null)).data.locations).toEqual([]); + expect( + decideToolCallUpdateEmission({ + previous: created, + next: cleared, + lastEmittedDetailLength: 0, + skippedSinceEmit: 0, + }).emit, + ).toBe(true); + }); + it("trims padded current mode updates before emitting a mode change", () => { const result = parseSessionUpdateEvent({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 20f26c4f7bb8..8cb059acb2ec 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -4,7 +4,10 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import type * as EffectAcpSchema from "effect-acp/compat"; -import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity"; +import { + deriveToolActivityPresentation, + mergeToolActivityData, +} from "@t3tools/shared/toolActivity"; import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import type { OrchestrationV2ProviderThreadNativeMetadata, @@ -555,7 +558,11 @@ function extractCommandFromTitle(title: string | undefined): string | undefined return match?.[1]?.trim() || undefined; } -function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined { +function extractToolCallCommand( + rawInput: unknown, + title: string | undefined, + kind: string | undefined, +): string | undefined { if (isRecord(rawInput)) { const directCommand = normalizeCommandValue(rawInput.command); if (directCommand) { @@ -570,9 +577,76 @@ function extractToolCallCommand(rawInput: unknown, title: string | undefined): s return executable; } } + // Titles like `Read \`src/a.ts\`` are not commands. Only execute tools + // fall back to a backtick or bare title. + if (kind !== "execute") { + return undefined; + } return extractCommandFromTitle(title); } +function filePathFromToolValue(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function locationsFromToolCallInput(input: { + readonly locations?: ReadonlyArray | null | undefined; + readonly content?: ReadonlyArray | null | undefined; + readonly rawInput?: unknown; + readonly rawOutput?: unknown; +}): ReadonlyArray | undefined { + if (input.locations === null || input.locations?.length === 0) { + return []; + } + const locations: EffectAcpSchema.ToolCallLocation[] = []; + const seen = new Set(); + const pushLocation = (location: EffectAcpSchema.ToolCallLocation) => { + const path = filePathFromToolValue(location.path); + if (!path || seen.has(path)) { + return; + } + seen.add(path); + locations.push({ ...location, path }); + }; + const pushPath = (value: unknown) => { + const path = filePathFromToolValue(value); + if (!path || seen.has(path)) { + return; + } + seen.add(path); + locations.push({ path }); + }; + + if (input.locations) { + for (const location of input.locations) { + pushLocation(location); + } + } + if (input.content) { + for (const entry of input.content) { + if (entry.type === "diff" && "path" in entry) { + pushPath(entry.path); + } + } + } + if (isRecord(input.rawInput)) { + pushPath(input.rawInput.path); + pushPath(input.rawInput.filePath); + pushPath(input.rawInput.file_path); + } + if (isRecord(input.rawOutput)) { + pushPath(input.rawOutput.path); + pushPath(input.rawOutput.filePath); + pushPath(input.rawOutput.file_path); + } + + return locations.length > 0 ? locations : undefined; +} + // Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call // output on every `tool_call_update` notification instead of a delta, so a redrawing // terminal progress bar can balloon a single tool call to hundreds of KB per update at @@ -745,6 +819,8 @@ function normalizeToolKind(kind: unknown): string | undefined { */ export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { switch (kind) { + case "read": + return "dynamic_tool_call"; case "execute": return "command_execution"; case "edit": @@ -780,7 +856,8 @@ function makeToolCallState( return undefined; } const title = input.title?.trim() || undefined; - const command = extractToolCallCommand(input.rawInput, title); + const kind = normalizeToolKind(input.kind); + const command = extractToolCallCommand(input.rawInput, title, kind); const extractedContent = extractTextContentFromToolCallContent(input.content); const textContent = extractedContent.text; const normalizedTitle = @@ -788,7 +865,6 @@ function makeToolCallState( ? title : undefined; const data: Record = { toolCallId }; - const kind = normalizeToolKind(input.kind); if (kind) { data.kind = kind; } @@ -815,8 +891,9 @@ function makeToolCallState( if (input.content != null) { data.content = sanitizeAcpToolCallContent(extractedContent.content ?? input.content); } - if (input.locations !== undefined) { - data.locations = input.locations; + const locations = locationsFromToolCallInput(input); + if (locations !== undefined) { + data.locations = locations; } if (isRecord(input._meta)) { data.meta = input._meta; @@ -881,6 +958,7 @@ export function mergeToolCallState( const status = next.status ?? previous?.status; const command = next.command ?? previous?.command; const detail = next.detail ?? previous?.detail; + const data = mergeToolActivityData(previous?.data, next.data) ?? next.data; return { toolCallId: next.toolCallId, ...(kind ? { kind } : {}), @@ -888,10 +966,7 @@ export function mergeToolCallState( ...(status ? { status } : {}), ...(command ? { command } : {}), ...(detail ? { detail } : {}), - data: { - ...previous?.data, - ...next.data, - }, + data, }; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e64d9dcf6621..7433da5ccd68 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -32,8 +32,10 @@ import { type MessagesTimelineRow, resolveTimelineToolPresentation, workEntryDisplayLabel, + workEntryReadOutput, workEntryIsVisibleInGroup, } from "./MessagesTimeline.logic"; +import type { WorkLogEntry } from "../../session-logic"; describe("expanded tool group scrolling", () => { const entries = [{ id: "first" }, { id: "second" }]; @@ -168,6 +170,87 @@ describe("work entry labels", () => { ); }); + it("labels file reads with the path and never the file body", () => { + const readEntry = { + ...entry, + itemType: "dynamic_tool" as const, + toolTitle: "Read", + label: "Read", + detail: "---\nname: env\n---\n cons t x = 1", + toolData: { input: { file_path: "src/env.ts" } }, + structuredPayload: { + type: "dynamic_tool", + toolName: "Read", + input: { file_path: "src/env.ts" }, + } as NonNullable, + }; + expect(workEntryDisplayLabel(readEntry, undefined)).toBe("Read src/env.ts"); + expect(workEntryReadOutput(readEntry, undefined)).toBe("src/env.ts"); + expect(workEntryReadOutput(readEntry, "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/workspace/ohseearr")).toBe( + "/workspace/ohseearr/src/env.ts", + ); + expect( + workEntryReadOutput( + { + structuredPayload: { + type: "dynamic_tool", + toolName: "Read", + input: {}, + } as NonNullable, + toolData: { locations: [{ path: "src/from-location.ts" }] }, + }, + "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/workspace/ohseearr", + ), + ).toBe("/workspace/ohseearr/src/from-location.ts"); + expect(workEntryReadOutput({ detail: "---", toolData: {} }, undefined)).toBeNull(); + }); + + it("labels Claude Grep from structured input instead of a generic tool heading", () => { + expect( + workEntryDisplayLabel( + { + ...entry, + itemType: "dynamic_tool", + toolTitle: "Grep", + label: "Grep", + toolData: { input: { pattern: "TODO", path: "apps/web" } }, + structuredPayload: { + type: "dynamic_tool", + toolName: "Grep", + input: { pattern: "TODO", path: "apps/web" }, + } as NonNullable, + }, + undefined, + ), + ).toBe("Searched TODO in web"); + }); + + it("labels file searches with the adapter title and its search target", () => { + expect( + workEntryDisplayLabel( + { + ...entry, + itemType: "file_search", + label: "Searched TODO in web", + toolTitle: "Searched TODO in web", + detail: "TODO", + toolData: { type: "file_search", pattern: "TODO" }, + }, + undefined, + ), + ).toBe("Searched TODO in web"); + }); + + it("keeps a multi-line approval prompt as its label", () => { + const prompt = "Allow this command?\nrm -rf dist"; + expect( + workEntryDisplayLabel( + { ...entry, itemType: "approval_request", requestKind: "command", detail: prompt }, + undefined, + ), + ).toBe(prompt); + }); + it("keeps custom titles and output for unrecognized tools", () => { const unknownEntry = { ...entry, toolTitle: "mcp__github__search_issues" }; expect(liveWorkEntryLabel(unknownEntry, undefined, true)).toBe("Mcp__github__search_issues"); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 34446b9a2288..bbe4814588ac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -50,6 +50,12 @@ import { import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { + collectToolFilePaths, + formatReadToolLabel, + formatSearchToolLabel, +} from "@t3tools/shared/toolActivity"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; function timelineEntryRunId(entry: TimelineEntry): RunId | null { if (entry.kind === "message") { @@ -74,29 +80,77 @@ function singleToolCallLabel(entry: WorkLogEntry): string { if (entry.itemType === "reasoning") return entry.detail?.trim().replace(/\s+/g, " ") || "Thought"; const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); if (toolPresentation) return toolPresentation.displayName; - const command = entry.command?.trim(); - if (command) return commandDisplayText(command); const item = entry.structuredPayload; const title = item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; - const heading = normalizeCompactToolLabel(title || entry.toolTitle || entry.label); - return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; + if (title) return title; + // A lone web search keeps its heading; the query stays in its detail. + if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; + return workEntryDisplayLabel(entry, undefined); +} + +function workEntryToolDataRecord(entry: WorkLogEntry): Record | undefined { + return entry.toolData !== null && + typeof entry.toolData === "object" && + !Array.isArray(entry.toolData) + ? (entry.toolData as Record) + : undefined; +} + +function workEntryReadRawPaths(entry: WorkLogEntry): readonly string[] { + const fromChanged = entry.changedFiles ?? []; + if (fromChanged.length > 0) return fromChanged; + const structured = entry.structuredPayload; + if (structured?.type === "dynamic_tool") { + const structuredPaths = collectToolFilePaths({ input: structured.input }); + if (structuredPaths.length > 0) return structuredPaths; + } + return collectToolFilePaths(workEntryToolDataRecord(entry)); +} + +function workEntryReadPaths(entry: WorkLogEntry, workspaceRoot: string | undefined): string[] { + return workEntryReadRawPaths(entry).map((filePath) => + formatWorkspaceRelativePath(filePath, workspaceRoot), + ); } export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) { if (entry.itemType === "system_notice") return entry.label; + if (entry.itemType === "reasoning" || entry.tone === "thinking") { + const thought = entry.detail?.trim().replace(/\s+/g, " "); + return thought || entry.label; + } const toolPresentation = resolveWorkEntryToolPresentation(entry); if (toolPresentation) return toolPresentation.displayName; if (entry.command) return commandDisplayText(entry.command); + const action = toolGroupAction(entry); + if (action === "code-search" || action === "search") { + // Adapters title file searches with their target; the item keeps only the pattern. + const searchLabel = + entry.itemType === "file_search" + ? entry.label + : formatSearchToolLabel(workEntryToolDataRecord(entry)); + if (searchLabel) return searchLabel; + } + const readPaths = action === "read" ? workEntryReadPaths(entry, workspaceRoot) : []; + if (action === "read" && readPaths[0]) { + return formatReadToolLabel(readPaths[0], readPaths.length - 1); + } // Retrying providers keep their progress label; other diagnostics expose - // the retained message instead of a generic error heading. + // the retained message instead of a generic error heading. File bodies + // are never a compact read label. const providerRetry = entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; const item = entry.structuredPayload; const title = item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; if (title) return title; - if (entry.detail && !providerRetry) return entry.detail; + const compactDetail = entry.detail?.trim(); + const detailIsSearchOutput = + (action === "code-search" || action === "search") && /[\r\n]/.test(compactDetail ?? ""); + if (compactDetail && !providerRetry && action !== "read" && !detailIsSearchOutput) { + return compactDetail; + } const [firstPath] = entry.changedFiles ?? []; if (firstPath) { const path = formatWorkspaceRelativePath(firstPath, workspaceRoot); @@ -104,10 +158,42 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string ? path : `${path} +${entry.changedFiles!.length - 1} more`; } + if (action === "read" && !entry.viewedImagePath) { + return "Read file"; + } const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label); return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; } +/** Inspectable read-file output is the path when we have one, otherwise nothing. */ +export function workEntryReadOutput( + entry: Pick< + WorkLogEntry, + "changedFiles" | "detail" | "viewedImagePath" | "structuredPayload" | "toolData" + >, + workspaceRoot: string | undefined, +): string | null { + const paths = [ + ...new Set( + workEntryReadRawPaths(entry as WorkLogEntry) + .map((path) => { + const trimmed = path.trim().replaceAll("\\", "/"); + if (!workspaceRoot || trimmed.startsWith("/") || isWindowsAbsolutePath(trimmed)) { + return trimmed; + } + const root = workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, ""); + const relative = trimmed.replace(/^\.\//, "").replace(/^\/+/, ""); + return `${root}/${relative}`; + }) + .filter((path) => path.length > 0), + ), + ]; + if (paths.length > 0) { + return paths.join("\n"); + } + return null; +} + export function liveWorkEntryLabel( entry: WorkLogEntry, workspaceRoot: string | undefined, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9a54c19a06c3..545e318b0cbc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -197,6 +197,7 @@ import { shouldPreserveAssistantLineBreaks, toolGroupAction, workEntryDisplayLabel, + workEntryReadOutput, workEntryIsVisibleInGroup, worktreeSetupAgentStarted, type StableMessagesTimelineRowsState, @@ -4987,6 +4988,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? getQuestionAnswerPreview(workEntry.questionAnswer) : null; const viewedImagePath = workEntryViewedImagePath(workEntry); + const isRead = toolGroupAction(workEntry) === "read"; + const readOutput = isRead ? workEntryReadOutput(workEntry, workspaceRoot) : null; const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { @@ -5006,14 +5009,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ); const expandedBody = expanded && !isReasoning - ? buildToolCallExpandedBody( - workEntry, - workspaceRoot, - previewText, - viewedImage ? viewedImagePath : null, - ) + ? isRead + ? readOutput + : buildToolCallExpandedBody( + workEntry, + workspaceRoot, + previewText, + viewedImage ? viewedImagePath : null, + ) : null; - const canExpandProjectedItem = canExpand || workEntry.projectedItem !== undefined; + const canExpandProjectedItem = isRead + ? Boolean(readOutput || viewedImage || workEntry.questionAnswer) + : canExpand || workEntry.projectedItem !== undefined; // Reserve destructive row styling for severe failures, not routine tool errors. const iconWrapperClass = cn( "flex size-4 items-center justify-center", @@ -5176,9 +5183,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { !isReasoning && !workEntry.questionAnswer && canExpandProjectedItem && - (expandedBody || workEntry.projectedItem) ? ( + (expandedBody || (workEntry.projectedItem && !isRead)) ? ( - {workEntry.projectedItem ? ( + {workEntry.projectedItem && !isRead ? ( { }); }); + it("labels a read of a bare filename from its structured input", () => { + const item = { + ...base, + type: "dynamic_tool" as const, + toolName: "Read", + input: { file_path: "README" }, + output: "project notes", + } satisfies OrchestrationV2TurnItem; + const [entry] = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [visible(item)], + optimisticMessages: [], + }); + expect(entry).toMatchObject({ kind: "work", entry: { label: "Read README" } }); + }); + it("keeps browser identity and its source on a completed tool row", () => { const item = { ...base, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 97d6edc16ea1..b1bc98934e8d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -14,6 +14,12 @@ import { type ToolActivitySource, } from "@t3tools/contracts"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; +import { + classifyToolActivity, + collectToolFilePaths, + formatReadToolLabel, + formatSearchToolLabel, +} from "@t3tools/shared/toolActivity"; import { contextCompactionLabel, workEntryIndicatesToolFailure, @@ -450,7 +456,7 @@ function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry case "file_search": return { ...common, - label: title ?? "Searched files", + label: title ?? formatSearchToolLabel(item) ?? "Searched files", ...(item.pattern ? { detail: item.pattern } : {}), toolTitle: title ?? "File search", toolData: item, @@ -489,13 +495,25 @@ function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry toolData: item, }; } - case "dynamic_tool": + case "dynamic_tool": { + const classified = classifyToolActivity({ + itemType: "dynamic_tool_call", + data: { toolName: item.toolName ?? undefined, input: item.input }, + }); + const [readPath] = collectToolFilePaths({ input: item.input }); return { ...common, - label: title ?? item.toolName ?? "Tool call", + label: + title ?? + (classified === "read" + ? formatReadToolLabel(readPath ?? "") + : classified === "search" + ? (formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call") + : (item.toolName ?? "Tool call")), toolTitle: title ?? item.toolName ?? "Tool", toolData: { input: item.input, output: item.output }, }; + } case "approval_request": return { ...common, diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 54325df35de2..8a73dd70fb73 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -750,6 +750,41 @@ describe("toolGroupAction", () => { }), ).toBe("read"); }); + + it("groups Claude Read and Grep from toolName, not file contents", () => { + expect( + toolGroupAction({ + id: "read", + createdAt: "2026-09-01T00:00:00Z", + label: "Read", + tone: "tool", + itemType: "dynamic_tool", + toolTitle: "Read", + toolData: { input: { file_path: "src/env.ts" } }, + structuredPayload: { + type: "dynamic_tool", + toolName: "Read", + input: { file_path: "src/env.ts" }, + } as NonNullable, + }), + ).toBe("read"); + expect( + toolGroupAction({ + id: "grep", + createdAt: "2026-09-01T00:00:00Z", + label: "Grep", + tone: "tool", + itemType: "dynamic_tool", + toolTitle: "Grep", + toolData: { input: { pattern: "TODO", path: "apps/web" } }, + structuredPayload: { + type: "dynamic_tool", + toolName: "Grep", + input: { pattern: "TODO", path: "apps/web" }, + } as NonNullable, + }), + ).toBe("code-search"); + }); }); describe("resolveViewedImageAsset", () => { diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 9f3395371944..fdaf28dcf3b7 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -18,6 +18,7 @@ import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { formatTokens } from "@t3tools/shared/usageFormat"; +import { classifyToolActivity } from "@t3tools/shared/toolActivity"; import { toolOutputIndicatesFailure } from "@t3tools/shared/toolOutput"; import { @@ -424,16 +425,45 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if (presentation?.icon === "browser") return "browser"; if (presentation?.icon === "device") return "device"; if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) return "read"; - if ( - entry.itemType === "dynamic_tool" && - /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) - ) { - return "read"; + // Approvals and questions describe requested work, not work that ran. + if (entry.itemType === "approval_request" || entry.itemType === "user_input_request") { + return workLogEntryIsToolLike(entry) ? "other" : "update"; + } + const data = asRecord(entry.toolData) ?? {}; + const toolName = + entry.structuredPayload?.type === "dynamic_tool" + ? entry.structuredPayload.toolName + : typeof data.toolName === "string" + ? data.toolName + : entry.toolTitle; + const classified = classifyToolActivity({ + itemType: + entry.itemType === "command_execution" || + entry.itemType === "file_change" || + entry.itemType === "web_search" + ? entry.itemType + : entry.itemType === "dynamic_tool" + ? "dynamic_tool_call" + : undefined, + title: entry.toolTitle ?? entry.label, + data: { + ...data, + ...(toolName ? { toolName } : {}), + }, + }); + if (classified === "read") return "read"; + if (classified === "file_change" || entry.itemType === "file_change") return "edit"; + if (classified === "command" || entry.itemType === "command_execution" || entry.command) { + return "command"; + } + if (classified === "search") { + return entry.itemType === "web_search" && !workLogEntryIsLocalCodeSearch(entry) + ? "search" + : "code-search"; } - if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; - if (entry.itemType === "command_execution" || entry.command) return "command"; if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; if (entry.itemType === "web_search") return "search"; + if ((entry.changedFiles?.length ?? 0) > 0) return "edit"; return workLogEntryIsToolLike(entry) ? "other" : "update"; } diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index f93928a9fb19..9a9c67d2e36c 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { deriveToolActivityPresentation } from "./toolActivity.ts"; +import { + classifyToolActivity, + collectToolFilePaths, + deriveToolActivityPresentation, + formatReadToolLabel, + formatSearchToolLabel, + mergeToolActivityData, +} from "./toolActivity.ts"; describe("toolActivity", () => { it("normalizes command tools to a stable ran-command label", () => { @@ -33,8 +40,7 @@ describe("toolActivity", () => { fallbackSummary: "Read File", }), ).toEqual({ - summary: "Read file", - detail: "/tmp/app.ts", + summary: "Read /tmp/app.ts", }); }); @@ -54,4 +60,64 @@ describe("toolActivity", () => { summary: "Read file", }); }); + + it("classifies from kind and toolName without sniffing titles", () => { + expect(classifyToolActivity({ data: { kind: "read" } })).toBe("read"); + expect(classifyToolActivity({ data: { toolName: "Grep" } })).toBe("search"); + expect(classifyToolActivity({ data: { toolName: "Read" } })).toBe("read"); + for (const toolName of ["github.read_file", "mongodb.find", "mcp__github__read_file"]) { + expect(classifyToolActivity({ data: { toolName } })).toBe("other"); + } + expect(classifyToolActivity({ title: "Find", data: {} })).toBe("other"); + }); + + it("classifies Claude search tools ahead of their broad file-read request kind", () => { + for (const toolName of ["Glob", "Grep", "LS"]) { + expect(classifyToolActivity({ requestKind: "file-read", data: { toolName } })).toBe("search"); + } + expect(classifyToolActivity({ requestKind: "file-read", data: { toolName: "Read" } })).toBe( + "read", + ); + }); + + it("formats read and search labels from structured input", () => { + expect(formatReadToolLabel("src/env.ts")).toBe("Read src/env.ts"); + expect(formatReadToolLabel("src/env.ts", 2)).toBe("Read src/env.ts +2 more"); + expect(formatReadToolLabel("")).toBe("Read file"); + expect( + formatSearchToolLabel({ + input: { pattern: "TODO", path: "apps/web" }, + }), + ).toBe("Searched TODO in web"); + expect( + formatSearchToolLabel({ + input: { glob: "*.ts", path: "/tmp/t3chat-new" }, + }), + ).toBe("Searched files *.ts in t3chat-new"); + expect( + formatSearchToolLabel({ rawInput: {}, input: { pattern: "TODO", path: "apps/web" } }), + ).toBe("Searched TODO in web"); + expect(formatSearchToolLabel({ input: { globPattern: "*.tsx", path: "apps/web" } })).toBe( + "Searched files *.tsx in web", + ); + expect( + formatSearchToolLabel({ input: { pattern: "TODO", glob: "*.ts", path: "apps/web" } }), + ).toBe("Searched TODO in web"); + }); + + it("keeps bare filenames from explicit path fields", () => { + expect(collectToolFilePaths({ input: { file_path: "README" } })).toEqual(["README"]); + }); + + it("keeps the first non-empty rawInput when a later update is empty", () => { + expect( + mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: {}, kind: "read" }), + ).toEqual({ + rawInput: { path: "src/a.ts" }, + kind: "read", + }); + expect( + mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }), + ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); + }); }); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 783299ee8dc2..04f746a01d1a 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -23,6 +23,12 @@ export function computerUseToolTitle( return asTrimmedString(asRecord(input)?.title); } +function recordHasKeys( + value: Record | undefined, +): value is Record { + return value !== undefined && Object.keys(value).length > 0; +} + function normalizeCommandValue(value: unknown): string | undefined { const direct = asTrimmedString(value); if (direct) { @@ -86,20 +92,16 @@ function extractToolCommand(data: Record | undefined, title: st return extractCommandFromTitle(title); } -function maybePathLike(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - if ( - value.includes("/") || - value.includes("\\") || - value.startsWith(".") || - /\.(?:[a-z0-9]{1,12})$/iu.test(value) - ) { - return value; - } - return undefined; -} +const PATH_KEYS = [ + "path", + "filePath", + "file_path", + "relativePath", + "filename", + "fileName", + "newPath", + "oldPath", +] as const; function collectPaths(value: unknown, paths: string[], seen: Set, depth: number): void { if (depth > 4 || paths.length >= 8) { @@ -118,8 +120,8 @@ function collectPaths(value: unknown, paths: string[], seen: Set, depth: if (!record) { return; } - for (const key of ["path", "filePath", "relativePath", "filename", "newPath", "oldPath"]) { - const candidate = maybePathLike(asTrimmedString(record[key])); + for (const key of PATH_KEYS) { + const candidate = asTrimmedString(record[key]); if (!candidate || seen.has(candidate)) { continue; } @@ -140,10 +142,45 @@ function collectPaths(value: unknown, paths: string[], seen: Set, depth: } } -function extractPrimaryPath(data: Record | undefined): string | undefined { +/** Structured paths from tool input, never file-body text. */ +export function collectToolFilePaths(data: unknown): string[] { const paths: string[] = []; collectPaths(data, paths, new Set(), 0); - return paths[0]; + return paths; +} + +function extractPrimaryPath(data: Record | undefined): string | undefined { + return collectToolFilePaths(data)[0]; +} + +/** + * Later ACP updates often resend `rawInput: {}`. Keep the first object that + * actually carried keys so a parsed path is not wiped out. + */ +export function mergeToolActivityData( + previous: unknown, + next: unknown, +): Record | undefined { + const previousRecord = asRecord(previous); + const nextRecord = asRecord(next); + if (!nextRecord) { + return previousRecord; + } + if (!previousRecord) { + return nextRecord; + } + const previousInput = asRecord(previousRecord.rawInput); + const nextInput = asRecord(nextRecord.rawInput); + const rawInput = recordHasKeys(nextInput) + ? { ...previousInput, ...nextInput } + : (previousInput ?? nextInput); + const merged = { ...previousRecord, ...nextRecord }; + if (recordHasKeys(rawInput)) { + merged.rawInput = rawInput; + } else { + delete merged.rawInput; + } + return merged; } function normalizeEquivalentValue(value: string | undefined): string | undefined { @@ -163,22 +200,47 @@ function isEquivalent(left: string | undefined, right: string | undefined): bool return normalizedLeft !== undefined && normalizedLeft === normalizedRight; } -function classifyToolAction(input: { - readonly itemType?: ToolLifecycleItemType | null | undefined; +export type ToolActivityAction = "command" | "read" | "file_change" | "search" | "other"; + +function toolNameToken(value: string | undefined): string | undefined { + const trimmed = asTrimmedString(value); + // Server-prefixed MCP names (`github.read_file`, `mcp__db__find`) are not local reads or searches. + if (!trimmed || /__|[./]/u.test(trimmed)) { + return undefined; + } + return trimmed.replace(/[_\s-]/gu, "").toLowerCase(); +} + +export function classifyToolActivity(input: { + readonly itemType?: ToolLifecycleItemType | string | null | undefined; + readonly requestKind?: string | null | undefined; readonly title?: string | undefined; readonly data?: Record | undefined; -}): "command" | "read" | "file_change" | "search" | "other" { +}): ToolActivityAction { const itemType = input.itemType ?? undefined; + const requestKind = asTrimmedString(input.requestKind)?.toLowerCase(); const kind = asTrimmedString(input.data?.kind)?.toLowerCase(); - const title = asTrimmedString(input.title)?.toLowerCase(); - if (itemType === "command_execution" || kind === "execute" || title === "terminal") { + const toolName = toolNameToken( + asTrimmedString(input.data?.toolName) ?? asTrimmedString(asRecord(input.data?.item)?.tool), + ); + + if (itemType === "command_execution") { return "command"; } - if (kind === "read" || title === "read file") { + if (itemType === "image_view") { return "read"; } + if (itemType === "file_change") { + return "file_change"; + } + if (itemType === "web_search") { + return "search"; + } + if (requestKind === "command" || kind === "execute") { + return "command"; + } if ( - itemType === "file_change" || + requestKind === "file-change" || kind === "edit" || kind === "move" || kind === "delete" || @@ -186,12 +248,115 @@ function classifyToolAction(input: { ) { return "file_change"; } - if (itemType === "web_search" || kind === "search" || title === "find" || title === "grep") { + if ( + kind === "search" || + toolName === "find" || + toolName === "grep" || + toolName === "glob" || + toolName === "rg" || + toolName === "ls" + ) { return "search"; } + if (requestKind === "file-read" || kind === "read") { + return "read"; + } + if (toolName === "terminal" || toolName === "bash" || toolName === "shell") { + return "command"; + } + if (toolName === "read" || toolName === "readfile") { + return "read"; + } return "other"; } +const SEARCH_QUERY_KEYS = ["pattern", "query", "searchTerm", "regex", "grep", "needle"] as const; +const SEARCH_GLOB_KEYS = [ + "glob", + "globPattern", + "glob_pattern", + "include", + "filePattern", + "file_pattern", +] as const; +const SEARCH_TARGET_KEYS = [ + "path", + "target_directory", + "targetDirectory", + "directory", + "cwd", + "root", +] as const; + +function firstInputString( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const value = asTrimmedString(record[key]); + if (value) { + return value; + } + } + return undefined; +} + +function searchInputRecord( + data: Record | undefined, +): Record | undefined { + return ( + [data?.rawInput, data?.input, asRecord(data?.item)?.input] + .map(asRecord) + .find((record) => recordHasKeys(record ?? undefined)) ?? data + ); +} + +function searchTargetName(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + return value.split(/[\\/]/u).findLast((part) => part.length > 0 && part !== "."); +} + +/** Cursor-style row: "Searched files *.{ts,tsx} in t3chat-new". */ +export function formatSearchToolLabel( + data: Record | undefined, +): string | undefined { + const input = searchInputRecord(data); + const query = firstInputString(input, SEARCH_QUERY_KEYS); + const glob = firstInputString(input, SEARCH_GLOB_KEYS); + const target = searchTargetName(firstInputString(input, SEARCH_TARGET_KEYS)); + if (query && target) { + return `Searched ${query} in ${target}`; + } + if (glob && target) { + return `Searched files ${glob} in ${target}`; + } + if (glob) { + return `Searched files ${glob}`; + } + if (query) { + return `Searched ${query}`; + } + if (target) { + return `Searched in ${target}`; + } + return undefined; +} + +/** Work-log heading for a file read: verb plus the structured path, never the path alone. */ +export function formatReadToolLabel(path: string, extraCount = 0): string { + const trimmed = path.trim(); + const suffix = extraCount > 0 ? ` +${extraCount} more` : ""; + if (!trimmed) { + return `Read file${suffix}`; + } + return `Read ${trimmed}${suffix}`; +} + export interface ToolActivityPresentationInput { readonly itemType?: ToolLifecycleItemType | null | undefined; readonly title?: string | null | undefined; @@ -214,7 +379,7 @@ export function deriveToolActivityPresentation( const data = asRecord(input.data); const command = extractToolCommand(data, title); const primaryPath = extractPrimaryPath(data); - const action = classifyToolAction({ + const action = classifyToolActivity({ itemType: input.itemType, title, data, @@ -230,8 +395,7 @@ export function deriveToolActivityPresentation( if (action === "read") { if (primaryPath) { return { - summary: "Read file", - detail: primaryPath, + summary: formatReadToolLabel(primaryPath), }; } return { @@ -247,13 +411,12 @@ export function deriveToolActivityPresentation( } if (action === "search") { - const query = - asTrimmedString(asRecord(data?.rawInput)?.query) ?? - asTrimmedString(asRecord(data?.rawInput)?.pattern) ?? - asTrimmedString(asRecord(data?.rawInput)?.searchTerm); + const searchLabel = formatSearchToolLabel(data); + if (searchLabel) { + return { summary: searchLabel }; + } return { summary: "Searched files", - ...(query ? { detail: query } : {}), }; }