| author | |
| committer | |
| log | 43bd6677392b80cd12742f865c277c9939239543 |
| tree | b8e1de2b6abdae14a551d01cf403a3b1733d9a0f |
| parent | bb7997709dd5ed91a3aa18c091286f5f5331b0ef |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>8 files changed, 75 insertions(+), 25 deletions(-)
apps/mobile/src/lib/threadActivity.ts+2-2| ... | ... | @@ -48,7 +48,7 @@ import { RunId, ThreadId } from "@t3tools/contracts"; |
| 48 | 48 | import { |
| 49 | 49 | classifyToolActivity, |
| 50 | 50 | collectToolFilePaths, |
| 51 | computerUseToolTitle, | |
| 51 | dynamicToolTitle, | |
| 52 | 52 | formatReadToolLabel, |
| 53 | 53 | formatSearchToolLabel, |
| 54 | 54 | } from "@t3tools/shared/toolActivity"; |
| ... | ... | @@ -540,7 +540,7 @@ function itemSummary( |
| 540 | 540 | if (item.type === "system_notice") return item.message; |
| 541 | 541 | if (item.type === "compaction") return contextCompactionLabel(item); |
| 542 | 542 | const title = |
| 543 | (item.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : undefined) ?? | |
| 543 | (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ?? | |
| 544 | 544 | item.title?.trim(); |
| 545 | 545 | if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent"); |
| 546 | 546 | if (title) return toolPresentation?.displayName ?? capitalizePhrase(title); |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+6-1| ... | ... | @@ -2771,7 +2771,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2771 | 2771 | }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), |
| 2772 | 2772 | ); |
| 2773 | 2773 | |
| 2774 | it.effect("retains image preview paths on Claude Read tool completion", () => | |
| 2774 | it.effect("titles Claude reads, searches, and skills on tool completion", () => | |
| 2775 | 2775 | Effect.gen(function* () { |
| 2776 | 2776 | const harness = yield* makeWakeHarness; |
| 2777 | 2777 | const now = yield* DateTime.now; |
| ... | ... | @@ -2789,6 +2789,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2789 | 2789 | { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } }, |
| 2790 | 2790 | { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } }, |
| 2791 | 2791 | { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } }, |
| 2792 | { id: "skill", name: "Skill", input: { skill: "full-send" } }, | |
| 2792 | 2793 | { |
| 2793 | 2794 | id: "write", |
| 2794 | 2795 | name: "Write", |
| ... | ... | @@ -2859,6 +2860,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2859 | 2860 | items.find((item) => item.nativeItemRef?.nativeId === "search")?.title, |
| 2860 | 2861 | "Searched TODO in src", |
| 2861 | 2862 | ); |
| 2863 | assert.equal( | |
| 2864 | items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title, | |
| 2865 | "Skill: full-send", | |
| 2866 | ); | |
| 2862 | 2867 | for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) |
| 2863 | 2868 | assert.notProperty(item, "viewedImagePath"); |
| 2864 | 2869 | }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))), |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+9-2| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import * as NodeCrypto from "node:crypto"; |
| 2 | 2 | |
| 3 | 3 | import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; |
| 4 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | |
| 4 | import { | |
| 5 | dynamicToolTitle, | |
| 6 | formatReadToolLabel, | |
| 7 | formatSearchToolLabel, | |
| 8 | } from "@t3tools/shared/toolActivity"; | |
| 5 | 9 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 6 | 10 | import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; |
| 7 | 11 | import { |
| ... | ... | @@ -3726,7 +3730,10 @@ export function makeClaudeAdapterV2( |
| 3726 | 3730 | title: |
| 3727 | 3731 | readPath !== undefined |
| 3728 | 3732 | ? formatReadToolLabel(readPath) |
| 3729 | : (searchTitle ?? input.presentation?.title ?? null), | |
| 3733 | : (searchTitle ?? | |
| 3734 | dynamicToolTitle(input.toolName, nativeToolInput) ?? | |
| 3735 | input.presentation?.title ?? | |
| 3736 | null), | |
| 3730 | 3737 | startedAt: input.startedAt, |
| 3731 | 3738 | completedAt, |
| 3732 | 3739 | updatedAt: input.updatedAt, |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+2-2| ... | ... | @@ -30,7 +30,7 @@ import { |
| 30 | 30 | } from "@t3tools/contracts"; |
| 31 | 31 | import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; |
| 32 | 32 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 33 | import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; | |
| 33 | import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; | |
| 34 | 34 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; |
| 35 | 35 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 36 | 36 | import type { |
| ... | ... | @@ -489,7 +489,7 @@ export function projectCodexDynamicToolItem( |
| 489 | 489 | item.type === "mcpToolCall" |
| 490 | 490 | ? `${item.server}.${item.tool}` |
| 491 | 491 | : [trimText(item.namespace), item.tool].filter(Boolean).join("."); |
| 492 | const title = computerUseToolTitle(toolName, item.arguments); | |
| 492 | const title = dynamicToolTitle(toolName, item.arguments); | |
| 493 | 493 | const projection: CodexDynamicToolProjection = { |
| 494 | 494 | ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}), |
| 495 | 495 | toolName, |
apps/web/src/components/chat/MessagesTimeline.logic.ts+3-5| ... | ... | @@ -48,7 +48,7 @@ import { |
| 48 | 48 | type T3McpToolPresentation, |
| 49 | 49 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 50 | 50 | import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; |
| 51 | import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; | |
| 51 | import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; | |
| 52 | 52 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 53 | 53 | import { |
| 54 | 54 | collectToolFilePaths, |
| ... | ... | @@ -81,8 +81,7 @@ function singleToolCallLabel(entry: WorkLogEntry): string { |
| 81 | 81 | const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); |
| 82 | 82 | if (toolPresentation) return toolPresentation.displayName; |
| 83 | 83 | const item = entry.structuredPayload; |
| 84 | const title = | |
| 85 | item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; | |
| 84 | const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; | |
| 86 | 85 | if (title) return title; |
| 87 | 86 | // A lone web search keeps its heading; the query stays in its detail. |
| 88 | 87 | if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search"; |
| ... | ... | @@ -142,8 +141,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string |
| 142 | 141 | const providerRetry = |
| 143 | 142 | entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined; |
| 144 | 143 | const item = entry.structuredPayload; |
| 145 | const title = | |
| 146 | item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null; | |
| 144 | const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null; | |
| 147 | 145 | if (title) return title; |
| 148 | 146 | const compactDetail = entry.detail?.trim(); |
| 149 | 147 | const detailIsSearchOutput = |
apps/web/src/components/chat/MessagesTimeline.tsx+22-9| ... | ... | @@ -57,6 +57,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 57 | 57 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 58 | 58 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 59 | 59 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 60 | import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; | |
| 60 | 61 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; |
| 61 | 62 | import { |
| 62 | 63 | createContext, |
| ... | ... | @@ -5034,8 +5035,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 5034 | 5035 | ? getQuestionAnswerPreview(workEntry.questionAnswer) |
| 5035 | 5036 | : null; |
| 5036 | 5037 | const viewedImagePath = workEntryViewedImagePath(workEntry); |
| 5037 | const isRead = toolGroupAction(workEntry) === "read"; | |
| 5038 | const readOutput = isRead ? workEntryReadOutput(workEntry, workspaceRoot) : null; | |
| 5038 | const payload = workEntry.structuredPayload; | |
| 5039 | const skill = | |
| 5040 | payload?.type === "dynamic_tool" | |
| 5041 | ? claudeSkillInvocation(payload.toolName, payload.input) | |
| 5042 | : undefined; | |
| 5043 | // Reads and skills expand to plain text instead of the item inspector. A | |
| 5044 | // skill's heading already names it, so only its arguments are left to show. | |
| 5045 | const plainOutput = | |
| 5046 | toolGroupAction(workEntry) === "read" | |
| 5047 | ? workEntryReadOutput(workEntry, workspaceRoot) | |
| 5048 | : skill | |
| 5049 | ? (skill.args ?? null) | |
| 5050 | : undefined; | |
| 5039 | 5051 | const viewedImage = |
| 5040 | 5052 | viewedImagePath && threadRef |
| 5041 | 5053 | ? resolveViewedImageAsset(viewedImagePath, { |
| ... | ... | @@ -5055,8 +5067,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 5055 | 5067 | ); |
| 5056 | 5068 | const expandedBody = |
| 5057 | 5069 | expanded && !isReasoning |
| 5058 | ? isRead | |
| 5059 | ? readOutput | |
| 5070 | ? plainOutput !== undefined | |
| 5071 | ? plainOutput | |
| 5060 | 5072 | : buildToolCallExpandedBody( |
| 5061 | 5073 | workEntry, |
| 5062 | 5074 | workspaceRoot, |
| ... | ... | @@ -5064,9 +5076,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 5064 | 5076 | viewedImage ? viewedImagePath : null, |
| 5065 | 5077 | ) |
| 5066 | 5078 | : null; |
| 5067 | const canExpandProjectedItem = isRead | |
| 5068 | ? Boolean(readOutput || viewedImage || workEntry.questionAnswer) | |
| 5069 | : canExpand || workEntry.projectedItem !== undefined; | |
| 5079 | const canExpandProjectedItem = | |
| 5080 | plainOutput !== undefined | |
| 5081 | ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer) | |
| 5082 | : canExpand || workEntry.projectedItem !== undefined; | |
| 5070 | 5083 | // Reserve destructive row styling for severe failures, not routine tool errors. |
| 5071 | 5084 | const iconWrapperClass = cn( |
| 5072 | 5085 | "flex size-4 items-center justify-center", |
| ... | ... | @@ -5237,9 +5250,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| 5237 | 5250 | !isReasoning && |
| 5238 | 5251 | !workEntry.questionAnswer && |
| 5239 | 5252 | canExpandProjectedItem && |
| 5240 | (expandedBody || (workEntry.projectedItem && !isRead)) ? ( | |
| 5253 | (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? ( | |
| 5241 | 5254 | <WorkLogDetails kind="panel"> |
| 5242 | {workEntry.projectedItem && !isRead ? ( | |
| 5255 | {workEntry.projectedItem && plainOutput === undefined ? ( | |
| 5243 | 5256 | <V2ItemInspector |
| 5244 | 5257 | projectedItem={workEntry.projectedItem} |
| 5245 | 5258 | environmentId={ctx.activeThreadEnvironmentId} |
packages/shared/src/toolActivity.test.ts+12| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | claudeSkillInvocation, | |
| 4 | 5 | classifyToolActivity, |
| 5 | 6 | collectToolFilePaths, |
| 6 | 7 | deriveToolActivityPresentation, |
| 8 | dynamicToolTitle, | |
| 7 | 9 | formatReadToolLabel, |
| 8 | 10 | formatSearchToolLabel, |
| 9 | 11 | mergeToolActivityData, |
| ... | ... | @@ -120,4 +122,14 @@ describe("toolActivity", () => { |
| 120 | 122 | mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }), |
| 121 | 123 | ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); |
| 122 | 124 | }); |
| 125 | ||
| 126 | it("titles Claude skill calls with the skill they load", () => { | |
| 127 | expect(dynamicToolTitle("Skill", { skill: "full-send" })).toBe("Skill: full-send"); | |
| 128 | expect(claudeSkillInvocation("Skill", { skill: "claude-api", args: " pricing " })).toEqual({ | |
| 129 | name: "claude-api", | |
| 130 | args: "pricing", | |
| 131 | }); | |
| 132 | expect(dynamicToolTitle("Skill", { skill: " " })).toBeUndefined(); | |
| 133 | expect(dynamicToolTitle("Read", { skill: "full-send" })).toBeUndefined(); | |
| 134 | }); | |
| 123 | 135 | }); |
packages/shared/src/toolActivity.ts+19-4| ... | ... | @@ -14,13 +14,28 @@ function asTrimmedString(value: unknown): string | undefined { |
| 14 | 14 | return trimmed.length > 0 ? trimmed : undefined; |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | /** CUA's `title` describes the action shown in the activity log. */ | |
| 18 | export function computerUseToolTitle( | |
| 17 | /** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */ | |
| 18 | export function claudeSkillInvocation( | |
| 19 | toolName: string | null | undefined, | |
| 20 | input: unknown, | |
| 21 | ): { readonly name: string; readonly args: string | undefined } | undefined { | |
| 22 | if (toolName !== "Skill") return undefined; | |
| 23 | const record = asRecord(input); | |
| 24 | const name = asTrimmedString(record?.skill); | |
| 25 | return name === undefined ? undefined : { name, args: asTrimmedString(record?.args) }; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Activity log heading a dynamic tool derives from its input: CUA's `title`, | |
| 30 | * or the skill a Claude `Skill` call loads. | |
| 31 | */ | |
| 32 | export function dynamicToolTitle( | |
| 19 | 33 | toolName: string | null | undefined, |
| 20 | 34 | input: unknown, |
| 21 | 35 | ): string | undefined { |
| 22 | if (toolName !== "cua_repl.js") return undefined; | |
| 23 | return asTrimmedString(asRecord(input)?.title); | |
| 36 | if (toolName === "cua_repl.js") return asTrimmedString(asRecord(input)?.title); | |
| 37 | const skill = claudeSkillInvocation(toolName, input); | |
| 38 | return skill === undefined ? undefined : `Skill: ${skill.name}`; | |
| 24 | 39 | } |
| 25 | 40 | |
| 26 | 41 | function recordHasKeys( |