authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-02 20:02:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 20:02:22-07:00
log43bd6677392b80cd12742f865c277c9939239543
treeb8e1de2b6abdae14a551d01cf403a3b1733d9a0f
parentbb7997709dd5ed91a3aa18c091286f5f5331b0ef
signature Signed by PGP key B5690EEEBB952194

fix(web): Claude skill calls show the skill name (#14980)

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,7 +48,7 @@ import { RunId, ThreadId } from "@t3tools/contracts";
48import {48import {
49 classifyToolActivity,49 classifyToolActivity,
50 collectToolFilePaths,50 collectToolFilePaths,
51 computerUseToolTitle,51 dynamicToolTitle,
52 formatReadToolLabel,52 formatReadToolLabel,
53 formatSearchToolLabel,53 formatSearchToolLabel,
54} from "@t3tools/shared/toolActivity";54} from "@t3tools/shared/toolActivity";
...@@ -540,7 +540,7 @@ function itemSummary(...@@ -540,7 +540,7 @@ function itemSummary(
540 if (item.type === "system_notice") return item.message;540 if (item.type === "system_notice") return item.message;
541 if (item.type === "compaction") return contextCompactionLabel(item);541 if (item.type === "compaction") return contextCompactionLabel(item);
542 const title =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 item.title?.trim();544 item.title?.trim();
545 if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent");545 if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent");
546 if (title) return toolPresentation?.displayName ?? capitalizePhrase(title);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,7 +2771,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2771 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),2771 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),
2772 );2772 );
27732773
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 Effect.gen(function* () {2775 Effect.gen(function* () {
2776 const harness = yield* makeWakeHarness;2776 const harness = yield* makeWakeHarness;
2777 const now = yield* DateTime.now;2777 const now = yield* DateTime.now;
...@@ -2789,6 +2789,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2789,6 +2789,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2789 { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } },2789 { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } },
2790 { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } },2790 { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } },
2791 { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } },2791 { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } },
2792 { id: "skill", name: "Skill", input: { skill: "full-send" } },
2792 {2793 {
2793 id: "write",2794 id: "write",
2794 name: "Write",2795 name: "Write",
...@@ -2859,6 +2860,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2859,6 +2860,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2859 items.find((item) => item.nativeItemRef?.nativeId === "search")?.title,2860 items.find((item) => item.nativeItemRef?.nativeId === "search")?.title,
2860 "Searched TODO in src",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 for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image"))2867 for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image"))
2863 assert.notProperty(item, "viewedImagePath");2868 assert.notProperty(item, "viewedImagePath");
2864 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),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,7 +1,11 @@
1import * as NodeCrypto from "node:crypto";1import * as NodeCrypto from "node:crypto";
22
3import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";3import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
4import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";4import {
5 dynamicToolTitle,
6 formatReadToolLabel,
7 formatSearchToolLabel,
8} from "@t3tools/shared/toolActivity";
5import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";9import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
6import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";10import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";
7import {11import {
...@@ -3726,7 +3730,10 @@ export function makeClaudeAdapterV2(...@@ -3726,7 +3730,10 @@ export function makeClaudeAdapterV2(
3726 title:3730 title:
3727 readPath !== undefined3731 readPath !== undefined
3728 ? formatReadToolLabel(readPath)3732 ? formatReadToolLabel(readPath)
3729 : (searchTitle ?? input.presentation?.title ?? null),3733 : (searchTitle ??
3734 dynamicToolTitle(input.toolName, nativeToolInput) ??
3735 input.presentation?.title ??
3736 null),
3730 startedAt: input.startedAt,3737 startedAt: input.startedAt,
3731 completedAt,3738 completedAt,
3732 updatedAt: input.updatedAt,3739 updatedAt: input.updatedAt,
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+2-2
...@@ -30,7 +30,7 @@ import {...@@ -30,7 +30,7 @@ import {
30} from "@t3tools/contracts";30} from "@t3tools/contracts";
31import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";31import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
32import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";32import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
33import { computerUseToolTitle } from "@t3tools/shared/toolActivity";33import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
34import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";34import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
35import { resolveSpawnCommand } from "@t3tools/shared/shell";35import { resolveSpawnCommand } from "@t3tools/shared/shell";
36import type {36import type {
...@@ -489,7 +489,7 @@ export function projectCodexDynamicToolItem(...@@ -489,7 +489,7 @@ export function projectCodexDynamicToolItem(
489 item.type === "mcpToolCall"489 item.type === "mcpToolCall"
490 ? `${item.server}.${item.tool}`490 ? `${item.server}.${item.tool}`
491 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");491 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");
492 const title = computerUseToolTitle(toolName, item.arguments);492 const title = dynamicToolTitle(toolName, item.arguments);
493 const projection: CodexDynamicToolProjection = {493 const projection: CodexDynamicToolProjection = {
494 ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}),494 ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}),
495 toolName,495 toolName,
apps/web/src/components/chat/MessagesTimeline.logic.ts+3-5
...@@ -48,7 +48,7 @@ import {...@@ -48,7 +48,7 @@ import {
48 type T3McpToolPresentation,48 type T3McpToolPresentation,
49} from "@t3tools/shared/t3McpToolPresentation";49} from "@t3tools/shared/t3McpToolPresentation";
50import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";50import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
51import { computerUseToolTitle } from "@t3tools/shared/toolActivity";51import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
52import { formatWorkspaceRelativePath } from "../../filePathDisplay";52import { formatWorkspaceRelativePath } from "../../filePathDisplay";
53import {53import {
54 collectToolFilePaths,54 collectToolFilePaths,
...@@ -81,8 +81,7 @@ function singleToolCallLabel(entry: WorkLogEntry): string {...@@ -81,8 +81,7 @@ function singleToolCallLabel(entry: WorkLogEntry): string {
81 const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed");81 const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed");
82 if (toolPresentation) return toolPresentation.displayName;82 if (toolPresentation) return toolPresentation.displayName;
83 const item = entry.structuredPayload;83 const item = entry.structuredPayload;
84 const title =84 const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null;
85 item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null;
86 if (title) return title;85 if (title) return title;
87 // A lone web search keeps its heading; the query stays in its detail.86 // A lone web search keeps its heading; the query stays in its detail.
88 if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search";87 if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search";
...@@ -142,8 +141,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string...@@ -142,8 +141,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
142 const providerRetry =141 const providerRetry =
143 entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined;142 entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined;
144 const item = entry.structuredPayload;143 const item = entry.structuredPayload;
145 const title =144 const title = item?.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : null;
146 item?.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : null;
147 if (title) return title;145 if (title) return title;
148 const compactDetail = entry.detail?.trim();146 const compactDetail = entry.detail?.trim();
149 const detailIsSearchOutput =147 const detailIsSearchOutput =
apps/web/src/components/chat/MessagesTimeline.tsx+22-9
...@@ -57,6 +57,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";...@@ -57,6 +57,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
57import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";57import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
58import { formatDuration } from "@t3tools/shared/orchestrationTiming";58import { formatDuration } from "@t3tools/shared/orchestrationTiming";
59import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";59import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
60import { claudeSkillInvocation } from "@t3tools/shared/toolActivity";
60import { observeVisibleAnimation } from "../../lib/visibleAnimation";61import { observeVisibleAnimation } from "../../lib/visibleAnimation";
61import {62import {
62 createContext,63 createContext,
...@@ -5034,8 +5035,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -5034,8 +5035,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
5034 ? getQuestionAnswerPreview(workEntry.questionAnswer)5035 ? getQuestionAnswerPreview(workEntry.questionAnswer)
5035 : null;5036 : null;
5036 const viewedImagePath = workEntryViewedImagePath(workEntry);5037 const viewedImagePath = workEntryViewedImagePath(workEntry);
5037 const isRead = toolGroupAction(workEntry) === "read";5038 const payload = workEntry.structuredPayload;
5038 const readOutput = isRead ? workEntryReadOutput(workEntry, workspaceRoot) : null;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 const viewedImage =5051 const viewedImage =
5040 viewedImagePath && threadRef5052 viewedImagePath && threadRef
5041 ? resolveViewedImageAsset(viewedImagePath, {5053 ? resolveViewedImageAsset(viewedImagePath, {
...@@ -5055,8 +5067,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -5055,8 +5067,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
5055 );5067 );
5056 const expandedBody =5068 const expandedBody =
5057 expanded && !isReasoning5069 expanded && !isReasoning
5058 ? isRead5070 ? plainOutput !== undefined
5059 ? readOutput5071 ? plainOutput
5060 : buildToolCallExpandedBody(5072 : buildToolCallExpandedBody(
5061 workEntry,5073 workEntry,
5062 workspaceRoot,5074 workspaceRoot,
...@@ -5064,9 +5076,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -5064,9 +5076,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
5064 viewedImage ? viewedImagePath : null,5076 viewedImage ? viewedImagePath : null,
5065 )5077 )
5066 : null;5078 : null;
5067 const canExpandProjectedItem = isRead5079 const canExpandProjectedItem =
5068 ? Boolean(readOutput || viewedImage || workEntry.questionAnswer)5080 plainOutput !== undefined
5069 : canExpand || workEntry.projectedItem !== undefined;5081 ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer)
5082 : canExpand || workEntry.projectedItem !== undefined;
5070 // Reserve destructive row styling for severe failures, not routine tool errors.5083 // Reserve destructive row styling for severe failures, not routine tool errors.
5071 const iconWrapperClass = cn(5084 const iconWrapperClass = cn(
5072 "flex size-4 items-center justify-center",5085 "flex size-4 items-center justify-center",
...@@ -5237,9 +5250,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {...@@ -5237,9 +5250,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
5237 !isReasoning &&5250 !isReasoning &&
5238 !workEntry.questionAnswer &&5251 !workEntry.questionAnswer &&
5239 canExpandProjectedItem &&5252 canExpandProjectedItem &&
5240 (expandedBody || (workEntry.projectedItem && !isRead)) ? (5253 (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? (
5241 <WorkLogDetails kind="panel">5254 <WorkLogDetails kind="panel">
5242 {workEntry.projectedItem && !isRead ? (5255 {workEntry.projectedItem && plainOutput === undefined ? (
5243 <V2ItemInspector5256 <V2ItemInspector
5244 projectedItem={workEntry.projectedItem}5257 projectedItem={workEntry.projectedItem}
5245 environmentId={ctx.activeThreadEnvironmentId}5258 environmentId={ctx.activeThreadEnvironmentId}
packages/shared/src/toolActivity.test.ts+12
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 claudeSkillInvocation,
4 classifyToolActivity,5 classifyToolActivity,
5 collectToolFilePaths,6 collectToolFilePaths,
6 deriveToolActivityPresentation,7 deriveToolActivityPresentation,
8 dynamicToolTitle,
7 formatReadToolLabel,9 formatReadToolLabel,
8 formatSearchToolLabel,10 formatSearchToolLabel,
9 mergeToolActivityData,11 mergeToolActivityData,
...@@ -120,4 +122,14 @@ describe("toolActivity", () => {...@@ -120,4 +122,14 @@ describe("toolActivity", () => {
120 mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }),122 mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }),
121 ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } });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,13 +14,28 @@ function asTrimmedString(value: unknown): string | undefined {
14 return trimmed.length > 0 ? trimmed : undefined;14 return trimmed.length > 0 ? trimmed : undefined;
15}15}
1616
17/** CUA's `title` describes the action shown in the activity log. */17/** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */
18export function computerUseToolTitle(18export 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 */
32export function dynamicToolTitle(
19 toolName: string | null | undefined,33 toolName: string | null | undefined,
20 input: unknown,34 input: unknown,
21): string | undefined {35): string | undefined {
22 if (toolName !== "cua_repl.js") return undefined;36 if (toolName === "cua_repl.js") return asTrimmedString(asRecord(input)?.title);
23 return asTrimmedString(asRecord(input)?.title);37 const skill = claudeSkillInvocation(toolName, input);
38 return skill === undefined ? undefined : `Skill: ${skill.name}`;
24}39}
2540
26function recordHasKeys(41function recordHasKeys(