diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5c8f146bcadf..d9ecf5a6e94a 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -46,6 +46,8 @@ import { } from "./toolkits/preview/tools.ts"; import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts"; import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; +import { ReviewToolkitHandlersLive } from "./toolkits/review/handlers.ts"; +import { ReviewToolkit } from "./toolkits/review/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; @@ -675,6 +677,10 @@ const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe( Layer.provide(WorktreeMcpService.layer), ); +const ReviewToolkitRegistrationLive = McpServer.toolkit(ReviewToolkit).pipe( + Layer.provide(ReviewToolkitHandlersLive), +); + const PreviewControlsRegistrationLive = McpServer.toolkit(PreviewControlsToolkit).pipe( Layer.provide(PreviewControlsHandlersLive), ); @@ -724,6 +730,7 @@ export const layer = Layer.mergeAll( EnvironmentRegistrationLive, PreviewControlsRegistrationLive, WorktreeToolkitRegistrationLive, + ReviewToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, DeviceToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 132b2d3f635f..bd72977e62e9 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -716,6 +716,14 @@ describe("orchestrator MCP toolkit", () => { expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull(); yield* invoke("t3_thread_organize", { action: "unpin" }); expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).toBeNull(); + yield* invoke("t3_thread_organize", { action: "auto_settle_off" }); + expect( + (yield* orchestrator.getThreadShell(parentThreadId))?.autoSettleDisabledAt, + ).not.toBeNull(); + yield* invoke("t3_thread_organize", { action: "auto_settle_on" }); + expect( + (yield* orchestrator.getThreadShell(parentThreadId))?.autoSettleDisabledAt, + ).toBeNull(); if (parentRun === undefined || parentRun.rootNodeId === null) { return yield* Effect.die(new Error("Parent run missing.")); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index a965b0823629..f25efa2a8db5 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -176,7 +176,7 @@ export const readThread = Effect.fn("mcp.readThread")(function* < } const projection = yield* context.threads .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { - turnItemTypes: ["user_input_request"], + turnItemTypes: ["user_input_request", "approval_request"], }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 99834a5ce5b4..60762346234a 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -31,6 +31,7 @@ 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 { ReviewToolkit } from "./review/tools.ts"; import { DeviceToolkit } from "./device/tools.ts"; import { PullRequestsToolkit } from "./pullRequests/tools.ts"; import { @@ -47,6 +48,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => { OrchestratorToolkit, PreviewToolkit, WorktreeToolkit, + ReviewToolkit, ThreadToolkit, AttachmentToolkit, ProjectToolkit, diff --git a/apps/server/src/mcp/toolkits/review/handlers.ts b/apps/server/src/mcp/toolkits/review/handlers.ts new file mode 100644 index 000000000000..80bdf1853311 --- /dev/null +++ b/apps/server/src/mcp/toolkits/review/handlers.ts @@ -0,0 +1,82 @@ +import { OrchestratorMcpFailure, type ReviewDiffPreviewInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Project from "../../../project/ProjectService.ts"; +import * as Review from "../../../review/ReviewService.ts"; +import { readThread, unavailable } from "../../threadAccess.ts"; +import { ReviewToolkit } from "./tools.ts"; + +const DEFAULT_DIFF_CHARACTERS = 20_000; + +const invalid = (message: string) => + new OrchestratorMcpFailure({ code: "invalid_request", message }); + +export const ReviewToolkitHandlersLive = ReviewToolkit.toLayer({ + t3_thread_diff: (input) => + Effect.gen(function* () { + const { + projection: { thread }, + } = yield* readThread(input.threadId); + const projects = yield* Project.ProjectService; + const project = yield* projects.getById(thread.projectId).pipe(Effect.mapError(unavailable)); + if (Option.isNone(project)) return yield* invalid("The project was not found."); + const review = yield* Review.ReviewService; + // A file request reads a single source; branch-range covers committed and uncommitted work. + const sourceKind = input.source ?? (input.file === undefined ? undefined : "branch-range"); + const readPreview = (baseRef: string | undefined, file?: ReviewDiffPreviewInput["file"]) => + review + .getDiffPreview({ + cwd: thread.worktreePath ?? project.value.workspaceRoot, + ...(baseRef === undefined ? {} : { baseRef }), + ...(file === undefined ? {} : { file }), + }) + .pipe( + Effect.mapError((error) => { + switch (error._tag) { + case "VcsRepositoryDetectionError": + case "VcsUnsupportedOperationError": + return invalid(error.detail); + // A baseRef with no common commit with HEAD is the caller's mistake. + case "GitCommandError": + return error.operation === "GitVcsDriver.resolveReviewMergeBase" + ? invalid(error.detail) + : unavailable(); + default: + return unavailable(); + } + }), + ); + let preview; + if (input.file !== undefined && sourceKind !== undefined) { + // A renamed file needs its old path, which only the full preview's stats know. The lookup + // is best effort: a working-tree read must not fail on a branch range it does not need. + const full = yield* readPreview(input.baseRef).pipe(Effect.orElseSucceed(() => undefined)); + const previousPath = + full?.sources + .find((source) => source.kind === sourceKind) + ?.files?.find((file) => file.path === input.file)?.previousPath ?? null; + preview = yield* readPreview(input.baseRef, { path: input.file, previousPath, sourceKind }); + } else { + // The working tree needs no base, so a baseRef the branch range can't use is not passed. + preview = yield* readPreview(sourceKind === "working-tree" ? undefined : input.baseRef); + } + if (preview.sources.length === 0) + return yield* invalid("The thread's checkout is not a git repository."); + const maxCharacters = input.maxCharacters ?? DEFAULT_DIFF_CHARACTERS; + return { + threadId: thread.id, + cwd: preview.cwd, + sources: preview.sources + .filter((source) => sourceKind === undefined || source.kind === sourceKind) + .map((source) => ({ + kind: source.kind, + title: source.title, + baseRef: source.baseRef, + headRef: source.headRef, + files: source.files ?? null, + diff: source.diff.slice(0, maxCharacters), + truncated: source.truncated || source.diff.length > maxCharacters, + })), + }; + }), +}); diff --git a/apps/server/src/mcp/toolkits/review/tools.ts b/apps/server/src/mcp/toolkits/review/tools.ts new file mode 100644 index 000000000000..6e7f5f3d6536 --- /dev/null +++ b/apps/server/src/mcp/toolkits/review/tools.ts @@ -0,0 +1,61 @@ +import { + OrchestratorMcpFailure, + ReviewDiffFileStat, + ReviewDiffPreviewSource, + ReviewDiffPreviewSourceKind, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as ReviewService from "../../../review/ReviewService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const MAX_DIFF_CHARACTERS = 100_000; + +const ThreadDiffTool = Tool.make("t3_thread_diff", { + description: + "Show what a thread changed in its checkout (omit threadId for this thread), as the diff panel does. Returns two sources: working-tree (uncommitted changes vs HEAD) and branch-range (committed and uncommitted changes since the merge base with baseRef, or the detected base branch). Each has per-file stats and a unified diff cut to maxCharacters (default 20,000, max 100,000) with truncated set when cut. Pass source to return one. Pass file (repository-relative path) for that file's patch only; it reads branch-range unless source says otherwise.", + parameters: Schema.Struct({ + threadId: Schema.optional(ThreadId), + baseRef: Schema.optional(TrimmedNonEmptyString), + source: Schema.optional(ReviewDiffPreviewSourceKind), + file: Schema.optional(Schema.NonEmptyString), + maxCharacters: Schema.optional( + Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_DIFF_CHARACTERS })), + ), + }), + success: Schema.Struct({ + threadId: ThreadId, + cwd: Schema.String, + sources: Schema.Array( + Schema.Struct({ + kind: ReviewDiffPreviewSource.fields.kind, + title: ReviewDiffPreviewSource.fields.title, + baseRef: ReviewDiffPreviewSource.fields.baseRef, + headRef: ReviewDiffPreviewSource.fields.headRef, + // Null when there were too many untracked files to count. + files: Schema.NullOr(Schema.Array(ReviewDiffFileStat)), + diff: Schema.String, + truncated: Schema.Boolean, + }), + ), + }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ProjectService.ProjectService, + ReviewService.ReviewService, + ], +}) + .annotate(Tool.Title, "Show thread diff") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ReviewToolkit = Toolkit.make(ThreadDiffTool); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index b3c3b75eb32e..7a5bd8e6bb80 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -3,11 +3,16 @@ import { type RuntimeRequestId, ThreadId, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, type RunId, OrchestratorMcpFailure, type OrchestrationV2Command, + type ProviderApprovalDecision, + ProviderRequestKind, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { modelSelectionCommandType } from "@t3tools/shared/model"; import { @@ -49,7 +54,19 @@ const dispatch = Effect.fn("mcp.dispatchThreadCommand")(function* ( return { sequence: result.sequence }; }); -const readQuestion = Effect.fn("mcp.readQuestion")(function* ( +const isApprovalKind = Schema.is(ProviderRequestKind); +// The composer's choices when a provider advertises none. +const defaultApprovalDecisions: ReadonlyArray = [ + "cancel", + "decline", + "acceptForSession", + "accept", +]; +/** Pending requests a caller can act on: user questions and approvals. */ +const isPendingRequest = (request: OrchestrationV2ThreadProjection["runtimeRequests"][number]) => + request.status === "pending" && (request.kind === "user_input" || isApprovalKind(request.kind)); + +const readPendingRequest = Effect.fn("mcp.readPendingRequest")(function* ( input: { threadId?: ThreadId | undefined; requestId: RuntimeRequestId; @@ -60,21 +77,56 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( ? readWritableThread(input.threadId, ["runtimeRequests", "turnItems"]) : readThread(input.threadId, ["runtimeRequests", "turnItems"]); const request = context.projection.runtimeRequests.find( - (request) => - request.id === input.requestId && - request.kind === "user_input" && - request.status === "pending", + (request) => request.id === input.requestId && isPendingRequest(request), ); const item = context.projection.turnItems.find( - (item) => item.type === "user_input_request" && item.requestId === input.requestId, + (item) => + (item.type === "user_input_request" || item.type === "approval_request") && + item.requestId === input.requestId, ); - if (request === undefined || item?.type !== "user_input_request") + if ( + request === undefined || + (request.kind === "user_input" && item?.type !== "user_input_request") + ) return yield* new OrchestratorMcpFailure({ code: "invalid_request", - message: "The pending user-input request was not found.", + message: "The pending request was not found.", }); return { ...context, request, item }; }); + +/** Mirrors the client's early wake: a fresh completion or failure after the snooze. */ +function raisedHandWhileSnoozed(shell: OrchestrationV2ThreadShell) { + const completedAt = shell.latestRunCompletedAt ?? null; + const snoozedAt = shell.snoozedAt ?? null; + if (snoozedAt === null) return shell.status === "failed"; + return ( + (shell.status === "completed" || shell.status === "failed") && + completedAt !== null && + DateTime.isGreaterThan(completedAt, snoozedAt) + ); +} + +/** Why a thread needs attention, if it does. unread mirrors the client's hasUnseenCompletion. */ +function inboxReason(shell: OrchestrationV2ThreadShell, now: DateTime.Utc) { + if (shell.pendingRuntimeRequest !== null) return "pending_request" as const; + if (shell.settledOverride === "settled") return null; + if ( + shell.snoozedUntil != null && + DateTime.isGreaterThan(shell.snoozedUntil, now) && + !raisedHandWhileSnoozed(shell) + ) + return null; + if (shell.status === "failed") return "error" as const; + const completedAt = shell.latestRunCompletedAt ?? null; + const visitedAt = shell.lastVisitedAt ?? null; + return completedAt !== null && + visitedAt !== null && + DateTime.isGreaterThan(completedAt, visitedAt) + ? ("unread" as const) + : null; +} +const inboxReasonOrder = ["pending_request", "error", "unread"] as const; export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ run_scheduled_task_now: (input) => Effect.gen(function* () { @@ -197,31 +249,134 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ t3_pending_request_list: (input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]); + const requests = projection.runtimeRequests + .filter(isPendingRequest) + .map((request) => ({ requestId: request.id, kind: request.kind })); return { - requestIds: projection.runtimeRequests - .filter((request) => request.kind === "user_input" && request.status === "pending") - .map((request) => request.id), + // The original output: user questions only. + requestIds: requests + .filter((request) => request.kind === "user_input") + .map((request) => request.requestId), + requests, }; }), t3_pending_request_read: (input) => Effect.gen(function* () { - const { item } = yield* readQuestion(input); - return { requestId: input.requestId, questions: item.questions }; + const { request, item } = yield* readPendingRequest(input); + const approval = item?.type === "approval_request" ? item : undefined; + return { + requestId: input.requestId, + kind: request.kind, + ...(item?.type === "user_input_request" ? { questions: item.questions } : {}), + ...(approval?.prompt === undefined + ? {} + : { prompt: Array.from(approval.prompt).slice(0, 4000).join("") }), + ...(approval?.options === undefined ? {} : { options: approval.options }), + }; }), t3_pending_request_respond: (input) => Effect.gen(function* () { - const { threads, projection } = yield* readQuestion(input, true); + const { threads, projection, request, item } = yield* readPendingRequest(input, true); + const approval = request.kind !== "user_input"; + const response = approval + ? input.decision === undefined + ? undefined + : { decision: input.decision } + : input.answers === undefined + ? undefined + : { answers: input.answers }; + if (response === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: approval ? "Approvals need a decision." : "User questions need answers.", + }); + // Like the composer, offer only the provider's options, else the same defaults. + const offered = item?.type === "approval_request" ? item.options : undefined; + if ( + approval && + input.decision !== undefined && + !(offered?.map((option) => option.decision) ?? defaultApprovalDecisions).includes( + input.decision, + ) + ) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "That decision was not offered for this approval.", + }); + // Approving lets the caller run commands in the target thread; declining or cancelling + // only stops one, which any caller that can reach the thread may do. + if (approval && input.decision !== "decline" && input.decision !== "cancel") + yield* readFullAccessCaller( + "Approving requires a live full-access/default thread or a full-access client.", + ); const result = yield* threads .dispatch({ type: "runtime-request.respond", threadId: projection.thread.id, commandId: yield* newCommandId(), requestId: input.requestId, - answers: input.answers, + ...response, }) .pipe(Effect.mapError(unavailable)); return { sequence: result.sequence }; }), + t3_pending_request_dismiss: (input) => + Effect.gen(function* () { + const { threads, projection, request } = yield* readPendingRequest(input, true); + if (request.kind !== "user_input") + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Only user questions can be dismissed. Decline an approval with a decision.", + }); + const result = yield* threads + .dispatch({ + type: "thread.user-input.dismiss", + threadId: projection.thread.id, + commandId: yield* newCommandId(), + requestId: input.requestId, + }) + .pipe(Effect.mapError(unavailable)); + return { sequence: result.sequence }; + }), + t3_inbox: (input) => + Effect.gen(function* () { + const { threads, caller } = yield* readCaller(); + // Like t3_thread_search, an omitted project means the caller's own; a client outside + // a thread sees every project. + const projectId = input.projectId ?? caller?.projectId; + const snapshot = yield* threads.getShellSnapshot().pipe(Effect.mapError(unavailable)); + const now = yield* DateTime.now; + const items = snapshot.threads + .flatMap((shell) => { + if (shell.archivedAt !== null || shell.deletedAt !== null) return []; + if (projectId !== undefined && shell.projectId !== projectId) return []; + const reason = inboxReason(shell, now); + return reason === null ? [] : [{ shell, reason }]; + }) + .toSorted( + (left, right) => + inboxReasonOrder.indexOf(left.reason) - inboxReasonOrder.indexOf(right.reason) || + DateTime.toEpochMillis(right.shell.updatedAt) - + DateTime.toEpochMillis(left.shell.updatedAt), + ); + return { + items: items.slice(0, input.limit ?? 50).map(({ shell, reason }) => ({ + threadId: shell.id, + projectId: shell.projectId, + title: shell.title, + updatedAt: DateTime.formatIso(shell.updatedAt), + reason, + ...(shell.pendingRuntimeRequest === null + ? {} + : { + pendingRequest: { + requestId: shell.pendingRuntimeRequest.id, + kind: shell.pendingRuntimeRequest.kind, + }, + }), + })), + }; + }), t3_queue_list: (input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); @@ -297,6 +452,34 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ case "mark_unread": command = { ...common, type: "thread.mark-unread" }; break; + case "mark_read": { + // The reverse of mark_unread: visit up to the thread's current state, as a client does. + const shell = yield* threads + .getThreadShell(projection.thread.id) + .pipe(Effect.mapError(unavailable)); + if (shell === null) return yield* unavailable(); + command = { + ...common, + type: "thread.visit", + visitedAt: DateTime.formatIso(shell.updatedAt), + }; + break; + } + case "auto_settle_on": + case "auto_settle_off": + command = { + ...common, + type: "thread.auto-settle.set", + enabled: input.action === "auto_settle_on", + }; + break; + case "delete": + // Deleting cannot be undone, so it needs full access on top of reaching the thread. + yield* readFullAccessCaller( + "Deleting a thread requires a live full-access/default thread or a full-access client.", + ); + command = { ...common, type: "thread.delete" }; + break; default: command = { ...common, type: `thread.${input.action}` }; } diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index bc2188037246..1551b9fa9d35 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -11,6 +11,9 @@ import { ProviderInteractionMode, RuntimeRequestId, ProviderUserInputAnswers, + ProviderApprovalDecision, + ProviderApprovalOption, + OrchestrationV2RuntimeRequest, IsoDateTime, OrchestratorMcpFailure, OrchestrationV2DispatchCommandResult, @@ -30,7 +33,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.", + "Pin, snooze, settle, archive, mark read or unread, toggle auto-settle, or permanently delete a thread. Omit threadId for this thread. snooze requires snoozedUntil. delete cannot be undone and requires a full-access/default caller. Existing thread lifecycle rules apply; this does not schedule a future action.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), action: Schema.Literals([ @@ -42,7 +45,11 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { "unsettle", "archive", "unarchive", + "mark_read", "mark_unread", + "auto_settle_on", + "auto_settle_off", + "delete", ]), snoozedUntil: Schema.optional(IsoDateTime), }), @@ -140,23 +147,34 @@ const question = Schema.Struct({ allowCustomAnswer: Schema.optional(Schema.Boolean), required: Schema.optional(Schema.Boolean), }); +const pendingRequestKind = OrchestrationV2RuntimeRequest.fields.kind; const pendingRequest = Schema.Struct({ requestId: RuntimeRequestId, - questions: Schema.Array(question), + kind: pendingRequestKind, + /** Present for user_input requests. */ + questions: Schema.optional(Schema.Array(question)), + /** Present for approvals when the provider supplied them. */ + prompt: Schema.optional(Schema.String), + options: Schema.optional(Schema.Array(ProviderApprovalOption)), }); const PendingRequestListTool = Tool.make("t3_pending_request_list", { ...commandTool, description: - "List pending user questions in a thread. Omit threadId for this thread. Approval requests are not included.", + "List pending user questions (kind user_input) and approval requests in a thread. Omit threadId for this thread.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), - success: Schema.Struct({ requestIds: Schema.Array(RuntimeRequestId) }), + success: Schema.Struct({ + requestIds: Schema.Array(RuntimeRequestId), + requests: Schema.Array( + Schema.Struct({ requestId: RuntimeRequestId, kind: pendingRequestKind }), + ), + }), }) .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); const PendingRequestReadTool = Tool.make("t3_pending_request_read", { ...commandTool, description: - "Read a pending user question. Answer with t3_pending_request_respond; existing live or message response handling is used.", + "Read a pending user question or approval request. Respond with t3_pending_request_respond; existing live or message response handling is used.", parameters: Schema.Struct(requestTarget), success: pendingRequest, }) @@ -165,11 +183,21 @@ const PendingRequestReadTool = Tool.make("t3_pending_request_read", { const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", { ...commandTool, description: - "Answer a pending user-input request using the existing runtime response command. This cannot approve a permission request.", - parameters: Schema.Struct({ ...requestTarget, answers: ProviderUserInputAnswers }), + "Respond to a pending request using the existing runtime response command: answers for a user question, decision for an approval (one of the options from t3_pending_request_read, else cancel, decline, acceptForSession, or accept). Approving requires a full-access/default caller; declining or cancelling does not.", + parameters: Schema.Struct({ + ...requestTarget, + answers: Schema.optional(ProviderUserInputAnswers), + decision: Schema.optional(ProviderApprovalDecision), + }), }) .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, true); +const PendingRequestDismissTool = Tool.make("t3_pending_request_dismiss", { + ...commandTool, + description: + "Dismiss a pending user question without answering it. Only questions answered by message can be dismissed; others need an answer or an interrupt.", + parameters: Schema.Struct(requestTarget), +}).annotate(Tool.Destructive, true); const ThreadConfigurationTool = Tool.make("t3_thread_configuration", { ...commandTool, @@ -250,6 +278,32 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { .annotate(Tool.Readonly, true) .annotate(Tool.Destructive, false); +const InboxTool = Tool.make("t3_inbox", { + ...commandTool, + description: + "List active threads that need attention: pending requests first, then failed runs, then unread completed work, newest first. Limited to one project (projectId, else the calling thread's project); a caller outside a T3 thread that omits projectId sees every project. Settled and snoozed threads only appear for pending requests.", + parameters: Schema.Struct({ + projectId: Schema.optional(ProjectId), + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 }))), + }), + success: Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: Schema.String, + updatedAt: IsoDateTime, + reason: Schema.Literals(["pending_request", "error", "unread"]), + pendingRequest: Schema.optional( + Schema.Struct({ requestId: RuntimeRequestId, kind: pendingRequestKind }), + ), + }), + ), + }), +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", { ...commandTool, description: @@ -278,6 +332,8 @@ export const ThreadToolkit = Toolkit.make( PendingRequestListTool, PendingRequestReadTool, PendingRequestRespondTool, + PendingRequestDismissTool, + InboxTool, ThreadOrganizeTool, QueueListTool, QueueReadTool, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index c9d6955a634d..52f48c86304c 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -17,6 +17,7 @@ import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdap import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; +import * as ReviewService from "../../../review/ReviewService.ts"; import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts"; import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; @@ -38,6 +39,7 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}), + Layer.mock(ReviewService.ReviewService)({}), ); const ToolsListPayload = Schema.fromJsonString( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b837575c1faf..9efa63b9ca1f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -55,6 +55,7 @@ import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; +import { ReviewToolkit } from "../../mcp/toolkits/review/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; @@ -648,6 +649,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(OrchestratorToolkit.tools), ...Object.values(ThreadToolkit.tools), ...Object.values(WorktreeToolkit.tools), + ...Object.values(ReviewToolkit.tools), ...Object.values(ProjectToolkit.tools), ...Object.values(EnvironmentToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0aca36abe77c..6a550737d691 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -937,9 +937,11 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_thread_transfers", "mcp__t3-code__t3_worktree_status", "mcp__t3-code__t3_worktree_list", + "mcp__t3-code__t3_thread_diff", "mcp__t3-code__t3_project_list", "mcp__t3-code__t3_project_read", "mcp__t3-code__t3_thread_search", + "mcp__t3-code__t3_inbox", "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", "mcp__t3-code__t3_queue_list", diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index 8ae001ea7769..499149907a6d 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -51,7 +51,7 @@ describe("summarizeT3ToolCalls", () => { completed({ requestId: "request-1" }), completed({ requestId: "request-2" }), ]).label, - ).toBe("Answered 2 pending question requests"); + ).toBe("Responded to 2 pending requests"); }); it("counts attachments in distinct messages and falls back when attachment counts are missing", () => { diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index c6b40e5a62ff..edbd26da1e06 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -269,20 +269,20 @@ export function summarizeT3ToolCalls( ); break; case "question-list": - label = phrase("Listed", "list", `pending questions ${times}`); + label = phrase("Listed", "list", `pending requests ${times}`); break; case "question-read": label = phrase( "Read", "read", - quantity(countEntities(entityIds("requestId")), "pending question request"), + quantity(countEntities(entityIds("requestId")), "pending request"), ); break; case "question-respond": label = phrase( - "Answered", - "answer", - quantity(countEntities(entityIds("requestId")), "pending question request"), + "Responded to", + "respond to", + quantity(countEntities(entityIds("requestId")), "pending request"), ); break; case "worktree-handoff": diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 8a73dd70fb73..06314bba741f 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -302,7 +302,11 @@ describe("resolveWorkEntryToolPresentation", () => { ["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_pending_request_respond", + "Responding to pending requests", + "Responded to pending requests", + ], ["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"], diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index d85104cf4bc1..586c82a1483b 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -234,15 +234,18 @@ const T3_MCP_TOOLS: Readonly> = { ["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_list: tool(["List", "Listing", "Listed", "pending requests"], "question-list"), + t3_pending_request_read: tool(["Read", "Reading", "Read", "pending requests"], "question-read"), t3_pending_request_respond: tool( - ["Answer", "Answering", "Answered", "pending questions"], + ["Respond to", "Responding to", "Responded to", "pending requests"], + "question-respond", + ), + t3_pending_request_dismiss: tool( + ["Dismiss", "Dismissing", "Dismissed", "a pending question"], "question-respond", ), + t3_inbox: tool(["Check", "Checking", "Checked", "threads needing attention"], "thread-list"), + t3_thread_diff: tool(["Read", "Reading", "Read", "a thread diff"], "thread-read"), t3_thread_configuration: tool( ["Read", "Reading", "Read", "thread configuration"], "thread-configuration",