From 5ffd21e31d8f40367cb8461d1387d0a83e2be939 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 22 Sep 2026 12:25:09 -0500 Subject: [PATCH 01/12] fix(v2): preserve tool inputs and improve read and search labels --- .../src/features/threads/thread-work-log.tsx | 6 +- apps/mobile/src/lib/threadActivity.ts | 59 ++++- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 16 ++ .../Adapters/ClaudeAdapterV2.ts | 23 +- .../Adapters/CursorAdapterV2.ts | 24 +- .../Adapters/OpenCodeAdapterV2.test.ts | 2 +- .../Adapters/OpenCodeAdapterV2.ts | 16 +- .../src/provider/acp/AcpRuntimeModel.test.ts | 20 ++ .../src/provider/acp/AcpRuntimeModel.ts | 149 ++++++++++- .../chat/MessagesTimeline.logic.test.ts | 40 +++ .../components/chat/MessagesTimeline.logic.ts | 84 +++++- .../src/components/chat/MessagesTimeline.tsx | 12 +- apps/web/src/session-logic.ts | 24 +- .../src/work-log/presentation.test.ts | 35 +++ .../src/work-log/presentation.ts | 37 ++- packages/shared/src/toolActivity.test.ts | 43 +++- packages/shared/src/toolActivity.ts | 242 ++++++++++++++++-- 17 files changed, 766 insertions(+), 66 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 988048823c5b..c688f8a9d040 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -368,8 +368,12 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { return { ios: "square.and.pencil", android: "edit" }; case "eye": return { ios: "eye", android: "visibility" }; + case "file-text": + return "doc.text"; case "globe": return { ios: "globe", android: "public" }; + case "search": + return "magnifyingglass"; case "hammer": return { ios: "hammer", android: "construction" }; case "lock": @@ -1350,7 +1354,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { case "list-prs": return "arrow.triangle.pull"; case "read": - return { ios: "eye", android: "visibility" }; + return "doc.text"; case "edit": return { ios: "square.and.pencil", android: "edit" }; case "thread-create": diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 653331a9dc35..1cfe2956b8be 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; @@ -79,7 +85,9 @@ export interface ThreadFeedActivity { | "command" | "edit" | "eye" + | "file-text" | "globe" + | "search" | "hammer" | "lock" | "message" @@ -206,9 +214,28 @@ 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); + if (action === "code-search" || action === "search") { + const toolData = + entry.toolData !== null && typeof entry.toolData === "object" && !Array.isArray(entry.toolData) + ? (entry.toolData as Record) + : undefined; + const searchLabel = formatSearchToolLabel(toolData); + if (searchLabel) return searchLabel; + } + if (action === "read") { + 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 ?? + (action === "read" ? null : entry.detail) ?? (entry.changedFiles?.length ? entry.changedFiles.length === 1 ? entry.changedFiles[0]! @@ -414,6 +441,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 item.viewedImagePath !== undefined ? "eye" : "file-text"; + } + if (classified === "search") return "search"; + } switch (item.type) { case "reasoning": return "agent"; @@ -422,7 +459,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 +523,7 @@ function itemSummary( ? `Changed ${item.changes.length} files` : `Changed ${item.fileName}`; case "file_search": - return "Searched files"; + return formatSearchToolLabel(item) ?? "Searched files"; case "web_search": return "Searched the web"; case "approval_request": @@ -507,8 +544,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": diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 91e3c05b3957..d3b75c4878bf 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 { @@ -3254,9 +3255,24 @@ 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: rawInputRecord ?? (path === undefined ? {} : { 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.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index ded8aae4a1d1..7e191968fabe 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 { @@ -3375,6 +3376,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, @@ -3386,7 +3401,10 @@ 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, @@ -3407,9 +3425,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.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index df0f4043cb09..add33a86dfba 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, @@ -1273,19 +1274,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 acceadce0268..d8cfc3f1315e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -2066,7 +2066,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 82751b085ca4..0575f7174eab 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,12 @@ 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 +1725,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/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 4afdf85ee1e5..047411dac9bd 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -874,6 +874,26 @@ describe("AcpRuntimeModel", () => { ).toEqual({ emit: true, skippedSinceEmit: 0 }); }); + it("coalesces streaming updates whose rawInput is equal by content", () => { + const previous: AcpToolCallState = { + toolCallId: "tool-1", + title: "Read File", + status: "inProgress", + data: { rawInput: { path: "src/a.ts", args: ["--foo"] } }, + }; + expect( + decideToolCallUpdateEmission({ + previous, + next: { + ...previous, + data: { rawInput: { path: "src/a.ts", args: ["--foo"] } }, + }, + lastEmittedDetailLength: 0, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: false, skippedSinceEmit: 0 }); + }); + it("emits immediately when the title changes, even with no growth", () => { const decision = decideToolCallUpdateEmission({ previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 20f26c4f7bb8..b9cd8e59be9b 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -4,7 +4,7 @@ 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 +555,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 +574,73 @@ 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 { + 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") { + 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 +813,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 +850,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 +859,6 @@ function makeToolCallState( ? title : undefined; const data: Record = { toolCallId }; - const kind = normalizeToolKind(input.kind); if (kind) { data.kind = kind; } @@ -815,8 +885,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 +952,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 +960,7 @@ export function mergeToolCallState( ...(status ? { status } : {}), ...(command ? { command } : {}), ...(detail ? { detail } : {}), - data: { - ...previous?.data, - ...next.data, - }, + data, }; } @@ -922,6 +991,63 @@ function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallSt ); } +function toolCallLocationsEqual(previous: unknown, next: unknown): boolean { + if (previous === next) { + return true; + } + if (!Array.isArray(previous) || !Array.isArray(next) || previous.length !== next.length) { + return false; + } + for (let index = 0; index < previous.length; index += 1) { + const left = previous[index]; + const right = next[index]; + if (left === right) { + continue; + } + if (!isRecord(left) || !isRecord(right)) { + return false; + } + if (left.path !== right.path || left.line !== right.line) { + return false; + } + } + return true; +} + +function toolCallRawInputEqual(previous: unknown, next: unknown): boolean { + if (previous === next) { + return true; + } + if (Array.isArray(previous) || Array.isArray(next)) { + return ( + Array.isArray(previous) && + Array.isArray(next) && + previous.length === next.length && + previous.every((value, index) => toolCallRawInputEqual(value, next[index])) + ); + } + if (!isRecord(previous) || !isRecord(next)) { + return Object.is(previous, next); + } + const previousKeys = Object.keys(previous); + const nextKeys = Object.keys(next); + return ( + previousKeys.length === nextKeys.length && + previousKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(next, key) && + toolCallRawInputEqual(previous[key], next[key]), + ) + ); +} + +function toolCallIdentityUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { + return ( + toolCallRawInputEqual(previous.data.rawInput, next.data.rawInput) && + toolCallLocationsEqual(previous.data.locations, next.data.locations) + ); +} + // Command tools keep `detail` equal to the command, so live stdout lives on // `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never // sees growth and in-progress output is held until completed/failed. @@ -962,6 +1088,9 @@ export function decideToolCallUpdateEmission( if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { return { emit: true, skippedSinceEmit: 0 }; } + if (!toolCallIdentityUnchanged(previous, next)) { + return { emit: true, skippedSinceEmit: 0 }; + } if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { return { emit: false, skippedSinceEmit }; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 245a745a898b..b2ffe9295200 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,44 @@ 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 WorkLogEntry["structuredPayload"], + }; + expect(workEntryDisplayLabel(readEntry, undefined)).toBe("Read src/env.ts"); + expect(workEntryReadOutput(readEntry, undefined)).toBe("src/env.ts"); + }); + + 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 WorkLogEntry["structuredPayload"], + }, + undefined, + ), + ).toBe("Searched TODO in web"); + }); + 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 d079a0501d2c..82451b29c7a5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -50,6 +50,11 @@ 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"; function timelineEntryRunId(entry: TimelineEntry): RunId | null { if (entry.kind === "message") { @@ -74,29 +79,71 @@ 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; + 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 workEntryReadPaths(entry: WorkLogEntry, workspaceRoot: string | undefined): string[] { + const fromChanged = (entry.changedFiles ?? []).map((filePath) => + formatWorkspaceRelativePath(filePath, workspaceRoot), + ); + if (fromChanged.length > 0) return fromChanged; + const structured = entry.structuredPayload; + if (structured?.type === "dynamic_tool") { + return collectToolFilePaths({ input: structured.input }).map((filePath) => + formatWorkspaceRelativePath(filePath, workspaceRoot), + ); + } + return collectToolFilePaths(workEntryToolDataRecord(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") { + const searchLabel = 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(); + if ( + compactDetail && + !providerRetry && + action !== "read" && + !/[\r\n]/.test(compactDetail) + ) { + return compactDetail; + } const [firstPath] = entry.changedFiles ?? []; if (firstPath) { const path = formatWorkspaceRelativePath(firstPath, workspaceRoot); @@ -104,10 +151,35 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string ? path : `${path} +${entry.changedFiles!.length - 1} more`; } + if (action === "read" && entry.itemType !== "reasoning" && !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, + workspaceRoot: string | undefined, +): string | null { + const paths = [ + ...new Set( + workEntryReadPaths(entry as WorkLogEntry, workspaceRoot) + .map((path) => path.trim()) + .filter((path) => path.length > 0), + ), + ]; + if (paths.length > 0) { + return paths.join("\n"); + } + if (entry.viewedImagePath) { + return null; + } + const detail = entry.detail?.trim(); + return detail && detail.length > 0 && !/[\r\n]/.test(detail) ? detail : 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 45420846d972..c41da5920f6d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -118,6 +118,7 @@ import { CircleAlertIcon, DownloadIcon, EyeIcon, + FileTextIcon, GitForkIcon, GlobeIcon, type LucideIcon, @@ -196,6 +197,7 @@ import { shouldPreserveAssistantLineBreaks, toolGroupAction, workEntryDisplayLabel, + workEntryReadOutput, workEntryIsVisibleInGroup, worktreeSetupAgentStarted, type StableMessagesTimelineRowsState, @@ -3592,7 +3594,7 @@ function toolGroupSummaryIconName( case "list-prs": return "pull-request"; case "read": - return "eye"; + return "file-text"; case "edit": return "square-pen"; case "command": @@ -4477,6 +4479,7 @@ type WorkEntryIconName = | "computer" | "device" | "eye" + | "file-text" | "globe" | "hammer" | "message-circle" @@ -4684,6 +4687,8 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className return ; case "eye": return ; + case "file-text": + return ; case "globe": return ; case "hammer": @@ -4757,6 +4762,10 @@ function buildToolCallExpandedBody( seen.add(text); blocks.push(text); }; + if (toolGroupAction(workEntry) === "read") { + addBlock(workEntryReadOutput(workEntry, workspaceRoot)); + return blocks.length > 0 ? blocks.join("\n\n") : null; + } if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) { const input = workEntry.structuredPayload?.type === "dynamic_tool" @@ -4813,6 +4822,7 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { const toolPresentation = resolveWorkEntryToolPresentation(workEntry); if (toolPresentation) return toolPresentation.icon; const action = toolGroupAction(workEntry); + if (action === "read" && workEntryViewedImagePath(workEntry)) return "eye"; if (action !== "other") return toolGroupSummaryIconName(action); switch (workEntry.itemType) { 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..7073f4b68a0b 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 WorkLogPresentationEntry["structuredPayload"], + }), + ).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 WorkLogPresentationEntry["structuredPayload"], + }), + ).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..40f31bf5c751 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 { @@ -423,17 +424,43 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if (presentation?.action !== undefined) return presentation.action; if (presentation?.icon === "browser") return "browser"; if (presentation?.icon === "device") return "device"; - if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) return "read"; + const data = asRecord(entry.toolData) ?? {}; + const toolName = workEntryToolName(entry); + 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, + requestKind: entry.requestKind, + title: entry.toolTitle ?? entry.label, + data: { + ...data, + ...(toolName ? { toolName } : {}), + }, + }); if ( - entry.itemType === "dynamic_tool" && - /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) + classified === "read" || + entry.requestKind === "file-read" || + entry.viewedImagePath !== undefined ) { return "read"; } - if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) return "edit"; - if (entry.itemType === "command_execution" || entry.command) return "command"; + 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 (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..b4dda5142f4b 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { deriveToolActivityPresentation } from "./toolActivity.ts"; +import { + classifyToolActivity, + deriveToolActivityPresentation, + formatReadToolLabel, + formatSearchToolLabel, + mergeToolActivityData, +} from "./toolActivity.ts"; describe("toolActivity", () => { it("normalizes command tools to a stable ran-command label", () => { @@ -33,8 +39,7 @@ describe("toolActivity", () => { fallbackSummary: "Read File", }), ).toEqual({ - summary: "Read file", - detail: "/tmp/app.ts", + summary: "Read /tmp/app.ts", }); }); @@ -54,4 +59,36 @@ 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"); + expect(classifyToolActivity({ title: "Find", data: {} })).toBe("other"); + }); + + 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"); + }); + + 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", + }); + }); }); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 783299ee8dc2..e1ac1656a0df 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -23,6 +23,10 @@ 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) { @@ -101,6 +105,17 @@ function maybePathLike(value: string | undefined): string | undefined { 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) { return; @@ -118,7 +133,7 @@ function collectPaths(value: unknown, paths: string[], seen: Set, depth: if (!record) { return; } - for (const key of ["path", "filePath", "relativePath", "filename", "newPath", "oldPath"]) { + for (const key of PATH_KEYS) { const candidate = maybePathLike(asTrimmedString(record[key])); if (!candidate || seen.has(candidate)) { continue; @@ -140,10 +155,43 @@ 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) ? 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 +211,53 @@ 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); + if (!trimmed) { + return undefined; + } + return trimmed + .split(/__|[./]/u) + .at(-1) + ?.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 (requestKind === "file-read" || kind === "read") { return "read"; } if ( - itemType === "file_change" || + requestKind === "file-change" || kind === "edit" || kind === "move" || kind === "delete" || @@ -186,12 +265,139 @@ function classifyToolAction(input: { ) { return "file_change"; } - if (itemType === "web_search" || kind === "search" || title === "find" || title === "grep") { + if (kind === "search") { + return "search"; + } + if (toolName === "terminal" || toolName === "bash" || toolName === "shell") { + return "command"; + } + if (toolName === "read" || toolName === "readfile") { + return "read"; + } + if ( + toolName === "find" || + toolName === "grep" || + toolName === "glob" || + toolName === "rg" || + toolName === "ls" + ) { return "search"; } return "other"; } +const SEARCH_QUERY_KEYS = ["pattern", "query", "searchTerm", "regex", "grep", "needle"] as const; +const SEARCH_GLOB_KEYS = [ + "glob", + "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 ( + asRecord(data?.rawInput) ?? + asRecord(data?.input) ?? + asRecord(asRecord(data?.item)?.input) ?? + data + ); +} + +function searchTargetName(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + return value + .split(/[\\/]/u) + .filter((part) => part.length > 0 && part !== ".") + .at(-1); +} + +const SEARCH_INPUT_KEYS = [...SEARCH_QUERY_KEYS, ...SEARCH_GLOB_KEYS, ...SEARCH_TARGET_KEYS]; + +/** + * Claude/OpenCode search tools put pattern/glob/path on `input`, not ACP `rawInput`. + * Copy only those keys so Edit/Write bodies stay out of persisted tool data. + */ +export function structuredSearchToolInput( + data: Record | undefined, +): Record | undefined { + const input = asRecord(data?.input) ?? asRecord(asRecord(data?.item)?.input); + if (!input) { + return undefined; + } + const picked: Record = {}; + for (const key of SEARCH_INPUT_KEYS) { + if (input[key] !== undefined) { + picked[key] = input[key]; + } + } + return Object.keys(picked).length > 0 ? picked : undefined; +} + +/** 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 (glob && target) { + return `Searched files ${glob} in ${target}`; + } + if (query && target) { + return `Searched ${query} 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 +420,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 +436,7 @@ export function deriveToolActivityPresentation( if (action === "read") { if (primaryPath) { return { - summary: "Read file", - detail: primaryPath, + summary: formatReadToolLabel(primaryPath), }; } return { @@ -247,13 +452,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 } : {}), }; } From 5248409afe4606a492e22dab41987d8247035f90 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 02:16:03 -0500 Subject: [PATCH 02/12] fix(v2): classify tool activity and show read paths --- apps/mobile/src/lib/threadActivity.test.ts | 40 ++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 27 ++++++---- apps/server/scripts/acp-mock-agent.ts | 21 ++++++++ .../Adapters/AcpAdapterV2.test.ts | 11 ++++ .../orchestration-v2/Adapters/AcpAdapterV2.ts | 6 ++- .../Adapters/ClaudeAdapterV2.test.ts | 10 ++++ .../Adapters/ClaudeAdapterV2.ts | 5 +- .../Adapters/CursorAdapterV2.test.ts | 25 +++++++++ .../Adapters/OpenCodeAdapterV2.ts | 4 +- .../src/provider/acp/AcpRuntimeModel.ts | 7 ++- .../chat/MessagesTimeline.logic.test.ts | 21 +++++++- .../components/chat/MessagesTimeline.logic.ts | 54 ++++++++++--------- .../src/components/chat/MessagesTimeline.tsx | 28 +++++----- .../src/work-log/presentation.test.ts | 4 +- .../src/work-log/presentation.ts | 7 ++- packages/shared/src/toolActivity.test.ts | 9 ++++ packages/shared/src/toolActivity.ts | 21 ++++---- 17 files changed, 229 insertions(+), 71 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ea8a2e0de438..02a7a11cadc8 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -59,6 +59,31 @@ 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); +}); + function base(id: string, updatedAt: string, ordinal: number) { const timestamp = DateTime.makeUnsafe(updatedAt); return { @@ -2049,6 +2074,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 1cfe2956b8be..56529fc4448a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -217,7 +217,9 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa const action = toolGroupAction(entry); if (action === "code-search" || action === "search") { const toolData = - entry.toolData !== null && typeof entry.toolData === "object" && !Array.isArray(entry.toolData) + entry.toolData !== null && + typeof entry.toolData === "object" && + !Array.isArray(entry.toolData) ? (entry.toolData as Record) : undefined; const searchLabel = formatSearchToolLabel(toolData); @@ -226,16 +228,15 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa if (action === "read") { const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData); if (firstPath) { - return formatReadToolLabel( - firstPath, - Math.max(0, (entry.changedFiles?.length ?? 1) - 1), - ); + return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1)); } if (!expanded) return "Read file"; } const preview = entry.command ?? - (action === "read" ? null : entry.detail) ?? + (action === "read" || (!expanded && (action === "code-search" || action === "search")) + ? null + : entry.detail) ?? (entry.changedFiles?.length ? entry.changedFiles.length === 1 ? entry.changedFiles[0]! @@ -700,8 +701,12 @@ 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 = 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, @@ -710,8 +715,8 @@ function toFeedActivity( }, null, 2, - ), - ); + ); + }); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] .filter( @@ -727,7 +732,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..3f6502a24e73 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1054,6 +1054,27 @@ const program = Effect.gen(function* () { }, ], }, + { + sessionUpdate: "tool_call_update", + toolCallId: "late-read-path", + title: "Read `src/env.ts`", + kind: "read", + status: "pending", + rawInput: {}, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "late-read-path", + status: "in_progress", + locations: [{ path: "src/env.ts", line: 4 }], + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "late-read-path", + status: "completed", + rawInput: {}, + rawOutput: { content: "---\nfile body" }, + }, { 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..71ce9325b4a8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1335,6 +1335,17 @@ describe("AcpAdapterV2", () => { item.changes[0]?.oldPath === "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/workspace/old.ts", ), ); + const read = items + .filter((item) => item.type === "dynamic_tool") + .filter((item) => item.toolName === "Read"); + assert.deepEqual( + read.map((item) => ({ title: item.title, input: item.input, status: item.status })), + [ + { title: "Read file", input: {}, status: "pending" }, + { title: "Read src/env.ts", input: { path: "src/env.ts" }, status: "running" }, + { title: "Read src/env.ts", input: { path: "src/env.ts" }, status: "completed" }, + ], + ); 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 d3b75c4878bf..d4015298a235 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -3260,7 +3260,11 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV title: path ? formatReadToolLabel(path) : (title ?? "Read file"), type: "dynamic_tool", toolName: "Read", - input: rawInputRecord ?? (path === undefined ? {} : { path }), + input: + path === undefined || + ["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path) + ? (rawInputRecord ?? {}) + : { ...rawInputRecord, path }, ...(rawOutput === undefined ? {} : { output: rawOutput }), }; break; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 3b93213d55b8..958e00f0033f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2654,6 +2654,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", @@ -2715,6 +2716,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 7e191968fabe..d1efa4bc5531 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3401,10 +3401,7 @@ export function makeClaudeAdapterV2( parentItemId: null, ordinal: input.ordinal, status: input.status, - title: - readPath !== undefined - ? formatReadToolLabel(readPath) - : (searchTitle ?? null), + title: readPath !== undefined ? formatReadToolLabel(readPath) : (searchTitle ?? null), startedAt: input.startedAt, completedAt, updatedAt: input.updatedAt, 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/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 0575f7174eab..63cc7df25366 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -1713,7 +1713,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid const pattern = recordString(input, "pattern", "query", "path", "filePath"); turnItem = { ...base, - title: formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? base.title, + title: + formatSearchToolLabel({ input, ...(pattern === undefined ? {} : { pattern }) }) ?? + base.title, type: "file_search", ...(pattern === undefined ? {} : { pattern }), }; diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index b9cd8e59be9b..173465f12e2d 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, mergeToolActivityData } from "@t3tools/shared/toolActivity"; +import { + deriveToolActivityPresentation, + mergeToolActivityData, +} from "@t3tools/shared/toolActivity"; import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import type { OrchestrationV2ProviderThreadNativeMetadata, @@ -622,7 +625,7 @@ function locationsFromToolCallInput(input: { } if (input.content) { for (const entry of input.content) { - if (entry.type === "diff") { + if (entry.type === "diff" && "path" in entry) { pushPath(entry.path); } } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2ffe9295200..fb4c25d471b1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -182,10 +182,27 @@ describe("work entry labels", () => { type: "dynamic_tool", toolName: "Read", input: { file_path: "src/env.ts" }, - } as WorkLogEntry["structuredPayload"], + } 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", () => { @@ -201,7 +218,7 @@ describe("work entry labels", () => { type: "dynamic_tool", toolName: "Grep", input: { pattern: "TODO", path: "apps/web" }, - } as WorkLogEntry["structuredPayload"], + } as NonNullable, }, undefined, ), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 82451b29c7a5..3deefd28ea5f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -55,6 +55,7 @@ import { formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; function timelineEntryRunId(entry: TimelineEntry): RunId | null { if (entry.kind === "message") { @@ -87,23 +88,26 @@ function singleToolCallLabel(entry: WorkLogEntry): string { } function workEntryToolDataRecord(entry: WorkLogEntry): Record | undefined { - return entry.toolData !== null && typeof entry.toolData === "object" && !Array.isArray(entry.toolData) + return entry.toolData !== null && + typeof entry.toolData === "object" && + !Array.isArray(entry.toolData) ? (entry.toolData as Record) : undefined; } -function workEntryReadPaths(entry: WorkLogEntry, workspaceRoot: string | undefined): string[] { - const fromChanged = (entry.changedFiles ?? []).map((filePath) => - formatWorkspaceRelativePath(filePath, workspaceRoot), - ); +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") { - return collectToolFilePaths({ input: structured.input }).map((filePath) => - formatWorkspaceRelativePath(filePath, workspaceRoot), - ); + const structuredPaths = collectToolFilePaths({ input: structured.input }); + if (structuredPaths.length > 0) return structuredPaths; } - return collectToolFilePaths(workEntryToolDataRecord(entry)).map((filePath) => + return collectToolFilePaths(workEntryToolDataRecord(entry)); +} + +function workEntryReadPaths(entry: WorkLogEntry, workspaceRoot: string | undefined): string[] { + return workEntryReadRawPaths(entry).map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot), ); } @@ -136,12 +140,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; if (title) return title; const compactDetail = entry.detail?.trim(); - if ( - compactDetail && - !providerRetry && - action !== "read" && - !/[\r\n]/.test(compactDetail) - ) { + if (compactDetail && !providerRetry && action !== "read" && !/[\r\n]/.test(compactDetail)) { return compactDetail; } const [firstPath] = entry.changedFiles ?? []; @@ -151,7 +150,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string ? path : `${path} +${entry.changedFiles!.length - 1} more`; } - if (action === "read" && entry.itemType !== "reasoning" && !entry.viewedImagePath) { + if (action === "read" && !entry.viewedImagePath) { return "Read file"; } const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label); @@ -160,24 +159,31 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string /** Inspectable read-file output is the path when we have one, otherwise nothing. */ export function workEntryReadOutput( - entry: Pick, + entry: Pick< + WorkLogEntry, + "changedFiles" | "detail" | "viewedImagePath" | "structuredPayload" | "toolData" + >, workspaceRoot: string | undefined, ): string | null { const paths = [ ...new Set( - workEntryReadPaths(entry as WorkLogEntry, workspaceRoot) - .map((path) => path.trim()) + 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"); } - if (entry.viewedImagePath) { - return null; - } - const detail = entry.detail?.trim(); - return detail && detail.length > 0 && !/[\r\n]/.test(detail) ? detail : null; + return null; } export function liveWorkEntryLabel( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c41da5920f6d..4b9c1e7d2b33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4762,10 +4762,6 @@ function buildToolCallExpandedBody( seen.add(text); blocks.push(text); }; - if (toolGroupAction(workEntry) === "read") { - addBlock(workEntryReadOutput(workEntry, workspaceRoot)); - return blocks.length > 0 ? blocks.join("\n\n") : null; - } if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) { const input = workEntry.structuredPayload?.type === "dynamic_tool" @@ -4979,6 +4975,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, { @@ -4998,14 +4996,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", @@ -5168,9 +5170,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { !isReasoning && !workEntry.questionAnswer && canExpandProjectedItem && - (expandedBody || workEntry.projectedItem) ? ( + (expandedBody || (workEntry.projectedItem && !isRead)) ? ( - {workEntry.projectedItem ? ( + {workEntry.projectedItem && !isRead ? ( { type: "dynamic_tool", toolName: "Read", input: { file_path: "src/env.ts" }, - } as WorkLogPresentationEntry["structuredPayload"], + } as NonNullable, }), ).toBe("read"); expect( @@ -781,7 +781,7 @@ describe("toolGroupAction", () => { type: "dynamic_tool", toolName: "Grep", input: { pattern: "TODO", path: "apps/web" }, - } as WorkLogPresentationEntry["structuredPayload"], + } as NonNullable, }), ).toBe("code-search"); }); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 40f31bf5c751..98b03f31f738 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -425,7 +425,12 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if (presentation?.icon === "browser") return "browser"; if (presentation?.icon === "device") return "device"; const data = asRecord(entry.toolData) ?? {}; - const toolName = workEntryToolName(entry); + 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" || diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index b4dda5142f4b..59e53bd5eb5d 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -81,6 +81,12 @@ describe("toolActivity", () => { 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", + ); }); it("keeps the first non-empty rawInput when a later update is empty", () => { @@ -90,5 +96,8 @@ describe("toolActivity", () => { 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 e1ac1656a0df..295386aa3e97 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -23,7 +23,9 @@ export function computerUseToolTitle( return asTrimmedString(asRecord(input)?.title); } -function recordHasKeys(value: Record | undefined): value is Record { +function recordHasKeys( + value: Record | undefined, +): value is Record { return value !== undefined && Object.keys(value).length > 0; } @@ -184,7 +186,9 @@ export function mergeToolActivityData( } const previousInput = asRecord(previousRecord.rawInput); const nextInput = asRecord(nextRecord.rawInput); - const rawInput = recordHasKeys(nextInput) ? nextInput : (previousInput ?? nextInput); + const rawInput = recordHasKeys(nextInput) + ? { ...previousInput, ...nextInput } + : (previousInput ?? nextInput); const merged = { ...previousRecord, ...nextRecord }; if (recordHasKeys(rawInput)) { merged.rawInput = rawInput; @@ -289,6 +293,7 @@ export function classifyToolActivity(input: { const SEARCH_QUERY_KEYS = ["pattern", "query", "searchTerm", "regex", "grep", "needle"] as const; const SEARCH_GLOB_KEYS = [ "glob", + "globPattern", "glob_pattern", "include", "filePattern", @@ -323,10 +328,9 @@ function searchInputRecord( data: Record | undefined, ): Record | undefined { return ( - asRecord(data?.rawInput) ?? - asRecord(data?.input) ?? - asRecord(asRecord(data?.item)?.input) ?? - data + [data?.rawInput, data?.input, asRecord(data?.item)?.input] + .map(asRecord) + .find((record) => recordHasKeys(record ?? undefined)) ?? data ); } @@ -334,10 +338,7 @@ function searchTargetName(value: string | undefined): string | undefined { if (!value) { return undefined; } - return value - .split(/[\\/]/u) - .filter((part) => part.length > 0 && part !== ".") - .at(-1); + return value.split(/[\\/]/u).findLast((part) => part.length > 0 && part !== "."); } const SEARCH_INPUT_KEYS = [...SEARCH_QUERY_KEYS, ...SEARCH_GLOB_KEYS, ...SEARCH_TARGET_KEYS]; From 4b04aca149170192127444e2e11230e3b6352500 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 04:12:40 -0500 Subject: [PATCH 03/12] fix(v2): restore eye icon for read activity --- apps/mobile/src/features/threads/thread-work-log.tsx | 4 +--- apps/mobile/src/lib/threadActivity.ts | 3 +-- apps/web/src/components/chat/MessagesTimeline.tsx | 7 +------ 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index c688f8a9d040..3ea856a7b854 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -368,8 +368,6 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { return { ios: "square.and.pencil", android: "edit" }; case "eye": return { ios: "eye", android: "visibility" }; - case "file-text": - return "doc.text"; case "globe": return { ios: "globe", android: "public" }; case "search": @@ -1354,7 +1352,7 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { case "list-prs": return "arrow.triangle.pull"; case "read": - return "doc.text"; + return { ios: "eye", android: "visibility" }; case "edit": return { ios: "square.and.pencil", android: "edit" }; case "thread-create": diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 56529fc4448a..5e4346911d86 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -85,7 +85,6 @@ export interface ThreadFeedActivity { | "command" | "edit" | "eye" - | "file-text" | "globe" | "search" | "hammer" @@ -448,7 +447,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { data: { toolName: item.toolName ?? undefined, input: item.input }, }); if (classified === "read") { - return item.viewedImagePath !== undefined ? "eye" : "file-text"; + return "eye"; } if (classified === "search") return "search"; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4b9c1e7d2b33..217347842339 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -118,7 +118,6 @@ import { CircleAlertIcon, DownloadIcon, EyeIcon, - FileTextIcon, GitForkIcon, GlobeIcon, type LucideIcon, @@ -3594,7 +3593,7 @@ function toolGroupSummaryIconName( case "list-prs": return "pull-request"; case "read": - return "file-text"; + return "eye"; case "edit": return "square-pen"; case "command": @@ -4479,7 +4478,6 @@ type WorkEntryIconName = | "computer" | "device" | "eye" - | "file-text" | "globe" | "hammer" | "message-circle" @@ -4687,8 +4685,6 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className return ; case "eye": return ; - case "file-text": - return ; case "globe": return ; case "hammer": @@ -4818,7 +4814,6 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { const toolPresentation = resolveWorkEntryToolPresentation(workEntry); if (toolPresentation) return toolPresentation.icon; const action = toolGroupAction(workEntry); - if (action === "read" && workEntryViewedImagePath(workEntry)) return "eye"; if (action !== "other") return toolGroupSummaryIconName(action); switch (workEntry.itemType) { From 2bc9efef1cdd2eb67a8aae08200f1b127b5066cf Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 04:23:46 -0500 Subject: [PATCH 04/12] fix(v2): classify OpenCode code search as file search --- .../Adapters/OpenCodeAdapterV2.test.ts | 77 ++++++++++++++++++- .../Adapters/OpenCodeAdapterV2.ts | 2 +- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index d8cfc3f1315e..9a63c99b0d6a 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -711,6 +711,80 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(idAllocatorLayer), Effect.scoped), ); + it.effect("projects OpenCode code search as a file search", () => + 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 [ + ["codesearch", { query: "restore icon", 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: "Found results", + 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 codeSearch = items.find((item) => item.type === "file_search"); + assert.equal(codeSearch?.title, "Searched restore icon in web"); + assert.equal(codeSearch?.type === "file_search" ? codeSearch.pattern : null, "restore icon"); + 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(); @@ -2069,7 +2143,8 @@ describe("OpenCodeAdapterV2", () => { assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); - assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); + assert.equal(openCodeToolProjectionKind("codesearch"), "file_search"); + assert.equal(openCodeToolProjectionKind("code_search"), "file_search"); assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool"); assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); }); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 63cc7df25366..063b6ce3e4bd 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -646,7 +646,7 @@ export function openCodeToolProjectionKind( if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { return "file_change"; } - if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { + if (normalized.includes("web")) { return "web_search"; } if (normalized === "read") { From 3c06334a0829375aed9f18f8fd1113c9681d8506 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 04:37:33 -0500 Subject: [PATCH 05/12] fix(v2): preserve read paths and search terms --- .../fixtures/subagent/cursor_output.ts | 2 +- .../testkit/fixtures/todo_list/grok_output.ts | 2 +- .../tool_call_read_only/cursor_output.ts | 25 +++++--------- .../src/provider/acp/AcpRuntimeModel.test.ts | 33 +++++++++++++++++++ .../src/provider/acp/AcpRuntimeModel.ts | 3 ++ apps/web/src/session-logic.test.ts | 15 +++++++++ packages/shared/src/toolActivity.test.ts | 8 +++++ packages/shared/src/toolActivity.ts | 23 +++---------- 8 files changed, 74 insertions(+), 37 deletions(-) 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 047411dac9bd..c606b825813b 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 173465f12e2d..83c7b12564f6 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -599,6 +599,9 @@ function locationsFromToolCallInput(input: { 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) => { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 886f1d5ae35b..4eebd9d6b307 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1084,6 +1084,21 @@ describe("native provider presentation in the v2 timeline", () => { }); }); + 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/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index 59e53bd5eb5d..f716823273a9 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { classifyToolActivity, + collectToolFilePaths, deriveToolActivityPresentation, formatReadToolLabel, formatSearchToolLabel, @@ -87,6 +88,13 @@ describe("toolActivity", () => { 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", () => { diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 295386aa3e97..1cdd6609eb63 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -92,21 +92,6 @@ 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", @@ -136,7 +121,7 @@ function collectPaths(value: unknown, paths: string[], seen: Set, depth: return; } for (const key of PATH_KEYS) { - const candidate = maybePathLike(asTrimmedString(record[key])); + const candidate = asTrimmedString(record[key]); if (!candidate || seen.has(candidate)) { continue; } @@ -371,12 +356,12 @@ export function formatSearchToolLabel( const query = firstInputString(input, SEARCH_QUERY_KEYS); const glob = firstInputString(input, SEARCH_GLOB_KEYS); const target = searchTargetName(firstInputString(input, SEARCH_TARGET_KEYS)); - if (glob && target) { - return `Searched files ${glob} in ${target}`; - } if (query && target) { return `Searched ${query} in ${target}`; } + if (glob && target) { + return `Searched files ${glob} in ${target}`; + } if (glob) { return `Searched files ${glob}`; } From 1488d65fa17887b538280cde18e295f2963c6a19 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 04:39:46 -0500 Subject: [PATCH 06/12] chore(v2): remove unused search input helper --- packages/shared/src/toolActivity.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 1cdd6609eb63..36c0c350eb9b 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -326,28 +326,6 @@ function searchTargetName(value: string | undefined): string | undefined { return value.split(/[\\/]/u).findLast((part) => part.length > 0 && part !== "."); } -const SEARCH_INPUT_KEYS = [...SEARCH_QUERY_KEYS, ...SEARCH_GLOB_KEYS, ...SEARCH_TARGET_KEYS]; - -/** - * Claude/OpenCode search tools put pattern/glob/path on `input`, not ACP `rawInput`. - * Copy only those keys so Edit/Write bodies stay out of persisted tool data. - */ -export function structuredSearchToolInput( - data: Record | undefined, -): Record | undefined { - const input = asRecord(data?.input) ?? asRecord(asRecord(data?.item)?.input); - if (!input) { - return undefined; - } - const picked: Record = {}; - for (const key of SEARCH_INPUT_KEYS) { - if (input[key] !== undefined) { - picked[key] = input[key]; - } - } - return Object.keys(picked).length > 0 ? picked : undefined; -} - /** Cursor-style row: "Searched files *.{ts,tsx} in t3chat-new". */ export function formatSearchToolLabel( data: Record | undefined, From be5b71ca62c8bf48e59036e2e569d9df7ea26856 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 24 Sep 2026 04:44:05 -0500 Subject: [PATCH 07/12] fix(v2): classify Claude search tools as search --- packages/shared/src/toolActivity.test.ts | 9 +++++++++ packages/shared/src/toolActivity.ts | 22 ++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index f716823273a9..e9dab3659117 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -68,6 +68,15 @@ describe("toolActivity", () => { 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"); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 36c0c350eb9b..7f3b6cac2286 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -242,9 +242,6 @@ export function classifyToolActivity(input: { if (requestKind === "command" || kind === "execute") { return "command"; } - if (requestKind === "file-read" || kind === "read") { - return "read"; - } if ( requestKind === "file-change" || kind === "edit" || @@ -254,16 +251,8 @@ export function classifyToolActivity(input: { ) { return "file_change"; } - if (kind === "search") { - return "search"; - } - if (toolName === "terminal" || toolName === "bash" || toolName === "shell") { - return "command"; - } - if (toolName === "read" || toolName === "readfile") { - return "read"; - } if ( + kind === "search" || toolName === "find" || toolName === "grep" || toolName === "glob" || @@ -272,6 +261,15 @@ export function classifyToolActivity(input: { ) { 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"; } From ace740a74dbd3c96c699cd3fb22bc78832baf1fd Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 26 Sep 2026 23:58:42 -0500 Subject: [PATCH 08/12] fix(v2): keep OpenCode codesearch classified as web search OpenCode's codesearch was Exa's web code-context API and the adapter already gates it as a network tool, so it should not project as a workspace file search. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/OpenCodeAdapterV2.test.ts | 55 ++++++++----------- .../Adapters/OpenCodeAdapterV2.ts | 2 +- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 9a63c99b0d6a..89ff6d8586f0 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -711,7 +711,7 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(idAllocatorLayer), Effect.scoped), ); - it.effect("projects OpenCode code search as a file search", () => + it.effect("keeps OpenCode web search queries", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); const nativeSessionId = "native-opencode-search"; @@ -737,35 +737,30 @@ describe("OpenCodeAdapterV2", () => { Stream.runCollect, Effect.forkScoped, ); - for (const [tool, input] of [ - ["codesearch", { query: "restore icon", path: "apps/web" }], - ["websearch", { query: "OpenCode documentation" }], - ] as const) { - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.part.updated", + properties: { + sessionID: nativeSessionId, + part: { + id: "part-websearch", sessionID: nativeSessionId, - part: { - id: `part-${tool}`, - sessionID: nativeSessionId, - messageID: "assistant-search", - type: "tool", - callID: `call-${tool}`, - tool, - state: { - status: "completed", - input, - output: "Found results", - title: tool, - metadata: {}, - time: { start: 1, end: 2 }, - }, + messageID: "assistant-search", + type: "tool", + callID: "call-websearch", + tool: "websearch", + state: { + status: "completed", + input: { query: "OpenCode documentation" }, + output: "Found results", + title: "websearch", + metadata: {}, + time: { start: 1, end: 2 }, }, }, - }), - ); - } + }, + }), + ); yield* Effect.promise(() => nativeEvents.push({ type: "session.compacted", @@ -775,9 +770,6 @@ describe("OpenCodeAdapterV2", () => { const items = (yield* Fiber.join(received)).flatMap((event) => event.type === "turn_item.updated" ? [event.turnItem] : [], ); - const codeSearch = items.find((item) => item.type === "file_search"); - assert.equal(codeSearch?.title, "Searched restore icon in web"); - assert.equal(codeSearch?.type === "file_search" ? codeSearch.pattern : null, "restore icon"); const webSearch = items.find((item) => item.type === "web_search"); assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ "OpenCode documentation", @@ -2143,8 +2135,7 @@ describe("OpenCodeAdapterV2", () => { assert.equal(openCodeToolProjectionKind("read"), "dynamic_tool"); assert.equal(openCodeToolProjectionKind("lsp"), "file_search"); assert.equal(openCodeToolProjectionKind("websearch"), "web_search"); - assert.equal(openCodeToolProjectionKind("codesearch"), "file_search"); - assert.equal(openCodeToolProjectionKind("code_search"), "file_search"); + assert.equal(openCodeToolProjectionKind("codesearch"), "web_search"); assert.equal(openCodeToolProjectionKind("todowrite"), "dynamic_tool"); assert.equal(openCodeToolProjectionKind("custom_tool"), "dynamic_tool"); }); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 063b6ce3e4bd..63cc7df25366 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -646,7 +646,7 @@ export function openCodeToolProjectionKind( if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { return "file_change"; } - if (normalized.includes("web")) { + if (normalized.includes("web") || normalized === "codesearch" || normalized === "code_search") { return "web_search"; } if (normalized === "read") { From c3b881e1cae9de29c1e220c9ab26b6506d0dd951 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 27 Sep 2026 00:09:33 -0500 Subject: [PATCH 09/12] fix(v2): keep approval rows out of tool action classification Approvals and questions describe requested work, so they no longer classify as commands, edits, or reads. Mobile only applies the read label and path-only expansion to dynamic tool reads, so a file-read approval keeps its prompt. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.test.ts | 34 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 10 ++++-- .../src/work-log/presentation.ts | 14 ++++---- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 02a7a11cadc8..d70e35c16d9d 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"; @@ -84,6 +85,39 @@ it("shows only the structured path in expanded mobile read details", () => { expect(withoutPath?.canExpand).toBe(false); }); +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 { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 5e4346911d86..fd42fbc2c95a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -214,6 +214,7 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa 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 && @@ -224,7 +225,7 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa const searchLabel = formatSearchToolLabel(toolData); if (searchLabel) return searchLabel; } - if (action === "read") { + if (isToolRead) { const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData); if (firstPath) { return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1)); @@ -233,7 +234,7 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa } const preview = entry.command ?? - (action === "read" || (!expanded && (action === "code-search" || action === "search")) + (isToolRead || (!expanded && (action === "code-search" || action === "search")) ? null : entry.detail) ?? (entry.changedFiles?.length @@ -700,7 +701,10 @@ 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 readPaths = toolGroupAction(workEntry) === "read" ? collectToolFilePaths(item) : null; + const readPaths = + item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read" + ? collectToolFilePaths(item) + : null; const getFullDetail = memoizeValue(() => { if (readPaths) { return readPaths.join("\n") || null; diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 98b03f31f738..fdaf28dcf3b7 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -424,6 +424,11 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if (presentation?.action !== undefined) return presentation.action; if (presentation?.icon === "browser") return "browser"; if (presentation?.icon === "device") return "device"; + if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) 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" @@ -440,20 +445,13 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio : entry.itemType === "dynamic_tool" ? "dynamic_tool_call" : undefined, - requestKind: entry.requestKind, title: entry.toolTitle ?? entry.label, data: { ...data, ...(toolName ? { toolName } : {}), }, }); - if ( - classified === "read" || - entry.requestKind === "file-read" || - entry.viewedImagePath !== undefined - ) { - return "read"; - } + 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"; From 2c444c6638b5c46d5e469619a6391923e51e1698 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 27 Sep 2026 00:09:34 -0500 Subject: [PATCH 10/12] fix(v2): address tool activity review notes - Drop ACP tool-call identity checks that V2 never reaches, and the mock-agent frames no recorded transcript shows. - Remove unreachable Cursor read branches in the search helpers. - Leave server-prefixed MCP tool names unclassified. - Keep multi-line prompts as labels; only search output is skipped. - Restore the heading for a lone web search row. - Cover OpenCode read and grep titles in the adapter test. Co-Authored-By: Claude Opus 5.5 --- apps/server/scripts/acp-mock-agent.ts | 21 ------- .../Adapters/AcpAdapterV2.test.ts | 11 ---- .../Adapters/CursorAdapterV2.ts | 8 --- .../Adapters/OpenCodeAdapterV2.test.ts | 55 ++++++++++------- .../src/provider/acp/AcpRuntimeModel.test.ts | 20 ------- .../src/provider/acp/AcpRuntimeModel.ts | 60 ------------------- .../chat/MessagesTimeline.logic.test.ts | 10 ++++ .../components/chat/MessagesTimeline.logic.ts | 6 +- packages/shared/src/toolActivity.test.ts | 3 + packages/shared/src/toolActivity.ts | 9 +-- 10 files changed, 54 insertions(+), 149 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 3f6502a24e73..3f9db76ad0fa 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1054,27 +1054,6 @@ const program = Effect.gen(function* () { }, ], }, - { - sessionUpdate: "tool_call_update", - toolCallId: "late-read-path", - title: "Read `src/env.ts`", - kind: "read", - status: "pending", - rawInput: {}, - }, - { - sessionUpdate: "tool_call_update", - toolCallId: "late-read-path", - status: "in_progress", - locations: [{ path: "src/env.ts", line: 4 }], - }, - { - sessionUpdate: "tool_call_update", - toolCallId: "late-read-path", - status: "completed", - rawInput: {}, - rawOutput: { content: "---\nfile body" }, - }, { 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 71ce9325b4a8..ee79510f58fc 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1335,17 +1335,6 @@ describe("AcpAdapterV2", () => { item.changes[0]?.oldPath === "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/workspace/old.ts", ), ); - const read = items - .filter((item) => item.type === "dynamic_tool") - .filter((item) => item.toolName === "Read"); - assert.deepEqual( - read.map((item) => ({ title: item.title, input: item.input, status: item.status })), - [ - { title: "Read file", input: {}, status: "pending" }, - { title: "Read src/env.ts", input: { path: "src/env.ts" }, status: "running" }, - { title: "Read src/env.ts", input: { path: "src/env.ts" }, status: "completed" }, - ], - ); const completedCompaction = items.find( (item) => item.type === "compaction" && diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index a9a3503e0af9..4829fb0cb3c7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -443,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": @@ -481,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": diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 5dba98b719a8..3715005de1fc 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -711,7 +711,7 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(idAllocatorLayer), Effect.scoped), ); - it.effect("keeps OpenCode web search queries", () => + it.effect("titles OpenCode reads and searches from their input", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); const nativeSessionId = "native-opencode-search"; @@ -737,30 +737,36 @@ describe("OpenCodeAdapterV2", () => { Stream.runCollect, Effect.forkScoped, ); - yield* Effect.promise(() => - nativeEvents.push({ - type: "message.part.updated", - properties: { - sessionID: nativeSessionId, - part: { - id: "part-websearch", + 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, - messageID: "assistant-search", - type: "tool", - callID: "call-websearch", - tool: "websearch", - state: { - status: "completed", - input: { query: "OpenCode documentation" }, - output: "Found results", - title: "websearch", - metadata: {}, - time: { start: 1, end: 2 }, + 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", @@ -770,6 +776,11 @@ describe("OpenCodeAdapterV2", () => { 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", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index c606b825813b..e69c851b258e 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -907,26 +907,6 @@ describe("AcpRuntimeModel", () => { ).toEqual({ emit: true, skippedSinceEmit: 0 }); }); - it("coalesces streaming updates whose rawInput is equal by content", () => { - const previous: AcpToolCallState = { - toolCallId: "tool-1", - title: "Read File", - status: "inProgress", - data: { rawInput: { path: "src/a.ts", args: ["--foo"] } }, - }; - expect( - decideToolCallUpdateEmission({ - previous, - next: { - ...previous, - data: { rawInput: { path: "src/a.ts", args: ["--foo"] } }, - }, - lastEmittedDetailLength: 0, - skippedSinceEmit: 0, - }), - ).toEqual({ emit: false, skippedSinceEmit: 0 }); - }); - it("emits immediately when the title changes, even with no growth", () => { const decision = decideToolCallUpdateEmission({ previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 83c7b12564f6..8cb059acb2ec 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -997,63 +997,6 @@ function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallSt ); } -function toolCallLocationsEqual(previous: unknown, next: unknown): boolean { - if (previous === next) { - return true; - } - if (!Array.isArray(previous) || !Array.isArray(next) || previous.length !== next.length) { - return false; - } - for (let index = 0; index < previous.length; index += 1) { - const left = previous[index]; - const right = next[index]; - if (left === right) { - continue; - } - if (!isRecord(left) || !isRecord(right)) { - return false; - } - if (left.path !== right.path || left.line !== right.line) { - return false; - } - } - return true; -} - -function toolCallRawInputEqual(previous: unknown, next: unknown): boolean { - if (previous === next) { - return true; - } - if (Array.isArray(previous) || Array.isArray(next)) { - return ( - Array.isArray(previous) && - Array.isArray(next) && - previous.length === next.length && - previous.every((value, index) => toolCallRawInputEqual(value, next[index])) - ); - } - if (!isRecord(previous) || !isRecord(next)) { - return Object.is(previous, next); - } - const previousKeys = Object.keys(previous); - const nextKeys = Object.keys(next); - return ( - previousKeys.length === nextKeys.length && - previousKeys.every( - (key) => - Object.prototype.hasOwnProperty.call(next, key) && - toolCallRawInputEqual(previous[key], next[key]), - ) - ); -} - -function toolCallIdentityUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { - return ( - toolCallRawInputEqual(previous.data.rawInput, next.data.rawInput) && - toolCallLocationsEqual(previous.data.locations, next.data.locations) - ); -} - // Command tools keep `detail` equal to the command, so live stdout lives on // `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never // sees growth and in-progress output is held until completed/failed. @@ -1094,9 +1037,6 @@ export function decideToolCallUpdateEmission( if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { return { emit: true, skippedSinceEmit: 0 }; } - if (!toolCallIdentityUnchanged(previous, next)) { - return { emit: true, skippedSinceEmit: 0 }; - } if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { return { emit: false, skippedSinceEmit }; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d9ba17e43722..9686885fd73c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -225,6 +225,16 @@ describe("work entry labels", () => { ).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 6314f1661146..0517635b09f5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -84,6 +84,8 @@ function singleToolCallLabel(entry: WorkLogEntry): string { const title = item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; 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); } @@ -140,7 +142,9 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; if (title) return title; const compactDetail = entry.detail?.trim(); - if (compactDetail && !providerRetry && action !== "read" && !/[\r\n]/.test(compactDetail)) { + const detailIsSearchOutput = + (action === "code-search" || action === "search") && /[\r\n]/.test(compactDetail ?? ""); + if (compactDetail && !providerRetry && action !== "read" && !detailIsSearchOutput) { return compactDetail; } const [firstPath] = entry.changedFiles ?? []; diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index e9dab3659117..9a9c67d2e36c 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -65,6 +65,9 @@ describe("toolActivity", () => { 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"); }); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 7f3b6cac2286..04f746a01d1a 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -204,14 +204,11 @@ export type ToolActivityAction = "command" | "read" | "file_change" | "search" | function toolNameToken(value: string | undefined): string | undefined { const trimmed = asTrimmedString(value); - if (!trimmed) { + // 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 - .split(/__|[./]/u) - .at(-1) - ?.replace(/[_\s-]/gu, "") - .toLowerCase(); + return trimmed.replace(/[_\s-]/gu, "").toLowerCase(); } export function classifyToolActivity(input: { From 486f5507cbc70c671b9264454a290e7a3d5584e3 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 27 Sep 2026 00:22:47 -0500 Subject: [PATCH 11/12] test(server): cover ACP read and search item titles Co-Authored-By: Claude Opus 5.5 --- apps/server/scripts/acp-mock-agent.ts | 17 +++++++++++++++++ .../Adapters/AcpAdapterV2.test.ts | 10 ++++++++++ 2 files changed, 27 insertions(+) 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" && From 6478aa4eda207f2ef887076a010b8340b21a959a Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 27 Sep 2026 01:33:57 -0500 Subject: [PATCH 12/12] fix(v2): keep the search target in file search row labels File search items only carry the pattern, so recomputing the label on the client dropped the "in " part the adapter already put in the item title. Web and mobile now use that title for file search rows. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.test.ts | 15 +++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 6 ++++-- .../chat/MessagesTimeline.logic.test.ts | 16 ++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 6 +++++- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d70e35c16d9d..69a730915149 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -85,6 +85,21 @@ it("shows only the structured path in expanded mobile read details", () => { 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, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fd42fbc2c95a..b5e93a9016c9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -222,7 +222,9 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa !Array.isArray(entry.toolData) ? (entry.toolData as Record) : undefined; - const searchLabel = formatSearchToolLabel(toolData); + // 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) { @@ -524,7 +526,7 @@ function itemSummary( ? `Changed ${item.changes.length} files` : `Changed ${item.fileName}`; case "file_search": - return formatSearchToolLabel(item) ?? "Searched files"; + return item.title?.trim() || formatSearchToolLabel(item) || "Searched files"; case "web_search": return "Searched the web"; case "approval_request": diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 9686885fd73c..7433da5ccd68 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -225,6 +225,22 @@ describe("work entry labels", () => { ).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( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 0517635b09f5..bbe4814588ac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -125,7 +125,11 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string if (entry.command) return commandDisplayText(entry.command); const action = toolGroupAction(entry); if (action === "code-search" || action === "search") { - const searchLabel = formatSearchToolLabel(workEntryToolDataRecord(entry)); + // 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) : [];