diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 8b73483380e2..6c502c1ef130 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -344,7 +344,7 @@ it.effect("refuses preview tools to a client outside a thread before they run", client: { sessionId: "session-1", label: "Claude Code", access: "read-only" as const }, }; const click = yield* server - .callTool({ name: "preview_click", arguments: { locator: "text=Send" } }) + .callTool({ name: "preview_act", arguments: { action: "click", locator: "text=Send" } }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, readOnly), Effect.provideService(McpSchema.McpServerClient, client), @@ -356,6 +356,29 @@ it.effect("refuses preview tools to a client outside a thread before they run", }).pipe(Effect.provide(layerTest)), ); +it.effect("checks each preview_act action against that action's own input", () => + Effect.scoped( + Effect.gen(function* () { + const inputs = yield* serveSnapshots("mcp-act-client", {}); + + const missingKey = yield* callTool("preview_act", { action: "press" }); + expect(missingKey.isError).toBe(true); + expect(inputs).toEqual([]); + + const click = yield* callTool("preview_act", { + action: "click", + locator: "text=Send", + key: "Enter", + }); + expect(click.isError).toBe(false); + expect(inputs[0]).toEqual({ locator: "text=Send" }); + + yield* callTool("preview_act", { action: "type", selector: "#name", text: "Ada" }); + expect(inputs.at(-2)).toEqual({ selector: "#name", text: "Ada" }); + }), + ).pipe(Effect.provide(layerTest)), +); + it.effect("decodes null optional arguments the way it decodes omitted ones", () => { const calls: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a4e62e3b241..883d61f18932 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -749,21 +749,31 @@ const HANDOFF_TOOLS: ReadonlySet = new Set([ "t3_thread_launch", "t3_thread_send", "t3_thread_send_attachments", - "t3_thread_fork", - "t3_thread_merge_back", - "t3_queue_edit", - "t3_queue_promote_to_steer", - "t3_pending_request_respond", "schedule_task", "run_scheduled_task_now", ]); -/** Handoff tools whose result is only `{ sequence }`; their input names the receiving thread. */ -const INPUT_TARGET_TOOLS: ReadonlySet = new Set([ - "t3_queue_edit", - "t3_queue_promote_to_steer", - "t3_pending_request_respond", -]); +/** Tool actions that hand work over with only a `{ sequence }` result; the input names the thread. */ +const INPUT_TARGET_ACTIONS: Readonly>> = { + t3_queue: new Set(["edit", "promote_to_steer"]), + t3_pending_request: new Set(["respond"]), +}; + +/** Tool actions that hand work over and name the receiving thread in their result. */ +const RESULT_TARGET_ACTIONS: Readonly>> = { + t3_thread_transfer: new Set(["fork", "merge_back"]), +}; + +const hasAction = ( + actions: Readonly>>, + tool: string, + args: unknown, +) => + actions[tool]?.has(String((args as { readonly action?: unknown } | null | undefined)?.action)) === + true; + +const isInputTargetHandoff = (tool: string, args: unknown) => + hasAction(INPUT_TARGET_ACTIONS, tool, args); const resultThreadKeys = ["childThreadId", "targetThreadId", "boundThreadId", "threadId"] as const; @@ -785,7 +795,7 @@ const handoffTargetIds = ( args: unknown, result: McpSchema.CallToolResult | undefined, ): ReadonlyArray => { - if (!INPUT_TARGET_TOOLS.has(tool)) return resultThreadIds(result?.structuredContent); + if (!isInputTargetHandoff(tool, args)) return resultThreadIds(result?.structuredContent); if (result === undefined || toolOutcome(result).outcome !== "ok") return []; const threadId = (args as { readonly threadId?: unknown } | null | undefined)?.threadId; return typeof threadId === "string" ? [threadId] : []; @@ -851,7 +861,10 @@ const withToolAnalytics = result: McpSchema.CallToolResult | undefined, durationMs: number, ) { - const handoff = HANDOFF_TOOLS.has(tool); + const handoff = + HANDOFF_TOOLS.has(tool) || + isInputTargetHandoff(tool, args) || + hasAction(RESULT_TARGET_ACTIONS, tool, args); const outcome = toolOutcome(result); // Agents call status, read, and preview tools in tight loops, so an // event per successful call floods analytics. Only failures count there. diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 9d99fb7da89e..627ee2c23925 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -181,6 +181,16 @@ export const startsThreads = ( }), ); +/** One tool with two access rules, picked per call, such as create versus update. */ +export const either = ( + useFirst: (params: P) => boolean, + first: Declaration<(params: P) => Effect.Effect>, + second: Declaration<(params: P) => Effect.Effect>, +) => + declare((params: P): Effect.Effect => + useFirst(params) ? checkedHandler(first)(params) : checkedHandler(second)(params), + ); + const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index 421d728461eb..405824b5d7be 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -70,7 +70,6 @@ function commandId(input: { function metadataCommand(input: { readonly commandId: CommandId; readonly threadId: ThreadId; - readonly projectId: OrchestrationV2AppThread["projectId"]; readonly update: ThreadMetadataMcpUpdateInput; }): Extract { switch (input.update.action) { @@ -88,23 +87,6 @@ function metadataCommand(input: { threadId: input.threadId, regenerateTitle: true, }; - case "link_pull_request": - return { - type: "thread.metadata.update", - commandId: input.commandId, - threadId: input.threadId, - linkedPullRequest: { - projectId: input.projectId, - ...input.update.pullRequest!, - }, - }; - case "unlink_pull_request": - return { - type: "thread.metadata.update", - commandId: input.commandId, - threadId: input.threadId, - linkedPullRequest: null, - }; } } @@ -188,7 +170,6 @@ const make = Effect.gen(function* () { metadataCommand({ commandId: updateCommandId, threadId, - projectId: target.thread.projectId, update: input, }), ) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 76d8131e27bb..d15d30946d59 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -135,8 +135,8 @@ it.effect("an answered question reports already answered instead of not found", const respond = (id: string) => server .callTool({ - name: "t3_pending_request_respond", - arguments: { requestId: id, answers: { size: "large" } }, + name: "t3_pending_request", + arguments: { action: "respond", requestId: id, answers: { size: "large" } }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, scope), @@ -469,8 +469,8 @@ it.effect("a client caller targets any thread within its ceiling and cannot act }); const forked = yield* call( - "t3_thread_fork", - { sourcePoint: { type: "latest_stable" } }, + "t3_thread_transfer", + { action: "fork", sourcePoint: { type: "latest_stable" } }, clientScope("auto"), ); expect(declaredFailure(forked)).toMatchObject({ code: "target_required" }); @@ -515,7 +515,7 @@ it.effect("a read-only client reads threads and is refused every write before it Effect.provideService(McpSchema.McpServerClient, client), ); - const configuration = yield* call("t3_thread_configuration", { + const configuration = yield* call("t3_thread_configure", { threadId: "other-project-thread", }); expect(configuration.isError).toBe(false); @@ -614,18 +614,20 @@ it.effect("a caller cannot rewrite a scheduled task that runs above its own mode Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), Effect.provideService(McpSchema.McpServerClient, client), ); - const update = yield* call("update_scheduled_task", { + const update = yield* call("schedule_task", { scheduledTaskId: "task-full-access", prompt: "Run something else", }); expect(declaredFailure(update)).toMatchObject({ code: "runtime_mode_escalation_denied" }); const remove = yield* call("delete_scheduled_task", { scheduledTaskId: "task-full-access" }); expect(declaredFailure(remove)).toMatchObject({ code: "runtime_mode_escalation_denied" }); - const allowed = yield* call("update_scheduled_task", { + const allowed = yield* call("schedule_task", { scheduledTaskId: "task-auto", enabled: false, }); expect(allowed.isError).toBe(false); + const incomplete = yield* call("schedule_task", { prompt: "Run nightly" }); + expect(declaredFailure(incomplete)).toMatchObject({ code: "invalid_request" }); }).pipe( Effect.provide( layerOrchestratorToolkit.pipe( diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index a6f9a1302c67..2d829fe428e9 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -78,14 +78,39 @@ const handlers = { ); }), ), - schedule_task: McpToolAccess.startsThreads( - () => ({}), - (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.scheduleTask(scope, input); - }), + schedule_task: McpToolAccess.either( + (input) => input.scheduledTaskId === undefined, + McpToolAccess.startsThreads( + () => ({}), + ({ prompt, schedule, ...input }) => + Effect.gen(function* () { + if (prompt === undefined || schedule === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A new scheduled task needs `prompt` and `schedule`.", + }); + } + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.scheduleTask(scope, { ...input, prompt, schedule }); + }), + ), + McpToolAccess.writes( + ({ scheduledTaskId, prompt, title, schedule, enabled, bindToCurrentThread }) => + Effect.gen(function* () { + if (scheduledTaskId === undefined) return yield* Effect.die("unreachable"); + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.updateScheduledTask(scope, { + scheduledTaskId, + prompt, + title, + schedule, + enabled, + bindToCurrentThread, + }); + }), + ), ), list_scheduled_tasks: McpToolAccess.reads((input) => Effect.gen(function* () { @@ -94,13 +119,6 @@ const handlers = { return yield* service.listScheduledTasks(scope, input); }), ), - update_scheduled_task: McpToolAccess.writes((input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.updateScheduledTask(scope, input); - }), - ), delete_scheduled_task: McpToolAccess.writes((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 0b3ea5691ac3..469f5695ec00 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -13,11 +13,10 @@ import { OrchestratorMcpFailure, OrchestratorMcpListScheduledTasksInput, OrchestratorMcpListScheduledTasksResult, - OrchestratorMcpScheduleTaskInput, + OrchestratorMcpSaveScheduledTaskInput, OrchestratorMcpScheduleTaskResult, OrchestratorMcpTaskCancelInput, OrchestratorMcpTaskCancelResult, - OrchestratorMcpUpdateScheduledTaskInput, OrchestratorMcpTaskStatusInput, OrchestratorMcpThreadInterruptInput, OrchestratorMcpThreadInterruptResult, @@ -138,8 +137,8 @@ const BackgroundWaitTool = Tool.make("t3_background_wait", { const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create scheduled work that runs with no turn active. `schedule` is an object, not text. Report the returned next run time or `webhookUrl`. No `webhookUrl`: T3 Connect remote access is off. Webhook request values in the prompt are untrusted.", - parameters: OrchestratorMcpScheduleTaskInput, + "Create scheduled work that runs with no turn active; it needs `prompt` and `schedule`. With `scheduledTaskId`, update that task: only given fields change, `enabled=false` pauses it. `schedule` is an object, not text. Report the returned next run time or `webhookUrl`. No `webhookUrl`: T3 Connect remote access is off. Webhook request values in the prompt are untrusted.", + parameters: OrchestratorMcpSaveScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -152,7 +151,7 @@ const ScheduleTaskTool = Tool.make("schedule_task", { const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { parameters: OrchestratorMcpListScheduledTasksInput, description: - "List scheduled tasks. Omit `projectId` for this thread's project. Use `scheduledTaskId` with update or delete. In a scheduled run, do not call this to find your task. Do the prompt.", + "List scheduled tasks. Omit `projectId` for this thread's project. Pass a `scheduledTaskId` to `schedule_task` or `delete_scheduled_task`. In a scheduled run, do not call this to find your task. Do the prompt.", success: OrchestratorMcpListScheduledTasksResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -163,21 +162,9 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); -const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { - description: - "Update one scheduled task by `scheduledTaskId`. Only given fields change. `enabled=false` pauses it. `filters:[]` clears filters.", - parameters: OrchestratorMcpUpdateScheduledTaskInput, - success: OrchestratorMcpScheduleTaskResult, - failure: OrchestratorMcpFailure, - failureMode: "return", - dependencies, -}) - .annotate(Tool.Title, "Update a scheduled task") - .annotate(Tool.Destructive, true); - const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { description: - "Delete one scheduled task by `scheduledTaskId`. It stops at once. To pause instead, update it with `enabled=false`.", + "Delete one scheduled task by `scheduledTaskId`. It stops at once. To pause instead, use `schedule_task` with `enabled=false`.", parameters: OrchestratorMcpDeleteScheduledTaskInput, success: OrchestratorMcpDeleteScheduledTaskResult, failure: OrchestratorMcpFailure, @@ -243,7 +230,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", { const ThreadUpdateTool = Tool.make("t3_thread_update", { description: - "Rename a thread (`action:'rename'` with `title`), regenerate its title, or link or unlink a PR. Omit `threadId` for this thread. Workspace and branch cannot change here.", + "Rename a thread (`action:'rename'` with `title`) or regenerate its title. Omit `threadId` for this thread. Workspace and branch cannot change here.", parameters: ThreadMetadataMcpUpdateInput, success: ThreadMetadataMcpUpdateResult, failure: OrchestratorMcpFailure, @@ -302,7 +289,6 @@ export const OrchestratorToolkit = Toolkit.make( BackgroundWaitTool, ScheduleTaskTool, ListScheduledTasksTool, - UpdateScheduledTaskTool, DeleteScheduledTaskTool, RequestSecretTool, CreateThreadsTool, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 37b4e0470783..39d6283ae7e5 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -7,6 +7,8 @@ import { PreviewAutomationRecordingTransferError, PreviewAutomationRecordingDesktopUpdateRequiredError, PreviewAutomationRecordingArtifact, + PreviewAutomationActInputs, + OrchestratorMcpFailure, type ToolActivityIcon, type ThreadId, type PreviewAutomationOperation, @@ -205,26 +207,20 @@ const handlers = { const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; return invokeTargeted("snapshot", operationInput); }), - preview_click: McpToolAccess.actsAsCaller((input) => - invokeTargeted("click", input, input.timeoutMs), - ), - preview_type: McpToolAccess.actsAsCaller((input) => - invokeTargeted("type", input, input.timeoutMs), - ), - preview_hover: McpToolAccess.actsAsCaller((input) => - invokeTargeted("hover", input, input.timeoutMs), - ), - preview_select: McpToolAccess.actsAsCaller((input) => - invokeTargeted("select", input, input.timeoutMs), - ), - preview_drag: McpToolAccess.actsAsCaller((input) => - invokeTargeted("drag", input, input.timeoutMs), - ), - preview_upload: McpToolAccess.actsAsCaller((input) => - invokeTargeted("upload", input, input.timeoutMs), + preview_act: McpToolAccess.actsAsCaller(({ action, ...input }) => + Schema.decodeUnknownEffect(PreviewAutomationActInputs[action])(input).pipe( + Effect.mapError( + (cause) => new OrchestratorMcpFailure({ code: "invalid_request", message: cause.message }), + ), + Effect.flatMap((decoded) => + invokeTargeted( + action, + decoded, + "timeoutMs" in decoded ? decoded.timeoutMs : undefined, + ), + ), + ), ), - preview_press: McpToolAccess.actsAsCaller((input) => invokeTargeted("press", input)), - preview_scroll: McpToolAccess.actsAsCaller((input) => invokeTargeted("scroll", input)), preview_evaluate: McpToolAccess.actsAsCaller(({ tabId, ...input }) => invoke("evaluate", input, undefined, tabId).pipe( Effect.map(({ result, toolIcon }) => ({ diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 4d0602ee928e..7feeb46a0830 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -1,29 +1,22 @@ import { OrchestratorMcpFailure, ToolActivityIcon, - PreviewAutomationClickInput, + PreviewAutomationActInput, PreviewAutomationDialogInput, - PreviewAutomationDragInput, PreviewAutomationError, PreviewAutomationEvaluateInput, - PreviewAutomationHoverInput, PreviewAutomationNavigateInput, PreviewAutomationOpenInput, - PreviewAutomationPressInput, PreviewAutomationRecordingArtifact, PreviewAutomationRecordingStatus, PreviewAutomationResizeInput, PreviewAutomationResizeResult, - PreviewAutomationScrollInput, - PreviewAutomationSelectInput, PreviewAutomationSelectResult, PreviewAutomationSetColorSchemeInput, PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, PreviewAutomationTabTargetInput, - PreviewAutomationTypeInput, - PreviewAutomationUploadInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -157,90 +150,18 @@ export const PreviewSnapshotTool = safeBrowserTool( }).annotate(Tool.Title, "Inspect browser page"), ); -const PreviewClickTool = browserTool( - Tool.make("preview_click", { - description: "Click exactly one target. Prefer a Playwright locator. `x` and `y` go together.", - parameters: PreviewAutomationClickInput, - success: PreviewActionResult, - failure: PreviewToolFailure, - dependencies, - }).annotate(Tool.Title, "Click preview page"), -); - -const PreviewTypeTool = browserTool( - Tool.make("preview_type", { - description: "Type literal text into one input. `clear=true` replaces the current text.", - parameters: PreviewAutomationTypeInput, - success: PreviewActionResult, - failure: PreviewToolFailure, - dependencies, - }).annotate(Tool.Title, "Type into preview page"), -); - -const PreviewHoverTool = safeBrowserTool( - Tool.make("preview_hover", { - description: "Hover one target to show menus or tooltips.", - parameters: PreviewAutomationHoverInput, - success: PreviewActionResult, - failure: PreviewToolFailure, - dependencies, - }).annotate(Tool.Title, "Hover preview page"), -); - -const PreviewSelectTool = browserTool( - Tool.make("preview_select", { - description: - "Pick options in one native .", - parameters: PreviewAutomationUploadInput, - success: PreviewActionResult, - failure: PreviewToolFailure, - dependencies, - }).annotate(Tool.Title, "Upload files to preview page"), -); - -const PreviewPressTool = browserTool( - Tool.make("preview_press", { - description: "Press one key, such as {key:'Enter'} or {key:'a',modifiers:['Meta']}.", - parameters: PreviewAutomationPressInput, - success: PreviewActionResult, - failure: PreviewToolFailure, - dependencies, - }).annotate(Tool.Title, "Press key in preview page"), -); - -const PreviewScrollTool = safeBrowserTool( - Tool.make("preview_scroll", { +const PreviewActTool = browserTool( + Tool.make("preview_act", { description: - "Scroll the chosen tab, or one container with a locator or selector. Positive `deltaY` scrolls down. Positive `deltaX` scrolls right.", - parameters: PreviewAutomationScrollInput, - success: PreviewActionResult, + "Act on the chosen tab. Prefer a Playwright `locator`; `x` and `y` go together. `timeoutMs` defaults to 15000. click: `button`, `clickCount` 1 to 3. type: literal `text`, `clear=true` replaces it. hover: shows menus or tooltips. select: `values` in one native . press: one `key`, such as 'Enter', with `modifiers`. scroll: the tab, or one container with a locator; positive `deltaY` scrolls down, positive `deltaX` right.", + parameters: PreviewAutomationActInput, + success: Schema.Union([ + Schema.Struct({ ...PreviewAutomationSelectResult.fields, ...presentationFields }), + PreviewActionResult, + ]), failure: PreviewToolFailure, dependencies, - }).annotate(Tool.Title, "Scroll preview page"), + }).annotate(Tool.Title, "Act in preview page"), ); /** Wrap non-objects: MCP requires object structuredContent, and Claude Code rejects invalid results entirely. */ @@ -302,14 +223,7 @@ export const PreviewToolkit = Toolkit.make( PreviewResizeTool, PreviewSetAppearanceTool, PreviewSnapshotTool, - PreviewClickTool, - PreviewTypeTool, - PreviewHoverTool, - PreviewSelectTool, - PreviewDragTool, - PreviewUploadTool, - PreviewPressTool, - PreviewScrollTool, + PreviewActTool, PreviewEvaluateTool, PreviewWaitForTool, PreviewRecordingStartTool, @@ -323,14 +237,7 @@ export const PreviewStandardToolkit = Toolkit.make( PreviewNavigateTool, PreviewResizeTool, PreviewSetAppearanceTool, - PreviewClickTool, - PreviewTypeTool, - PreviewHoverTool, - PreviewSelectTool, - PreviewDragTool, - PreviewUploadTool, - PreviewPressTool, - PreviewScrollTool, + PreviewActTool, PreviewEvaluateTool, PreviewWaitForTool, PreviewRecordingStartTool, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 8a15d1f15656..2573841eff66 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -76,6 +76,16 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { }); return { ...context, request, item }; }); +/** A field the chosen action needs. `null` counts as given, as in `beforeRunId: null`. */ +const required = (value: A | undefined, name: string) => + value === undefined + ? Effect.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: `This action needs \`${name}\`.`, + }), + ) + : Effect.succeed(value); const writesThread =

( handle: (params: P) => Effect.Effect, ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); @@ -117,181 +127,198 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { }; }), ), - t3_thread_fork: writesThread((input) => - Effect.gen(function* () { - const { threads, projection } = yield* readThread(input.threadId); - const commandId = yield* newCommandId(); - const targetThreadId = ThreadId.make(`${commandId}:fork`); - const result = yield* threads - .dispatch({ - type: "thread.fork", - commandId, - sourceThreadId: projection.thread.id, - targetThreadId, - sourcePoint: input.sourcePoint, - ...(input.title === undefined ? {} : { title: input.title }), - createdBy: "agent", - creationSource: "mcp", - }) - .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence, targetThreadId }; - }), + t3_thread_transfer: McpToolAccess.either( + (input) => input.action === "status", + McpToolAccess.reads((input) => + Effect.gen(function* () { + const { projection } = yield* readThread(input.threadId, ["contextTransfers"]); + return { + transfers: projection.contextTransfers.map( + ({ id, sourceThreadId, targetThreadId, status }) => ({ + id, + sourceThreadId, + targetThreadId, + status, + }), + ), + }; + }), + ), + McpToolAccess.writesThreads( + (input) => [input.threadId, input.targetThreadId], + (input) => + Effect.gen(function* () { + const sourcePoint = yield* required(input.sourcePoint, "sourcePoint"); + const { threads, projection } = yield* readThread(input.threadId); + const commandId = yield* newCommandId(); + if (input.action === "merge_back") { + const targetThreadId = yield* required(input.targetThreadId, "targetThreadId"); + yield* readThread(targetThreadId); + const result = yield* threads + .dispatch({ + type: "thread.merge_back", + commandId, + sourceThreadId: projection.thread.id, + targetThreadId, + sourcePoint, + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence, targetThreadId }; + } + const targetThreadId = ThreadId.make(`${commandId}:fork`); + const result = yield* threads + .dispatch({ + type: "thread.fork", + commandId, + sourceThreadId: projection.thread.id, + targetThreadId, + sourcePoint, + ...(input.title === undefined ? {} : { title: input.title }), + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence, targetThreadId }; + }), + ), ), - t3_thread_merge_back: McpToolAccess.writesThreads( - (input) => [input.targetThreadId, input.sourceThreadId], - (input) => + t3_thread_configure: McpToolAccess.either( + (input) => input.modelSelection === undefined, + McpToolAccess.reads((input) => + Effect.gen(function* () { + const { + projection: { thread }, + } = yield* readThread(input.threadId); + return { + threadId: thread.id, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + }; + }), + ), + writesThread((input) => + Effect.gen(function* () { + const modelSelection = yield* required(input.modelSelection, "modelSelection"); + const { + threads, + projection: { thread }, + } = yield* readThread(input.threadId); + const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); + const result = yield* threads + .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence }; + }), + ), + ), + t3_pending_request: McpToolAccess.either( + (input) => input.action !== "respond", + McpToolAccess.reads((input) => + Effect.gen(function* () { + if (input.action === "list") { + const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]); + return { + requestIds: projection.runtimeRequests + .filter((request) => request.kind === "user_input" && request.status === "pending") + .map((request) => request.id), + }; + } + const requestId = yield* required(input.requestId, "requestId"); + const { item } = yield* readQuestion({ threadId: input.threadId, requestId }); + return { requestId, questions: item.questions }; + }), + ), + writesThread((input) => Effect.gen(function* () { - const context = yield* readThread(input.targetThreadId); - const source = yield* readThread(input.sourceThreadId); - const result = yield* context.threads + const requestId = yield* required(input.requestId, "requestId"); + const answers = yield* required(input.answers, "answers"); + const { threads, projection } = yield* readQuestion({ + threadId: input.threadId, + requestId, + }); + const result = yield* threads .dispatch({ - type: "thread.merge_back", + type: "runtime-request.respond", + threadId: projection.thread.id, commandId: yield* newCommandId(), - sourceThreadId: source.projection.thread.id, - targetThreadId: input.targetThreadId, - sourcePoint: input.sourcePoint, - createdBy: "agent", - creationSource: "mcp", + requestId, + answers, }) .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence, targetThreadId: input.targetThreadId }; + return { sequence: result.sequence }; }), + ), ), - t3_thread_transfers: McpToolAccess.reads((input) => - Effect.gen(function* () { - const { projection } = yield* readThread(input.threadId, ["contextTransfers"]); - return { - transfers: projection.contextTransfers.map( - ({ id, sourceThreadId, targetThreadId, status }) => ({ - id, - sourceThreadId, - targetThreadId, - status, - }), - ), - }; - }), - ), - t3_thread_configuration: McpToolAccess.reads((input) => - Effect.gen(function* () { - const { - projection: { thread }, - } = yield* readThread(input.threadId); - return { - threadId: thread.id, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - }; - }), - ), - t3_thread_configure: writesThread((input) => - Effect.gen(function* () { - const { - threads, - projection: { thread }, - } = yield* readThread(input.threadId); - const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection); - const result = yield* threads - .dispatch({ - type, - threadId: thread.id, - commandId: yield* newCommandId(), - modelSelection: input.modelSelection, - }) - .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence }; - }), - ), - t3_pending_request_list: McpToolAccess.reads((input) => - Effect.gen(function* () { - const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]); - return { - requestIds: projection.runtimeRequests - .filter((request) => request.kind === "user_input" && request.status === "pending") - .map((request) => request.id), - }; - }), - ), - t3_pending_request_read: McpToolAccess.reads((input) => - Effect.gen(function* () { - const { item } = yield* readQuestion(input); - return { requestId: input.requestId, questions: item.questions }; - }), - ), - t3_pending_request_respond: writesThread((input) => - Effect.gen(function* () { - const { threads, projection } = yield* readQuestion(input); - const result = yield* threads - .dispatch({ - type: "runtime-request.respond", - threadId: projection.thread.id, - commandId: yield* newCommandId(), - requestId: input.requestId, - answers: input.answers, - }) - .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence }; - }), - ), - t3_queue_list: McpToolAccess.reads((input) => - Effect.gen(function* () { - const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); - const runs = queuedRunsInDeliveryOrder(projection); - const cursor = input.cursor ?? 0; - const end = cursor + (input.limit ?? 20); - return { - items: runs.slice(cursor, end).flatMap((run) => { - const entry = queueEntry(projection, run.id, 1000); - return entry === undefined ? [] : [entry]; - }), - nextCursor: end < runs.length ? end : null, - }; - }), - ), - t3_queue_read: McpToolAccess.reads((input) => - Effect.gen(function* () { - const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); - const entry = queueEntry(projection, input.queuedRunId, 16000); - return ( - entry ?? - (yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "The queued message was not found.", - })) - ); - }), - ), - t3_queue_edit: writesThread((input) => - dispatch(input.threadId, (common) => ({ - ...common, - type: "queued-run.edit", - runId: input.queuedRunId, - text: input.text, - })), - ), - t3_queue_cancel: writesThread((input) => - dispatch(input.threadId, (common) => ({ - ...common, - type: "queued-run.cancel", - runId: input.queuedRunId, - })), - ), - t3_queue_reorder: writesThread((input) => - dispatch(input.threadId, (common) => ({ - ...common, - type: "queued-run.reorder", - runId: input.queuedRunId, - beforeRunId: input.beforeRunId, - })), - ), - t3_queue_promote_to_steer: writesThread((input) => - dispatch(input.threadId, (common) => ({ - ...common, - type: "queued-message.promote-to-steer", - queuedRunId: input.queuedRunId, - targetRunId: input.targetRunId, - })), + t3_queue: McpToolAccess.either( + (input) => input.action === "list" || input.action === "read", + McpToolAccess.reads((input) => + Effect.gen(function* () { + const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); + if (input.action === "list") { + const runs = queuedRunsInDeliveryOrder(projection); + const cursor = input.cursor ?? 0; + const end = cursor + (input.limit ?? 20); + return { + items: runs.slice(cursor, end).flatMap((run) => { + const entry = queueEntry(projection, run.id, 1000); + return entry === undefined ? [] : [entry]; + }), + nextCursor: end < runs.length ? end : null, + }; + } + const queuedRunId = yield* required(input.queuedRunId, "queuedRunId"); + return ( + queueEntry(projection, queuedRunId, 16000) ?? + (yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "The queued message was not found.", + })) + ); + }), + ), + writesThread((input) => + Effect.gen(function* () { + const runId = yield* required(input.queuedRunId, "queuedRunId"); + switch (input.action) { + case "edit": { + const text = yield* required(input.text, "text"); + return yield* dispatch(input.threadId, (common) => ({ + ...common, + type: "queued-run.edit", + runId, + text, + })); + } + case "reorder": { + const beforeRunId = yield* required(input.beforeRunId, "beforeRunId"); + return yield* dispatch(input.threadId, (common) => ({ + ...common, + type: "queued-run.reorder", + runId, + beforeRunId, + })); + } + case "promote_to_steer": { + const targetRunId = yield* required(input.targetRunId, "targetRunId"); + return yield* dispatch(input.threadId, (common) => ({ + ...common, + type: "queued-message.promote-to-steer", + queuedRunId: runId, + targetRunId, + })); + } + default: + return yield* dispatch(input.threadId, (common) => ({ + ...common, + type: "queued-run.cancel", + runId, + })); + } + }), + ), ), t3_thread_organize: writesThread((input) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index e7c4f56c30f2..72240e8f3956 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -61,7 +61,6 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { .annotate(Tool.Title, "Organize a thread") .annotate(Tool.Destructive, true); -const queueTarget = { threadId: Schema.optional(ThreadId), queuedRunId: RunId }; const commandTool = { success: OrchestrationV2DispatchCommandResult, failure: OrchestratorMcpFailure, @@ -77,55 +76,34 @@ const queueEntry = Schema.Struct({ text: Schema.String, truncated: Schema.Boolean, }); -const QueueListTool = Tool.make("t3_queue_list", { +const QUEUE_READ_ACTIONS = ["list", "read"] as const; +const QueueTool = Tool.make("t3_queue", { ...commandTool, - description: "List queued messages in send order. For full history, use `t3_thread_read`.", + description: + "Manage a thread's queued messages. Omit `threadId` for this thread. list: send order, paged by `cursor` and `limit`. read: up to 16,000 chars of one. edit: replace its `text`; attachments stay. cancel. reorder: before `beforeRunId`, or to the end with null. promote_to_steer: send it now as steering to active `targetRunId`. Writes fail once the run is no longer queued.", parameters: Schema.Struct({ + action: Schema.Literals([ + ...QUEUE_READ_ACTIONS, + "edit", + "cancel", + "reorder", + "promote_to_steer", + ]), threadId: Schema.optional(ThreadId), + queuedRunId: Schema.optional(RunId), + text: Schema.optional(Schema.String.check(Schema.isMaxLength(100000))), + beforeRunId: Schema.optional(Schema.NullOr(RunId)), + targetRunId: Schema.optional(RunId), cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), }), - success: Schema.Struct({ - items: Schema.Array(queueEntry), - nextCursor: Schema.NullOr(NonNegativeInt), - }), -}) - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); -const QueueReadTool = Tool.make("t3_queue_read", { - ...commandTool, - description: "Read up to 16,000 chars of one queued message. Omit `threadId` for this thread.", - parameters: Schema.Struct(queueTarget), - success: queueEntry, -}) - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); -const QueueEditTool = Tool.make("t3_queue_edit", { - ...commandTool, - description: - "Replace one queued message's text. Attachments stay. Fails if the run is no longer queued.", - parameters: Schema.Struct({ - ...queueTarget, - text: Schema.String.check(Schema.isMaxLength(100000)), - }), -}).annotate(Tool.Destructive, true); -const QueueCancelTool = Tool.make("t3_queue_cancel", { - ...commandTool, - description: "Cancel one queued run.", - parameters: Schema.Struct(queueTarget), -}).annotate(Tool.Destructive, true); -const QueueReorderTool = Tool.make("t3_queue_reorder", { - ...commandTool, - description: "Move one queued run before another, or to the end with `beforeRunId=null`.", - parameters: Schema.Struct({ ...queueTarget, beforeRunId: Schema.NullOr(RunId) }), -}).annotate(Tool.Destructive, true); -const QueuePromoteTool = Tool.make("t3_queue_promote_to_steer", { - ...commandTool, - description: "Send one queued message now as steering to the given active run.", - parameters: Schema.Struct({ ...queueTarget, targetRunId: RunId }), + success: Schema.Union([ + Schema.Struct({ items: Schema.Array(queueEntry), nextCursor: Schema.NullOr(NonNegativeInt) }), + queueEntry, + OrchestrationV2DispatchCommandResult, + ]), }).annotate(Tool.Destructive, true); -const requestTarget = { threadId: Schema.optional(ThreadId), requestId: RuntimeRequestId }; const question = Schema.Struct({ id: Schema.String, header: Schema.String, @@ -142,98 +120,67 @@ const question = Schema.Struct({ initialAnswer: Schema.optional(Schema.String), required: Schema.optional(Schema.Boolean), }); -const pendingRequest = Schema.Struct({ - requestId: RuntimeRequestId, - questions: Schema.Array(question), -}); -const PendingRequestListTool = Tool.make("t3_pending_request_list", { +const PendingRequestTool = Tool.make("t3_pending_request", { ...commandTool, description: - "List pending user questions in a thread. Omit `threadId` for this thread. Approvals are not listed.", - parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), - success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }), -}) - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); -const PendingRequestReadTool = Tool.make("t3_pending_request_read", { - ...commandTool, - description: "Read one pending user question. Answer it with `t3_pending_request_respond`.", - parameters: Schema.Struct(requestTarget), - success: pendingRequest, -}) - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); -const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", { - ...commandTool, - description: "Answer one pending user question. This cannot approve a permission request.", - parameters: Schema.Struct({ ...requestTarget, answers: ProviderUserInputAnswers }), + "Pending user questions in a thread. Omit `threadId` for this thread. list: their ids; approvals are not listed. read: one by `requestId`. respond: answer one with `answers`; this cannot approve a permission request.", + parameters: Schema.Struct({ + action: Schema.Literals(["list", "read", "respond"]), + threadId: Schema.optional(ThreadId), + requestId: Schema.optional(RuntimeRequestId), + answers: Schema.optional(ProviderUserInputAnswers), + }), + success: Schema.Union([ + Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }), + Schema.Struct({ requestId: RuntimeRequestId, questions: Schema.Array(question) }), + OrchestrationV2DispatchCommandResult, + ]), }) .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); -const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { - ...commandTool, - description: "Read a thread's provider, model, and modes. Omit `threadId` for this thread.", - parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), - success: Schema.Struct({ - threadId: ThreadId, - modelSelection: ModelSelection, - runtimeMode: RuntimeMode, - interactionMode: ProviderInteractionMode, - }), -}) - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); +const threadConfiguration = Schema.Struct({ + threadId: ThreadId, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, +}); const ThreadConfigureTool = Tool.make("t3_thread_configure", { ...commandTool, description: - "Set a thread's provider, model, and options. Omit `threadId` for this thread. Permission modes stay. Values from `orchestrator_capabilities`.", + "Read a thread's provider, model, and modes, or set its provider, model, and options with `modelSelection`. Omit `threadId` for this thread. Permission modes stay. Values from `orchestrator_capabilities`.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), - modelSelection: ModelSelection, + modelSelection: Schema.optional(ModelSelection), }), + success: Schema.Union([threadConfiguration, OrchestrationV2DispatchCommandResult]), }).annotate(Tool.Destructive, true); -const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }); -const ThreadForkTool = Tool.make("t3_thread_fork", { +const ThreadTransferTool = Tool.make("t3_thread_transfer", { ...commandTool, description: - "Fork a thread from a stable run or checkpoint. Omit `threadId` for this thread. Keeps source setup. No turn has run yet.", + "Move context between threads. Omit `threadId` for this thread. fork: copy it from `sourcePoint` into a new thread with the same setup; no turn runs yet. merge_back: merge it from `sourcePoint` into a related `targetThreadId` in the same project. status: its context transfers.", parameters: Schema.Struct({ + action: Schema.Literals(["fork", "merge_back", "status"]), threadId: Schema.optional(ThreadId), - sourcePoint: OrchestrationV2ThreadForkSourcePoint, + sourcePoint: Schema.optional(OrchestrationV2ThreadForkSourcePoint), + targetThreadId: Schema.optional(ThreadId), title: Schema.optional(TrimmedNonEmptyString), }), - success: transferResult, -}).annotate(Tool.Destructive, true); -const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { - ...commandTool, - description: - "Merge context from one thread into a related thread in the same project. Omit `sourceThreadId` for this thread.", - parameters: Schema.Struct({ - sourceThreadId: Schema.optional(ThreadId), - targetThreadId: ThreadId, - sourcePoint: OrchestrationV2ThreadForkSourcePoint, - }), - success: transferResult, + success: Schema.Union([ + Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }), + Schema.Struct({ + transfers: Schema.Array( + Schema.Struct({ + id: OrchestrationV2ContextTransfer.fields.id, + sourceThreadId: OrchestrationV2ContextTransfer.fields.sourceThreadId, + targetThreadId: OrchestrationV2ContextTransfer.fields.targetThreadId, + status: OrchestrationV2ContextTransfer.fields.status, + }), + ), + }), + ]), }).annotate(Tool.Destructive, true); -const ThreadTransfersTool = Tool.make("t3_thread_transfers", { - ...commandTool, - description: "Read context transfer status for a thread. Omit `threadId` for this thread.", - parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), - success: Schema.Struct({ - transfers: Schema.Array( - Schema.Struct({ - id: OrchestrationV2ContextTransfer.fields.id, - sourceThreadId: OrchestrationV2ContextTransfer.fields.sourceThreadId, - targetThreadId: OrchestrationV2ContextTransfer.fields.targetThreadId, - status: OrchestrationV2ContextTransfer.fields.status, - }), - ), - }), -}) - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); const ThreadSearchTool = Tool.make("t3_thread_search", { ...commandTool, @@ -281,19 +228,9 @@ const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { export const ThreadToolkit = Toolkit.make( ScheduledTaskRunTool, ThreadSearchTool, - ThreadForkTool, - ThreadMergeBackTool, - ThreadTransfersTool, - ThreadConfigurationTool, ThreadConfigureTool, - PendingRequestListTool, - PendingRequestReadTool, - PendingRequestRespondTool, + ThreadTransferTool, ThreadOrganizeTool, - QueueListTool, - QueueReadTool, - QueueEditTool, - QueueCancelTool, - QueueReorderTool, - QueuePromoteTool, + PendingRequestTool, + QueueTool, ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e15d52e07f1c..24292d1ac8f8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1515,10 +1515,6 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__list_scheduled_tasks", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", - "mcp__t3-code__t3_pending_request_list", - "mcp__t3-code__t3_pending_request_read", - "mcp__t3-code__t3_thread_configuration", - "mcp__t3-code__t3_thread_transfers", "mcp__t3-code__t3_worktree_status", "mcp__t3-code__t3_worktree_list", "mcp__t3-code__t3_project_list", @@ -1526,8 +1522,6 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_thread_search", "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", - "mcp__t3-code__t3_queue_list", - "mcp__t3-code__t3_queue_read", "mcp__t3-code__html_preview", "mcp__t3-code__html_render", ]; diff --git a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts index 50ae6c74678f..f6eeb9a00ede 100644 --- a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts +++ b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts @@ -45,7 +45,7 @@ const make = Effect.gen(function* () { commandId: CommandId.make(`server:delegated-task-question:${key}`), threadId: task.threadId, messageId: MessageId.make(`message:delegated-task-question:${key}`), - text: `${situation} Read it with t3_pending_request_read using threadId ${task.childThreadId} and requestId ${request.id}. Answer it with t3_pending_request_respond when the answer follows from the task or the user's instructions; otherwise ask the user, then answer it. If it is no longer pending, it was answered or dismissed.`, + text: `${situation} Read it with t3_pending_request action read, threadId ${task.childThreadId}, and requestId ${request.id}. Answer it with action respond when the answer follows from the task or the user's instructions; otherwise ask the user, then answer it. If it is no longer pending, it was answered or dismissed.`, notification: { source: { kind: "delegated_task", taskIds: [task.id], childThreadId: task.childThreadId }, outcome: "updated", diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index dc4af856b8c3..e41f42c8248a 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -270,9 +270,8 @@ provider session. The request becomes the V2 command the timeout expires. A wait timeout does not cancel the child; the result sets `waitTimedOut: true`, and the caller can continue with `task_status`. A pending blocking question also ends the wait with `hasPendingQuestion: true` and `waitTimedOut: false`. -If `hasPendingQuestion`, pass `threadId=childThreadId` to `t3_pending_request_list`, -`t3_pending_request_read` (with `requestId`) and `t3_pending_request_respond`, or ask -the user; the task's completion still wakes this thread. A pending live approval for the +If `hasPendingQuestion`, pass `threadId=childThreadId` to `t3_pending_request` with +action `list`, `read` (with `requestId`), or `respond`, or ask the user; the task's completion still wakes this thread. A pending live approval for the task's own unfinished work also ends the wait. Its identity appears in `pendingApproval`; it grants no approval authority. Read the fresh full request in the child thread before responding, since the durable record can outlive its provider callback. Async questions diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index a28ae17c2620..816259aa36bc 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -10,6 +10,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { + resolveT3McpSummaryAction, resolveT3McpToolDefinition, type T3McpToolDefinition, type T3McpToolSummaryAction, @@ -545,7 +546,10 @@ export function summarizeToolGroup(entries: ReadonlyArray(); const sources = new Map(); for (const entry of entries) { - const t3Action = resolveT3McpToolDefinition(workEntryToolName(entry))?.summaryAction ?? null; + const t3Action = resolveT3McpSummaryAction( + workEntryToolName(entry), + t3ToolSummaryCall(entry).input, + ); if (entry.toolSource && t3Action === null) { sources.set(entry.toolSource.key, entry.toolSource); continue; diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index df65ee6ca320..bb2820ad4428 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -200,7 +200,7 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ resultContextTransferId: Schema.NullOr(ContextTransferId), hasPendingQuestion: Schema.Boolean.annotate({ description: - "A question blocks the child and ends a wait. Answer via `t3_pending_request_*` on `childThreadId`, or the user does.", + "A question blocks the child and ends a wait. Answer via `t3_pending_request` on `childThreadId`, or the user does.", }), pendingApproval: Schema.NullOr( Schema.Struct({ requestId: RuntimeRequestId, kind: ProviderRequestKind, runId: RunId }), @@ -582,6 +582,16 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; +/** `schedule_task` creates a task, or updates one when `scheduledTaskId` is given. */ +export const OrchestratorMcpSaveScheduledTaskInput = Schema.Struct({ + ...OrchestratorMcpScheduleTaskInput.fields, + scheduledTaskId: Schema.optional(ScheduledTaskId), + prompt: Schema.optional(OrchestratorMcpPrompt), + schedule: Schema.optional(OrchestratorMcpSchedule), +}); +export type OrchestratorMcpSaveScheduledTaskInput = + typeof OrchestratorMcpSaveScheduledTaskInput.Type; + export const OrchestratorMcpScheduledTask = Schema.Struct({ scheduledTaskId: ScheduledTaskId, title: Schema.String, diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index abaa03512d1c..5862b643e00a 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -411,6 +411,52 @@ export const PreviewAutomationScrollInput = Schema.Struct({ ); export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; +/** Strict input for each `preview_act` action. */ +export const PreviewAutomationActInputs = { + click: PreviewAutomationClickInput, + type: PreviewAutomationTypeInput, + hover: PreviewAutomationHoverInput, + select: PreviewAutomationSelectInput, + drag: PreviewAutomationDragInput, + upload: PreviewAutomationUploadInput, + press: PreviewAutomationPressInput, + scroll: PreviewAutomationScrollInput, +} as const; +export type PreviewAutomationAction = keyof typeof PreviewAutomationActInputs; + +/** One flat shape for every page action. The server decodes it with the action's strict input. */ +export const PreviewAutomationActInput = Schema.Struct({ + action: Schema.Literals([ + "click", + "type", + "hover", + "select", + "drag", + "upload", + "press", + "scroll", + ]), + ...PreviewAutomationTabTargetFields, + locator: Schema.optional(Locator), + selector: Schema.optional(LegacySelector), + x: Schema.optional(Schema.Finite), + y: Schema.optional(Schema.Finite), + button: Schema.optional(Schema.Literals(["left", "right", "middle"])), + clickCount: Schema.optional(Schema.Int), + text: Schema.optional(Schema.String), + clear: Schema.optional(Schema.Boolean), + values: Schema.optional(Schema.Array(Schema.String)), + source: Schema.optional(Locator), + target: Schema.optional(Locator), + paths: Schema.optional(Schema.Array(Schema.String)), + key: Schema.optional(Schema.String), + modifiers: PreviewAutomationPressInput.fields.modifiers, + deltaX: Schema.optional(Schema.Finite), + deltaY: Schema.optional(Schema.Finite), + timeoutMs: Schema.optional(Schema.Int), +}); +export type PreviewAutomationActInput = typeof PreviewAutomationActInput.Type; + export const PreviewAutomationEvaluateInput = Schema.Struct({ ...PreviewAutomationTabTargetFields, expression: Schema.String.check(Schema.isTrimmed()) diff --git a/packages/contracts/src/threadMetadataMcp.test.ts b/packages/contracts/src/threadMetadataMcp.test.ts index 3e99b35aee6f..884741b762d6 100644 --- a/packages/contracts/src/threadMetadataMcp.test.ts +++ b/packages/contracts/src/threadMetadataMcp.test.ts @@ -19,31 +19,4 @@ describe("ThreadMetadataMcpUpdateInput", () => { decodeUpdate({ action: "regenerate_title", clientRequestId: "metadata-\udc00" }), ); }); - - it("accepts HTTP(S) pull request URLs and rejects other or malformed URLs", () => { - assert.deepEqual( - decodeUpdate({ - action: "link_pull_request", - pullRequest: { - repository: "engineering/t3code", - number: 42, - url: "https://git.corp.example/engineering/t3code/pulls/42", - }, - }).pullRequest, - { - repository: "engineering/t3code", - number: 42, - url: "https://git.corp.example/engineering/t3code/pulls/42", - }, - ); - - for (const url of ["not a URL", "javascript:alert(1)", "file:///tmp/pull-request"]) { - assert.throws(() => - decodeUpdate({ - action: "link_pull_request", - pullRequest: { repository: "pingdotgg/t3code", number: 8690, url }, - }), - ); - } - }); }); diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts index 7c132e4d45f4..8c921ae9ba60 100644 --- a/packages/contracts/src/threadMetadataMcp.ts +++ b/packages/contracts/src/threadMetadataMcp.ts @@ -4,7 +4,6 @@ import { CommandId, IsoDateTime, NonNegativeInt, - PositiveInt, ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; @@ -27,15 +26,6 @@ function hasOnlyPairedSurrogates(value: string): boolean { return true; } -function isHttpUrl(value: string): boolean { - try { - const url = new URL(value); - return url.protocol === "http:" || url.protocol === "https:"; - } catch { - return false; - } -} - const ThreadMetadataTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({ description: "New concise display title. Required only when action is rename.", }); @@ -50,62 +40,20 @@ const ThreadMetadataClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLe ) .annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); -const ThreadMetadataMcpPullRequestUrl = TrimmedNonEmptyString.check( - Schema.makeFilter((value) => - isHttpUrl(value) ? true : "Pull request URL must be a well-formed HTTP(S) URL.", - ), -).annotate({ - description: "Canonical HTTP(S) pull request URL, including self-hosted repository URLs.", -}); - -export const ThreadMetadataMcpAction = Schema.Literals([ - "rename", - "regenerate_title", - "link_pull_request", - "unlink_pull_request", -]).annotate({ - description: - "Metadata mutation: rename, regenerate_title, link_pull_request, or unlink_pull_request.", -}); +export const ThreadMetadataMcpAction = Schema.Literals(["rename", "regenerate_title"]); export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type; -export const ThreadMetadataMcpPullRequest = Schema.Struct({ - repository: TrimmedNonEmptyString.annotate({ - description: "Repository name as owner/name.", - }), - number: PositiveInt.annotate({ description: "Pull request number." }), - url: ThreadMetadataMcpPullRequestUrl, -}); -export type ThreadMetadataMcpPullRequest = typeof ThreadMetadataMcpPullRequest.Type; - export const ThreadMetadataMcpUpdateInput = Schema.Struct({ - threadId: Schema.optional(ThreadId).annotate({ - description: "Thread to update. Omit to update the calling thread.", - }), + threadId: Schema.optional(ThreadId), action: ThreadMetadataMcpAction, title: Schema.optional(ThreadMetadataTitle), - pullRequest: Schema.optional(ThreadMetadataMcpPullRequest).annotate({ - description: "Pull request to link. Required only when action is link_pull_request.", - }), clientRequestId: Schema.optional(ThreadMetadataClientRequestId), }).check( - Schema.makeFilter((input) => { - switch (input.action) { - case "rename": - return input.title !== undefined && input.pullRequest === undefined - ? true - : "rename requires title and does not accept pullRequest."; - case "link_pull_request": - return input.pullRequest !== undefined && input.title === undefined - ? true - : "link_pull_request requires pullRequest and does not accept title."; - case "regenerate_title": - case "unlink_pull_request": - return input.title === undefined && input.pullRequest === undefined - ? true - : `${input.action} does not accept title or pullRequest.`; - } - }), + Schema.makeFilter((input) => + (input.action === "rename") === (input.title !== undefined) + ? true + : "rename requires title; regenerate_title does not accept one.", + ), ); export type ThreadMetadataMcpUpdateInput = typeof ThreadMetadataMcpUpdateInput.Type; diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index fc3345d6c537..b968ca3fc124 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { T3_MCP_TOOL_NAMES, resolveT3McpToolPresentation } from "./t3McpToolPresentation.ts"; +import { + T3_MCP_TOOL_NAMES, + resolveT3McpSummaryAction, + resolveT3McpToolPresentation, +} from "./t3McpToolPresentation.ts"; describe("resolveT3McpToolPresentation", () => { it("recognizes every T3 tool across provider prefixes and completion suffixes", () => { @@ -32,3 +36,20 @@ describe("resolveT3McpToolPresentation", () => { expect(resolveT3McpToolPresentation("t3-code-thread_opencode2-adapter_not_a_tool")).toBeNull(); }); }); + +describe("resolveT3McpSummaryAction", () => { + it("summarizes an action tool by the action each call ran", () => { + expect(resolveT3McpSummaryAction("mcp__t3-code__t3_queue", { action: "list" })).toBe( + "queue-list", + ); + expect(resolveT3McpSummaryAction("t3_queue", { action: "edit" })).toBe("queue-edit"); + expect(resolveT3McpSummaryAction("t3_thread_transfer", { action: "status" })).toBe( + "thread-transfers", + ); + expect(resolveT3McpSummaryAction("t3_thread_configure", {})).toBe("thread-configuration"); + expect(resolveT3McpSummaryAction("schedule_task", { scheduledTaskId: "task-1" })).toBe( + "schedule-update", + ); + expect(resolveT3McpSummaryAction("delegate_task", { task: "x" })).toBe("delegate"); + }); +}); diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 18746a4d3d76..0548c1f7a6e6 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -190,6 +190,7 @@ const T3_MCP_TOOLS: Readonly> = { "browser", "Snapshot the preview page", ), + preview_act: tool(["Use", "Using", "Used", "the preview browser"], "browser", "browser"), preview_click: tool( ["Click", "Clicking", "Clicked", "in the preview browser"], "browser", @@ -272,6 +273,11 @@ const T3_MCP_TOOLS: Readonly> = { ["Run", "Running", "Requested a run of", "a scheduled task"], "schedule-run", ), + t3_queue: tool(["Manage", "Managing", "Managed", "queued messages"], "queue-edit"), + t3_pending_request: tool( + ["Handle", "Handling", "Handled", "pending questions"], + "question-respond", + ), 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"), @@ -297,7 +303,8 @@ const T3_MCP_TOOLS: Readonly> = { ["Read", "Reading", "Read", "thread configuration"], "thread-configuration", ), - t3_thread_configure: tool(["Set", "Setting", "Set", "thread model"], "thread-configure"), + t3_thread_configure: tool(["Use", "Using", "Used", "thread model settings"], "thread-configure"), + t3_thread_transfer: tool(["Use", "Using", "Used", "thread transfers"], "thread-fork"), 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"], @@ -388,6 +395,55 @@ export function resolveT3McpToolDefinition( return name !== null && Object.hasOwn(T3_MCP_TOOLS, name) ? T3_MCP_TOOLS[name]! : null; } +type SummaryInput = Readonly>; + +const byAction = + (summaries: Readonly>) => + ({ action }: SummaryInput) => + typeof action === "string" ? summaries[action] : undefined; + +/** Action tools summarize each call by the action it ran, not by the tool name. */ +const ACTION_SUMMARIES: Readonly< + Record T3McpToolSummaryAction | undefined> +> = { + t3_queue: byAction({ + list: "queue-list", + read: "queue-read", + edit: "queue-edit", + cancel: "queue-cancel", + reorder: "queue-reorder", + promote_to_steer: "queue-steer", + }), + t3_pending_request: byAction({ + list: "question-list", + read: "question-read", + respond: "question-respond", + }), + t3_thread_transfer: byAction({ + fork: "thread-fork", + merge_back: "thread-merge", + status: "thread-transfers", + }), + t3_thread_configure: ({ modelSelection }) => + modelSelection === undefined ? "thread-configuration" : "thread-configure", + schedule_task: ({ scheduledTaskId }) => + scheduledTaskId === undefined ? "schedule-create" : "schedule-update", +}; + +export function resolveT3McpSummaryAction( + toolName: string | null | undefined, + input: unknown, +): T3McpToolSummaryAction | null { + const definition = resolveT3McpToolDefinition(toolName); + if (definition === null) return null; + const summarize = ACTION_SUMMARIES[resolveT3McpToolName(toolName!) ?? ""]; + const fromInput = + summarize !== undefined && typeof input === "object" && input !== null + ? summarize(input as SummaryInput) + : undefined; + return fromInput ?? definition.summaryAction; +} + export function resolveT3McpToolPresentation( toolName: string | null | undefined, ): T3McpToolPresentation | null {