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";
4848import {
4949 classifyToolActivity,
5050 collectToolFilePaths,
51 computerUseToolTitle,
51 dynamicToolTitle,
5252 formatReadToolLabel,
5353 formatSearchToolLabel,
5454} from "@t3tools/shared/toolActivity";
......@@ -540,7 +540,7 @@ function itemSummary(
540540 if (item.type === "system_notice") return item.message;
541541 if (item.type === "compaction") return contextCompactionLabel(item);
542542 const title =
543 (item.type === "dynamic_tool" ? computerUseToolTitle(item.toolName, item.input) : undefined) ??
543 (item.type === "dynamic_tool" ? dynamicToolTitle(item.toolName, item.input) : undefined) ??
544544 item.title?.trim();
545545 if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent");
546546 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", () => {
27712771 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, IdAllocator.layer))),
27722772 );
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", () =>
27752775 Effect.gen(function* () {
27762776 const harness = yield* makeWakeHarness;
27772777 const now = yield* DateTime.now;
......@@ -2789,6 +2789,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
27892789 { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } },
27902790 { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } },
27912791 { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } },
2792 { id: "skill", name: "Skill", input: { skill: "full-send" } },
27922793 {
27932794 id: "write",
27942795 name: "Write",
......@@ -2859,6 +2860,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
28592860 items.find((item) => item.nativeItemRef?.nativeId === "search")?.title,
28602861 "Searched TODO in src",
28612862 );
2863 assert.equal(
2864 items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title,
2865 "Skill: full-send",
2866 );
28622867 for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image"))
28632868 assert.notProperty(item, "viewedImagePath");
28642869 }).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 @@
11import * as NodeCrypto from "node:crypto";
22
33import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
4import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
4import {
5 dynamicToolTitle,
6 formatReadToolLabel,
7 formatSearchToolLabel,
8} from "@t3tools/shared/toolActivity";
59import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
610import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";
711import {
......@@ -3726,7 +3730,10 @@ export function makeClaudeAdapterV2(
37263730 title:
37273731 readPath !== undefined
37283732 ? formatReadToolLabel(readPath)
3729 : (searchTitle ?? input.presentation?.title ?? null),
3733 : (searchTitle ??
3734 dynamicToolTitle(input.toolName, nativeToolInput) ??
3735 input.presentation?.title ??
3736 null),
37303737 startedAt: input.startedAt,
37313738 completedAt,
37323739 updatedAt: input.updatedAt,
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+2-2
......@@ -30,7 +30,7 @@ import {
3030} from "@t3tools/contracts";
3131import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
3232import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
33import { computerUseToolTitle } from "@t3tools/shared/toolActivity";
33import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
3434import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
3535import { resolveSpawnCommand } from "@t3tools/shared/shell";
3636import type {
......@@ -489,7 +489,7 @@ export function projectCodexDynamicToolItem(
489489 item.type === "mcpToolCall"
490490 ? `${item.server}.${item.tool}`
491491 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");
492 const title = computerUseToolTitle(toolName, item.arguments);
492 const title = dynamicToolTitle(toolName, item.arguments);
493493 const projection: CodexDynamicToolProjection = {
494494 ...(item.type === "mcpToolCall" ? mcpToolPresentation(item) : {}),
495495 toolName,
apps/web/src/components/chat/MessagesTimeline.logic.ts+3-5
......@@ -48,7 +48,7 @@ import {
4848 type T3McpToolPresentation,
4949} from "@t3tools/shared/t3McpToolPresentation";
5050import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
51import { computerUseToolTitle } from "@t3tools/shared/toolActivity";
51import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
5252import { formatWorkspaceRelativePath } from "../../filePathDisplay";
5353import {
5454 collectToolFilePaths,
......@@ -81,8 +81,7 @@ function singleToolCallLabel(entry: WorkLogEntry): string {
8181 const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed");
8282 if (toolPresentation) return toolPresentation.displayName;
8383 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;
8685 if (title) return title;
8786 // A lone web search keeps its heading; the query stays in its detail.
8887 if (entry.itemType === "web_search") return entry.toolTitle ?? "Web search";
......@@ -142,8 +141,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
142141 const providerRetry =
143142 entry.projectedItem?.item.type === "error" && entry.projectedItem.item.retry !== undefined;
144143 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;
147145 if (title) return title;
148146 const compactDetail = entry.detail?.trim();
149147 const detailIsSearchOutput =
apps/web/src/components/chat/MessagesTimeline.tsx+22-9
......@@ -57,6 +57,7 @@ import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
5757import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
5858import { formatDuration } from "@t3tools/shared/orchestrationTiming";
5959import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
60import { claudeSkillInvocation } from "@t3tools/shared/toolActivity";
6061import { observeVisibleAnimation } from "../../lib/visibleAnimation";
6162import {
6263 createContext,
......@@ -5034,8 +5035,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
50345035 ? getQuestionAnswerPreview(workEntry.questionAnswer)
50355036 : null;
50365037 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;
50395051 const viewedImage =
50405052 viewedImagePath && threadRef
50415053 ? resolveViewedImageAsset(viewedImagePath, {
......@@ -5055,8 +5067,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
50555067 );
50565068 const expandedBody =
50575069 expanded && !isReasoning
5058 ? isRead
5059 ? readOutput
5070 ? plainOutput !== undefined
5071 ? plainOutput
50605072 : buildToolCallExpandedBody(
50615073 workEntry,
50625074 workspaceRoot,
......@@ -5064,9 +5076,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
50645076 viewedImage ? viewedImagePath : null,
50655077 )
50665078 : 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;
50705083 // Reserve destructive row styling for severe failures, not routine tool errors.
50715084 const iconWrapperClass = cn(
50725085 "flex size-4 items-center justify-center",
......@@ -5237,9 +5250,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
52375250 !isReasoning &&
52385251 !workEntry.questionAnswer &&
52395252 canExpandProjectedItem &&
5240 (expandedBody || (workEntry.projectedItem && !isRead)) ? (
5253 (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? (
52415254 <WorkLogDetails kind="panel">
5242 {workEntry.projectedItem && !isRead ? (
5255 {workEntry.projectedItem && plainOutput === undefined ? (
52435256 <V2ItemInspector
52445257 projectedItem={workEntry.projectedItem}
52455258 environmentId={ctx.activeThreadEnvironmentId}
packages/shared/src/toolActivity.test.ts+12
......@@ -1,9 +1,11 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 claudeSkillInvocation,
45 classifyToolActivity,
56 collectToolFilePaths,
67 deriveToolActivityPresentation,
8 dynamicToolTitle,
79 formatReadToolLabel,
810 formatSearchToolLabel,
911 mergeToolActivityData,
......@@ -120,4 +122,14 @@ describe("toolActivity", () => {
120122 mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }),
121123 ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } });
122124 });
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 });
123135});
packages/shared/src/toolActivity.ts+19-4
......@@ -14,13 +14,28 @@ function asTrimmedString(value: unknown): string | undefined {
1414 return trimmed.length > 0 ? trimmed : undefined;
1515}
1616
17/** CUA's `title` describes the action shown in the activity log. */
18export function computerUseToolTitle(
17/** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */
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(
1933 toolName: string | null | undefined,
2034 input: unknown,
2135): 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}`;
2439}
2540
2641function recordHasKeys(