diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 266e3b709e5e..8468a38d8638 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1083,6 +1083,52 @@ describe("buildThreadFeed", () => { }, ]); }); + + it("presents project calls and summarizes successful clones through the mobile feed", () => { + const items: OrchestrationV2TurnItem[] = [ + { + ...base("list", "2026-09-19T00:00:01.000Z", 1), + type: "dynamic_tool", + title: "Custom provider title", + toolName: "T3-code.t3_project_list", + input: {}, + output: { projects: [] }, + }, + { + ...base("clone", "2026-09-19T00:00:02.000Z", 2), + type: "dynamic_tool", + title: "Custom provider title", + toolName: "mcp__t3_code__t3_project_clone", + input: {}, + output: { cwd: "/tmp/repo" }, + }, + { + ...base("failed-clone", "2026-09-19T00:00:03.000Z", 3), + type: "dynamic_tool", + title: "Custom provider title", + toolName: "t3_project_clone", + input: {}, + output: { isError: true }, + }, + ]; + const feed = buildThreadFeed(items.map((item, position) => projected(item, position))); + const activities = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(workEntryRowLabel(activities[0]!.workEntry)).toBe("Listed projects"); + expect(workEntryRowLabel(activities[1]!.workEntry)).toBe("Cloned a repository"); + expect(workEntryRowLabel(activities[2]!.workEntry)).toBe("Failed to clone a repository"); + expect(activities.every((activity) => activity.logo === "t3-code")).toBe(true); + const presented = deriveThreadFeedPresentation( + feed, + { runId, status: "running", startedAt: null, completedAt: null }, + new Set(), + ); + expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ + summary: "Listed projects 1 time and cloned 1 repository", + hasFailure: true, + }); + }); }); describe("retained v2 feed presentation", () => { diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index f17b339e5d1c..56913f5c2f31 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -27,6 +27,13 @@ import { AttachmentToolkit } from "./attachment/tools.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; import { ThreadToolkit } from "./thread/tools.ts"; import { WorktreeToolkit } from "./worktree/tools.ts"; +import { DeviceToolkit } from "./device/tools.ts"; +import { PullRequestsToolkit } from "./pullRequests/tools.ts"; +import { + resolveT3McpToolDefinition, + resolveT3McpToolPresentation, + resolveT3McpToolSummaryAction, +} from "@t3tools/shared/t3McpToolPresentation"; const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); @@ -41,6 +48,8 @@ it("publishes unique tool names with reference-free object-root inputs", () => { ProjectToolkit, EnvironmentToolkit, PreviewControlsToolkit, + DeviceToolkit, + PullRequestsToolkit, ]) { for (const tool of Object.values(toolkit.tools)) { expect(names.has(tool.name)).toBe(false); @@ -49,6 +58,17 @@ it("publishes unique tool names with reference-free object-root inputs", () => { expect(schema).toMatchObject({ type: "object" }); // The published tool catalog must also work with providers without $ref support. expect(JSON.stringify(schema), tool.name).not.toContain('"$ref"'); + // Every published tool must have labels for its lifecycle, branding, and a summary. + const definition = resolveT3McpToolDefinition(tool.name); + expect(definition, tool.name).not.toBeNull(); + expect( + definition?.labels.every((label) => label.trim().length > 0), + tool.name, + ).toBe(true); + for (const name of [tool.name, `mcp__t3-code__${tool.name}`, `T3-code.${tool.name}`]) { + expect(resolveT3McpToolPresentation(name)?.logo, name).toBe("t3-code"); + expect(resolveT3McpToolSummaryAction(name), name).not.toBeNull(); + } } } expect(names.has("t3_thread_launch")).toBe(true); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c54490114d5e..c4e7e34a4a99 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -526,6 +526,73 @@ describe("resolveAssistantMessageCopyState", () => { }); describe("deriveMessagesTimelineRows", () => { + it("presents project MCP calls and summarizes successful clones through the web timeline", () => { + const fixture = makeStreamingTimelineFixture(); + const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!; + if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture"); + const items: OrchestrationV2ProjectedTurnItem["item"][] = [ + { + ...source.item, + type: "dynamic_tool", + id: TurnItemId.make("list"), + status: "completed", + title: "Custom provider title", + toolName: "T3-code.t3_project_list", + input: {}, + output: { projects: [] }, + }, + { + ...source.item, + type: "dynamic_tool", + id: TurnItemId.make("clone"), + status: "completed", + title: "Custom provider title", + toolName: "mcp__t3_code__t3_project_clone", + input: {}, + output: { cwd: "/tmp/repo" }, + }, + { + ...source.item, + type: "dynamic_tool", + id: TurnItemId.make("failed-clone"), + status: "completed", + title: "Custom provider title", + toolName: "t3_project_clone", + input: {}, + output: { isError: true }, + }, + ]; + const entries = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: items.map((item, position) => ({ + ...source, + item, + position, + sourceItemId: item.id, + })), + optimisticMessages: [], + }); + const work = entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : [])); + expect(workEntryDisplayLabel(work[0]!, undefined)).toBe("Listed projects"); + expect(workEntryDisplayLabel(work[1]!, undefined)).toBe("Cloned a repository"); + expect(workEntryDisplayLabel(work[2]!, undefined)).toBe("Failed to clone a repository"); + expect( + resolveTimelineToolPresentation(items[1]!.type === "dynamic_tool" ? items[1].toolName : null) + ?.logo, + ).toBe("t3-code"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: entries, + isWorking: false, + runningRunId: fixture.runId, + activeTurnStartedAt: fixture.time(0), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ + summary: "Listed projects 1 time and cloned 1 repository", + hasFailure: true, + }); + }); + it.each(["waiting", "completed"] as const)( "groups approval and user-input requests with commands without expanding them when %s", (status) => { diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index a5dca870829d..8ae001ea7769 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -7,6 +7,106 @@ function completed(input: unknown, output?: unknown): T3ToolSummaryCall { } describe("summarizeT3ToolCalls", () => { + it("counts registered projects, repository destinations, and accepted thread launches", () => { + expect( + summarizeT3ToolCalls("project-create", [ + completed({}, { id: "project-1" }), + completed({}, { id: "project-1" }), + completed({}, { id: "project-2" }), + ]).label, + ).toBe("Registered 2 projects"); + expect( + summarizeT3ToolCalls("project-clone", [ + completed({}, { cwd: "/tmp/first" }), + completed({}, { cwd: "/tmp/second" }), + ]).label, + ).toBe("Cloned 2 repositories"); + expect( + summarizeT3ToolCalls("thread-create", [ + completed({}, { threadId: "launched-thread", status: "preparing" }), + ]).label, + ).toBe("Created 1 thread"); + }); + + it.each([ + ["queue-read", "Read 1 queued message"], + ["queue-edit", "Edited 1 queued message"], + ["queue-cancel", "Requested cancellation of 1 queued run"], + ["queue-reorder", "Reordered 1 queued run"], + ["queue-steer", "Requested steering with 1 queued message"], + ] as const)("deduplicates the queued run target for %s", (action, label) => { + expect( + summarizeT3ToolCalls(action, [ + completed({ queuedRunId: "queued-1" }), + completed({ queuedRunId: "queued-1" }), + { input: { queuedRunId: "queued-2" }, output: undefined, outcome: "unfinished" }, + ]), + ).toEqual({ label, failedCount: 0 }); + }); + + it("counts answered requests rather than pretending every request contains one question", () => { + expect( + summarizeT3ToolCalls("question-respond", [ + completed({ requestId: "request-1", answers: { one: ["Yes"], two: ["No"] } }), + completed({ requestId: "request-1" }), + completed({ requestId: "request-2" }), + ]).label, + ).toBe("Answered 2 pending question requests"); + }); + + it("counts attachments in distinct messages and falls back when attachment counts are missing", () => { + const first = completed( + { threadId: "thread-1", attachments: [{ id: "one" }, { id: "two" }] }, + { messageId: "message-1", threadId: "thread-1" }, + ); + const second = completed( + { threadId: "thread-2", attachments: [{ id: "one" }] }, + { messageId: "message-2", threadId: "thread-2" }, + ); + expect(summarizeT3ToolCalls("attachment-send", [first, first, second])).toEqual({ + label: "Sent 3 attachments to 2 threads", + failedCount: 0, + }); + expect( + summarizeT3ToolCalls("attachment-send", [first, completed({ threadId: "thread-1" })]).label, + ).toBe("Sent attachments to 1 thread 2 times"); + }); + + it("keeps repeated manual runs separate and describes asynchronous controls as requests", () => { + expect( + summarizeT3ToolCalls("schedule-run", [ + completed({ taskId: "schedule-1" }, { lastRunStatus: "running" }), + completed({ taskId: "schedule-1" }, { lastRunStatus: "skipped" }), + ]).label, + ).toBe("Requested 2 scheduled task runs"); + expect( + summarizeT3ToolCalls("thread-fork", [completed({}, { targetThreadId: "fork", sequence: 3 })]) + .label, + ).toBe("Requested 1 thread fork"); + expect( + summarizeT3ToolCalls("thread-merge", [ + completed({ targetThreadId: "parent" }, { sequence: 4 }), + ]).label, + ).toBe("Requested 1 context merge"); + }); + + it.each([ + "WorktreeMcpFailure", + "DeviceOperationError", + "PreviewAutomationExecutionError", + "PullRequestOperationError", + ])("treats a returned %s as a failed call even if the provider says completed", (_tag) => { + const output = [ + { + type: "content", + content: { type: "text", text: JSON.stringify({ _tag, message: "Unavailable" }) }, + }, + ]; + expect(summarizeT3ToolCalls("browser", [completed({}, output)])).toEqual({ + label: "Tried to use browser 1 time", + failedCount: 1, + }); + }); it("counts messages and distinct destinations across delivery modes, deduplicating retries", () => { const calls = Array.from({ length: 5 }, (_, i) => completed( diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index c501e9502b86..79577892a697 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -35,7 +35,8 @@ function readResult(value: unknown, depth = 0): ToolResult { let data: ToolResult["data"]; let failed = false; for (const block of value) { - const text = asRecord(block)?.text; + const record = asRecord(block); + const text = record?.text ?? asRecord(record?.content)?.text; const result = readResult(asRecord(text)?.text ?? text, depth + 1); data ??= result.data; failed ||= result.failed; @@ -47,7 +48,7 @@ function readResult(value: unknown, depth = 0): ToolResult { const failed = record.isError === true || record.is_error === true || - record._tag === "OrchestratorMcpFailure" || + (typeof record._tag === "string" && /(?:Error|Failure)$/.test(record._tag)) || record.error != null; const content = record.structuredContent ?? record.content; if (content !== undefined) { @@ -63,6 +64,11 @@ function readInput(value: unknown): Record | undefined { return input && typeof input.toolName === "string" ? asRecord(input.args) : input; } +/** MCP errors can be returned as data even when the provider completed the tool call. */ +export function t3ToolResultIndicatesFailure(output: unknown): boolean { + return readResult(output).failed; +} + function countEntities(ids: ReadonlyArray): number { return ( new Set(ids.filter((value) => value !== undefined)).size + @@ -70,8 +76,8 @@ function countEntities(ids: ReadonlyArray): number { ); } -function quantity(count: number, noun: string): string { - return `${count} ${noun}${count === 1 ? "" : "s"}`; +function quantity(count: number, noun: string, plural = `${noun}s`): string { + return `${count} ${count === 1 ? noun : plural}`; } /** Counts successful effects separately from failed or unfinished tool calls. */ @@ -95,6 +101,9 @@ export function summarizeT3ToolCalls( `${completed.length > 0 ? past : `Tried to ${infinitive}`} ${object}`; const entityIds = (key: string) => selected.map((call) => id(call.output?.[key]) ?? id(call.input?.[key])); + const projectIds = selected.map( + (call) => id(call.output?.id) ?? id(call.output?.projectId) ?? id(call.input?.projectId), + ); const threadIds = selected.map( (call) => id(call.output?.threadId) ?? @@ -153,13 +162,21 @@ export function summarizeT3ToolCalls( label = phrase("Listed", "list", `threads ${times}`); break; case "thread-interrupt": - label = `Requested interrupts for ${quantity(countEntities(threadIds), "thread")}`; + label = phrase( + "Requested interrupts for", + "interrupt", + quantity(countEntities(threadIds), "thread"), + ); break; case "task-status": label = phrase("Checked", "check", `task status ${times}`); break; case "task-cancel": - label = `Requested cancellation of ${quantity(countEntities(entityIds("taskId")), "task")}`; + label = phrase( + "Requested cancellation of", + "cancel", + quantity(countEntities(entityIds("taskId")), "task"), + ); break; case "schedule-create": label = phrase( @@ -180,7 +197,193 @@ export function summarizeT3ToolCalls( break; case "schedule-delete": // A successful delete can report deleted:false; it still represents a deletion request. - label = `Requested deletion of ${quantity(countEntities(entityIds("scheduledTaskId")), "scheduled task")}`; + label = phrase( + "Requested deletion of", + "delete", + quantity(countEntities(entityIds("scheduledTaskId")), "scheduled task"), + ); + break; + case "schedule-run": + label = phrase("Requested", "request", quantity(selected.length, "scheduled task run")); + break; + case "thread-configuration": + label = phrase("Checked", "check", `thread configuration ${times}`); + break; + case "thread-configure": + label = phrase("Set", "set", `thread model ${times}`); + break; + case "thread-fork": + label = phrase("Requested", "request", quantity(selected.length, "thread fork")); + break; + case "thread-merge": + label = phrase("Requested", "request", quantity(selected.length, "context merge")); + break; + case "thread-search": + label = phrase("Searched", "search", `threads ${times}`); + break; + case "thread-transfers": + label = phrase("Checked", "check", `thread transfers ${times}`); + break; + case "thread-organize": + label = phrase("Organized", "organize", `threads ${times}`); + break; + case "thread-update": + label = phrase("Updated", "update", quantity(countEntities(threadIds), "thread")); + break; + case "queue-list": + label = phrase("Listed", "list", `queued messages ${times}`); + break; + case "queue-read": + label = phrase( + "Read", + "read", + quantity(countEntities(entityIds("queuedRunId")), "queued message"), + ); + break; + case "queue-edit": + label = phrase( + "Edited", + "edit", + quantity(countEntities(entityIds("queuedRunId")), "queued message"), + ); + break; + case "queue-cancel": + label = phrase( + "Requested cancellation of", + "cancel", + quantity(countEntities(entityIds("queuedRunId")), "queued run"), + ); + break; + case "queue-reorder": + label = phrase( + "Reordered", + "reorder", + quantity(countEntities(entityIds("queuedRunId")), "queued run"), + ); + break; + case "queue-steer": + label = phrase( + "Requested steering with", + "steer with", + quantity(countEntities(entityIds("queuedRunId")), "queued message"), + ); + break; + case "question-list": + label = phrase("Listed", "list", `pending questions ${times}`); + break; + case "question-read": + label = phrase( + "Read", + "read", + quantity(countEntities(entityIds("requestId")), "pending question request"), + ); + break; + case "question-respond": + label = phrase( + "Answered", + "answer", + quantity(countEntities(entityIds("requestId")), "pending question request"), + ); + break; + case "worktree-handoff": + label = phrase( + "Handed off to", + "hand off to", + quantity(countEntities(entityIds("worktreePath")), "worktree"), + ); + break; + case "worktree-list": + label = phrase("Listed", "list", `workspace branches ${times}`); + break; + case "worktree-status": + label = phrase("Checked", "check", `worktree status ${times}`); + break; + case "project-list": + label = phrase("Listed", "list", `projects ${times}`); + break; + case "project-read": + label = phrase("Read", "read", quantity(countEntities(projectIds), "project")); + break; + case "project-create": + label = phrase("Registered", "register", quantity(countEntities(projectIds), "project")); + break; + case "project-update": + label = phrase("Updated", "update", quantity(countEntities(projectIds), "project")); + break; + case "project-delete": + label = phrase("Deleted", "delete", quantity(countEntities(projectIds), "project")); + break; + case "project-clone": + label = phrase( + "Cloned", + "clone", + quantity(countEntities(entityIds("cwd")), "repository", "repositories"), + ); + break; + case "environment-read": + label = phrase("Checked", "check", `environment preferences ${times}`); + break; + case "environment-update": + label = phrase("Updated", "update", `environment preferences ${times}`); + break; + case "attachment-prepare": + label = phrase( + "Prepared", + "prepare", + quantity(countEntities(entityIds("attachmentId")), "attachment upload"), + ); + break; + case "attachment-discard": + label = phrase( + "Discarded", + "discard", + quantity(countEntities(entityIds("attachmentId")), "pending attachment"), + ); + break; + case "attachment-send": { + // A message can contain several attachments. Count retries by message identity. + const messages = new Map(); + selected.forEach((call, index) => + messages.set(id(call.output?.messageId) ?? `call-${index}`, call), + ); + const attachmentCount = [...messages.values()].reduce( + (count, call) => + count + (Array.isArray(call.input?.attachments) ? call.input.attachments.length : 0), + 0, + ); + const countsKnown = [...messages.values()].every( + (call) => Array.isArray(call.input?.attachments) && call.input.attachments.length > 0, + ); + const targets = threadIds.every((value) => value !== undefined) + ? ` to ${quantity(new Set(threadIds).size, "thread")}` + : ""; + label = phrase( + "Sent", + "send", + countsKnown + ? `${quantity(attachmentCount, "attachment")}${targets}` + : `attachments${targets} ${times}`, + ); + break; + } + case "link-pr": + label = phrase("Linked", "link", quantity(selected.length, "pull request")); + break; + case "unlink-pr": + label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request")); + break; + case "list-prs": + label = phrase( + "Checked", + "check", + `linked pull requests${selected.length === 1 ? "" : ` ${times}`}`, + ); + break; + case "browser": + label = phrase("Used", "use", `browser ${times}`); + break; + case "device": + label = phrase("Used", "use", `device controls ${times}`); break; case "capabilities": label = phrase("Checked", "check", `orchestration capabilities ${times}`); diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 96d06146ad54..54325df35de2 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import { commandDetailRepeatsCommand, @@ -268,6 +269,122 @@ describe("summarizeToolGroup", () => { }); describe("resolveWorkEntryToolPresentation", () => { + it("presents and summarizes every T3 tool using the same structured identity", () => { + for (const tool of T3_MCP_TOOL_NAMES) { + const entry: WorkLogPresentationEntry = { + id: tool, + createdAt: "2026-09-19T00:00:00.000Z", + tone: "tool", + label: "Custom provider title", + toolData: { server: "t3-code", tool }, + toolLifecycleStatus: "completed", + itemType: "dynamic_tool", + toolSource: { key: "t3-code", name: "T3 Code", kind: "integration" }, + }; + const presentation = resolveWorkEntryToolPresentation(entry); + expect(presentation, tool).not.toBeNull(); + expect(presentation?.displayName, tool).not.toContain(tool); + const summary = summarizeToolGroup([entry]); + expect(summary.summary, tool).not.toMatch(/Used (?:1 tool|T3 Code integration)/); + expect(summary.hasFailure, tool).toBe(false); + const failed = { ...entry, toolLifecycleStatus: "failed" as const }; + expect(resolveWorkEntryToolPresentation(failed)?.displayName, tool).toMatch(/^Failed to /); + expect(summarizeToolGroup([failed]).hasFailure, tool).toBe(true); + expect(summarizeToolGroup([failed]).summary, tool).toMatch( + /^(?:Tried to |Requested thread creation)/, + ); + } + }); + + it.each([ + ["t3_project_list", "Listing projects", "Listed projects"], + ["t3_project_clone", "Cloning a repository", "Cloned a repository"], + ["t3_project_create", "Registering a project", "Registered a project"], + ["t3_thread_launch", "Launching a project thread", "Launched a project thread"], + ["t3_queue_edit", "Editing a queued message", "Edited a queued message"], + ["t3_pending_request_respond", "Answering pending questions", "Answered pending questions"], + ["t3_thread_configure", "Setting thread model", "Set thread model"], + ["t3_thread_fork", "Forking this thread", "Requested a fork of this thread"], + ["t3_thread_send_attachments", "Sending attachments", "Sent attachments"], + ["run_scheduled_task_now", "Running a scheduled task", "Requested a run of a scheduled task"], + ])("labels %s through its lifecycle", (tool, running, completed) => { + expect(resolveWorkEntryToolPresentation({ label: `T3-code.${tool}` })?.displayName).toBe( + running, + ); + expect( + resolveWorkEntryToolPresentation({ + label: `T3-code.${tool}`, + toolLifecycleStatus: "completed", + })?.displayName, + ).toBe(completed); + }); + + it("summarizes project tools from MCP arguments and results without claiming failed effects", () => { + const entry: WorkLogPresentationEntry = { + id: "clone", + createdAt: "2026-09-19T00:00:00.000Z", + tone: "tool", + label: "Custom title", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + toolData: { + server: "t3-code", + tool: "t3_project_clone", + arguments: { url: "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/acme/repo" }, + result: { cwd: "/tmp/repo" }, + }, + }; + const list = { ...entry, toolData: { server: "t3-code", tool: "t3_project_list" } }; + expect(summarizeToolGroup([list, entry])).toEqual({ + summary: "Listed projects 1 time and cloned 1 repository", + hasFailure: false, + }); + const failed = { + ...entry, + toolData: { toolName: "T3-code.t3_project_clone", rawOutput: { isError: true } }, + }; + expect(summarizeToolGroup([entry, failed])).toEqual({ + summary: "Cloned 1 repository", + hasFailure: true, + }); + }); + + it("does not summarize a foreign structured identity as T3 work", () => { + const entry: WorkLogPresentationEntry = { + id: "foreign", + createdAt: "2026-09-19T00:00:00.000Z", + tone: "tool", + label: "t3_project_clone", + toolLifecycleStatus: "completed", + toolData: { server: "another-server", tool: "t3_project_clone" }, + }; + expect(summarizeToolGroup([entry]).summary).toBe("Used 1 tool"); + }); + + it("shows returned MCP errors as failures even in the live activity row", () => { + const entry: WorkLogPresentationEntry = { + id: "clone", + createdAt: "2026-09-19T00:00:00.000Z", + tone: "tool", + label: "T3-code.t3_project_clone", + toolLifecycleStatus: "inProgress", + itemType: "dynamic_tool", + toolData: { output: { isError: true } }, + }; + expect(resolveWorkEntryToolPresentation(entry)?.displayName).toBe( + "Failed to clone a repository", + ); + expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(true); + expect(workEntryIndicatesToolSuccess(entry)).toBe(false); + const childFailure = { + ...entry, + label: "T3-code.task_status", + toolLifecycleStatus: "completed" as const, + toolData: { output: { taskId: "child", status: "failed", summary: "command not found" } }, + }; + expect(workEntryDisplayIndicatesToolFailure(childFailure)).toBe(false); + expect(workEntryIndicatesToolSuccess(childFailure)).toBe(true); + }); it.each([ "mcp__t3-code__preview_click", "mcp__t3_code__preview_click", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 86da6b89a08e..9f3395371944 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -9,7 +9,8 @@ import { type ThreadId, } from "@t3tools/contracts"; import { - resolveT3McpToolSummaryAction, + resolveT3McpToolDefinition, + type T3McpToolDefinition, type T3McpToolSummaryAction, } from "@t3tools/shared/t3McpToolPresentation"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; @@ -21,6 +22,7 @@ import { toolOutputIndicatesFailure } from "@t3tools/shared/toolOutput"; import { summarizeT3ToolCalls, + t3ToolResultIndicatesFailure, type T3ToolSummaryCall, } from "@t3tools/client-runtime/t3ToolSummary"; @@ -106,79 +108,37 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } -const T3_MCP_TOOL_LABELS: Record< - string, - readonly [action: string, running: string, completed: string, detail: string] -> = { - link_pull_request: ["Link", "Linking", "Linked", "a pull request"], - unlink_pull_request: ["Unlink", "Unlinking", "Unlinked", "a pull request"], - list_thread_pull_requests: ["Check", "Checking", "Checked", "linked pull requests"], - orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], - delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], - task_status: ["Get", "Getting", "Got", "delegated task status"], - task_cancel: ["Cancel", "Canceling", "Canceled", "delegated task"], - schedule_task: ["Schedule", "Scheduling", "Scheduled", "a recurring task"], - list_scheduled_tasks: ["List", "Listing", "Listed", "scheduled tasks"], - update_scheduled_task: ["Update", "Updating", "Updated", "a scheduled task"], - delete_scheduled_task: ["Delete", "Deleting", "Deleted", "a scheduled task"], - create_threads: ["Create", "Creating", "Created", "T3 threads"], - t3_thread_start: ["Start", "Starting", "Started", "a T3 thread"], - t3_thread_list: ["List", "Listing", "Listed", "T3 threads"], - t3_thread_read: ["Read", "Reading", "Read", "a T3 thread"], - t3_thread_send: ["Send", "Sending", "Sent", "to a T3 thread"], - t3_thread_wait: ["Wait", "Waiting", "Waited", "for a T3 thread"], - t3_thread_interrupt: ["Interrupt", "Interrupting", "Interrupted", "a T3 thread"], - t3_worktree_handoff: ["Hand off", "Handing off", "Handed off", "thread to a git worktree"], - t3_worktree_status: ["Get", "Getting", "Got", "thread worktree status"], - preview_status: ["Get", "Getting", "Got", "preview browser status"], - preview_open: ["Open", "Opening", "Opened", "a page in the preview browser"], - preview_navigate: ["Navigate", "Navigating", "Navigated", "the preview browser"], - preview_snapshot: [ - "Take a snapshot of", - "Taking a snapshot of", - "Took a snapshot of", - "the preview page", - ], - preview_click: ["Click", "Clicking", "Clicked", "in the preview browser"], - preview_press: ["Press", "Pressing", "Pressed", "a key in the preview browser"], - preview_type: ["Type", "Typing", "Typed", "in the preview browser"], - preview_scroll: ["Scroll", "Scrolling", "Scrolled", "the preview browser"], - preview_resize: ["Resize", "Resizing", "Resized", "the preview browser"], - preview_evaluate: ["Evaluate", "Evaluating", "Evaluated", "script in the preview browser"], - preview_wait_for: ["Wait", "Waiting", "Waited", "for the preview page"], - preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"], - preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"], - preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], - device_list: ["List", "Listing", "Listed", "simulators and emulators"], - device_open: ["Open", "Opening", "Opened", "a device in the Device panel"], - device_screenshot: [ - "Take a screenshot of", - "Taking a screenshot of", - "Took a screenshot of", - "the device", - ], - device_close: ["Close", "Closing", "Closed", "a device"], -}; - -const PR_TOOL_ACTIONS: Readonly> = { - link_pull_request: "link-pr", - unlink_pull_request: "unlink-pr", - list_thread_pull_requests: "list-prs", -}; +/** Structured identity is authoritative, including when it identifies a foreign server. */ +function workEntryToolName( + entry: Pick, +): string | undefined { + const item = entry.structuredPayload; + if (item?.type === "dynamic_tool" && item.toolName) return item.toolName; + const data = asRecord(entry.toolData); + if (typeof data?.server === "string" && typeof data.tool === "string") { + return `${data.server}.${data.tool}`; + } + if (typeof data?.toolName === "string") return data.toolName; + return resolveT3McpToolDefinition(entry.toolTitle) ? entry.toolTitle : entry.label; +} + +function workEntryToolOutput( + entry: Pick, +): unknown { + const item = entry.structuredPayload; + const data = asRecord(entry.toolData); + return item?.type === "dynamic_tool" + ? item.output + : (data?.output ?? data?.result ?? data?.rawOutput ?? data?.content); +} function resolveT3McpToolPresentation( - value: string | undefined, + definition: T3McpToolDefinition | null, status: string | undefined, data?: unknown, ) { - if (!value) return null; - const name = normalizeCompactToolLabel(value).replace( - /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i, - "", - ); - if (!Object.hasOwn(T3_MCP_TOOL_LABELS, name)) return null; - - const [action, running, completed, detail] = T3_MCP_TOOL_LABELS[name]!; + if (!definition) return null; + const [action, running, completed, detail] = definition.labels; const verb = status === "inProgress" ? running @@ -192,7 +152,12 @@ function resolveT3McpToolPresentation( ? `Stopped ${running.toLowerCase()}` : running; - const actionKind = Object.hasOwn(PR_TOOL_ACTIONS, name) ? PR_TOOL_ACTIONS[name] : undefined; + const actionKind = + definition.summaryAction === "link-pr" || + definition.summaryAction === "unlink-pr" || + definition.summaryAction === "list-prs" + ? definition.summaryAction + : undefined; const payload = asRecord(data); const input = asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput); @@ -208,14 +173,7 @@ function resolveT3McpToolPresentation( : detail; return { displayName: `${verb} ${target}`, - icon: - actionKind !== undefined - ? ("pull-request" as const) - : name.startsWith("preview_") - ? ("browser" as const) - : name.startsWith("device_") - ? ("device" as const) - : ("t3-code" as const), + icon: definition.icon, ...(actionKind === undefined ? {} : { action: actionKind }), }; } @@ -231,28 +189,18 @@ export function liveActivityToolStatus(status: string | undefined, presentTense: /** Resolves tool identity before choosing labels or icons in either client. */ export function resolveWorkEntryToolPresentation( - entry: Pick, + entry: Pick< + WorkLogPresentationEntry, + "label" | "toolTitle" | "toolData" | "toolLifecycleStatus" | "structuredPayload" + >, fallbackStatus?: "inProgress" | "completed", ) { + const definition = resolveT3McpToolDefinition(workEntryToolName(entry)); const status = entry.toolLifecycleStatus ?? fallbackStatus; - const data = entry.toolData; - if (data !== null && typeof data === "object") { - if ( - "server" in data && - typeof data.server === "string" && - "tool" in data && - typeof data.tool === "string" - ) { - return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status, data); - } - if ("toolName" in data && typeof data.toolName === "string") { - return resolveT3McpToolPresentation(data.toolName, status, data); - } - } - - return ( - resolveT3McpToolPresentation(entry.toolTitle, status, data) ?? - resolveT3McpToolPresentation(entry.label, status, data) + return resolveT3McpToolPresentation( + definition, + definition && t3ToolResultIndicatesFailure(workEntryToolOutput(entry)) ? "failed" : status, + entry.toolData, ); } @@ -416,6 +364,12 @@ function workEntryIndicatesToolFailureFromOutput( return true; } if (!workLogEntryIsToolLike(entry)) return false; + if ( + resolveT3McpToolDefinition(workEntryToolName(entry)) && + t3ToolResultIndicatesFailure(workEntryToolOutput(entry)) + ) { + return true; + } const item = entry.structuredPayload; if (item?.type === "command_execution") { if (item.outputIndicatesFailure || (item.exitCode !== undefined && item.exitCode !== 0)) { @@ -589,8 +543,11 @@ function t3ToolSummaryCall(entry: WorkLogPresentationEntry): T3ToolSummaryCall { ? (entry.toolData as Record) : undefined; return { - input: item?.type === "dynamic_tool" ? item.input : data?.input, - output: item?.type === "dynamic_tool" ? item.output : data?.output, + input: + item?.type === "dynamic_tool" + ? item.input + : (data?.arguments ?? data?.input ?? data?.rawInput), + output: workEntryToolOutput(entry), outcome: entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined" || @@ -614,6 +571,26 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) case "schedule-create": case "schedule-update": case "schedule-delete": + case "schedule-run": + case "thread-configure": + case "thread-fork": + case "thread-merge": + case "thread-organize": + case "thread-update": + case "queue-edit": + case "queue-cancel": + case "queue-reorder": + case "queue-steer": + case "question-respond": + case "worktree-handoff": + case "project-create": + case "project-update": + case "project-delete": + case "project-clone": + case "environment-update": + case "attachment-prepare": + case "attachment-discard": + case "attachment-send": return 0; case "other": case "update": @@ -646,14 +623,11 @@ export function summarizeToolGroup(entries: ReadonlyArray(); const sources = new Map(); for (const entry of entries) { - if (entry.toolSource && resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request") { + const t3Action = resolveT3McpToolDefinition(workEntryToolName(entry))?.summaryAction ?? null; + if (entry.toolSource && t3Action === null) { sources.set(entry.toolSource.key, entry.toolSource); continue; } - const item = entry.structuredPayload; - const t3Action = resolveT3McpToolSummaryAction( - (item?.type === "dynamic_tool" ? item.toolName : null) ?? entry.toolTitle ?? entry.label, - ); const action = toolGroupAction(entry); const key = t3Action ?? action; const group = groups.get(key); @@ -696,7 +670,7 @@ export function summarizeToolGroup(entries: ReadonlyArray entry.toolSource !== undefined && - resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request", + resolveT3McpToolDefinition(workEntryToolName(entry)) === null, ).length; const remainingCount = entries.length - sourcedCount - selected.reduce((count, group) => count + group.count, 0); diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index 8a507d2fe21c..43b2d9c2de15 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -1,8 +1,29 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveT3McpToolPresentation } from "./t3McpToolPresentation.ts"; +import { T3_MCP_TOOL_NAMES, resolveT3McpToolPresentation } from "./t3McpToolPresentation.ts"; describe("resolveT3McpToolPresentation", () => { + it("recognizes every T3 tool across provider prefixes and completion suffixes", () => { + for (const tool of T3_MCP_TOOL_NAMES) { + const presentation = resolveT3McpToolPresentation(tool); + for (const prefix of [ + "mcp__t3-code__", + "mcp__t3_code__", + "mcp__t3code__", + "T3-code.", + "t3_code/", + "t3code:", + "mcp_t3-code_", + "T3 Code ", + "t3-code · ", + ]) { + expect(resolveT3McpToolPresentation(`${prefix}${tool} completed`), tool).toEqual( + presentation, + ); + } + expect(resolveT3McpToolPresentation(`mcp__another-server__${tool}`), tool).toBeNull(); + } + }); it("pretty prints Claude and Cursor T3 MCP tool names", () => { expect(resolveT3McpToolPresentation("mcp__t3-code__t3_thread_read")).toEqual({ displayName: "Read a T3 thread", diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index ed6b76d68dd1..dd38709bd0e6 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -10,6 +10,7 @@ export type T3McpToolSummaryAction = | "delegate" | "task-status" | "task-cancel" + | "schedule-run" | "schedule-create" | "schedule-list" | "schedule-update" @@ -19,87 +20,258 @@ export type T3McpToolSummaryAction = | "thread-read" | "thread-send" | "thread-wait" - | "thread-interrupt"; + | "thread-interrupt" + | "thread-configuration" + | "thread-configure" + | "thread-fork" + | "thread-merge" + | "thread-search" + | "thread-transfers" + | "thread-organize" + | "thread-update" + | "queue-list" + | "queue-read" + | "queue-edit" + | "queue-cancel" + | "queue-reorder" + | "queue-steer" + | "question-list" + | "question-read" + | "question-respond" + | "worktree-handoff" + | "worktree-list" + | "worktree-status" + | "project-list" + | "project-read" + | "project-create" + | "project-update" + | "project-delete" + | "project-clone" + | "environment-read" + | "environment-update" + | "attachment-prepare" + | "attachment-discard" + | "attachment-send" + | "link-pr" + | "unlink-pr" + | "list-prs" + | "browser" + | "device"; + +export interface T3McpToolDefinition { + readonly displayName: string; + readonly labels: readonly [action: string, running: string, completed: string, detail: string]; + readonly icon: "t3-code" | "browser" | "device" | "pull-request"; + readonly summaryAction: T3McpToolSummaryAction; +} + +function tool( + labels: T3McpToolDefinition["labels"], + summaryAction: T3McpToolSummaryAction, + icon: T3McpToolDefinition["icon"] = "t3-code", + displayName = `${labels[0]} ${labels[3]}`, +): T3McpToolDefinition { + return { displayName, labels, icon, summaryAction }; +} const T3_MCP_SERVER_ALIASES = new Set(["t3-code", "t3_code", "t3code"]); -const T3_MCP_TOOLS: Record< - string, - { readonly displayName: string; readonly summaryAction?: T3McpToolSummaryAction } -> = { - orchestrator_capabilities: { - displayName: "Get orchestration capabilities", - summaryAction: "capabilities", - }, - delegate_task: { displayName: "Delegate a child task", summaryAction: "delegate" }, - task_status: { displayName: "Get delegated task status", summaryAction: "task-status" }, - task_cancel: { displayName: "Cancel delegated task", summaryAction: "task-cancel" }, - run_scheduled_task_now: { displayName: "Run scheduled task now" }, - schedule_task: { displayName: "Schedule a recurring task", summaryAction: "schedule-create" }, - list_scheduled_tasks: { displayName: "List scheduled tasks", summaryAction: "schedule-list" }, - update_scheduled_task: { - displayName: "Update a scheduled task", - summaryAction: "schedule-update", - }, - delete_scheduled_task: { - displayName: "Delete a scheduled task", - summaryAction: "schedule-delete", - }, - create_threads: { displayName: "Create T3 threads", summaryAction: "thread-create" }, - t3_thread_start: { displayName: "Start a T3 thread", summaryAction: "thread-create" }, - t3_thread_list: { displayName: "List T3 threads", summaryAction: "thread-list" }, - t3_thread_read: { displayName: "Read a T3 thread", summaryAction: "thread-read" }, - t3_queue_list: { displayName: "List queued messages" }, - t3_queue_read: { displayName: "Read a queued message" }, - t3_queue_edit: { displayName: "Edit a queued message" }, - t3_queue_cancel: { displayName: "Cancel a queued run" }, - t3_queue_reorder: { displayName: "Reorder a queued run" }, - t3_queue_promote_to_steer: { displayName: "Steer with a queued message" }, - t3_pending_request_list: { displayName: "List pending questions" }, - t3_pending_request_read: { displayName: "Read pending questions" }, - t3_pending_request_respond: { displayName: "Answer pending questions" }, - t3_thread_configuration: { displayName: "Read thread configuration" }, - t3_thread_configure: { displayName: "Set thread model" }, - t3_thread_fork: { displayName: "Fork this thread" }, - t3_thread_merge_back: { displayName: "Merge thread context" }, - t3_thread_search: { displayName: "Search thread content" }, - t3_thread_transfers: { displayName: "Read thread transfers" }, - t3_thread_organize: { displayName: "Organize a thread" }, - t3_thread_update: { displayName: "Update T3 thread metadata" }, - t3_thread_send: { displayName: "Send to a T3 thread", summaryAction: "thread-send" }, - t3_thread_wait: { displayName: "Wait for a T3 thread", summaryAction: "thread-wait" }, - t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" }, - t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, - t3_worktree_list: { displayName: "List workspace branches" }, - t3_worktree_status: { displayName: "Get thread worktree status" }, - t3_preview_list: { displayName: "List preview tabs" }, - t3_preview_close: { displayName: "Close a preview tab" }, - t3_environment_read: { displayName: "Read environment preferences" }, - t3_environment_preferences_update: { displayName: "Update environment preferences" }, - t3_thread_launch: { displayName: "Launch a project thread" }, - t3_project_list: { displayName: "List projects" }, - t3_project_read: { displayName: "Read a project" }, - t3_project_create: { displayName: "Create a project" }, - t3_project_update: { displayName: "Update a project" }, - t3_project_delete: { displayName: "Delete a project" }, - t3_project_clone: { displayName: "Clone a repository" }, - t3_attachment_prepare_upload: { displayName: "Prepare attachment upload" }, - t3_attachment_discard: { displayName: "Discard pending attachment" }, - t3_thread_send_attachments: { displayName: "Send attachments" }, - preview_status: { displayName: "Get preview browser status" }, - preview_open: { displayName: "Open a page in the preview browser" }, - preview_navigate: { displayName: "Navigate the preview browser" }, - preview_snapshot: { displayName: "Snapshot the preview page" }, - preview_click: { displayName: "Click in the preview browser" }, - preview_press: { displayName: "Press a key in the preview browser" }, - preview_type: { displayName: "Type in the preview browser" }, - preview_scroll: { displayName: "Scroll the preview browser" }, - preview_resize: { displayName: "Resize the preview browser" }, - preview_evaluate: { displayName: "Evaluate script in the preview browser" }, - preview_wait_for: { displayName: "Wait for the preview page" }, - preview_set_appearance: { displayName: "Set preview browser appearance" }, - preview_recording_start: { displayName: "Start recording the preview browser" }, - preview_recording_stop: { displayName: "Stop recording the preview browser" }, +// Cards, activity rows, summaries, and provider identity recovery share this inventory. +const T3_MCP_TOOLS: Readonly> = { + link_pull_request: tool( + ["Link", "Linking", "Linked", "a pull request"], + "link-pr", + "pull-request", + ), + unlink_pull_request: tool( + ["Unlink", "Unlinking", "Unlinked", "a pull request"], + "unlink-pr", + "pull-request", + ), + list_thread_pull_requests: tool( + ["Check", "Checking", "Checked", "linked pull requests"], + "list-prs", + "pull-request", + ), + orchestrator_capabilities: tool( + ["Get", "Getting", "Got", "orchestration capabilities"], + "capabilities", + ), + delegate_task: tool(["Delegate", "Delegating", "Delegated", "a child task"], "delegate"), + task_status: tool(["Get", "Getting", "Got", "delegated task status"], "task-status"), + task_cancel: tool( + ["Cancel", "Canceling", "Requested cancellation of", "delegated task"], + "task-cancel", + ), + schedule_task: tool( + ["Schedule", "Scheduling", "Scheduled", "a recurring task"], + "schedule-create", + ), + list_scheduled_tasks: tool(["List", "Listing", "Listed", "scheduled tasks"], "schedule-list"), + update_scheduled_task: tool( + ["Update", "Updating", "Updated", "a scheduled task"], + "schedule-update", + ), + delete_scheduled_task: tool( + ["Delete", "Deleting", "Requested deletion of", "a scheduled task"], + "schedule-delete", + ), + create_threads: tool(["Create", "Creating", "Created", "T3 threads"], "thread-create"), + t3_thread_start: tool(["Start", "Starting", "Started", "a T3 thread"], "thread-create"), + t3_thread_list: tool(["List", "Listing", "Listed", "T3 threads"], "thread-list"), + t3_thread_read: tool(["Read", "Reading", "Read", "a T3 thread"], "thread-read"), + t3_thread_send: tool(["Send", "Sending", "Sent", "to a T3 thread"], "thread-send"), + t3_thread_wait: tool(["Wait", "Waiting", "Waited", "for a T3 thread"], "thread-wait"), + t3_thread_interrupt: tool( + ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], + "thread-interrupt", + ), + t3_worktree_handoff: tool( + ["Hand off", "Handing off", "Handed off", "thread to a git worktree"], + "worktree-handoff", + ), + t3_worktree_status: tool(["Get", "Getting", "Got", "thread worktree status"], "worktree-status"), + preview_status: tool(["Get", "Getting", "Got", "preview browser status"], "browser", "browser"), + preview_open: tool( + ["Open", "Opening", "Opened", "a page in the preview browser"], + "browser", + "browser", + ), + preview_navigate: tool( + ["Navigate", "Navigating", "Navigated", "the preview browser"], + "browser", + "browser", + ), + preview_snapshot: tool( + ["Take a snapshot of", "Taking a snapshot of", "Took a snapshot of", "the preview page"], + "browser", + "browser", + "Snapshot the preview page", + ), + preview_click: tool( + ["Click", "Clicking", "Clicked", "in the preview browser"], + "browser", + "browser", + ), + preview_press: tool( + ["Press", "Pressing", "Pressed", "a key in the preview browser"], + "browser", + "browser", + ), + preview_type: tool(["Type", "Typing", "Typed", "in the preview browser"], "browser", "browser"), + preview_scroll: tool( + ["Scroll", "Scrolling", "Scrolled", "the preview browser"], + "browser", + "browser", + ), + preview_resize: tool( + ["Resize", "Resizing", "Resized", "the preview browser"], + "browser", + "browser", + ), + preview_evaluate: tool( + ["Evaluate", "Evaluating", "Evaluated", "script in the preview browser"], + "browser", + "browser", + ), + preview_wait_for: tool( + ["Wait", "Waiting", "Waited", "for the preview page"], + "browser", + "browser", + ), + preview_set_appearance: tool( + ["Set", "Setting", "Set", "preview browser appearance"], + "browser", + "browser", + ), + preview_recording_start: tool( + ["Start", "Starting", "Started", "recording the preview browser"], + "browser", + "browser", + ), + preview_recording_stop: tool( + ["Stop", "Stopping", "Stopped", "recording the preview browser"], + "browser", + "browser", + ), + device_list: tool(["List", "Listing", "Listed", "simulators and emulators"], "device", "device"), + device_open: tool( + ["Open", "Opening", "Opened", "a device in the Device panel"], + "device", + "device", + ), + device_screenshot: tool( + ["Take a screenshot of", "Taking a screenshot of", "Took a screenshot of", "the device"], + "device", + "device", + ), + device_close: tool(["Close", "Closing", "Closed", "a device"], "device", "device"), + run_scheduled_task_now: tool( + ["Run", "Running", "Requested a run of", "a scheduled task"], + "schedule-run", + ), + t3_queue_list: tool(["List", "Listing", "Listed", "queued messages"], "queue-list"), + t3_queue_read: tool(["Read", "Reading", "Read", "a queued message"], "queue-read"), + t3_queue_edit: tool(["Edit", "Editing", "Edited", "a queued message"], "queue-edit"), + t3_queue_cancel: tool( + ["Cancel", "Canceling", "Requested cancellation of", "a queued run"], + "queue-cancel", + ), + t3_queue_reorder: tool(["Reorder", "Reordering", "Reordered", "a queued run"], "queue-reorder"), + t3_queue_promote_to_steer: tool( + ["Steer with", "Steering with", "Requested steering with", "a queued message"], + "queue-steer", + ), + t3_pending_request_list: tool( + ["List", "Listing", "Listed", "pending questions"], + "question-list", + ), + t3_pending_request_read: tool(["Read", "Reading", "Read", "pending questions"], "question-read"), + t3_pending_request_respond: tool( + ["Answer", "Answering", "Answered", "pending questions"], + "question-respond", + ), + t3_thread_configuration: tool( + ["Read", "Reading", "Read", "thread configuration"], + "thread-configuration", + ), + t3_thread_configure: tool(["Set", "Setting", "Set", "thread model"], "thread-configure"), + t3_thread_fork: tool(["Fork", "Forking", "Requested a fork of", "this thread"], "thread-fork"), + t3_thread_merge_back: tool( + ["Merge", "Merging", "Requested a merge of", "thread context"], + "thread-merge", + ), + t3_thread_search: tool(["Search", "Searching", "Searched", "thread content"], "thread-search"), + t3_thread_transfers: tool(["Read", "Reading", "Read", "thread transfers"], "thread-transfers"), + t3_thread_organize: tool(["Organize", "Organizing", "Organized", "a thread"], "thread-organize"), + t3_thread_update: tool(["Update", "Updating", "Updated", "T3 thread metadata"], "thread-update"), + t3_worktree_list: tool(["List", "Listing", "Listed", "workspace branches"], "worktree-list"), + t3_preview_list: tool(["List", "Listing", "Listed", "preview tabs"], "browser", "browser"), + t3_preview_close: tool(["Close", "Closing", "Closed", "a preview tab"], "browser", "browser"), + t3_environment_read: tool( + ["Read", "Reading", "Read", "environment preferences"], + "environment-read", + ), + t3_environment_preferences_update: tool( + ["Update", "Updating", "Updated", "environment preferences"], + "environment-update", + ), + t3_thread_launch: tool(["Launch", "Launching", "Launched", "a project thread"], "thread-create"), + t3_project_list: tool(["List", "Listing", "Listed", "projects"], "project-list"), + t3_project_read: tool(["Read", "Reading", "Read", "a project"], "project-read"), + t3_project_create: tool(["Register", "Registering", "Registered", "a project"], "project-create"), + t3_project_update: tool(["Update", "Updating", "Updated", "a project"], "project-update"), + t3_project_delete: tool(["Delete", "Deleting", "Deleted", "a project"], "project-delete"), + t3_project_clone: tool(["Clone", "Cloning", "Cloned", "a repository"], "project-clone"), + t3_attachment_prepare_upload: tool( + ["Prepare", "Preparing", "Prepared", "an attachment upload"], + "attachment-prepare", + ), + t3_attachment_discard: tool( + ["Discard", "Discarding", "Discarded", "a pending attachment"], + "attachment-discard", + ), + t3_thread_send_attachments: tool(["Send", "Sending", "Sent", "attachments"], "attachment-send"), }; /** @@ -117,11 +289,11 @@ function normalizeT3McpToolLabel(value: string): string { * `mcp__t3-code__x` (Claude/Cursor), `t3-code.x` (Codex), plus single * underscore, colon, slash, dash, and space separators seen from registry * agents. The prefix match is deliberately loose because the display-name - * table below is the real gate; unknown tools stay on the generic renderer. + * inventory is the real gate; unknown tools stay on the generic renderer. */ function resolveT3McpToolName(value: string): string | null { const label = normalizeT3McpToolLabel(value); - const mcpMatch = /^mcp__(?.+?)__(?.+)$/.exec(label); + const mcpMatch = /^mcp__(?.+?)__(?.+)$/i.exec(label); if (mcpMatch?.groups) { const { server, tool } = mcpMatch.groups; return server !== undefined && @@ -131,7 +303,9 @@ function resolveT3McpToolName(value: string): string | null { : null; } - const namespaceMatch = /^(?t3-code|t3_code|t3code)[.:/](?.+)$/i.exec(label); + const namespaceMatch = /^(?t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*)(?.+)$/i.exec( + label, + ); if (namespaceMatch?.groups) { return namespaceMatch.groups.tool ?? null; } @@ -141,27 +315,22 @@ function resolveT3McpToolName(value: string): string | null { return Object.hasOwn(T3_MCP_TOOLS, candidate) ? candidate : null; } +export function resolveT3McpToolDefinition( + toolName: string | null | undefined, +): T3McpToolDefinition | null { + const name = toolName == null ? null : resolveT3McpToolName(toolName); + return name !== null && Object.hasOwn(T3_MCP_TOOLS, name) ? T3_MCP_TOOLS[name]! : null; +} + export function resolveT3McpToolPresentation( toolName: string | null | undefined, ): T3McpToolPresentation | null { - const resolvedToolName = - toolName === undefined || toolName === null ? null : resolveT3McpToolName(toolName); - if (resolvedToolName === null) { - return null; - } - const displayName = T3_MCP_TOOLS[resolvedToolName]?.displayName; - if (displayName === undefined) { - return null; - } - return { - displayName, - logo: "t3-code", - }; + const definition = resolveT3McpToolDefinition(toolName); + return definition === null ? null : { displayName: definition.displayName, logo: "t3-code" }; } export function resolveT3McpToolSummaryAction( toolName: string | null | undefined, ): T3McpToolSummaryAction | null { - const name = toolName == null ? null : resolveT3McpToolName(toolName); - return name === null ? null : (T3_MCP_TOOLS[name]?.summaryAction ?? null); + return resolveT3McpToolDefinition(toolName)?.summaryAction ?? null; }