From 852c6075da28cf5fc029dcc71b61eb9a19f73a1a Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Tue, 15 Sep 2026 12:30:41 -0300 Subject: [PATCH 1/2] fix(chat): show tool identity in work logs --- apps/mobile/src/lib/threadActivity.test.ts | 49 +++++++++++- apps/mobile/src/lib/threadActivity.ts | 46 ++++++----- .../ActivityPayloadProjection.test.ts | 57 ++++++++++++++ .../ActivityPayloadProjection.ts | 27 ++++++- apps/web/src/session-logic.ts | 8 +- .../src/work-log/toolPresentation.test.ts | 9 +++ .../src/work-log/toolPresentation.ts | 13 ++++ packages/shared/src/toolActivity.ts | 76 +++++++++++++++---- 8 files changed, 242 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4e301288c94f..2f485a0bf65f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -291,6 +291,50 @@ function makeThread( } describe("buildThreadFeed", () => { + it("retains untitled tool identity through sparse lifecycle updates", () => { + const turnId = TurnId.make("untitled-turn"); + const activities = ["tool.updated", "tool.updated", "tool.completed"].map((kind, index) => + makeActivity({ + id: EventId.make(`untitled-${index}`), + kind, + tone: "tool", + summary: kind === "tool.completed" ? "Tool" : "Tool updated", + createdAt: `2026-09-15T00:00:0${index}.000Z`, + turnId, + payload: { + itemType: "dynamic_tool_call", + toolCallId: "read-1", + ...(index === 0 ? { data: { toolName: "Read", kind: "read" } } : {}), + }, + }), + ); + for (let count = 1; count <= activities.length; count++) { + const [group] = buildThreadFeed( + makeThread({ + id: ThreadId.make("untitled-tools"), + projectId: ProjectId.make("project-1"), + title: "Untitled tools", + activities: activities.slice(0, count), + }), + ); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + expect(group.activities).toHaveLength(1); + expect(workEntryRowLabel(group.activities[0]!.workEntry)).toBe("Read file"); + const rows = deriveThreadFeedPresentation( + [group], + { + turnId, + state: count < activities.length ? "running" : "completed", + startedAt: activities[0]!.createdAt, + completedAt: count < activities.length ? null : activities.at(-1)!.createdAt, + }, + new Set([turnId]), + ); + expect(rows.find((row) => row.type === "work-toggle")?.summary).toBe("Read file"); + } + }); + it("reuses unchanged feed and presentation rows during an assistant text update", () => { const completedTurnId = TurnId.make("completed-turn"); const activeTurnId = TurnId.make("active-turn"); @@ -579,7 +623,10 @@ describe("buildThreadFeed", () => { expect(row?.workEntry.detail).toBe(command); expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); expect(row?.canExpand).toBe(true); - expect(workEntryRowLabel(row!.workEntry, true)).toBe("Command"); + expect(workEntryRowLabel(row!.workEntry, true)).toBe("Ran printf"); + expect(workEntryRowLabel({ ...row!.workEntry, toolLifecycleStatus: "inProgress" }, true)).toBe( + "Running printf", + ); }); it.each([ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f284812dc8ac..8771bd4587ae 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -463,8 +463,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : null; const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); - const title = extractToolTitle(payload); - const toolPresentation = extractToolActivityPresentation(payload); + const toolPresentation = extractToolActivityPresentation(payload, activity.summary); + const title = toolPresentation.toolTitle ?? null; // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = activity.kind === "task.started" || @@ -1018,7 +1018,9 @@ export function workEntryRowLabel(entry: WorkLogEntry, expanded = false): string if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn); const presentation = resolveWorkEntryToolPresentation(entry); if (presentation) return presentation.displayName; - if (expanded && entry.command?.trim()) return "Command"; + if (expanded && entry.command?.trim()) { + return commandWorkEntryLabel(entry.command, entry.toolLifecycleStatus); + } const preview = workEntryPreview(entry); if (expanded) return preview?.trim() || workEntryHeading(entry); const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview)); @@ -1381,10 +1383,6 @@ function extractToolCommand(payload: Record | null): { }; } -function extractToolTitle(payload: Record | null): string | null { - return asTrimmedString(payload?.title); -} - function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; @@ -2079,20 +2077,26 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo if (presentation) return presentation.displayName; const command = activity.workEntry.command?.trim(); if (command) { - const program = commandProgramName(command); - const verb = - status === "inProgress" - ? "Running" - : status === "failed" - ? "Failed" - : status === "declined" - ? "Declined" - : status === "stopped" - ? "Stopped" - : "Ran"; - return `${verb} ${program ?? "command"}`; - } - return activity.detail ?? activity.summary; + return commandWorkEntryLabel(command, status); + } + return workEntryRowLabel(activity.workEntry); +} + +function commandWorkEntryLabel( + command: string, + status: WorkLogToolLifecycleStatus | undefined, +): string { + const verb = + status === "inProgress" + ? "Running" + : status === "failed" + ? "Failed" + : status === "declined" + ? "Declined" + : status === "stopped" + ? "Stopped" + : "Ran"; + return `${verb} ${commandProgramName(command) ?? "command"}`; } export function setPendingUserInputCustomAnswer( diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index e6468ff8f789..a1c0ca45a642 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -21,6 +21,63 @@ function activity(payload: Record): OrchestrationThreadActivity * assertions are the tripwire. */ describe("projectActivityPayload", () => { + it.each([ + { + data: { toolName: "Read", input: { file_path: "/repo/app.ts" } }, + title: "Read file", + detail: "/repo/app.ts", + }, + { + data: { kind: "read", locations: [{ path: "/repo/app.ts" }] }, + title: "Read file", + detail: "/repo/app.ts", + }, + { + data: { tool: "grep", input: { pattern: "cleanup" } }, + title: "Searched files", + detail: "cleanup", + }, + { + data: { toolName: "WebSearch", input: { query: "React cleanup" } }, + title: "Searched the web", + detail: "React cleanup", + }, + { + data: { toolName: "Edit", input: { file_path: "/repo/app.ts" } }, + title: "Changed files", + detail: "/repo/app.ts", + }, + { + data: { item: { tool: "list_issues", server: "github" } }, + title: "github.list_issues", + detail: undefined, + }, + ])("retains untitled $title identity before slimming", ({ data, title, detail }) => { + const itemType = "item" in data ? "mcp_tool_call" : "dynamic_tool_call"; + const source = activity({ itemType, data }); + const projected = projectActivityPayload(source); + expect(projected.payload).toMatchObject({ title, ...(detail ? { detail } : {}) }); + expect(source.payload).toEqual({ itemType, data }); + expect(projectActivityPayload(projected)).toEqual(projected); + }); + + it("bounds inferred targets without replacing existing output or useful labels", () => { + const data = { toolName: "Read", input: { file_path: `/repo/${"a".repeat(1000)}.ts` } }; + expect( + projectActivityPayload(activity({ itemType: "dynamic_tool_call", data })).payload, + ).toMatchObject({ title: "Read file", detail: data.input.file_path.slice(0, 180) }); + expect( + projectActivityPayload( + activity({ itemType: "dynamic_tool_call", detail: "Permission denied", data }), + ).payload, + ).toMatchObject({ title: "Read file", detail: "Permission denied" }); + expect( + projectActivityPayload( + activity({ itemType: "dynamic_tool_call", title: "Inspect config", data }), + ).payload, + ).toMatchObject({ title: "Inspect config" }); + }); + it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 0525aae7b72b..c1bea7efd8d4 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -1,4 +1,9 @@ -import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; +import { + deriveToolActivityPresentation, + isGenericToolLabel, + projectQuestionToolInput, +} from "@t3tools/shared/toolActivity"; +import { isToolLifecycleItemType } from "@t3tools/contracts"; import type { OrchestrationEvent, OrchestrationThreadActivity, @@ -425,12 +430,30 @@ function projectAcpContent(value: unknown): Record | undefined export function projectActivityPayload( activity: OrchestrationThreadActivity, ): OrchestrationThreadActivity { - const payload = asRecord(activity.payload); + let payload = asRecord(activity.payload); const data = asRecord(payload?.data); if (!payload || !data) { return activity; } + // Derive identity before slimming drops primary arguments, including historical rows. + if ( + typeof payload.itemType === "string" && + isToolLifecycleItemType(payload.itemType) && + isGenericToolLabel(asTrimmedString(payload.title) ?? activity.summary) + ) { + const presentation = deriveToolActivityPresentation({ itemType: payload.itemType, data }); + if (!isGenericToolLabel(presentation.summary)) { + payload = { + ...payload, + title: presentation.summary.slice(0, 180), + ...(!payload.detail && presentation.detail + ? { detail: presentation.detail.slice(0, 180) } + : {}), + }; + } + } + const itemStatus = asRecord(data.item)?.status; const statusPayload = payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 20a5671da850..6f53dc95a505 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -550,8 +550,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : null; const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); - const title = extractToolTitle(payload); - const toolPresentation = extractToolActivityPresentation(payload); + const toolPresentation = extractToolActivityPresentation(payload, activity.summary); + const title = toolPresentation.toolTitle ?? null; const isTaskActivity = activity.kind === "task.started" || activity.kind === "task.progress" || @@ -1142,10 +1142,6 @@ function extractToolCommand(payload: Record | null): { }; } -function extractToolTitle(payload: Record | null): string | null { - return asTrimmedString(payload?.title); -} - function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); diff --git a/packages/client-runtime/src/work-log/toolPresentation.test.ts b/packages/client-runtime/src/work-log/toolPresentation.test.ts index fe88f15769c5..917053a0abe7 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.test.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.test.ts @@ -3,6 +3,15 @@ import { describe, expect, it } from "@effect/vitest"; import { extractToolActivityPresentation } from "./toolPresentation.ts"; describe("extractToolActivityPresentation", () => { + it("uses retained tool metadata without inventing identity for sparse updates", () => { + const payload = { itemType: "dynamic_tool_call", title: "Tool updated" }; + expect( + extractToolActivityPresentation({ ...payload, data: { toolName: "Read" } }, "Tool updated"), + ).toEqual({ toolTitle: "Read file" }); + expect(extractToolActivityPresentation(payload, "Tool updated")).toEqual({}); + expect(extractToolActivityPresentation({ itemType: "web_search" }, "Web search")).toEqual({}); + }); + it("reads provider-neutral presentation fields", () => { expect( extractToolActivityPresentation({ diff --git a/packages/client-runtime/src/work-log/toolPresentation.ts b/packages/client-runtime/src/work-log/toolPresentation.ts index 9b007bf81c33..fe6e356ea13b 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.ts @@ -4,8 +4,11 @@ import type { ToolActivitySource, ToolActivitySurface, } from "@t3tools/contracts"; +import { isToolLifecycleItemType } from "@t3tools/contracts"; +import { deriveToolActivityPresentation, isGenericToolLabel } from "@t3tools/shared/toolActivity"; export interface ExtractedToolActivityPresentation { + readonly toolTitle?: string; readonly toolSurface?: ToolActivitySurface; readonly toolIcon?: ToolActivityIcon; readonly toolSource?: ToolActivitySource; @@ -107,8 +110,17 @@ function activitySource(value: unknown): ToolActivitySource | undefined { export function extractToolActivityPresentation( payloadValue: unknown, + fallbackSummary?: string, ): ExtractedToolActivityPresentation { const payload = asRecord(payloadValue); + const title = typeof payload?.title === "string" ? payload.title.trim() : undefined; + const label = title || fallbackSummary; + const toolTitle = + isGenericToolLabel(label) && + typeof payload?.itemType === "string" && + isToolLifecycleItemType(payload.itemType) + ? deriveToolActivityPresentation({ itemType: payload.itemType, data: payload.data }).summary + : title; const toolSurface = payload?.toolSurface === "browser" || payload?.toolSurface === "computer" ? payload.toolSurface @@ -116,6 +128,7 @@ export function extractToolActivityPresentation( const toolIcon = activityIcon(payload?.toolIcon); const toolSource = activitySource(payload?.toolSource); return { + ...(toolTitle && !isGenericToolLabel(toolTitle) ? { toolTitle } : {}), ...(toolSurface ? { toolSurface } : {}), ...(toolIcon ? { toolIcon } : {}), ...(toolSource ? { toolSource } : {}), diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 8c1c65565f51..1aa5063f686d 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -61,6 +61,7 @@ function extractToolCommand(data: Record | undefined, title: st normalizeCommandValue(itemResult?.command), normalizeCommandValue(data?.command), normalizeCommandValue(rawInput?.command), + normalizeCommandValue(asRecord(data?.input)?.command), ]; const direct = candidates.find((candidate) => candidate !== undefined); if (direct) { @@ -109,7 +110,15 @@ 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", + "filePath", + "file_path", + "relativePath", + "filename", + "newPath", + "oldPath", + ]) { const candidate = maybePathLike(asTrimmedString(record[key])); if (!candidate || seen.has(candidate)) { continue; @@ -120,7 +129,17 @@ function collectPaths(value: unknown, paths: string[], seen: Set, depth: return; } } - for (const nestedKey of ["locations", "item", "input", "result", "rawInput", "data", "changes"]) { + for (const nestedKey of [ + "locations", + "item", + "input", + "arguments", + "result", + "rawInput", + "data", + "changes", + "files", + ]) { if (!(nestedKey in record)) { continue; } @@ -162,10 +181,15 @@ function classifyToolAction(input: { const itemType = input.itemType ?? undefined; const kind = asTrimmedString(input.data?.kind)?.toLowerCase(); const title = asTrimmedString(input.title)?.toLowerCase(); - if (itemType === "command_execution" || kind === "execute" || title === "terminal") { + if ( + itemType === "command_execution" || + kind === "execute" || + title === "terminal" || + title === "bash" + ) { return "command"; } - if (kind === "read" || title === "read file") { + if (kind === "read" || title === "read file" || title === "read") { return "read"; } if ( @@ -173,11 +197,21 @@ function classifyToolAction(input: { kind === "edit" || kind === "move" || kind === "delete" || - kind === "write" + kind === "write" || + title === "edit" || + title === "write" ) { return "file_change"; } - if (itemType === "web_search" || kind === "search" || title === "find" || title === "grep") { + if ( + itemType === "web_search" || + kind === "search" || + title === "find" || + title === "grep" || + title === "glob" || + title === "websearch" || + title === "web_search" + ) { return "search"; } return "other"; @@ -196,20 +230,32 @@ export interface ToolActivityPresentation { readonly detail?: string | undefined; } +export function isGenericToolLabel(value: string | null | undefined): boolean { + return !value?.trim() || /^tool(?: started| updated| completed)?$/iu.test(value.trim()); +} + export function deriveToolActivityPresentation( input: ToolActivityPresentationInput, ): ToolActivityPresentation { - const title = asTrimmedString(input.title); + const data = asRecord(input.data); + const item = asRecord(data?.item); + const itemTool = asTrimmedString(item?.tool); + const server = asTrimmedString(item?.server); + const title = + (isGenericToolLabel(input.title) ? undefined : asTrimmedString(input.title)) ?? + asTrimmedString(data?.toolName) ?? + asTrimmedString(data?.tool) ?? + (itemTool && server ? `${server}.${itemTool}` : itemTool); const detail = stripTrailingExitCode(asTrimmedString(input.detail)); const fallbackSummary = asTrimmedString(input.fallbackSummary) ?? "Tool"; - const data = asRecord(input.data); const command = extractToolCommand(data, title); - const primaryPath = extractPrimaryPath(data); const action = classifyToolAction({ itemType: input.itemType, title, data, }); + const primaryPath = + action === "read" || action === "file_change" ? extractPrimaryPath(data) : undefined; if (action === "command") { return { @@ -238,12 +284,16 @@ export function deriveToolActivityPresentation( } if (action === "search") { + const args = asRecord(data?.rawInput) ?? asRecord(data?.input) ?? asRecord(item?.arguments); const query = - asTrimmedString(asRecord(data?.rawInput)?.query) ?? - asTrimmedString(asRecord(data?.rawInput)?.pattern) ?? - asTrimmedString(asRecord(data?.rawInput)?.searchTerm); + asTrimmedString(args?.query) ?? + asTrimmedString(args?.pattern) ?? + asTrimmedString(args?.searchTerm); return { - summary: "Searched files", + summary: + input.itemType === "web_search" || /^web_?search$/iu.test(title ?? "") + ? "Searched the web" + : "Searched files", ...(query ? { detail: query } : {}), }; } From 2d1666d8933858b6a821eed281baee88a6a8154f Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Tue, 15 Sep 2026 12:51:35 -0300 Subject: [PATCH 2/2] fix(shared): preserve search queries from fallback metadata --- packages/shared/src/toolActivity.test.ts | 29 ++++++++++++++++++++++++ packages/shared/src/toolActivity.ts | 13 +++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index f93928a9fb19..5f1a0e33fab2 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -3,6 +3,35 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveToolActivityPresentation } from "./toolActivity.ts"; describe("toolActivity", () => { + it.each([ + { data: { rawInput: {}, input: { query: " cleanup " } }, detail: "cleanup" }, + { + data: { + rawInput: { query: " ", pattern: 42 }, + input: { searchTerm: null }, + item: { arguments: { pattern: " TODO " } }, + }, + detail: "TODO", + }, + { + data: { + rawInput: { searchTerm: "raw" }, + input: { query: "input" }, + item: { arguments: { query: "item" } }, + }, + detail: "raw", + }, + { + data: { rawInput: { query: "query", pattern: "pattern", searchTerm: "term" } }, + detail: "query", + }, + ])("uses the first valid search query ($detail)", ({ data, detail }) => { + expect(deriveToolActivityPresentation({ title: "Grep", data })).toEqual({ + summary: "Searched files", + detail, + }); + }); + it("normalizes command tools to a stable ran-command label", () => { expect( deriveToolActivityPresentation({ diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 1aa5063f686d..fa5fe54217de 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -284,11 +284,14 @@ export function deriveToolActivityPresentation( } if (action === "search") { - const args = asRecord(data?.rawInput) ?? asRecord(data?.input) ?? asRecord(item?.arguments); - const query = - asTrimmedString(args?.query) ?? - asTrimmedString(args?.pattern) ?? - asTrimmedString(args?.searchTerm); + const query = [asRecord(data?.rawInput), asRecord(data?.input), asRecord(item?.arguments)] + .map( + (args) => + asTrimmedString(args?.query) ?? + asTrimmedString(args?.pattern) ?? + asTrimmedString(args?.searchTerm), + ) + .find((value) => value !== undefined); return { summary: input.itemType === "web_search" || /^web_?search$/iu.test(title ?? "")