From cb54c3b0caefb7d60462cbd809b7430afe7019b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 17:00:25 -0700 Subject: [PATCH 01/13] feat(mcp): support thread attachments --- apps/server/src/mcp/AttachmentMcpService.ts | 139 +++++++++ apps/server/src/mcp/McpHttpServer.ts | 9 + .../mcp/OrchestratorMcpAttachments.test.ts | 140 +++++++++ .../OrchestratorMcpService.activity.test.ts | 14 + .../src/mcp/OrchestratorMcpService.test.ts | 13 + apps/server/src/mcp/OrchestratorMcpService.ts | 272 ++++++++++++++---- ...OrchestratorMcpToolkit.integration.test.ts | 172 +++++++++++ .../attachment/attachment.integration.test.ts | 122 ++++++++ .../src/mcp/toolkits/attachment/handlers.ts | 22 ++ .../src/mcp/toolkits/attachment/tools.ts | 44 +++ .../src/mcp/toolkits/orchestrator/handlers.ts | 3 +- .../src/mcp/toolkits/orchestrator/tools.ts | 8 +- .../toolkits/worktree/registration.test.ts | 8 + .../src/orchestration-v2/AttachmentClaims.ts | 7 +- .../src/orchestration-v2/Orchestrator.ts | 4 + .../ThreadManagementService.test.ts | 63 ++++ .../ThreadManagementService.ts | 31 +- .../orchestrator-mcp-server.md | 60 +++- docs/user/composer.md | 5 + packages/contracts/src/attachmentMcp.test.ts | 39 +++ packages/contracts/src/attachmentMcp.ts | 69 +++++ packages/contracts/src/index.ts | 1 + .../contracts/src/orchestratorMcp.test.ts | 39 +++ packages/contracts/src/orchestratorMcp.ts | 57 +++- .../shared/src/t3McpToolPresentation.test.ts | 11 + packages/shared/src/t3McpToolPresentation.ts | 2 + 26 files changed, 1275 insertions(+), 79 deletions(-) create mode 100644 apps/server/src/mcp/AttachmentMcpService.ts create mode 100644 apps/server/src/mcp/OrchestratorMcpAttachments.test.ts create mode 100644 apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts create mode 100644 apps/server/src/mcp/toolkits/attachment/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/attachment/tools.ts create mode 100644 packages/contracts/src/attachmentMcp.test.ts create mode 100644 packages/contracts/src/attachmentMcp.ts diff --git a/apps/server/src/mcp/AttachmentMcpService.ts b/apps/server/src/mcp/AttachmentMcpService.ts new file mode 100644 index 000000000000..6b724575eabd --- /dev/null +++ b/apps/server/src/mcp/AttachmentMcpService.ts @@ -0,0 +1,139 @@ +import { + AttachmentMcpFailure, + IsoDateTime, + PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, + type AttachmentMcpDiscardUploadInput, + type AttachmentMcpDiscardUploadResult, + type AttachmentMcpPrepareUploadInput, + type AttachmentMcpPrepareUploadResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { + deletePendingAttachment, + issueAttachmentUploadUrl, + validateAttachmentUploadToken, +} from "../assets/AttachmentUpload.ts"; +import { + parseThreadSegmentFromAttachmentId, + PENDING_ATTACHMENT_THREAD_SEGMENT, +} from "../attachmentStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; + +export class AttachmentMcpService extends Context.Service< + AttachmentMcpService, + { + readonly prepareUpload: ( + scope: McpInvocationScope, + input: AttachmentMcpPrepareUploadInput, + ) => Effect.Effect; + readonly discardUpload: ( + scope: McpInvocationScope, + input: AttachmentMcpDiscardUploadInput, + ) => Effect.Effect; + } +>()("t3/mcp/AttachmentMcpService") {} + +function failure(code: AttachmentMcpFailure["code"], message: string): AttachmentMcpFailure { + return new AttachmentMcpFailure({ code, message }); +} + +const requireCapability = (scope: McpInvocationScope) => + scope.capabilities.has("orchestration") + ? Effect.void + : Effect.fail( + failure( + "capability_denied", + "This MCP credential does not grant orchestration capabilities.", + ), + ); + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const fileSystem = yield* FileSystem.FileSystem; + const issueUpload = (input: Parameters[0]) => + issueAttachmentUploadUrl(input).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), + ); + const discardPending = (attachmentId: string) => + deletePendingAttachment(attachmentId).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + const validateUploadToken = (token: string) => + validateAttachmentUploadToken(token).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), + ); + + return AttachmentMcpService.of({ + prepareUpload: (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + const uploadInput = + input.type === "file" + ? { + type: "file" as const, + name: input.name, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + } + : { + type: "image" as const, + name: input.name, + mimeType: + input.mimeType as (typeof PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES)[number], + sizeBytes: input.sizeBytes, + }; + const issued = yield* issueUpload(uploadInput).pipe( + Effect.mapError(() => + failure("upload_error", "Unable to prepare a signed attachment upload."), + ), + ); + return { + attachmentId: issued.attachmentId, + type: input.type ?? "image", + name: input.name, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + upload: { + method: "PUT", + relativeUrl: issued.relativeUrl, + expiresAt: IsoDateTime.make(DateTime.formatIso(DateTime.makeUnsafe(issued.expiresAt))), + }, + }; + }), + discardUpload: (scope, input) => + Effect.gen(function* () { + yield* requireCapability(scope); + if ( + parseThreadSegmentFromAttachmentId(input.attachmentId) !== + PENDING_ATTACHMENT_THREAD_SEGMENT + ) { + return yield* failure( + "invalid_attachment", + "Only a pending upload can be discarded; thread-owned attachments are immutable here.", + ); + } + const token = input.uploadRelativeUrl.split("/").at(-1) ?? ""; + const claims = yield* validateUploadToken(token); + if (claims?.attachmentId !== input.attachmentId) { + return yield* failure( + "invalid_attachment", + "The signed upload URL does not authorize this pending attachment id.", + ); + } + yield* discardPending(input.attachmentId); + return { attachmentId: input.attachmentId, discarded: true }; + }), + }); +}); + +export const layer = Layer.effect(AttachmentMcpService, make); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index aeb47996e690..57d54952315f 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -10,6 +10,7 @@ import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; +import * as AttachmentMcpService from "./AttachmentMcpService.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "./ThreadMetadataMcpService.ts"; @@ -29,6 +30,8 @@ import { import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts"; import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; +import { AttachmentToolkitHandlersLive } from "./toolkits/attachment/handlers.ts"; +import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -233,6 +236,11 @@ export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit Layer.provide(WorktreeMcpService.layer), ); +export const AttachmentToolkitRegistrationLive = McpServer.toolkit(AttachmentToolkit).pipe( + Layer.provide(AttachmentToolkitHandlersLive), + Layer.provide(AttachmentMcpService.layer), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -244,4 +252,5 @@ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, WorktreeToolkitRegistrationLive, + AttachmentToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts b/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts new file mode 100644 index 000000000000..d26e26fbebb3 --- /dev/null +++ b/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts @@ -0,0 +1,140 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + ChatAttachmentId, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ServerProvider, + ThreadId, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { createPendingAttachmentId } from "../attachmentStore.ts"; +import * as ServerConfig from "../config.ts"; +import { + ThreadManagementPostDispatchProjectionError, + ThreadManagementService, +} from "../orchestration-v2/ThreadManagementService.ts"; +import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; +import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { layer, OrchestratorMcpService } from "./OrchestratorMcpService.ts"; + +it.effect( + "retains fresh accepted claims and releases unused replay claims after projection errors", + () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:mcp-attachment-cleanup"); + const threadId = ThreadId.make("thread:mcp-attachment-cleanup"); + const projection = { + thread: { + id: threadId, + projectId, + runtimeMode: "full-access", + interactionMode: "default", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }, + deletedAt: null, + }, + messages: [], + } as unknown as OrchestrationV2ThreadProjection; + const dispatchReplayed = yield* Ref.make(false); + const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-attachment-cleanup-", + }).pipe(Layer.provide(NodeServices.layer)); + const dependencies = Layer.mergeAll( + NodeServices.layer, + configLayer, + Layer.mock(ThreadManagementService)({ + getThreadProjection: () => Effect.succeed(projection), + sendToThread: (input) => + Ref.get(dispatchReplayed).pipe( + Effect.flatMap((replayed) => + Effect.fail( + new ThreadManagementPostDispatchProjectionError({ + projectId, + threadId, + messageId: input.messageId, + dispatchReplayed: replayed, + cause: new Error("projection unavailable after accepted dispatch"), + }), + ), + ), + ), + }), + Layer.mock(ProviderRegistry)({ + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + } as unknown as ServerProvider, + ]), + }), + Layer.mock(ScheduledTaskService)({}), + ); + const testLayer = layer.pipe(Layer.provideMerge(dependencies)); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService; + const config = yield* ServerConfig.ServerConfig; + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-attachment-cleanup"), + threadId, + providerSessionId: "provider-session:mcp-attachment-cleanup", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const stage = (name: string) => { + const id = createPendingAttachmentId(); + if (id === null) throw new Error("Expected a pending attachment id."); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `${id}.png`), + Buffer.from([1, 2, 3, 4]), + ); + return { + type: "image" as const, + id: ChatAttachmentId.make(id), + name, + mimeType: "image/png", + sizeBytes: 4, + }; + }; + const claimedFiles = () => + NodeFS.readdirSync(config.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ); + + const freshError = yield* service + .sendToThread(scope, { + threadId, + attachments: [stage("fresh.png")], + clientRequestId: "fresh-accepted-claim", + }) + .pipe(Effect.flip); + expect(freshError.code).toBe("orchestration_error"); + expect(claimedFiles()).toHaveLength(1); + + yield* Ref.set(dispatchReplayed, true); + const replayError = yield* service + .sendToThread(scope, { + threadId, + attachments: [stage("replay.png")], + clientRequestId: "replayed-accepted-claim", + }) + .pipe(Effect.flip); + expect(replayError.code).toBe("orchestration_error"); + expect(claimedFiles()).toHaveLength(1); + }).pipe(Effect.provide(testLayer)); + }), +); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index ccb4393684cf..e09a4c240e79 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -12,11 +12,13 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "vite-plus/test"; import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import * as ServerConfig from "../config.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import { layer as orchestratorMcpServiceLayer, @@ -136,6 +138,10 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), NodeCrypto.layer, + NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-activity-", + }).pipe(Layer.provide(NodeServices.layer)), ), ), ); @@ -185,6 +191,10 @@ it("readThread prefers waiting activity status over a newer cancelled queued run list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), NodeCrypto.layer, + NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-activity-", + }).pipe(Layer.provide(NodeServices.layer)), ), ), ); @@ -291,6 +301,10 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy list: () => Effect.succeed({ tasks: [] }), } satisfies Partial), NodeCrypto.layer, + NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-activity-", + }).pipe(Layer.provide(NodeServices.layer)), ), ), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index d1fd1975003a..9ef3f50a1bca 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -15,6 +15,7 @@ import * as Ref from "effect/Ref"; import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerConfig from "../config.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; @@ -51,6 +52,9 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const dependencies = Layer.mergeAll( NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-service-", + }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(ThreadManagementService)({ getThreadProjection: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), @@ -124,6 +128,9 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const dependencies = Layer.mergeAll( NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-service-", + }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(ThreadManagementService)({ getThreadProjection: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), @@ -186,6 +193,9 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const dependencies = Layer.mergeAll( NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-service-", + }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(ThreadManagementService)({ getThreadProjection: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), @@ -251,6 +261,9 @@ describe("OrchestratorMcpService", () => { } as unknown as OrchestrationV2ThreadProjection; const dependencies = Layer.mergeAll( NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-service-", + }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(ThreadManagementService)({ getThreadProjection: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ea0abc1e9785..11ff2c81ea1d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,4 +1,5 @@ import { + type ChatAttachment, CommandId, isProviderAvailable, MessageId, @@ -56,11 +57,17 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { isBuiltInProviderAdapterDriverV2 } from "../orchestration-v2/builtInProviderAdapterDrivers.ts"; +import { + attachmentIsPendingUpload, + claimPendingAttachments, + releaseClaimedAttachments, +} from "../orchestration-v2/AttachmentClaims.ts"; import { subagentResultForRun } from "../orchestration-v2/SubagentProjection.ts"; import { isActiveRun, @@ -71,7 +78,9 @@ import { ThreadManagementService, } from "../orchestration-v2/ThreadManagementService.ts"; import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; +import { isOpenCodeNativeFilePart } from "../provider/opencodeRuntime.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerConfig from "../config.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; @@ -84,6 +93,7 @@ const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000; interface ResolvedTarget { readonly modelSelection: ModelSelection; + readonly provider: ServerProvider; } type TerminalTaskStatus = Extract< @@ -159,6 +169,42 @@ function failure(code: OrchestratorMcpFailure["code"], message: string): Orchest return new OrchestratorMcpFailure({ code, message }); } +function providerAttachmentKinds(provider: ServerProvider): ReadonlyArray<"image" | "file"> { + switch (provider.driver) { + case "codex": + case "claudeAgent": + case "cursor": + case "grok": + return ["image"]; + case "opencode": + return ["image", "file"]; + default: + return []; + } +} + +function validateProviderAttachments( + provider: ServerProvider, + attachments: ReadonlyArray, +): Effect.Effect { + if (attachments.length === 0) return Effect.void; + const kinds = providerAttachmentKinds(provider); + const unsupported = attachments.find( + (attachment) => + (attachment.type !== "image" && attachment.type !== "file") || + !kinds.includes(attachment.type) || + (provider.driver === "opencode" && !isOpenCodeNativeFilePart(attachment)), + ); + return unsupported === undefined + ? Effect.void + : Effect.fail( + failure( + "invalid_request", + `Provider ${provider.instanceId} does not support attachment '${unsupported.name}' (${unsupported.type}, ${unsupported.mimeType}) through MCP. Check orchestrator_capabilities.providers[].attachmentKinds before sending.`, + ), + ); +} + function threadManagementFailure(error: ThreadManagementError): OrchestratorMcpFailure { switch (error._tag) { case "ThreadManagementThreadNotFoundError": @@ -173,6 +219,7 @@ function threadManagementFailure(error: ThreadManagementError): OrchestratorMcpF case "ThreadManagementProjectionLoadError": case "ThreadManagementProjectThreadsListError": case "ThreadManagementDurableRunProjectionError": + case "ThreadManagementPostDispatchProjectionError": return failure("orchestration_error", error.message); } } @@ -707,15 +754,64 @@ function timelineItem(input: { title: input.row.item.title, text: textTruncated ? `${text.slice(0, input.maxChars)}\n…[truncated]` : text, textTruncated, + attachments: message?.attachments ?? [], updatedAt: DateTime.formatIso(input.row.item.updatedAt), }; } +function sameAttachment(left: ChatAttachment, right: ChatAttachment): boolean { + return ( + left.id === right.id && + left.type === right.type && + left.name === right.name && + left.mimeType === right.mimeType && + left.sizeBytes === right.sizeBytes + ); +} + +function validateAttachmentOwnership( + target: OrchestrationV2ThreadProjection | null, + attachments: ReadonlyArray, +): Effect.Effect { + const owned = target?.messages.flatMap((message) => message.attachments) ?? []; + const unowned = attachments.find( + (attachment) => + !attachmentIsPendingUpload(attachment) && + !owned.some((candidate) => sameAttachment(candidate, attachment)), + ); + return unowned === undefined + ? Effect.void + : Effect.fail( + failure( + "invalid_request", + `Attachment '${unowned.id}' is not a pending upload or an attachment owned by the target thread.`, + ), + ); +} + +function dispatchAcceptedError( + error: ThreadManagementError, +): { readonly accepted: true; readonly replayed: boolean } | { readonly accepted: false } { + return error._tag === "ThreadManagementDurableRunProjectionError" || + error._tag === "ThreadManagementPostDispatchProjectionError" + ? { accepted: true, replayed: error.dispatchReplayed } + : { accepted: false }; +} + const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig.ServerConfig; const threadManagement = yield* ThreadManagementService; const providerRegistry = yield* ProviderRegistry; const scheduledTasks = yield* ScheduledTaskService; + const claimAttachments = (input: Parameters[0]) => + claimPendingAttachments(input).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + ); + const releaseAttachments = (paths: ReadonlyArray) => + releaseClaimedAttachments(paths).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("orchestration") @@ -858,6 +954,7 @@ const make = Effect.gen(function* () { } return { + provider, modelSelection: instanceId === inheritedSelection.instanceId && model === inheritedSelection.model && @@ -1165,6 +1262,7 @@ const make = Effect.gen(function* () { })) ?? [], canRunChildTask: constraints.length === 0, canRunCrossProviderChildTask: constraints.length === 0, + attachmentKinds: [...providerAttachmentKinds(provider)], constraints: [...constraints], }; }), @@ -1176,6 +1274,8 @@ const make = Effect.gen(function* () { threadManagement: true, incrementalThreadRead: true, scheduledTasks: true, + attachmentReferences: true, + attachmentUploadPreparation: true, maxBatchThreads: 20, }, }; @@ -1414,6 +1514,8 @@ const make = Effect.gen(function* () { target: request.target, providers, }); + const requestedAttachments = request.attachments ?? []; + yield* validateProviderAttachments(target.provider, requestedAttachments); const runtimeMode = yield* resolveRuntimeMode( parent.thread.runtimeMode, request.runtimeMode, @@ -1461,38 +1563,50 @@ const make = Effect.gen(function* () { ), ), ); - if (request.prompt !== undefined) { - yield* threadManagement - .dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "mcp", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "dispatch-thread", - index, - }), - threadId, - messageId: stableMessageId({ - scope, - requestKey: key, - index, - }), - text: request.prompt, - attachments: [], - modelSelection: target.modelSelection, - dispatchMode: { type: "start_immediately" }, - }) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to start thread ${index + 1}: ${errorMessage(error)}`, + yield* validateAttachmentOwnership(null, requestedAttachments); + const initialMessageId = stableMessageId({ + scope, + requestKey: key, + index, + }); + yield* Effect.gen(function* () { + const claimed = yield* claimAttachments({ + threadId, + attachments: requestedAttachments, + }).pipe(Effect.mapError((error) => failure("invalid_request", error.message))); + if (request.prompt !== undefined || claimed.attachments.length > 0) { + const dispatch = yield* threadManagement + .dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "mcp", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "dispatch-thread", + index, + }), + threadId, + messageId: initialMessageId, + text: request.prompt ?? "", + attachments: claimed.attachments, + modelSelection: target.modelSelection, + dispatchMode: { type: "start_immediately" }, + }) + .pipe( + Effect.tapError(() => releaseAttachments(claimed.claimedPaths)), + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to start thread ${index + 1}: ${errorMessage(error)}`, + ), ), - ), - ); - } + ); + if (dispatch.replayed === true) { + yield* releaseAttachments(claimed.claimedPaths); + } + } + }).pipe(Effect.uninterruptible); const projection = yield* loadProjection(threadId); const run = projection.runs.at(-1); yield* threadManagement @@ -1527,6 +1641,9 @@ const make = Effect.gen(function* () { creationSource: projection.thread.creationSource, providerInstanceId: target.modelSelection.instanceId, model: target.modelSelection.model, + attachments: + projection.messages.find((message) => message.id === initialMessageId) + ?.attachments ?? [], } satisfies OrchestratorMcpCreatedThread; }), { concurrency: 1 }, @@ -1640,38 +1757,74 @@ const make = Effect.gen(function* () { requestKey: key, operation: "thread-send", }); - const result = yield* threadManagement - .sendToThread({ - projectId: parent.thread.projectId, - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "thread-send", - }), - threadId: input.threadId, - messageId, - text: input.message, - attachments: [], - mode, - createdBy: "agent", - creationSource: "mcp", - }) - .pipe( - Effect.mapError((error) => - isThreadManagementError(error) - ? threadManagementFailure(error) - : failure( - "orchestration_error", - `Unable to send to thread ${input.threadId}: ${errorMessage(error)}`, - ), - ), + const requestedAttachments = input.attachments ?? []; + if (requestedAttachments.length > 0) { + const providers = yield* loadProviders; + const provider = providers.find( + (candidate) => candidate.instanceId === target.thread.modelSelection.instanceId, ); + if (provider === undefined) { + return yield* failure( + "provider_unavailable", + `Provider instance ${target.thread.modelSelection.instanceId} is not registered.`, + ); + } + yield* validateProviderAttachments(provider, requestedAttachments); + } + yield* validateAttachmentOwnership(target, requestedAttachments); + const result = yield* Effect.gen(function* () { + const claimed = yield* claimAttachments({ + threadId: input.threadId, + attachments: requestedAttachments, + }).pipe(Effect.mapError((error) => failure("invalid_request", error.message))); + return yield* threadManagement + .sendToThread({ + projectId: parent.thread.projectId, + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "thread-send", + }), + threadId: input.threadId, + messageId, + text: input.message ?? "", + attachments: claimed.attachments, + mode, + createdBy: "agent", + creationSource: "mcp", + }) + .pipe( + Effect.tap((sent) => + sent.dispatch.replayed === true + ? releaseAttachments(claimed.claimedPaths) + : Effect.void, + ), + Effect.tapError((error) => { + if (!isThreadManagementError(error)) { + return releaseAttachments(claimed.claimedPaths); + } + const accepted = dispatchAcceptedError(error); + return accepted.accepted && !accepted.replayed + ? Effect.void + : releaseAttachments(claimed.claimedPaths); + }), + Effect.mapError((error) => + isThreadManagementError(error) + ? threadManagementFailure(error) + : failure( + "orchestration_error", + `Unable to send to thread ${input.threadId}: ${errorMessage(error)}`, + ), + ), + ); + }).pipe(Effect.uninterruptible); return { threadId: input.threadId, messageId, runId: result.run.id, status: result.run.status, delivery: result.delivery, + attachments: result.message.attachments, } satisfies OrchestratorMcpThreadSendResult; }), waitForThread: (scope, input) => @@ -1740,5 +1893,10 @@ const make = Effect.gen(function* () { export const layer: Layer.Layer< OrchestratorMcpService, never, - Crypto.Crypto | ThreadManagementService | ProviderRegistry | ScheduledTaskService + | Crypto.Crypto + | FileSystem.FileSystem + | ServerConfig.ServerConfig + | ThreadManagementService + | ProviderRegistry + | ScheduledTaskService > = Layer.effect(OrchestratorMcpService, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index d9e06a5bacb6..89727c444acb 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,7 +1,12 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { CommandId, + ChatAttachmentId, EnvironmentId, IsoDateTime, MessageId, @@ -72,6 +77,11 @@ import { } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ServerConfig from "../config.ts"; +import { + createPendingAttachmentId, + parseThreadSegmentFromAttachmentId, +} from "../attachmentStore.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; @@ -133,6 +143,7 @@ interface CapturedTurn { readonly instanceId: ProviderInstanceId; readonly threadId: ThreadId; readonly text: string; + readonly attachments: ProviderAdapterV2TurnInput["message"]["attachments"]; } function unsupported(driver: ProviderDriverKind, detail: string) { @@ -249,6 +260,7 @@ function makeDeterministicAdapter(input: { instanceId: input.instanceId, threadId: turnInput.threadId, text: turnInput.message.text, + attachments: turnInput.message.attachments, }, ]); const eventTime = yield* DateTime.now; @@ -613,11 +625,15 @@ describe("orchestrator MCP toolkit", () => { runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), }), ); + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-orchestrator-toolkit-", + }).pipe(Layer.provide(NodeServices.layer)); const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(orchestrationLayer), Layer.provide(providerRegistryLayer), Layer.provide(scheduledTaskStubLayer), + Layer.provideMerge(serverConfigLayer), Layer.provide(NodeServices.layer), ); @@ -1241,10 +1257,12 @@ describe("orchestrator MCP toolkit", () => { expect.objectContaining({ providerInstanceId: claudeInstanceId, canRunCrossProviderChildTask: true, + attachmentKinds: ["image"], }), expect.objectContaining({ providerInstanceId: "opencode", canRunChildTask: true, + attachmentKinds: ["image", "file"], }), // Models advertise their option descriptors so agents can // discover valid target.options ids and values. @@ -1412,6 +1430,7 @@ describe("orchestrator MCP toolkit", () => { instanceId: claudeInstanceId, threadId: delegated.childThreadId, text: delegatedPrompt, + attachments: [], }, ]); expect( @@ -1921,6 +1940,159 @@ describe("orchestrator MCP toolkit", () => { ), ).toHaveLength(2); + const serverConfig = yield* ServerConfig.ServerConfig; + const unsupportedPendingId = createPendingAttachmentId(); + if (unsupportedPendingId === null) { + return yield* Effect.die("Expected a pending attachment id."); + } + const unsupportedFile = { + type: "file", + id: ChatAttachmentId.make(unsupportedPendingId), + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 4, + } as const; + NodeFS.writeFileSync( + NodePath.join(serverConfig.attachmentsDir, `${unsupportedPendingId}.txt`), + Buffer.from("test"), + ); + const claimedBeforeUnsupported = NodeFS.readdirSync(serverConfig.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ); + const unsupportedSend = yield* invoke("t3_thread_send", { + threadId: emptyThread.threadId, + attachments: [unsupportedFile], + clientRequestId: "attachment-unsupported-provider-1", + }); + expect(unsupportedSend.structuredContent).toMatchObject({ code: "invalid_request" }); + expect( + NodeFS.existsSync( + NodePath.join(serverConfig.attachmentsDir, `${unsupportedPendingId}.txt`), + ), + ).toBe(true); + expect( + NodeFS.readdirSync(serverConfig.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ), + ).toEqual(claimedBeforeUnsupported); + + const pendingId = createPendingAttachmentId(); + if (pendingId === null) return yield* Effect.die("Expected a pending attachment id."); + const pendingAttachment = { + type: "image", + id: ChatAttachmentId.make(pendingId), + name: "mcp-screen.png", + mimeType: "image/png", + sizeBytes: 4, + } as const; + NodeFS.writeFileSync( + NodePath.join(serverConfig.attachmentsDir, `${pendingId}.png`), + Buffer.from([1, 2, 3, 4]), + ); + const attachmentStartInput = { + title: "Attachment-only thread", + attachments: [pendingAttachment], + clientRequestId: "attachment-only-start-1", + } as const; + const attachmentStartCall = yield* invoke("t3_thread_start", attachmentStartInput); + expect(attachmentStartCall.isError).toBe(false); + const attachmentThread = yield* decodeCreatedThread( + attachmentStartCall.structuredContent, + ).pipe(Effect.orDie); + expect(attachmentThread.attachments).toHaveLength(1); + expect(attachmentThread.attachments[0]?.id).not.toBe(pendingId); + const claimedAttachment = attachmentThread.attachments[0]!; + const attachmentProjection = yield* waitForProjection( + orchestrator, + attachmentThread.threadId, + (projection) => projection.runs.some((run) => run.status === "completed"), + ); + expect( + attachmentProjection.messages.find((message) => message.role === "user"), + ).toEqual(expect.objectContaining({ text: "", attachments: [claimedAttachment] })); + expect( + NodeFS.existsSync(NodePath.join(serverConfig.attachmentsDir, `${pendingId}.png`)), + ).toBe(true); + const claimedPrefix = `${parseThreadSegmentFromAttachmentId(claimedAttachment.id)}-`; + expect( + NodeFS.readdirSync(serverConfig.attachmentsDir).filter((entry) => + entry.startsWith(claimedPrefix), + ), + ).toHaveLength(1); + expect( + (yield* Ref.get(capturedTurns)).find( + (turn) => turn.threadId === attachmentThread.threadId, + )?.attachments, + ).toEqual([claimedAttachment]); + + const repeatedAttachmentStart = yield* decodeCreatedThread( + (yield* invoke("t3_thread_start", attachmentStartInput)).structuredContent, + ).pipe(Effect.orDie); + expect(repeatedAttachmentStart.threadId).toBe(attachmentThread.threadId); + expect(repeatedAttachmentStart.attachments).toEqual([claimedAttachment]); + // A retry reclaims the pending source with a new id before the + // command receipt is replayed; that unused copy is removed. + expect( + NodeFS.readdirSync(serverConfig.attachmentsDir).filter((entry) => + entry.startsWith(claimedPrefix), + ), + ).toHaveLength(1); + + const attachmentRead = yield* decodeThreadReadResult( + (yield* invoke("t3_thread_read", { + threadId: attachmentThread.threadId, + limit: 1, + })).structuredContent, + ).pipe(Effect.orDie); + expect(attachmentRead.items[0]?.attachments).toEqual([claimedAttachment]); + + const ownedAttachmentSend = yield* decodeThreadSendResult( + (yield* invoke("t3_thread_send", { + threadId: attachmentThread.threadId, + attachments: [claimedAttachment], + clientRequestId: "attachment-owned-send-1", + })).structuredContent, + ).pipe(Effect.orDie); + expect(ownedAttachmentSend.attachments).toEqual([claimedAttachment]); + const crossThreadAttachment = yield* invoke("t3_thread_send", { + threadId: emptyThread.threadId, + message: "Do not accept another thread's attachment.", + attachments: [claimedAttachment], + clientRequestId: "attachment-cross-thread-reject-1", + }); + expect(crossThreadAttachment.structuredContent).toMatchObject({ + code: "invalid_request", + }); + + const rejectedPendingId = createPendingAttachmentId(); + if (rejectedPendingId === null) { + return yield* Effect.die("Expected a pending attachment id."); + } + const rejectedPending = { + ...pendingAttachment, + id: ChatAttachmentId.make(rejectedPendingId), + name: "rejected.png", + }; + NodeFS.writeFileSync( + NodePath.join(serverConfig.attachmentsDir, `${rejectedPendingId}.png`), + Buffer.from([1, 2, 3, 4]), + ); + const claimedFilesBeforeRejectedSteer = NodeFS.readdirSync( + serverConfig.attachmentsDir, + ).filter((entry) => !entry.startsWith("pending-")); + const rejectedSteer = yield* invoke("t3_thread_send", { + threadId: emptyThread.threadId, + attachments: [rejectedPending], + mode: "steer", + clientRequestId: "attachment-rejected-steer-1", + }); + expect(rejectedSteer.structuredContent).toMatchObject({ code: "thread_not_sendable" }); + expect( + NodeFS.readdirSync(serverConfig.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ), + ).toEqual(claimedFilesBeforeRejectedSteer); + const promptedReadCall = yield* invoke("t3_thread_read", { threadId: promptedThread.threadId, limit: 1, diff --git a/apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts b/apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts new file mode 100644 index 000000000000..c619d1862a38 --- /dev/null +++ b/apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts @@ -0,0 +1,122 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + AttachmentMcpDiscardUploadResult, + AttachmentMcpPrepareUploadResult, + EnvironmentId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer } from "effect/unstable/ai"; + +import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; +import { + storeAttachmentUpload, + validateAttachmentUploadToken, +} from "../../../assets/AttachmentUpload.ts"; +import * as ServerConfig from "../../../config.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-attachment-mcp"), + threadId: ThreadId.make("thread-attachment-mcp"), + providerSessionId: "provider-session-attachment-mcp", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, +}; + +const client = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "attachment-mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-attachment-mcp-", +}).pipe(Layer.provide(NodeServices.layer)); +const attachmentInfrastructure = ServerSecretStore.layer.pipe( + Layer.provideMerge(configLayer), + Layer.provideMerge(NodeServices.layer), +); +const testLayer = McpHttpServer.AttachmentToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(attachmentInfrastructure), +); + +const decodePrepare = Schema.decodeUnknownEffect(AttachmentMcpPrepareUploadResult); +const decodeDiscard = Schema.decodeUnknownEffect(AttachmentMcpDiscardUploadResult); + +describe("attachment MCP toolkit", () => { + it.effect("prepares, uploads, and discards through the existing attachment store", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const prepareCall = yield* call("t3_attachment_prepare_upload", { + name: "screen.png", + mimeType: "image/png", + sizeBytes: 4, + }); + expect(prepareCall.isError).toBe(false); + const prepared = yield* decodePrepare(prepareCall.structuredContent).pipe(Effect.orDie); + expect(prepared).toMatchObject({ + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 4, + upload: { method: "PUT" }, + }); + + const token = prepared.upload.relativeUrl.split("/").at(-1)!; + const claims = yield* validateAttachmentUploadToken(token); + expect(claims).toMatchObject({ attachmentId: prepared.attachmentId }); + if (claims === null) return yield* Effect.die("Expected valid upload claims."); + expect(yield* storeAttachmentUpload(claims, new Uint8Array([1, 2, 3, 4]))).toEqual({ + ok: true, + }); + + const discardCall = yield* call("t3_attachment_discard_upload", { + attachmentId: prepared.attachmentId, + uploadRelativeUrl: prepared.upload.relativeUrl, + }); + expect(yield* decodeDiscard(discardCall.structuredContent).pipe(Effect.orDie)).toEqual({ + attachmentId: prepared.attachmentId, + discarded: true, + }); + // Repeating a pending-id discard is a truthful idempotent success. + expect( + yield* decodeDiscard( + (yield* call("t3_attachment_discard_upload", { + attachmentId: prepared.attachmentId, + uploadRelativeUrl: prepared.upload.relativeUrl, + })).structuredContent, + ).pipe(Effect.orDie), + ).toMatchObject({ discarded: true }); + + const claimedDiscard = yield* call("t3_attachment_discard_upload", { + attachmentId: "thread-attachment-mcp-00000000-0000-4000-8000-000000000001", + uploadRelativeUrl: prepared.upload.relativeUrl, + }); + expect(claimedDiscard.structuredContent).toMatchObject({ + _tag: "AttachmentMcpFailure", + code: "invalid_attachment", + }); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts new file mode 100644 index 000000000000..1ebf3eafab7f --- /dev/null +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; + +import { AttachmentMcpService } from "../../AttachmentMcpService.ts"; +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { AttachmentToolkit } from "./tools.ts"; + +const handlers = { + t3_attachment_prepare_upload: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* AttachmentMcpService; + return yield* service.prepareUpload(scope, input); + }), + t3_attachment_discard_upload: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const service = yield* AttachmentMcpService; + return yield* service.discardUpload(scope, input); + }), +} satisfies Parameters[0]; + +export const AttachmentToolkitHandlersLive = AttachmentToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts new file mode 100644 index 000000000000..2f9889c90c8d --- /dev/null +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -0,0 +1,44 @@ +import { + AttachmentMcpDiscardUploadInput, + AttachmentMcpDiscardUploadResult, + AttachmentMcpFailure, + AttachmentMcpPrepareUploadInput, + AttachmentMcpPrepareUploadResult, +} from "@t3tools/contracts"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import { AttachmentMcpService } from "../../AttachmentMcpService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext, AttachmentMcpService]; + +export const AttachmentPrepareUploadTool = Tool.make("t3_attachment_prepare_upload", { + description: + "Prepare a bounded image or file attachment for a T3 thread message. Upload exactly sizeBytes with HTTP PUT to the returned environment-relative signed URL, then pass the returned attachment metadata to t3_thread_start, create_threads, or t3_thread_send. This tool does not read host files or upload bytes itself; each call issues a new pending attachment id.", + parameters: AttachmentMcpPrepareUploadInput, + success: AttachmentMcpPrepareUploadResult, + failure: AttachmentMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Prepare a T3 attachment upload") + .annotate(Tool.Destructive, false) + .annotate(Tool.OpenWorld, true); + +export const AttachmentDiscardUploadTool = Tool.make("t3_attachment_discard_upload", { + description: + "Discard a pending T3 attachment upload using its id and signed uploadRelativeUrl from t3_attachment_prepare_upload. This is idempotent while the signed URL remains valid and cannot delete an attachment already claimed by a thread.", + parameters: AttachmentMcpDiscardUploadInput, + success: AttachmentMcpDiscardUploadResult, + failure: AttachmentMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Discard a pending T3 attachment") + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true); + +export const AttachmentToolkit = Toolkit.make( + AttachmentPrepareUploadTool, + AttachmentDiscardUploadTool, +); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index c72ba2da07a2..2ec7cf17dd77 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -68,7 +68,8 @@ const handlers = { ...(input.clientRequestId === undefined ? {} : { clientRequestId: input.clientRequestId }), threads: [ { - prompt: input.prompt, + ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + ...(input.attachments === undefined ? {} : { attachments: input.attachments }), ...(input.title === undefined ? {} : { title: input.title }), ...(input.target === undefined ? {} : { target: input.target }), ...(input.runtimeMode === undefined ? {} : { runtimeMode: input.runtimeMode }), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 2a884c6de109..a10635843661 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -145,7 +145,7 @@ export const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { export const CreateThreadsTool = Tool.make("create_threads", { description: - "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. If the user asks for agents, subagents, workers, delegation, or parallel help, call delegate_task once per child instead—even when selecting different providers. Use create_threads only when the user explicitly asks for separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit.", + "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. If the user asks for agents, subagents, workers, delegation, or parallel help, call delegate_task once per child instead—even when selecting different providers. Use create_threads only when the user explicitly asks for separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Attachment refs come from t3_attachment_prepare_upload; a thread may start with attachments and empty text.", parameters: OrchestratorMcpCreateThreadsInput, success: OrchestratorMcpCreateThreadsResult, failure: OrchestratorMcpFailure, @@ -158,7 +158,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { export const ThreadStartTool = Tool.make("t3_thread_start", { description: - "Create an ordinary TOP-LEVEL T3 conversation and immediately start its first turn. This is not a child agent/subagent; use delegate_task for delegated work. The new thread inherits this thread's project, checkout, provider, model, and runtime settings unless overridden. Use t3_thread_wait and t3_thread_read to collect its result.", + "Create an ordinary TOP-LEVEL T3 conversation and immediately start its first turn. This is not a child agent/subagent; use delegate_task for delegated work. The new thread inherits this thread's project, checkout, provider, model, and runtime settings unless overridden. Attachment refs come from t3_attachment_prepare_upload and may be sent with empty text. Use t3_thread_wait and t3_thread_read to collect its result.", parameters: OrchestratorMcpThreadStartInput, success: OrchestratorMcpCreatedThread, failure: OrchestratorMcpFailure, @@ -185,7 +185,7 @@ export const ThreadListTool = Tool.make("t3_thread_list", { export const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from a T3 thread in the calling project. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition.", + "Read durable state and a paginated timeline from a T3 thread in the calling project, including bounded attachment metadata on message items. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, @@ -212,7 +212,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", { export const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.", + "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. Attachment refs come from t3_attachment_prepare_upload or this target thread's own read results; attachments may be sent with empty text. clientRequestId makes retries idempotent.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 3300a869fe67..bece903dc14f 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -15,6 +15,8 @@ import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRu import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as ServerConfig from "../../../config.ts"; +import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; @@ -24,6 +26,10 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(ThreadManagementService)({}), Layer.mock(ProviderRegistry)({}), Layer.mock(ScheduledTaskService)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-registration-" }).pipe( + Layer.provide(NodeServices.layer), + ), + Layer.mock(ServerSecretStore.ServerSecretStore)({}), Layer.mock(ProjectService.ProjectService)({}), ServerSettings.layerTest({}), Layer.mock(GitWorkflowService.GitWorkflowService)({}), @@ -114,6 +120,8 @@ it.effect("production mcp layer lists worktree tools over http", () => // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); + expect(toolNames).toContain("t3_attachment_prepare_upload"); + expect(toolNames).toContain("t3_attachment_discard_upload"); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.ts b/apps/server/src/orchestration-v2/AttachmentClaims.ts index 4657cc4c0321..bf767ecc0cea 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.ts @@ -1,6 +1,7 @@ import * as FileSystem from "effect/FileSystem"; import { ChatAttachmentId, type ChatAttachment } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; import { @@ -120,7 +121,11 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA return normalized; }), { concurrency: 1 }, - ).pipe(Effect.tapError(() => releaseClaimedAttachments(claimedPaths))); + ).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) ? releaseClaimedAttachments(claimedPaths) : Effect.void, + ), + ); return { attachments, claimedPaths } satisfies ClaimedAttachments; }, ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 88fe7598325f..96168ef21090 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -174,6 +174,8 @@ export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; export interface OrchestratorV2DispatchResult { readonly sequence: number; readonly storedEvents: ReadonlyArray; + /** True when this call returned an already-accepted command receipt. */ + readonly replayed?: boolean; } export interface OrchestratorV2Shape { @@ -7101,6 +7103,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { sequence: receipt.resultSequence, storedEvents, + replayed: true, } satisfies OrchestratorV2DispatchResult; } @@ -7180,6 +7183,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { sequence: committed.receipt.resultSequence, storedEvents: committed.storedEvents, + replayed: !committed.committed, } satisfies OrchestratorV2DispatchResult; }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 570e69f6d007..0793fe11a241 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { LegacyV1ThreadImporter, LegacyV1ThreadImportError } from "./LegacyV1ThreadImporter.ts"; import { OrchestratorProjectionError, OrchestratorV2 } from "./Orchestrator.ts"; @@ -21,6 +22,7 @@ import { layerWithLegacyImporter, ThreadManagementDurableRunProjectionError, ThreadManagementProjectThreadsListError, + ThreadManagementPostDispatchProjectionError, ThreadManagementProjectionLoadError, ThreadManagementRunNotFoundError, ThreadManagementService, @@ -61,6 +63,66 @@ it("stamps authoritative provenance on commands that create threads or messages" }); }); +it.effect("marks projection failures that occur after an accepted message dispatch", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:thread-management:post-dispatch"); + const threadId = ThreadId.make("thread:thread-management:post-dispatch"); + const messageId = MessageId.make("message:thread-management:post-dispatch"); + const projection = { + thread: { + id: threadId, + projectId, + deletedAt: null, + archivedAt: null, + }, + runs: [], + } as unknown as OrchestrationV2ThreadProjection; + const reads = yield* Ref.make(0); + const projectionError = new OrchestratorProjectionError({ + threadId, + cause: new Error("projection unavailable after commit"), + }); + const testLayer = layer.pipe( + Layer.provide( + Layer.mock(OrchestratorV2)({ + getThreadProjection: () => + Ref.updateAndGet(reads, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 ? Effect.succeed(projection) : Effect.fail(projectionError), + ), + ), + dispatch: () => Effect.succeed({ sequence: 1, storedEvents: [], replayed: false }), + }), + ), + ); + + yield* Effect.gen(function* () { + const service = yield* ThreadManagementService; + const error = yield* service + .sendToThread({ + projectId, + commandId: CommandId.make("command:thread-management:post-dispatch"), + threadId, + messageId, + text: "hello", + attachments: [], + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(ThreadManagementPostDispatchProjectionError); + expect(error).toMatchObject({ + projectId, + threadId, + messageId, + dispatchReplayed: false, + }); + }).pipe(Effect.provide(testLayer)); + }), +); + it("leaves commands that do not create durable authored content unchanged", () => { const command: OrchestrationV2Command = { type: "run.interrupt", @@ -255,6 +317,7 @@ it("derives thread management messages from structural error attributes", () => const durableProjectionFailure = new ThreadManagementDurableRunProjectionError({ threadId, messageId, + dispatchReplayed: false, }); expect(durableProjectionFailure).toMatchObject({ threadId, messageId }); expect(durableProjectionFailure.message).toBe( diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 258c553b705e..e86ceaf719e2 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -243,6 +243,7 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro { threadId: ThreadId, messageId: MessageId, + dispatchReplayed: Schema.Boolean, }, ) { override get message(): string { @@ -250,6 +251,21 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro } } +export class ThreadManagementPostDispatchProjectionError extends Schema.TaggedErrorClass()( + "ThreadManagementPostDispatchProjectionError", + { + projectId: ProjectId, + threadId: ThreadId, + messageId: MessageId, + dispatchReplayed: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Message ${this.messageId} was accepted on thread ${this.threadId}, but its durable projection could not be loaded.`; + } +} + export const ThreadManagementError = Schema.Union([ ThreadManagementThreadNotFoundError, ThreadManagementRunNotFoundError, @@ -259,6 +275,7 @@ export const ThreadManagementError = Schema.Union([ ThreadManagementProjectionLoadError, ThreadManagementProjectThreadsListError, ThreadManagementDurableRunProjectionError, + ThreadManagementPostDispatchProjectionError, ]); export type ThreadManagementError = typeof ThreadManagementError.Type; @@ -522,7 +539,18 @@ const make = Effect.gen(function* () { createdBy: input.createdBy, creationSource: input.creationSource, }); - const projection = yield* getProjectThread(input); + const projection = yield* getProjectThread(input).pipe( + Effect.mapError( + (cause) => + new ThreadManagementPostDispatchProjectionError({ + projectId: input.projectId, + threadId: input.threadId, + messageId: input.messageId, + dispatchReplayed: dispatch.replayed === true, + cause, + }), + ), + ); const message = projection.messages.find((candidate) => candidate.id === input.messageId); const run = message?.runId === null || message?.runId === undefined @@ -547,6 +575,7 @@ const make = Effect.gen(function* () { return yield* new ThreadManagementDurableRunProjectionError({ threadId: input.threadId, messageId: input.messageId, + dispatchReplayed: dispatch.replayed === true, }); } const delivery: ThreadManagementSendResult["delivery"] = diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 215a350a2f04..2942e3ac0c8a 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -11,7 +11,8 @@ agent can use this endpoint to: - create one or more ordinary top-level T3 threads; - list and incrementally read project threads; - rename threads, regenerate titles, and link or unlink pull requests; -- send or steer follow-up messages; and +- send or steer follow-up messages with thread-owned attachments; +- prepare and discard pending attachment uploads; and - wait for or interrupt ordinary thread runs. These are T3 orchestration operations, not provider-native sub-agent APIs. @@ -141,7 +142,7 @@ selection model-visible without allowing a request that cannot run. ## Tool Surface -The server exposes eleven orchestration tools. +The server exposes a focused set of orchestration and attachment tools. ### `orchestrator_capabilities` @@ -151,7 +152,9 @@ Returns: - the parent runtime and interaction modes; - registered provider instances and advertised models; - whether each provider can run a child task; and -- feature flags for polling, cancellation, and batch thread creation. +- the attachment kinds each provider can accept; and +- feature flags for polling, cancellation, batch thread creation, attachment + references, and upload preparation. Unavailable providers include model-visible constraints such as missing V2 adapter support, disabled state, missing executable, or missing authentication. @@ -237,6 +240,7 @@ Creates between one and twenty ordinary top-level T3 threads: type CreateThreadsInput = { threads: Array<{ prompt?: string; + attachments?: ChatAttachment[]; title?: string; target?: { providerInstanceId?: string; @@ -252,15 +256,16 @@ type CreateThreadsInput = { Each entry independently resolves provider, model, and modes. The new threads inherit the parent's project, branch, and worktree path, but they have no -sub-agent lineage. Entries with a prompt immediately dispatch a run; entries -without a prompt remain idle. +sub-agent lineage. Entries with a prompt or attachment immediately dispatch a +run; entries with neither remain idle. ### `t3_thread_start` Creates one ordinary top-level thread and immediately dispatches its first -prompt. It is the single-thread convenience form of `create_threads` and -returns the created thread and run IDs. Use `clientRequestId` when a caller may -retry the request. +message. The message may contain text, attachments, or both. It is the +single-thread convenience form of `create_threads` and returns the created +thread and run IDs plus bounded attachment metadata. Use `clientRequestId` +when a caller may retry the request. ### `t3_thread_list` @@ -276,7 +281,8 @@ timeline. The default `messages` view returns user messages, assistant messages, and proposed plans. The `activity` view also returns summarized tool, reasoning, checkpoint, handoff, and runtime-request items. Large item text is bounded and reports whether it was truncated. `afterPosition` and -`nextPosition` support incremental reads. +`nextPosition` support incremental reads. Message rows include attachment IDs, +names, MIME types, and sizes, never attachment bytes. Thread and message results include required `createdBy` and `creationSource` provenance. MCP-created threads and user-role messages use `createdBy: "agent"` @@ -313,6 +319,42 @@ The target runtime and interaction modes may not be broader than the caller's. Stable command and message IDs are derived from `clientRequestId` for idempotent retries. +`message` may be omitted when at least one attachment is supplied. Attachment +references must either be pending uploads or exactly match an attachment +already owned by the target thread. A claimed attachment from another thread +is rejected. The server retains a pending source while claiming a +thread-scoped copy, which makes accepted-command retries safe. Failed commands +release newly claimed copies; a post-commit projection failure does not delete +the accepted message's copy. + +Provider support is explicit in +`orchestrator_capabilities.providers[].attachmentKinds`. Codex, Claude, +Cursor, and Grok accept images. OpenCode accepts supported images, text files, +and PDFs within its native direct-attachment size limit. Generic ACP providers +do not advertise attachment kinds because support is negotiated only after a +provider session starts. Requests with an unsupported kind fail before the +server creates a new thread or claims a pending upload. + +### `t3_attachment_prepare_upload` + +Allocates a pending attachment ID and returns a short-lived, signed relative +URL for an HTTP `PUT`. The request supplies the attachment name, MIME type, +size, and optional `image` or `file` kind. The tool does not read arbitrary +host files and does not accept base64 payloads. After the caller uploads the +exact number of bytes, it passes the returned metadata as an attachment to +`create_threads`, `t3_thread_start`, or `t3_thread_send`. + +Preparation is intentionally non-idempotent: every call creates a new pending +ID. This avoids treating an upload that may have partially reached the server +as complete. + +### `t3_attachment_discard_upload` + +Discards an unused pending upload. The request must include both the pending ID +and its signed upload URL, so one MCP session cannot delete a pending upload it +did not prepare. Repeating a discard is a successful no-op. Claimed, +thread-owned attachment IDs cannot be discarded through this tool. + ### `t3_thread_wait` Waits for a selected run to become `completed`, `failed`, `cancelled`, diff --git a/docs/user/composer.md b/docs/user/composer.md index 44f2954267f1..bbc23122bcd5 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,11 @@ Attach up to eight files per message. Images can be up to 10 MB; other files can be up to 50 MB, subject to the environment's upload support and limit. The agent receives them on the environment's machine. +Agents using T3 Code's built-in orchestration tools can also pass uploaded attachments into a new +thread or a follow-up message. T3 Code shares only the attachment reference and metadata between +threads; it does not place file contents in tool results. An attachment already claimed by a thread +can be reused only in that same thread. The active provider determines which file types it accepts. + Uploads begin when you add an attachment. All uploads must finish before the message can send. Retry or remove a failed upload. On web and desktop, reloading before an upload finishes requires you to attach that file again. diff --git a/packages/contracts/src/attachmentMcp.test.ts b/packages/contracts/src/attachmentMcp.test.ts new file mode 100644 index 000000000000..8b681012185a --- /dev/null +++ b/packages/contracts/src/attachmentMcp.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + AttachmentMcpDiscardUploadInput, + AttachmentMcpPrepareUploadInput, +} from "./attachmentMcp.ts"; + +const decodePrepare = Schema.decodeUnknownSync(AttachmentMcpPrepareUploadInput); + +describe("attachment MCP contracts", () => { + it("keeps upload and discard tool schemas rooted at objects", () => { + expect(Schema.toJsonSchemaDocument(AttachmentMcpPrepareUploadInput).schema.type).toBe("object"); + expect(Schema.toJsonSchemaDocument(AttachmentMcpDiscardUploadInput).schema.type).toBe("object"); + }); + + it("accepts bounded image and file uploads", () => { + expect( + decodePrepare({ name: "screen.png", mimeType: "image/png", sizeBytes: 4 }), + ).toMatchObject({ mimeType: "image/png", sizeBytes: 4 }); + expect( + decodePrepare({ + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 4, + }), + ).toMatchObject({ type: "file", mimeType: "application/pdf" }); + }); + + it("rejects unsupported image types and image-sized payloads above the image limit", () => { + expect(() => + decodePrepare({ name: "vector.svg", mimeType: "image/svg+xml", sizeBytes: 4 }), + ).toThrow(); + expect(() => + decodePrepare({ name: "large.png", mimeType: "image/png", sizeBytes: 10 * 1024 * 1024 + 1 }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/attachmentMcp.ts b/packages/contracts/src/attachmentMcp.ts new file mode 100644 index 000000000000..95cf42712049 --- /dev/null +++ b/packages/contracts/src/attachmentMcp.ts @@ -0,0 +1,69 @@ +import * as Schema from "effect/Schema"; + +import { + ChatAttachmentId, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, +} from "./chatAttachment.ts"; +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const AttachmentMcpPrepareUploadInput = Schema.Struct({ + type: Schema.optional(Schema.Literals(["image", "file"])), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), + ), +}).check( + Schema.makeFilter((input) => { + if (input.type === "file") return true; + if ( + !PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.includes( + input.mimeType as (typeof PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES)[number], + ) + ) { + return `Image mimeType must be one of: ${PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.join(", ")}.`; + } + return ( + input.sizeBytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES || + `Images must not exceed ${PROVIDER_SEND_TURN_MAX_IMAGE_BYTES} bytes.` + ); + }), +); +export type AttachmentMcpPrepareUploadInput = typeof AttachmentMcpPrepareUploadInput.Type; + +export const AttachmentMcpPrepareUploadResult = Schema.Struct({ + attachmentId: ChatAttachmentId, + type: Schema.Literals(["image", "file"]), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt, + upload: Schema.Struct({ + method: Schema.Literal("PUT"), + relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + expiresAt: IsoDateTime, + }), +}); +export type AttachmentMcpPrepareUploadResult = typeof AttachmentMcpPrepareUploadResult.Type; + +export const AttachmentMcpDiscardUploadInput = Schema.Struct({ + attachmentId: ChatAttachmentId, + uploadRelativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), +}); +export type AttachmentMcpDiscardUploadInput = typeof AttachmentMcpDiscardUploadInput.Type; + +export const AttachmentMcpDiscardUploadResult = Schema.Struct({ + attachmentId: ChatAttachmentId, + discarded: Schema.Boolean, +}); +export type AttachmentMcpDiscardUploadResult = typeof AttachmentMcpDiscardUploadResult.Type; + +export class AttachmentMcpFailure extends Schema.TaggedErrorClass()( + "AttachmentMcpFailure", + { + code: Schema.Literals(["capability_denied", "invalid_attachment", "upload_error"]), + message: Schema.String, + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 092de0f48f26..53f409c6625a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -19,6 +19,7 @@ export * from "./usageLimitSourceId.ts"; export * from "./providerPolicy.ts"; export * from "./modelSelection.ts"; export * from "./chatAttachment.ts"; +export * from "./attachmentMcp.ts"; export * from "./checkpointDiff.ts"; export * from "./model.ts"; export * from "./keybindings.ts"; diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 3e698f882c78..d949531a4bcb 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -5,6 +5,7 @@ import { OrchestratorMcpCreateThreadsInput, OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, + OrchestratorMcpProviderCapability, OrchestratorMcpThreadInterruptInput, OrchestratorMcpThreadListInput, OrchestratorMcpThreadReadInput, @@ -16,6 +17,7 @@ import { const decodeCreateThreadsInput = Schema.decodeUnknownSync(OrchestratorMcpCreateThreadsInput); const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput); const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult); +const decodeProviderCapability = Schema.decodeUnknownSync(OrchestratorMcpProviderCapability); const decodeThreadInterruptInput = Schema.decodeUnknownSync(OrchestratorMcpThreadInterruptInput); const decodeThreadListInput = Schema.decodeUnknownSync(OrchestratorMcpThreadListInput); const decodeThreadReadInput = Schema.decodeUnknownSync(OrchestratorMcpThreadReadInput); @@ -127,6 +129,43 @@ describe("orchestrator MCP contracts", () => { expect(request.threads[1]?.target?.driverKind).toBe("claudeAgent"); }); + it("accepts attachment-only thread messages and rejects empty messages", () => { + const attachment = { + type: "image", + id: "pending-00000000-0000-4000-8000-000000000001", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 4, + } as const; + + expect( + decodeThreadStartInput({ attachments: [attachment], clientRequestId: "attachment-start" }), + ).toMatchObject({ attachments: [attachment] }); + expect( + decodeThreadSendInput({ threadId: "thread-loop-1", attachments: [attachment] }), + ).toMatchObject({ attachments: [attachment] }); + expect( + decodeCreateThreadsInput({ threads: [{ attachments: [attachment] }] }).threads[0], + ).toMatchObject({ attachments: [attachment] }); + + expect(() => decodeThreadStartInput({ prompt: "" })).toThrow(); + expect(() => decodeThreadSendInput({ threadId: "thread-loop-1", message: " " })).toThrow(); + }); + + it("defaults attachment kinds for older capability responses", () => { + expect( + decodeProviderCapability({ + providerInstanceId: "codex", + driverKind: "codex", + displayName: "Codex", + models: [], + canRunChildTask: true, + canRunCrossProviderChildTask: true, + constraints: [], + }).attachmentKinds, + ).toEqual([]); + }); + it("decodes project-scoped thread orchestration requests", () => { expect( decodeThreadStartInput({ diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 98a891af273d..3169f9c9bc50 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -35,10 +35,28 @@ import { ProviderOptionSelectionValue, } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "./chatAttachment.ts"; const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({ description: "Complete task or message text for the target agent.", }); +const OrchestratorMcpMessageText = Schema.String.check(Schema.isMaxLength(120_000)).annotate({ + description: "Message text. It may be empty only when at least one attachment is supplied.", +}); +const OrchestratorMcpAttachments = Schema.Array(ChatAttachment) + .check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)) + .annotate({ + description: + "Attachment references prepared by t3_attachment_prepare_upload or already owned by the target thread.", + }); + +const hasMessageContent = (input: { + readonly text: string | undefined; + readonly attachments: ReadonlyArray | undefined; +}) => + input.text?.trim().length || (input.attachments?.length ?? 0) > 0 + ? true + : "A message requires non-empty text or at least one attachment."; const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({ description: "Optional concise display title.", }); @@ -229,12 +247,19 @@ export const OrchestratorMcpTaskCancelResult = Schema.Struct({ export type OrchestratorMcpTaskCancelResult = typeof OrchestratorMcpTaskCancelResult.Type; export const OrchestratorMcpCreateThreadRequest = Schema.Struct({ - prompt: Schema.optional(OrchestratorMcpPrompt), + prompt: Schema.optional(OrchestratorMcpMessageText), + attachments: Schema.optional(OrchestratorMcpAttachments), title: Schema.optional(OrchestratorMcpTitle), target: Schema.optional(OrchestratorMcpTarget), runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode), interactionMode: Schema.optional(OrchestratorMcpInteractionMode), -}); +}).check( + Schema.makeFilter((input) => + input.prompt === undefined && (input.attachments?.length ?? 0) === 0 + ? true + : hasMessageContent({ text: input.prompt, attachments: input.attachments }), + ), +); export type OrchestratorMcpCreateThreadRequest = typeof OrchestratorMcpCreateThreadRequest.Type; export const OrchestratorMcpCreateThreadsInput = Schema.Struct({ @@ -264,6 +289,7 @@ export const OrchestratorMcpCreatedThread = Schema.Struct({ creationSource: OrchestrationV2CreationSource, providerInstanceId: ProviderInstanceId, model: Schema.String, + attachments: Schema.Array(ChatAttachment).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }); export type OrchestratorMcpCreatedThread = typeof OrchestratorMcpCreatedThread.Type; @@ -273,13 +299,18 @@ export const OrchestratorMcpCreateThreadsResult = Schema.Struct({ export type OrchestratorMcpCreateThreadsResult = typeof OrchestratorMcpCreateThreadsResult.Type; export const OrchestratorMcpThreadStartInput = Schema.Struct({ - prompt: OrchestratorMcpPrompt, + prompt: Schema.optional(OrchestratorMcpMessageText), + attachments: Schema.optional(OrchestratorMcpAttachments), title: Schema.optional(OrchestratorMcpTitle), target: Schema.optional(OrchestratorMcpTarget), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), runtimeMode: Schema.optional(OrchestratorMcpRuntimeMode), interactionMode: Schema.optional(OrchestratorMcpInteractionMode), -}); +}).check( + Schema.makeFilter((input) => + hasMessageContent({ text: input.prompt, attachments: input.attachments }), + ), +); export type OrchestratorMcpThreadStartInput = typeof OrchestratorMcpThreadStartInput.Type; export const OrchestratorMcpThreadStatus = Schema.Union([ @@ -392,6 +423,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({ title: Schema.NullOr(Schema.String), text: Schema.NullOr(Schema.String), textTruncated: Schema.Boolean, + attachments: Schema.Array(ChatAttachment).pipe(Schema.withDecodingDefault(Effect.succeed([]))), updatedAt: IsoDateTime, }); export type OrchestratorMcpThreadTimelineItem = typeof OrchestratorMcpThreadTimelineItem.Type; @@ -407,10 +439,15 @@ export type OrchestratorMcpThreadReadResult = typeof OrchestratorMcpThreadReadRe export const OrchestratorMcpThreadSendInput = Schema.Struct({ threadId: ThreadId, - message: OrchestratorMcpPrompt, + message: Schema.optional(OrchestratorMcpMessageText), + attachments: Schema.optional(OrchestratorMcpAttachments), mode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), -}); +}).check( + Schema.makeFilter((input) => + hasMessageContent({ text: input.message, attachments: input.attachments }), + ), +); export type OrchestratorMcpThreadSendInput = typeof OrchestratorMcpThreadSendInput.Type; export const OrchestratorMcpThreadSendResult = Schema.Struct({ @@ -419,6 +456,7 @@ export const OrchestratorMcpThreadSendResult = Schema.Struct({ runId: RunId, status: OrchestrationV2RunStatus, delivery: Schema.Literals(["started", "queued", "steered", "restarted"]), + attachments: Schema.Array(ChatAttachment).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }); export type OrchestratorMcpThreadSendResult = typeof OrchestratorMcpThreadSendResult.Type; @@ -470,6 +508,9 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ ), canRunChildTask: Schema.Boolean, canRunCrossProviderChildTask: Schema.Boolean, + attachmentKinds: Schema.Array(Schema.Literals(["image", "file"])).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), constraints: Schema.Array(Schema.String), }); export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; @@ -489,6 +530,10 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ threadManagement: Schema.Boolean, incrementalThreadRead: Schema.Boolean, scheduledTasks: Schema.Boolean, + attachmentReferences: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + attachmentUploadPreparation: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), maxBatchThreads: Schema.Number, }), }); diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index 765c4f6ca822..f684e2ce0a66 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -42,6 +42,17 @@ describe("resolveT3McpToolPresentation", () => { }); }); + it("pretty prints attachment T3 MCP tool names", () => { + expect(resolveT3McpToolPresentation("mcp__t3-code__t3_attachment_prepare_upload")).toEqual({ + displayName: "Prepare a T3 attachment upload", + logo: "t3-code", + }); + expect(resolveT3McpToolPresentation("t3-code.t3_attachment_discard_upload")).toEqual({ + displayName: "Discard a pending T3 attachment", + logo: "t3-code", + }); + }); + it("pretty prints preview T3 MCP tool names", () => { expect(resolveT3McpToolPresentation("T3-code.preview_open")).toEqual({ displayName: "Open a page in the preview browser", diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index a9dcd685cc0c..8df144ec9c90 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -52,6 +52,8 @@ const T3_MCP_TOOLS: Record< t3_thread_send: { displayName: "Send to a T3 thread", summaryAction: "thread-send" }, t3_thread_wait: { displayName: "Wait for a T3 thread", summaryAction: "thread-wait" }, t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" }, + t3_attachment_prepare_upload: { displayName: "Prepare a T3 attachment upload" }, + t3_attachment_discard_upload: { displayName: "Discard a pending T3 attachment" }, t3_worktree_handoff: { displayName: "Hand off thread to a git worktree" }, t3_worktree_status: { displayName: "Get thread worktree status" }, preview_status: { displayName: "Get preview browser status" }, From 2342d30ba63b4072a818e22dd604edaaa5b5dcfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 17:19:42 -0700 Subject: [PATCH 02/13] fix(mcp): harden attachment retries --- apps/server/src/mcp/AttachmentMcpService.ts | 10 +- .../mcp/OrchestratorMcpAttachments.test.ts | 2 + apps/server/src/mcp/OrchestratorMcpService.ts | 279 ++++++++++++++---- ...OrchestratorMcpToolkit.integration.test.ts | 179 ++++++++++- .../attachment/attachment.integration.test.ts | 9 +- .../src/mcp/toolkits/attachment/tools.ts | 2 +- .../Orchestrator.migration.test.ts | 84 +++++- .../src/orchestration-v2/Orchestrator.ts | 14 +- .../ThreadManagementService.ts | 2 + .../testkit/ProviderReplayHarness.ts | 18 +- .../orchestrator-mcp-server.md | 9 +- packages/contracts/src/attachmentMcp.ts | 4 +- 12 files changed, 527 insertions(+), 85 deletions(-) diff --git a/apps/server/src/mcp/AttachmentMcpService.ts b/apps/server/src/mcp/AttachmentMcpService.ts index 6b724575eabd..c98a32b89ea1 100644 --- a/apps/server/src/mcp/AttachmentMcpService.ts +++ b/apps/server/src/mcp/AttachmentMcpService.ts @@ -97,14 +97,22 @@ const make = Effect.gen(function* () { failure("upload_error", "Unable to prepare a signed attachment upload."), ), ); + const attachment = { + id: issued.attachmentId, + type: input.type ?? "image", + name: input.name, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + } as const; return { attachmentId: issued.attachmentId, + attachment, type: input.type ?? "image", name: input.name, mimeType: input.mimeType, sizeBytes: input.sizeBytes, upload: { - method: "PUT", + method: "POST", relativeUrl: issued.relativeUrl, expiresAt: IsoDateTime.make(DateTime.formatIso(DateTime.makeUnsafe(issued.expiresAt))), }, diff --git a/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts b/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts index d26e26fbebb3..fbca52bbb900 100644 --- a/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts @@ -15,6 +15,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { createPendingAttachmentId } from "../attachmentStore.ts"; @@ -56,6 +57,7 @@ it.effect( NodeServices.layer, configLayer, Layer.mock(ThreadManagementService)({ + getCommandReceipt: () => Effect.succeed(Option.none()), getThreadProjection: () => Effect.succeed(projection), sendToThread: (input) => Ref.get(dispatchReplayed).pipe( diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 11ff2c81ea1d..df7d81454fb9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,6 +1,7 @@ import { type ChatAttachment, CommandId, + isProviderSendTurnSupportedImageMimeType, isProviderAvailable, MessageId, type ModelSelection, @@ -193,6 +194,8 @@ function validateProviderAttachments( (attachment) => (attachment.type !== "image" && attachment.type !== "file") || !kinds.includes(attachment.type) || + (attachment.type === "image" && + !isProviderSendTurnSupportedImageMimeType(attachment.mimeType)) || (provider.driver === "opencode" && !isOpenCodeNativeFilePart(attachment)), ); return unsupported === undefined @@ -798,6 +801,46 @@ function dispatchAcceptedError( : { accepted: false }; } +function acceptedMessageResult( + projection: OrchestrationV2ThreadProjection, + messageId: MessageId, + mode: "auto" | "queue" | "steer" | "restart", +): Effect.Effect< + { + readonly message: OrchestrationV2ThreadProjection["messages"][number]; + readonly run: OrchestrationV2Run; + readonly delivery: OrchestratorMcpThreadSendResult["delivery"]; + }, + OrchestratorMcpFailure +> { + const message = projection.messages.find((candidate) => candidate.id === messageId); + const run = + message?.runId === null || message?.runId === undefined + ? undefined + : projection.runs.find((candidate) => candidate.id === message.runId); + const turnItem = projection.turnItems.find( + (candidate): candidate is Extract => + candidate.type === "user_message" && candidate.messageId === messageId, + ); + if (message === undefined || run === undefined) { + return Effect.fail( + failure( + "orchestration_error", + `Accepted message ${messageId} is missing from thread ${projection.thread.id}.`, + ), + ); + } + const delivery: OrchestratorMcpThreadSendResult["delivery"] = + turnItem === undefined || turnItem.inputIntent === "queued_turn" + ? "queued" + : turnItem.inputIntent === "turn_start" + ? "started" + : mode === "restart" + ? "restarted" + : "steered"; + return Effect.succeed({ message, run, delivery }); +} + const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; @@ -812,6 +855,27 @@ const make = Effect.gen(function* () { ); const releaseAttachments = (paths: ReadonlyArray) => releaseClaimedAttachments(paths).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + const acceptedReceipt = (input: { + readonly commandId: CommandId; + readonly threadId: ThreadId; + readonly commandType: string; + }) => + threadManagement.getCommandReceipt(input.commandId).pipe( + Effect.map( + Option.exists( + (receipt) => + receipt.status === "accepted" && + receipt.threadId === input.threadId && + receipt.commandType === input.commandType, + ), + ), + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to inspect retry receipt ${input.commandId}: ${errorMessage(error)}`, + ), + ), + ); const requireCapability = (scope: McpInvocationScope) => scope.capabilities.has("orchestration") @@ -1503,19 +1567,122 @@ const make = Effect.gen(function* () { ); } const parentNodeId = parentRun.rootNodeId; - const providers = yield* loadProviders; const key = yield* requestKey(input.clientRequestId); + let providersCache: ReadonlyArray | undefined; const created = yield* Effect.forEach( input.threads, (request, index) => Effect.gen(function* () { - const target = yield* resolveTarget({ - parent, - target: request.target, - providers, + const threadId = stableThreadId({ + scope, + requestKey: key, + index, + }); + const createCommandId = stableCommandId({ + scope, + requestKey: key, + operation: "create-thread", + index, + }); + const dispatchCommandId = stableCommandId({ + scope, + requestKey: key, + operation: "dispatch-thread", + index, }); + const initialMessageId = stableMessageId({ + scope, + requestKey: key, + index, + }); + const hasInitialMessage = + request.prompt !== undefined || (request.attachments?.length ?? 0) > 0; + const dispatchAccepted = hasInitialMessage + ? yield* acceptedReceipt({ + commandId: dispatchCommandId, + threadId, + commandType: "message.dispatch", + }) + : false; + if (dispatchAccepted) { + const projection = yield* loadProjection(threadId); + const accepted = yield* acceptedMessageResult(projection, initialMessageId, "auto"); + yield* threadManagement + .dispatch({ + type: "thread.created.record", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "record-created-thread", + index, + }), + parentThreadId: scope.threadId, + parentRunId: parentRun.id, + parentNodeId, + targetThreadId: threadId, + targetRunId: accepted.run.id, + }) + .pipe( + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to record thread ${index + 1} in the parent timeline: ${errorMessage(error)}`, + ), + ), + ); + return { + threadId, + runId: accepted.run.id, + status: accepted.run.status, + title: projection.thread.title, + createdBy: projection.thread.createdBy, + creationSource: projection.thread.creationSource, + providerInstanceId: projection.thread.modelSelection.instanceId, + model: projection.thread.modelSelection.model, + attachments: accepted.message.attachments, + } satisfies OrchestratorMcpCreatedThread; + } + + const createAccepted = yield* acceptedReceipt({ + commandId: createCommandId, + threadId, + commandType: "thread.create", + }); + const existingProjection = createAccepted + ? yield* loadProjection(threadId) + : undefined; + const providers = providersCache ?? (yield* loadProviders); + providersCache = providers; + const existingProvider = + existingProjection === undefined + ? undefined + : providers.find( + (candidate) => + candidate.instanceId === + existingProjection.thread.modelSelection.instanceId, + ); + let target: ResolvedTarget; + if (existingProjection === undefined) { + target = yield* resolveTarget({ + parent, + target: request.target, + providers, + }); + } else { + if (existingProvider === undefined) { + return yield* failure( + "provider_unavailable", + `Provider instance ${existingProjection.thread.modelSelection.instanceId} is not registered.`, + ); + } + target = { + modelSelection: existingProjection.thread.modelSelection, + provider: existingProvider, + }; + } const requestedAttachments = request.attachments ?? []; yield* validateProviderAttachments(target.provider, requestedAttachments); + yield* validateAttachmentOwnership(null, requestedAttachments); const runtimeMode = yield* resolveRuntimeMode( parent.thread.runtimeMode, request.runtimeMode, @@ -1524,68 +1691,50 @@ const make = Effect.gen(function* () { parent.thread.interactionMode, request.interactionMode, ); - const threadId = stableThreadId({ - scope, - requestKey: key, - index, - }); const title = threadTitle({ parentTitle: parent.thread.title, prompt: request.prompt, title: request.title, index, }); - yield* threadManagement - .dispatch({ - type: "thread.create", - createdBy: "agent", - creationSource: "mcp", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "create-thread", - index, - }), - threadId, - projectId: parent.thread.projectId, - title, - modelSelection: target.modelSelection, - runtimeMode, - interactionMode, - branch: parent.thread.branch, - worktreePath: parent.thread.worktreePath, - }) - .pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to create thread ${index + 1}: ${errorMessage(error)}`, - ), - ), - ); - yield* validateAttachmentOwnership(null, requestedAttachments); - const initialMessageId = stableMessageId({ - scope, - requestKey: key, - index, - }); yield* Effect.gen(function* () { const claimed = yield* claimAttachments({ threadId, attachments: requestedAttachments, }).pipe(Effect.mapError((error) => failure("invalid_request", error.message))); - if (request.prompt !== undefined || claimed.attachments.length > 0) { + if (!createAccepted) { + yield* threadManagement + .dispatch({ + type: "thread.create", + createdBy: "agent", + creationSource: "mcp", + commandId: createCommandId, + threadId, + projectId: parent.thread.projectId, + title, + modelSelection: target.modelSelection, + runtimeMode, + interactionMode, + branch: parent.thread.branch, + worktreePath: parent.thread.worktreePath, + }) + .pipe( + Effect.tapError(() => releaseAttachments(claimed.claimedPaths)), + Effect.mapError((error) => + failure( + "orchestration_error", + `Unable to create thread ${index + 1}: ${errorMessage(error)}`, + ), + ), + ); + } + if (hasInitialMessage) { const dispatch = yield* threadManagement .dispatch({ type: "message.dispatch", createdBy: "agent", creationSource: "mcp", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "dispatch-thread", - index, - }), + commandId: dispatchCommandId, threadId, messageId: initialMessageId, text: request.prompt ?? "", @@ -1757,6 +1906,28 @@ const make = Effect.gen(function* () { requestKey: key, operation: "thread-send", }); + const commandId = stableCommandId({ + scope, + requestKey: key, + operation: "thread-send", + }); + if ( + yield* acceptedReceipt({ + commandId, + threadId: input.threadId, + commandType: "message.dispatch", + }) + ) { + const accepted = yield* acceptedMessageResult(target, messageId, mode); + return { + threadId: input.threadId, + messageId, + runId: accepted.run.id, + status: accepted.run.status, + delivery: accepted.delivery, + attachments: accepted.message.attachments, + } satisfies OrchestratorMcpThreadSendResult; + } const requestedAttachments = input.attachments ?? []; if (requestedAttachments.length > 0) { const providers = yield* loadProviders; @@ -1780,11 +1951,7 @@ const make = Effect.gen(function* () { return yield* threadManagement .sendToThread({ projectId: parent.thread.projectId, - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "thread-send", - }), + commandId, threadId: input.threadId, messageId, text: input.message ?? "", diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 89727c444acb..3ac0fccff4bb 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -2,11 +2,13 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { CommandId, ChatAttachmentId, + AttachmentMcpPrepareUploadResult, EnvironmentId, IsoDateTime, MessageId, @@ -49,7 +51,10 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/unstable/ai"; +import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { attachmentUploadRouteLayer } from "../http.ts"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; @@ -101,6 +106,7 @@ const queuedFollowupPrompt = "Complete the queued follow-up and return the final const queuedFollowupResult = "Queued delegated follow-up completed."; const decodeCreateThreadsResult = Schema.decodeUnknownEffect(OrchestratorMcpCreateThreadsResult); +const decodePrepareUploadResult = Schema.decodeUnknownEffect(AttachmentMcpPrepareUploadResult); const decodeCreatedThread = Schema.decodeUnknownEffect(OrchestratorMcpCreatedThread); const decodeDelegateTaskResult = Schema.decodeUnknownEffect(OrchestratorMcpDelegateTaskResult); const decodeTaskCancelResult = Schema.decodeUnknownEffect(OrchestratorMcpTaskCancelResult); @@ -628,7 +634,19 @@ describe("orchestrator MCP toolkit", () => { const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-orchestrator-toolkit-", }).pipe(Layer.provide(NodeServices.layer)); - const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( + const attachmentInfrastructure = ServerSecretStore.layer.pipe( + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const toolkitRegistrations = Layer.merge( + McpHttpServer.OrchestratorToolkitRegistrationLive, + McpHttpServer.AttachmentToolkitRegistrationLive, + ).pipe(Layer.provideMerge(attachmentInfrastructure)); + const testLayer = Layer.mergeAll( + toolkitRegistrations, + attachmentInfrastructure, + NodeHttpServer.layerTest, + ).pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(orchestrationLayer), Layer.provide(providerRegistryLayer), @@ -640,6 +658,11 @@ describe("orchestrator MCP toolkit", () => { yield* Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; const server = yield* McpServer.McpServer; + yield* HttpRouter.serve(attachmentUploadRouteLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.build); + const httpClient = yield* HttpClient.HttpClient; yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -1976,19 +1999,24 @@ describe("orchestrator MCP toolkit", () => { ), ).toEqual(claimedBeforeUnsupported); - const pendingId = createPendingAttachmentId(); - if (pendingId === null) return yield* Effect.die("Expected a pending attachment id."); - const pendingAttachment = { - type: "image", - id: ChatAttachmentId.make(pendingId), + const prepareUploadCall = yield* invoke("t3_attachment_prepare_upload", { name: "mcp-screen.png", mimeType: "image/png", sizeBytes: 4, - } as const; - NodeFS.writeFileSync( - NodePath.join(serverConfig.attachmentsDir, `${pendingId}.png`), - Buffer.from([1, 2, 3, 4]), - ); + }); + expect(prepareUploadCall.isError).toBe(false); + const preparedUpload = yield* decodePrepareUploadResult( + prepareUploadCall.structuredContent, + ).pipe(Effect.orDie); + expect(preparedUpload.upload.method).toBe("POST"); + expect(preparedUpload.attachment.id).toBe(preparedUpload.attachmentId); + const uploadResponse = yield* httpClient.post(preparedUpload.upload.relativeUrl, { + headers: { "content-length": "4" }, + body: HttpBody.text("test", "image/png"), + }); + expect(uploadResponse.status).toBe(204); + const pendingId = preparedUpload.attachmentId; + const pendingAttachment = preparedUpload.attachment; const attachmentStartInput = { title: "Attachment-only thread", attachments: [pendingAttachment], @@ -2002,10 +2030,19 @@ describe("orchestrator MCP toolkit", () => { expect(attachmentThread.attachments).toHaveLength(1); expect(attachmentThread.attachments[0]?.id).not.toBe(pendingId); const claimedAttachment = attachmentThread.attachments[0]!; - const attachmentProjection = yield* waitForProjection( - orchestrator, + if (attachmentThread.runId === null) { + return yield* Effect.die("Expected the attachment-only start to create a run."); + } + const attachmentWait = yield* decodeThreadWaitResult( + (yield* invoke("t3_thread_wait", { + threadId: attachmentThread.threadId, + runId: attachmentThread.runId, + timeoutMs: 5_000, + })).structuredContent, + ).pipe(Effect.orDie); + expect(attachmentWait.timedOut).toBe(false); + const attachmentProjection = yield* orchestrator.getThreadProjection( attachmentThread.threadId, - (projection) => projection.runs.some((run) => run.status === "completed"), ); expect( attachmentProjection.messages.find((message) => message.role === "user"), @@ -2025,19 +2062,86 @@ describe("orchestrator MCP toolkit", () => { )?.attachments, ).toEqual([claimedAttachment]); + NodeFS.unlinkSync(NodePath.join(serverConfig.attachmentsDir, `${pendingId}.png`)); const repeatedAttachmentStart = yield* decodeCreatedThread( (yield* invoke("t3_thread_start", attachmentStartInput)).structuredContent, ).pipe(Effect.orDie); expect(repeatedAttachmentStart.threadId).toBe(attachmentThread.threadId); expect(repeatedAttachmentStart.attachments).toEqual([claimedAttachment]); - // A retry reclaims the pending source with a new id before the - // command receipt is replayed; that unused copy is removed. + // Accepted retries read the durable message before mutable upload + // and provider preflight, so an expired/swept pending source does + // not create another claim or block the original receipt replay. expect( NodeFS.readdirSync(serverConfig.attachmentsDir).filter((entry) => entry.startsWith(claimedPrefix), ), ).toHaveLength(1); + const threadCountBeforeRejectedStarts = (yield* orchestrator.getShellSnapshot()).threads + .length; + const unownedStart = yield* invoke("t3_thread_start", { + attachments: [claimedAttachment], + clientRequestId: "attachment-unowned-start-1", + }); + expect(unownedStart.structuredContent).toMatchObject({ code: "invalid_request" }); + + const missingPendingId = createPendingAttachmentId(); + if (missingPendingId === null) { + return yield* Effect.die("Expected a missing pending attachment id."); + } + const claimedBeforeMissingStart = NodeFS.readdirSync( + serverConfig.attachmentsDir, + ).filter((entry) => !entry.startsWith("pending-")); + const missingStart = yield* invoke("t3_thread_start", { + attachments: [ + { + type: "image", + id: ChatAttachmentId.make(missingPendingId), + name: "missing.png", + mimeType: "image/png", + sizeBytes: 4, + }, + ], + clientRequestId: "attachment-missing-start-1", + }); + expect(missingStart.structuredContent).toMatchObject({ code: "invalid_request" }); + expect( + NodeFS.readdirSync(serverConfig.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ), + ).toEqual(claimedBeforeMissingStart); + + const svgPendingId = createPendingAttachmentId(); + if (svgPendingId === null) { + return yield* Effect.die("Expected an SVG pending attachment id."); + } + NodeFS.writeFileSync( + NodePath.join(serverConfig.attachmentsDir, `${svgPendingId}.svg`), + Buffer.from(""), + ); + const unsupportedImageStart = yield* invoke("t3_thread_start", { + attachments: [ + { + type: "image", + id: ChatAttachmentId.make(svgPendingId), + name: "vector.svg", + mimeType: "image/svg+xml", + sizeBytes: 7, + }, + ], + target: { providerInstanceId: claudeInstanceId }, + clientRequestId: "attachment-unsupported-image-start-1", + }); + expect(unsupportedImageStart.structuredContent).toMatchObject({ + code: "invalid_request", + }); + expect( + NodeFS.existsSync(NodePath.join(serverConfig.attachmentsDir, `${svgPendingId}.svg`)), + ).toBe(true); + expect((yield* orchestrator.getShellSnapshot()).threads).toHaveLength( + threadCountBeforeRejectedStarts, + ); + const attachmentRead = yield* decodeThreadReadResult( (yield* invoke("t3_thread_read", { threadId: attachmentThread.threadId, @@ -2054,6 +2158,49 @@ describe("orchestrator MCP toolkit", () => { })).structuredContent, ).pipe(Effect.orDie); expect(ownedAttachmentSend.attachments).toEqual([claimedAttachment]); + + const sendUpload = yield* decodePrepareUploadResult( + (yield* invoke("t3_attachment_prepare_upload", { + name: "follow-up.png", + mimeType: "image/png", + sizeBytes: 4, + })).structuredContent, + ).pipe(Effect.orDie); + expect( + (yield* httpClient.post(sendUpload.upload.relativeUrl, { + headers: { "content-length": "4" }, + body: HttpBody.text("next", "image/png"), + })).status, + ).toBe(204); + const pendingSendInput = { + threadId: attachmentThread.threadId, + message: "Use the uploaded follow-up.", + attachments: [sendUpload.attachment], + clientRequestId: "attachment-pending-send-retry-1", + } as const; + const pendingSend = yield* decodeThreadSendResult( + (yield* invoke("t3_thread_send", pendingSendInput)).structuredContent, + ).pipe(Effect.orDie); + const claimedAfterPendingSend = NodeFS.readdirSync(serverConfig.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ); + NodeFS.unlinkSync( + NodePath.join(serverConfig.attachmentsDir, `${sendUpload.attachmentId}.png`), + ); + const repeatedPendingSend = yield* decodeThreadSendResult( + (yield* invoke("t3_thread_send", pendingSendInput)).structuredContent, + ).pipe(Effect.orDie); + expect(repeatedPendingSend).toMatchObject({ + threadId: pendingSend.threadId, + messageId: pendingSend.messageId, + runId: pendingSend.runId, + attachments: pendingSend.attachments, + }); + expect( + NodeFS.readdirSync(serverConfig.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ), + ).toEqual(claimedAfterPendingSend); const crossThreadAttachment = yield* invoke("t3_thread_send", { threadId: emptyThread.threadId, message: "Do not accept another thread's attachment.", diff --git a/apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts b/apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts index c619d1862a38..d4e10bc2a97d 100644 --- a/apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts +++ b/apps/server/src/mcp/toolkits/attachment/attachment.integration.test.ts @@ -76,11 +76,18 @@ describe("attachment MCP toolkit", () => { expect(prepareCall.isError).toBe(false); const prepared = yield* decodePrepare(prepareCall.structuredContent).pipe(Effect.orDie); expect(prepared).toMatchObject({ + attachment: { + id: prepared.attachmentId, + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 4, + }, type: "image", name: "screen.png", mimeType: "image/png", sizeBytes: 4, - upload: { method: "PUT" }, + upload: { method: "POST" }, }); const token = prepared.upload.relativeUrl.split("/").at(-1)!; diff --git a/apps/server/src/mcp/toolkits/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 2f9889c90c8d..bda2bd7e568c 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -14,7 +14,7 @@ const dependencies = [McpInvocationContext.McpInvocationContext, AttachmentMcpSe export const AttachmentPrepareUploadTool = Tool.make("t3_attachment_prepare_upload", { description: - "Prepare a bounded image or file attachment for a T3 thread message. Upload exactly sizeBytes with HTTP PUT to the returned environment-relative signed URL, then pass the returned attachment metadata to t3_thread_start, create_threads, or t3_thread_send. This tool does not read host files or upload bytes itself; each call issues a new pending attachment id.", + "Prepare a bounded image or file attachment for a T3 thread message. Upload exactly sizeBytes with HTTP POST to upload.relativeUrl, then pass attachment unchanged to t3_thread_start, create_threads, or t3_thread_send. The signed URL is bearer authorization until upload.expiresAt. This tool does not read host files or upload bytes itself; each call issues a new pending attachment id.", parameters: AttachmentMcpPrepareUploadInput, success: AttachmentMcpPrepareUploadResult, failure: AttachmentMcpFailure, diff --git a/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts b/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts index 3fa5acfdc5cb..5c8391219d1e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.migration.test.ts @@ -1,12 +1,27 @@ -import { assert, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; +import { + CommandId, + ContextHandoffId, + OrchestrationV2Command, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import { ContextHandoffId, OrchestrationV2Command, ThreadId } from "@t3tools/contracts"; import { appendContextHandoffId, canReplayCommandReceipt, + OrchestratorV2, shouldPrepareLegacyImportHandoff, } from "./Orchestrator.ts"; +import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; it("reissues imported context until a V2 run completes", () => { assert.isTrue( @@ -63,6 +78,71 @@ it("only replays a command receipt for the thread it was recorded against", () = assert.strictEqual(canReplayCommandReceipt(threadA, threadB), false); }); +it.effect("rejects a cross-thread command id that loses the commit race", () => + Effect.gen(function* () { + const commandId = CommandId.make("command:cross-thread-commit-race"); + const arrived = yield* Ref.make(0); + const release = yield* Deferred.make(); + const testLayer = makeOrchestratorV2ReplayLayerWithRegistry( + { name: "cross-thread-command-id-race" }, + Layer.mock(ProviderAdapterRegistryV2)({}), + { + runEffectWorker: false, + transformEventSink: (delegate) => ({ + ...delegate, + commitCommand: (input) => + input.commandId !== commandId + ? delegate.commitCommand(input) + : Effect.gen(function* () { + const count = yield* Ref.updateAndGet(arrived, (value) => value + 1); + if (count === 2) yield* Deferred.succeed(release, undefined); + yield* Deferred.await(release); + return yield* delegate.commitCommand(input); + }), + }), + }, + ); + + yield* Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const create = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.create", + commandId, + threadId, + projectId: ProjectId.make("project:cross-thread-command-id-race"), + title: String(threadId), + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "agent", + creationSource: "mcp", + }); + const results = yield* Effect.all( + [ + create(ThreadId.make("thread:cross-thread-race:a")).pipe(Effect.result), + create(ThreadId.make("thread:cross-thread-race:b")).pipe(Effect.result), + ], + { concurrency: "unbounded" }, + ); + const accepted = results.filter(Result.isSuccess); + const rejected = results.filter(Result.isFailure); + expect(accepted).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.failure).toMatchObject({ + _tag: "OrchestratorCommandIdConflictError", + commandId, + }); + expect((yield* orchestrator.getShellSnapshot()).threads).toHaveLength(1); + }).pipe(Effect.provide(testLayer)); + }), +); + it("links and unlinks a pull request through thread.metadata.update (#8160)", () => { // The fold is exercised through the schema: a command carrying the link // must round-trip, and one without it must leave the field untouched. diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 96168ef21090..aca969baf5b6 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -41,7 +41,7 @@ import * as Stream from "effect/Stream"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2 } from "./CommandPolicy.ts"; -import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; +import { CommandReceiptStoreV2, type CommandReceiptStoreV2Shape } from "./CommandReceiptStore.ts"; import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; @@ -183,6 +183,7 @@ export interface OrchestratorV2Shape { readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; + readonly getCommandReceipt: CommandReceiptStoreV2Shape["getByCommandId"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -7176,6 +7177,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio detail: committed.receipt.error ?? "Previously rejected.", }); } + const dispatchThreadId = commandThreadId(command); + if (!canReplayCommandReceipt(committed.receipt.threadId, dispatchThreadId)) { + return yield* new OrchestratorCommandIdConflictError({ + commandId: command.commandId, + commandType: command.type, + receiptThreadId: committed.receipt.threadId, + commandThreadId: dispatchThreadId, + }); + } if (command.type === "delegated_task.wake-policy") { yield* mapDispatchError(command)(offerDelegatedCompletionDeliveries(command.parentThreadId)); } @@ -7339,6 +7349,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return OrchestratorV2.of({ resumeQueuedRuns, dispatch: dispatchWithReceipt, + getCommandReceipt: commandReceipts.getByCommandId, getThreadProjection: (threadId) => projectionStore .getThreadProjection(threadId) @@ -7447,6 +7458,7 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + getCommandReceipt: () => Effect.die("Orchestration V2 live runtime is not configured."), getThreadProjection: (threadId) => Effect.fail( new OrchestratorProjectionError({ diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index e86ceaf719e2..b2e7afec7703 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -288,6 +288,7 @@ export interface ThreadManagementServiceShape { readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; + readonly getCommandReceipt: OrchestratorV2["Service"]["getCommandReceipt"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; @@ -688,6 +689,7 @@ const make = Effect.gen(function* () { return ThreadManagementService.of({ ensureLegacyTranscript, dispatch, + getCommandReceipt: orchestrator.getCommandReceipt, getThreadProjection, getCheckpointContext, getThreadSnapshot, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 73750176fd39..047d5a05540a 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -29,7 +29,11 @@ import { layer as effectWorkerLayer, runDaemon as runEffectWorkerDaemon, } from "../EffectWorker.ts"; -import { layerFromStores as eventSinkLayer } from "../EventSink.ts"; +import { + EventSinkV2, + type EventSinkV2Shape, + layerFromStores as eventSinkLayer, +} from "../EventSink.ts"; import { layer as eventStoreLayer } from "../EventStore.ts"; import { layer as idAllocatorLayer } from "../IdAllocator.ts"; import { layer as orchestratorLayer } from "../Orchestrator.ts"; @@ -180,6 +184,7 @@ export function runOrchestratorV2ProviderReplayScenario< >; readonly enableLegacyTokenStreaming?: boolean; readonly runEffectWorker?: boolean; + readonly transformEventSink?: (eventSink: EventSinkV2Shape) => EventSinkV2Shape; } = {}, ): Effect.Effect< OrchestratorV2ScenarioResult, @@ -220,6 +225,7 @@ export function makeOrchestratorV2ProviderReplayLayer< readonly enableLegacyTokenStreaming?: boolean; readonly runEffectWorker?: boolean; readonly replayGate?: ProviderReplayGate; + readonly transformEventSink?: (eventSink: EventSinkV2Shape) => EventSinkV2Shape; } = {}, ): Layer.Layer { const registryLayer = harness.makeProviderAdapterRegistryLayer( @@ -239,6 +245,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( >; readonly enableLegacyTokenStreaming?: boolean; readonly runEffectWorker?: boolean; + readonly transformEventSink?: (eventSink: EventSinkV2Shape) => EventSinkV2Shape; } = {}, ): Layer.Layer { const serverConfigLayer = Layer.effect( @@ -262,9 +269,16 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( effectOutboxLayer, turnItemPositionStoreLayer, ).pipe(Layer.provide(databaseLayer)); - const eventSinkProvided = eventSinkLayer.pipe( + const baseEventSinkProvided = eventSinkLayer.pipe( Layer.provide(Layer.mergeAll(storesLayer, databaseLayer)), ); + const transformEventSink = options.transformEventSink; + const eventSinkProvided = + transformEventSink === undefined + ? baseEventSinkProvided + : Layer.effect(EventSinkV2, Effect.map(EventSinkV2, transformEventSink)).pipe( + Layer.provide(baseEventSinkProvided), + ); const commandReceiptStoreProvided = commandReceiptStoreLayer.pipe(Layer.provide(databaseLayer)); const providerEventIngestorProvided = providerEventIngestorLayer.pipe( Layer.provide(Layer.mergeAll(storesLayer, eventSinkProvided, idAllocatorLayer)), diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 2942e3ac0c8a..72aa7669c35f 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -338,10 +338,10 @@ server creates a new thread or claims a pending upload. ### `t3_attachment_prepare_upload` Allocates a pending attachment ID and returns a short-lived, signed relative -URL for an HTTP `PUT`. The request supplies the attachment name, MIME type, +URL for an HTTP `POST`. The request supplies the attachment name, MIME type, size, and optional `image` or `file` kind. The tool does not read arbitrary host files and does not accept base64 payloads. After the caller uploads the -exact number of bytes, it passes the returned metadata as an attachment to +exact number of bytes, it passes the returned `attachment` object unchanged to `create_threads`, `t3_thread_start`, or `t3_thread_send`. Preparation is intentionally non-idempotent: every call creates a new pending @@ -351,8 +351,9 @@ as complete. ### `t3_attachment_discard_upload` Discards an unused pending upload. The request must include both the pending ID -and its signed upload URL, so one MCP session cannot delete a pending upload it -did not prepare. Repeating a discard is a successful no-op. Claimed, +and its signed upload URL. The URL is bearer authorization rather than MCP +session-bound authorization, and expires at the returned time. Repeating a +discard is a successful no-op only while that URL remains valid. Claimed, thread-owned attachment IDs cannot be discarded through this tool. ### `t3_thread_wait` diff --git a/packages/contracts/src/attachmentMcp.ts b/packages/contracts/src/attachmentMcp.ts index 95cf42712049..60718d6ec321 100644 --- a/packages/contracts/src/attachmentMcp.ts +++ b/packages/contracts/src/attachmentMcp.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import { + ChatAttachment, ChatAttachmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, @@ -36,12 +37,13 @@ export type AttachmentMcpPrepareUploadInput = typeof AttachmentMcpPrepareUploadI export const AttachmentMcpPrepareUploadResult = Schema.Struct({ attachmentId: ChatAttachmentId, + attachment: ChatAttachment, type: Schema.Literals(["image", "file"]), name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), sizeBytes: NonNegativeInt, upload: Schema.Struct({ - method: Schema.Literal("PUT"), + method: Schema.Literal("POST"), relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), expiresAt: IsoDateTime, }), From 181502a1f82f5c3b1fd7e90309846d6818c32438 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 17:36:10 -0700 Subject: [PATCH 03/13] fix(mcp): reload accepted send results --- .../src/mcp/OrchestratorMcpService.test.ts | 84 +++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 3 +- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 9ef3f50a1bca..eb548a62786d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1,15 +1,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { + CommandId, EnvironmentId, + MessageId, NodeId, + ProjectId, ProviderInstanceId, RunId, ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; @@ -20,6 +25,85 @@ import type { McpInvocationScope } from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; describe("OrchestratorMcpService", () => { + it.effect("reloads the accepted send projection after an overlapping commit", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:mcp-send-replay-race"); + const projectId = ProjectId.make("project:mcp-send-replay-race"); + const runId = RunId.make("run:mcp-send-replay-race"); + const requestKey = "send-replay-race"; + const providerSessionId = "provider-session:mcp-send-replay-race"; + const messageId = MessageId.make( + `message:mcp:${encodeURIComponent(providerSessionId)}:thread-send:${requestKey}`, + ); + const staleProjection = { + thread: { + id: threadId, + projectId, + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + runs: [], + messages: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + const acceptedProjection = { + ...staleProjection, + runs: [{ id: runId, status: "queued" }], + messages: [{ id: messageId, runId, attachments: [], text: "Accepted once." }], + } as unknown as OrchestrationV2ThreadProjection; + const projectionReads = yield* Ref.make(0); + const dependencies = Layer.mergeAll( + NodeServices.layer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-mcp-send-replay-race-", + }).pipe(Layer.provide(NodeServices.layer)), + Layer.mock(ThreadManagementService)({ + getThreadProjection: () => + Ref.updateAndGet(projectionReads, (count) => count + 1).pipe( + Effect.map((count) => (count === 1 ? staleProjection : acceptedProjection)), + ), + getCommandReceipt: () => + Effect.succeed( + Option.some({ + commandId: CommandId.make("command:mcp-send-replay-race"), + threadId, + commandType: "message.dispatch", + acceptedAt: DateTime.makeUnsafe("2026-08-30T00:00:00.000Z"), + resultSequence: 2, + status: "accepted", + error: null, + }), + ), + dispatch: () => Effect.die("Accepted send replay must not dispatch again."), + }), + Layer.mock(ProviderRegistry)({ getProviders: Effect.die("Provider lookup is mutable.") }), + Layer.mock(ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-send-replay-race"), + threadId, + providerSessionId, + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service.sendToThread(scope, { + threadId, + message: "Accepted once.", + clientRequestId: requestKey, + }); + assert.equal(result.messageId, messageId); + assert.equal(result.runId, runId); + assert.equal(result.delivery, "queued"); + assert.equal(yield* Ref.get(projectionReads), 2); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + }), + ); + it.effect("retries terminal acknowledgement with a fresh command id", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-ack-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index df7d81454fb9..10b9b95efd17 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1918,7 +1918,8 @@ const make = Effect.gen(function* () { commandType: "message.dispatch", }) ) { - const accepted = yield* acceptedMessageResult(target, messageId, mode); + const acceptedProjection = yield* loadProjection(input.threadId); + const accepted = yield* acceptedMessageResult(acceptedProjection, messageId, mode); return { threadId: input.threadId, messageId, From 0d93b71046ea487ddfaa818acc52cbfb59466335 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 17:43:03 -0700 Subject: [PATCH 04/13] fix(mcp): preserve OpenCode file attachments --- .../src/mcp/OrchestratorMcpService.test.ts | 47 ++++++++++++++----- apps/server/src/mcp/OrchestratorMcpService.ts | 4 +- .../Adapters/OpenCodeAdapterV2.ts | 25 +++------- .../opencodeRuntime.cliParsers.test.ts | 33 +++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 17 +++++++ 5 files changed, 93 insertions(+), 33 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index eb548a62786d..7d6341d94091 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -12,6 +12,7 @@ import { type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -53,6 +54,9 @@ describe("OrchestratorMcpService", () => { messages: [{ id: messageId, runId, attachments: [], text: "Accepted once." }], } as unknown as OrchestrationV2ThreadProjection; const projectionReads = yield* Ref.make(0); + const committed = yield* Ref.make(false); + const receiptRequested = yield* Deferred.make(); + const releaseReceipt = yield* Deferred.make(); const dependencies = Layer.mergeAll( NodeServices.layer, ServerConfig.layerTest(process.cwd(), { @@ -61,11 +65,21 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService)({ getThreadProjection: () => Ref.updateAndGet(projectionReads, (count) => count + 1).pipe( - Effect.map((count) => (count === 1 ? staleProjection : acceptedProjection)), + Effect.flatMap((count) => + count === 1 + ? Effect.succeed(staleProjection) + : Ref.get(committed).pipe( + Effect.map((isCommitted) => + isCommitted ? acceptedProjection : staleProjection, + ), + ), + ), ), getCommandReceipt: () => - Effect.succeed( - Option.some({ + Effect.gen(function* () { + yield* Deferred.succeed(receiptRequested, undefined); + yield* Deferred.await(releaseReceipt); + return Option.some({ commandId: CommandId.make("command:mcp-send-replay-race"), threadId, commandType: "message.dispatch", @@ -73,8 +87,8 @@ describe("OrchestratorMcpService", () => { resultSequence: 2, status: "accepted", error: null, - }), - ), + }); + }), dispatch: () => Effect.die("Accepted send replay must not dispatch again."), }), Layer.mock(ProviderRegistry)({ getProviders: Effect.die("Provider lookup is mutable.") }), @@ -89,18 +103,29 @@ describe("OrchestratorMcpService", () => { issuedAt: 1, }; - yield* Effect.gen(function* () { + const call = Effect.gen(function* () { const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const result = yield* service.sendToThread(scope, { + return yield* service.sendToThread(scope, { threadId, message: "Accepted once.", clientRequestId: requestKey, }); - assert.equal(result.messageId, messageId); - assert.equal(result.runId, runId); - assert.equal(result.delivery, "queued"); - assert.equal(yield* Ref.get(projectionReads), 2); }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + const [result] = yield* Effect.all( + [ + call, + Effect.gen(function* () { + yield* Deferred.await(receiptRequested); + yield* Ref.set(committed, true); + yield* Deferred.succeed(releaseReceipt, undefined); + }), + ], + { concurrency: "unbounded" }, + ); + assert.equal(result.messageId, messageId); + assert.equal(result.runId, runId); + assert.equal(result.delivery, "queued"); + assert.equal(yield* Ref.get(projectionReads), 2); }), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 10b9b95efd17..5b49a94811ba 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -79,7 +79,6 @@ import { ThreadManagementService, } from "../orchestration-v2/ThreadManagementService.ts"; import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; -import { isOpenCodeNativeFilePart } from "../provider/opencodeRuntime.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; import * as ServerConfig from "../config.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; @@ -195,8 +194,7 @@ function validateProviderAttachments( (attachment.type !== "image" && attachment.type !== "file") || !kinds.includes(attachment.type) || (attachment.type === "image" && - !isProviderSendTurnSupportedImageMimeType(attachment.mimeType)) || - (provider.driver === "opencode" && !isOpenCodeNativeFilePart(attachment)), + !isProviderSendTurnSupportedImageMimeType(attachment.mimeType)), ); return unsupported === undefined ? Effect.void diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index ae8fa2d7263c..2431b3e0bb76 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -71,7 +71,7 @@ import { openCodeRuntimeErrorDetail, parseOpenCodeModelSlug, runOpenCodeSdk, - toOpenCodeFileParts, + toOpenCodePromptParts, toOpenCodePermissionReply, toOpenCodeQuestionAnswers, type OpenCodeRuntimeShape, @@ -79,7 +79,6 @@ import { import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; import { makeProviderFailure } from "../ProviderFailure.ts"; import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; -import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; import { ProviderAdapterEnsureThreadError, ProviderAdapterForkThreadError, @@ -2629,20 +2628,16 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid }; const resolvePromptParts = (turnInput: ProviderAdapterV2TurnInput) => { - const text = providerMessageTextWithAttachmentPaths({ + const parts = toOpenCodePromptParts({ text: turnInput.message.text, - attachments: turnInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, - }).trim(); - const files = toOpenCodeFileParts({ attachments: turnInput.message.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); - if (text.length === 0 && files.length === 0) { + if (parts.length === 0) { throw protocolError("OpenCode turns require text or at least one valid attachment"); } - return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; + return parts; }; const readSnapshot = Effect.fnUntraced(function* ( @@ -2997,12 +2992,8 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, ); } - const text = providerMessageTextWithAttachmentPaths({ + const parts = toOpenCodePromptParts({ text: steerInput.message.text, - attachments: steerInput.message.attachments, - attachmentsDir: serverConfig.attachmentsDir, - }).trim(); - const files = toOpenCodeFileParts({ attachments: steerInput.message.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ @@ -3010,13 +3001,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid attachment, }), }); - if (text.length === 0 && files.length === 0) { + if (parts.length === 0) { return yield* protocolError("OpenCode steering requires text or an attachment"); } - const parts = [ - ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), - ...files, - ]; turn.admissionGeneration = state.nextAdmissionGeneration++; turn.admissionMessageId = yield* makeOpenCodeMessageId(); turn.admissionPending = true; diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 35a7791c62f0..018119e7c508 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -7,6 +7,7 @@ import { parseModelsCliOutput, parseSkillsCliOutput, toOpenCodeFileParts, + toOpenCodePromptParts, } from "./opencodeRuntime.ts"; describe("parseModelsCliOutput", () => { @@ -318,4 +319,36 @@ describe("toOpenCodeFileParts", () => { ["application/pdf", "text/markdown", "image/png"], ); }); + + it("keeps non-native and oversized files available through resolved paths", () => { + const paths = new Map([ + ["oversized.pdf", "/tmp/oversized.pdf"], + ["archive.zip", "/tmp/archive.zip"], + ["vector.svg", "/tmp/vector.svg"], + ["native.png", "/tmp/native.png"], + ]); + const parts = toOpenCodePromptParts({ + text: "Inspect every attachment.", + attachments: [ + { ...attachment("application/pdf", 25 * 1024 * 1024), name: "oversized.pdf" }, + { ...attachment("application/zip"), name: "archive.zip" }, + { ...attachment("image/svg+xml"), name: "vector.svg" }, + { ...attachment("image/png"), name: "native.png" }, + ], + resolveAttachmentPath: (candidate) => paths.get(candidate.name) ?? null, + }); + + NodeAssert.deepEqual(parts.slice(1), [ + { + type: "file", + mime: "image/png", + filename: "native.png", + url: "file:///tmp/native.png", + }, + ]); + NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /oversized\.pdf/); + NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /archive\.zip/); + NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /vector\.svg/); + NodeAssert.doesNotMatch(parts[0]?.type === "text" ? parts[0].text : "", /native\.png/); + }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 19725d9472ca..a659d208a4e2 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -481,6 +481,23 @@ export function toOpenCodeFileParts(input: { return parts; } +export function toOpenCodePromptParts(input: { + readonly text: string; + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}) { + const nativeFiles = toOpenCodeFileParts(input); + const fallbackPaths = (input.attachments ?? []).flatMap((attachment) => { + if (isOpenCodeNativeFilePart(attachment)) return []; + const attachmentPath = input.resolveAttachmentPath(attachment); + return attachmentPath === null + ? [] + : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; + }); + const text = [input.text.trim(), fallbackPaths.join("\n")].filter(Boolean).join("\n\n"); + return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...nativeFiles]; +} + export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { if (runtimeMode === "full-access") { return [ From 90c311038719e773ae0491aa72cc59b622c6cf54 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 17:47:44 -0700 Subject: [PATCH 05/13] refactor(mcp): export attachment service constructor --- apps/server/src/mcp/AttachmentMcpService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/AttachmentMcpService.ts b/apps/server/src/mcp/AttachmentMcpService.ts index c98a32b89ea1..5d98950077dc 100644 --- a/apps/server/src/mcp/AttachmentMcpService.ts +++ b/apps/server/src/mcp/AttachmentMcpService.ts @@ -54,7 +54,7 @@ const requireCapability = (scope: McpInvocationScope) => ), ); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const secretStore = yield* ServerSecretStore.ServerSecretStore; const fileSystem = yield* FileSystem.FileSystem; From 4fdfbd9dc5ae1802ef39ef18422e0fc6ff5f1629 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 20:02:36 -0700 Subject: [PATCH 06/13] fix(server): report replayed launch messages --- .../ThreadLaunchService.test.ts | 59 ++++++++++++++++++- .../orchestration-v2/ThreadLaunchService.ts | 4 ++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 924cea782c2e..b7e16866de5f 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -15,6 +15,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -77,6 +78,9 @@ interface HarnessOptions { readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"]; readonly serverSettings?: Parameters[0]; readonly providers?: ReadonlyArray; + readonly mapCommandReceipts?: ( + service: CommandReceiptStore.CommandReceiptStoreV2["Service"], + ) => CommandReceiptStore.CommandReceiptStoreV2["Service"]; } function makeHarness(options: HarnessOptions = {}) { @@ -88,7 +92,14 @@ function makeHarness(options: HarnessOptions = {}) { { databaseLayer: database, runEffectWorker: false }, ); const threadManagement = ThreadManagement.layer.pipe(Layer.provide(orchestrator)); - const receipts = CommandReceiptStore.layer.pipe(Layer.provide(database)); + const baseReceipts = CommandReceiptStore.layer.pipe(Layer.provide(database)); + const receipts = + options.mapCommandReceipts === undefined + ? baseReceipts + : Layer.effect( + CommandReceiptStore.CommandReceiptStoreV2, + Effect.map(CommandReceiptStore.CommandReceiptStoreV2, options.mapCommandReceipts), + ).pipe(Layer.provide(baseReceipts)); const outbox = EffectOutbox.layer.pipe(Layer.provide(database)); const createWorktree = vi.fn( options.createWorktree ?? @@ -332,6 +343,52 @@ it.effect("provisions independent launches concurrently instead of behind a glob }), ); +it.effect("reports an initial-message replay that wins after the receipt preflight", () => + Effect.gen(function* () { + const receiptLookupCompleted = yield* Deferred.make(); + const allowReceiptLookup = yield* Deferred.make(); + const messageCommandId = CommandId.make("command:launch:message-replay:initial-message"); + let gated = false; + const harness = makeHarness({ + mapCommandReceipts: (service) => ({ + ...service, + getByCommandId: (commandId) => + service.getByCommandId(commandId).pipe( + Effect.flatMap((receipt) => { + if (gated || commandId !== messageCommandId || Option.isSome(receipt)) { + return Effect.succeed(receipt); + } + gated = true; + return Deferred.succeed(receiptLookupCompleted, undefined).pipe( + Effect.andThen(Deferred.await(allowReceiptLookup)), + Effect.as(receipt), + ); + }), + ), + }), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const input = launchInput({ + command: "command:launch:message-replay", + thread: "thread:launch:message-replay", + message: "Use the durable message once", + }); + + const first = yield* launches.launch(input).pipe(Effect.forkChild); + yield* Deferred.await(receiptLookupCompleted); + const accepted = yield* launches.launch(input); + yield* Deferred.succeed(allowReceiptLookup, undefined); + const replayed = yield* Fiber.join(first); + + assert.isFalse(accepted.initialMessageReplayed); + assert.isTrue(replayed.initialMessageReplayed); + assert.lengthOf(replayed.projection.messages, 1); + assert.lengthOf(replayed.projection.runs, 1); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("enqueues provider work only after setup has been initiated", () => Effect.gen(function* () { const setupEntered = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index a7695f527450..da0898de7cd7 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -75,6 +75,7 @@ export interface ThreadLaunchResult { readonly threadId: ThreadId; readonly projection: OrchestrationV2ThreadProjection; readonly resumed: boolean; + readonly initialMessageReplayed: boolean; } export class ThreadLaunchError extends Schema.TaggedErrorClass()( @@ -508,6 +509,7 @@ export const make = Effect.gen(function* () { let runId: RunId | null = null; let messageWasAlreadyAccepted = false; + let initialMessageReplayed = false; if (input.initialMessage !== undefined) { const messageCommandId = CommandId.make(`${input.commandId}:initial-message`); const messageReceipt = yield* readReceipt(input, messageCommandId); @@ -532,6 +534,7 @@ export const make = Effect.gen(function* () { creationSource: input.creationSource, }) .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId))); + initialMessageReplayed = messageWasAlreadyAccepted || dispatched.replayed === true; const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); @@ -578,6 +581,7 @@ export const make = Effect.gen(function* () { threadId, projection, resumed: Option.isSome(launchReceipt) || messageWasAlreadyAccepted, + initialMessageReplayed, }; }); }, From 172d4f7fce386ea2f1e3495bacf720095cdb36ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 29 Aug 2026 20:27:00 -0700 Subject: [PATCH 07/13] fix(mcp): preserve launched run identity --- apps/server/src/mcp/OrchestratorMcpService.ts | 9 ++++---- .../ThreadLaunchService.test.ts | 21 +++++++++++++++++-- .../orchestration-v2/ThreadLaunchService.ts | 3 +++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5b49a94811ba..a7cc6e4c3620 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1755,7 +1755,10 @@ const make = Effect.gen(function* () { } }).pipe(Effect.uninterruptible); const projection = yield* loadProjection(threadId); - const run = projection.runs.at(-1); + const accepted = hasInitialMessage + ? yield* acceptedMessageResult(projection, initialMessageId, "auto") + : undefined; + const run = accepted?.run; yield* threadManagement .dispatch({ type: "thread.created.record", @@ -1788,9 +1791,7 @@ const make = Effect.gen(function* () { creationSource: projection.thread.creationSource, providerInstanceId: target.modelSelection.instanceId, model: target.modelSelection.model, - attachments: - projection.messages.find((message) => message.id === initialMessageId) - ?.attachments ?? [], + attachments: accepted?.message.attachments ?? [], } satisfies OrchestratorMcpCreatedThread; }), { concurrency: 1 }, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index b7e16866de5f..f8da136541dd 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -369,6 +369,7 @@ it.effect("reports an initial-message replay that wins after the receipt preflig }); yield* Effect.gen(function* () { const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; const input = launchInput({ command: "command:launch:message-replay", thread: "thread:launch:message-replay", @@ -378,13 +379,29 @@ it.effect("reports an initial-message replay that wins after the receipt preflig const first = yield* launches.launch(input).pipe(Effect.forkChild); yield* Deferred.await(receiptLookupCompleted); const accepted = yield* launches.launch(input); + const acceptedRunId = accepted.initialMessageRunId; + assert.isNotNull(acceptedRunId); + yield* threads.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:launch:message-replay:later-message"), + threadId: accepted.threadId, + messageId: MessageId.make("message:launch:message-replay:later-message"), + text: "A later queued message", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); yield* Deferred.succeed(allowReceiptLookup, undefined); const replayed = yield* Fiber.join(first); assert.isFalse(accepted.initialMessageReplayed); assert.isTrue(replayed.initialMessageReplayed); - assert.lengthOf(replayed.projection.messages, 1); - assert.lengthOf(replayed.projection.runs, 1); + assert.equal(replayed.initialMessageRunId, acceptedRunId); + assert.lengthOf(replayed.projection.messages, 2); + assert.lengthOf(replayed.projection.runs, 2); + assert.notEqual(replayed.projection.runs.at(-1)?.id, replayed.initialMessageRunId); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index da0898de7cd7..7cccbdf1ae61 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -76,6 +76,8 @@ export interface ThreadLaunchResult { readonly projection: OrchestrationV2ThreadProjection; readonly resumed: boolean; readonly initialMessageReplayed: boolean; + /** The durable run created for `initialMessage`, or null when no initial message was requested. */ + readonly initialMessageRunId: RunId | null; } export class ThreadLaunchError extends Schema.TaggedErrorClass()( @@ -582,6 +584,7 @@ export const make = Effect.gen(function* () { projection, resumed: Option.isSome(launchReceipt) || messageWasAlreadyAccepted, initialMessageReplayed, + initialMessageRunId: runId, }; }); }, From 72c4752b2296319a127899929d67431752a60e69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:12:46 -0700 Subject: [PATCH 08/13] fix(mcp): preserve OpenCode attachment path cues --- .../provider/opencodeRuntime.cliParsers.test.ts | 17 +++++++++++++---- apps/server/src/provider/opencodeRuntime.ts | 5 ++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 018119e7c508..a7aead2385f7 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -326,6 +326,7 @@ describe("toOpenCodeFileParts", () => { ["archive.zip", "/tmp/archive.zip"], ["vector.svg", "/tmp/vector.svg"], ["native.png", "/tmp/native.png"], + ["native.pdf", "/tmp/native.pdf"], ]); const parts = toOpenCodePromptParts({ text: "Inspect every attachment.", @@ -334,6 +335,7 @@ describe("toOpenCodeFileParts", () => { { ...attachment("application/zip"), name: "archive.zip" }, { ...attachment("image/svg+xml"), name: "vector.svg" }, { ...attachment("image/png"), name: "native.png" }, + { ...attachment("application/pdf"), name: "native.pdf" }, ], resolveAttachmentPath: (candidate) => paths.get(candidate.name) ?? null, }); @@ -345,10 +347,17 @@ describe("toOpenCodeFileParts", () => { filename: "native.png", url: "file:///tmp/native.png", }, + { + type: "file", + mime: "application/pdf", + filename: "native.pdf", + url: "file:///tmp/native.pdf", + }, ]); - NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /oversized\.pdf/); - NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /archive\.zip/); - NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /vector\.svg/); - NodeAssert.doesNotMatch(parts[0]?.type === "text" ? parts[0].text : "", /native\.png/); + const text = parts[0]?.type === "text" ? parts[0].text : ""; + for (const [name, path] of paths) { + const cue = `[Attached file "${name}" is saved at: ${path}]`; + NodeAssert.equal(text.split(cue).length - 1, 1); + } }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index a659d208a4e2..1f6575daa56d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -487,14 +487,13 @@ export function toOpenCodePromptParts(input: { readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; }) { const nativeFiles = toOpenCodeFileParts(input); - const fallbackPaths = (input.attachments ?? []).flatMap((attachment) => { - if (isOpenCodeNativeFilePart(attachment)) return []; + const attachmentPaths = (input.attachments ?? []).flatMap((attachment) => { const attachmentPath = input.resolveAttachmentPath(attachment); return attachmentPath === null ? [] : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`]; }); - const text = [input.text.trim(), fallbackPaths.join("\n")].filter(Boolean).join("\n\n"); + const text = [input.text.trim(), attachmentPaths.join("\n")].filter(Boolean).join("\n\n"); return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...nativeFiles]; } From 7de6d641b76091a89aa03c5b6c823487e54bb5c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:28:51 -0700 Subject: [PATCH 09/13] test(mcp): preserve service module namespace --- apps/server/src/mcp/OrchestratorMcpAttachments.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts b/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts index fbca52bbb900..e277427372b4 100644 --- a/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpAttachments.test.ts @@ -27,7 +27,7 @@ import { import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; -import { layer, OrchestratorMcpService } from "./OrchestratorMcpService.ts"; +import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; it.effect( "retains fresh accepted claims and releases unused replay claims after projection errors", @@ -84,10 +84,10 @@ it.effect( }), Layer.mock(ScheduledTaskService)({}), ); - const testLayer = layer.pipe(Layer.provideMerge(dependencies)); + const testLayer = OrchestratorMcpService.layer.pipe(Layer.provideMerge(dependencies)); yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; const config = yield* ServerConfig.ServerConfig; const scope: McpInvocationScope = { environmentId: EnvironmentId.make("environment:mcp-attachment-cleanup"), From b72f8610bb7307eea15d06744f0bd50b7a9d23d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:29:34 -0700 Subject: [PATCH 10/13] fix(mcp): accept case-insensitive image MIME types --- packages/contracts/src/attachmentMcp.test.ts | 3 +++ packages/contracts/src/attachmentMcp.ts | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/contracts/src/attachmentMcp.test.ts b/packages/contracts/src/attachmentMcp.test.ts index 8b681012185a..13d916f81dd5 100644 --- a/packages/contracts/src/attachmentMcp.test.ts +++ b/packages/contracts/src/attachmentMcp.test.ts @@ -18,6 +18,9 @@ describe("attachment MCP contracts", () => { expect( decodePrepare({ name: "screen.png", mimeType: "image/png", sizeBytes: 4 }), ).toMatchObject({ mimeType: "image/png", sizeBytes: 4 }); + expect( + decodePrepare({ name: "screen.png", mimeType: "IMAGE/PNG", sizeBytes: 4 }), + ).toMatchObject({ mimeType: "IMAGE/PNG", sizeBytes: 4 }); expect( decodePrepare({ type: "file", diff --git a/packages/contracts/src/attachmentMcp.ts b/packages/contracts/src/attachmentMcp.ts index 60718d6ec321..355bcf07b01d 100644 --- a/packages/contracts/src/attachmentMcp.ts +++ b/packages/contracts/src/attachmentMcp.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { ChatAttachment, ChatAttachmentId, + isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, @@ -20,11 +21,7 @@ export const AttachmentMcpPrepareUploadInput = Schema.Struct({ }).check( Schema.makeFilter((input) => { if (input.type === "file") return true; - if ( - !PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.includes( - input.mimeType as (typeof PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES)[number], - ) - ) { + if (!isProviderSendTurnSupportedImageMimeType(input.mimeType)) { return `Image mimeType must be one of: ${PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.join(", ")}.`; } return ( From c032339d5013a309e5cd1d28c2c3e2ba01683c19 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 10:41:06 -0700 Subject: [PATCH 11/13] fix(server): align receipt service typing --- apps/server/src/orchestration-v2/Orchestrator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index aca969baf5b6..2bccf6effd07 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -41,7 +41,7 @@ import * as Stream from "effect/Stream"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2 } from "./CommandPolicy.ts"; -import { CommandReceiptStoreV2, type CommandReceiptStoreV2Shape } from "./CommandReceiptStore.ts"; +import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; @@ -183,7 +183,7 @@ export interface OrchestratorV2Shape { readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; - readonly getCommandReceipt: CommandReceiptStoreV2Shape["getByCommandId"]; + readonly getCommandReceipt: CommandReceiptStoreV2["Service"]["getByCommandId"]; readonly getThreadProjection: ( threadId: ThreadId, ) => Effect.Effect; From aa49a218831b36075eec49e968b2629a61310fed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 30 Aug 2026 13:29:37 -0700 Subject: [PATCH 12/13] test(server): use inferred event sink service type --- .../testkit/ProviderReplayHarness.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 047d5a05540a..b3a3a6e2dcc2 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -29,11 +29,7 @@ import { layer as effectWorkerLayer, runDaemon as runEffectWorkerDaemon, } from "../EffectWorker.ts"; -import { - EventSinkV2, - type EventSinkV2Shape, - layerFromStores as eventSinkLayer, -} from "../EventSink.ts"; +import { EventSinkV2, layerFromStores as eventSinkLayer } from "../EventSink.ts"; import { layer as eventStoreLayer } from "../EventStore.ts"; import { layer as idAllocatorLayer } from "../IdAllocator.ts"; import { layer as orchestratorLayer } from "../Orchestrator.ts"; @@ -184,7 +180,7 @@ export function runOrchestratorV2ProviderReplayScenario< >; readonly enableLegacyTokenStreaming?: boolean; readonly runEffectWorker?: boolean; - readonly transformEventSink?: (eventSink: EventSinkV2Shape) => EventSinkV2Shape; + readonly transformEventSink?: (eventSink: EventSinkV2["Service"]) => EventSinkV2["Service"]; } = {}, ): Effect.Effect< OrchestratorV2ScenarioResult, @@ -225,7 +221,7 @@ export function makeOrchestratorV2ProviderReplayLayer< readonly enableLegacyTokenStreaming?: boolean; readonly runEffectWorker?: boolean; readonly replayGate?: ProviderReplayGate; - readonly transformEventSink?: (eventSink: EventSinkV2Shape) => EventSinkV2Shape; + readonly transformEventSink?: (eventSink: EventSinkV2["Service"]) => EventSinkV2["Service"]; } = {}, ): Layer.Layer { const registryLayer = harness.makeProviderAdapterRegistryLayer( @@ -245,7 +241,7 @@ export function makeOrchestratorV2ReplayLayerWithRegistry( >; readonly enableLegacyTokenStreaming?: boolean; readonly runEffectWorker?: boolean; - readonly transformEventSink?: (eventSink: EventSinkV2Shape) => EventSinkV2Shape; + readonly transformEventSink?: (eventSink: EventSinkV2["Service"]) => EventSinkV2["Service"]; } = {}, ): Layer.Layer { const serverConfigLayer = Layer.effect( From 6991ca726981ad8a608484beffd0dd76c9bef020 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:00:58 -0700 Subject: [PATCH 13/13] fix(mcp): support Antigravity image attachments --- apps/server/src/mcp/OrchestratorMcpService.ts | 1 + ...OrchestratorMcpToolkit.integration.test.ts | 98 +++++++++++++++++++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + 3 files changed, 100 insertions(+) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index a7cc6e4c3620..00752bed6779 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -175,6 +175,7 @@ function providerAttachmentKinds(provider: ServerProvider): ReadonlyArray<"image case "claudeAgent": case "cursor": case "grok": + case "antigravity": return ["image"]; case "opencode": return ["image", "file"]; diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 3ac0fccff4bb..1aeaca70c3ca 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -55,6 +55,7 @@ import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { attachmentUploadRouteLayer } from "../http.ts"; +import { AntigravityProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; @@ -95,8 +96,10 @@ const parentThreadId = ThreadId.make("thread:mcp-orchestrator-parent"); const projectId = ProjectId.make("project:mcp-orchestrator"); const codexInstanceId = ProviderInstanceId.make("codex"); const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); +const antigravityInstanceId = ProviderInstanceId.make("antigravity"); const codexModel = "gpt-5.4"; const claudeModel = "claude-sonnet-4-6"; +const antigravityModel = "gemini-3-pro"; const parentPrompt = "Keep this parent turn active while orchestration tools are tested."; const delegatedPrompt = "Inspect the delegated API boundary and return the result."; const delegatedResult = "Delegated API boundary inspected."; @@ -524,6 +527,14 @@ describe("orchestrator MCP toolkit", () => { ? delegatedResult : `Claude completed: ${turn.message.text}`, }), + makeDeterministicAdapter({ + instanceId: antigravityInstanceId, + driver: ProviderDriverKind.make("antigravity"), + capabilities: AntigravityProviderCapabilitiesV2, + capturedTurns, + shouldComplete: () => true, + response: (turn) => `Antigravity completed: ${turn.message.text}`, + }), ]); // Captures parent-wake offers made when a delegated child // terminalizes after the parent run settled. @@ -604,6 +615,11 @@ describe("orchestrator MCP toolkit", () => { driver: ProviderDriverKind.make("opencode"), model: "opencode/test", }), + makeProviderSnapshot({ + instanceId: antigravityInstanceId, + driver: ProviderDriverKind.make("antigravity"), + model: antigravityModel, + }), ]); // In-memory ScheduledTaskService stub so the schedule/list/update/ // delete tools can be exercised without SQL/launch wiring. @@ -1287,6 +1303,10 @@ describe("orchestrator MCP toolkit", () => { canRunChildTask: true, attachmentKinds: ["image", "file"], }), + expect.objectContaining({ + providerInstanceId: antigravityInstanceId, + attachmentKinds: ["image"], + }), // Models advertise their option descriptors so agents can // discover valid target.options ids and values. expect.objectContaining({ @@ -1964,6 +1984,62 @@ describe("orchestrator MCP toolkit", () => { ).toHaveLength(2); const serverConfig = yield* ServerConfig.ServerConfig; + const antigravityThreadId = ThreadId.make("thread:mcp-antigravity-attachments"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-antigravity-attachments:create"), + threadId: antigravityThreadId, + projectId, + title: "Antigravity attachment thread", + modelSelection: { + instanceId: antigravityInstanceId, + model: antigravityModel, + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const antigravityPendingId = createPendingAttachmentId(); + if (antigravityPendingId === null) { + return yield* Effect.die("Expected an Antigravity pending attachment id."); + } + const antigravityImage = { + type: "image", + id: ChatAttachmentId.make(antigravityPendingId), + name: "prompt.gif", + mimeType: "image/gif", + sizeBytes: 4, + } as const; + NodeFS.writeFileSync( + NodePath.join(serverConfig.attachmentsDir, `${antigravityPendingId}.gif`), + Buffer.from("test"), + ); + const antigravitySend = yield* decodeThreadSendResult( + (yield* invoke("t3_thread_send", { + threadId: antigravityThreadId, + message: "Inspect this image.", + attachments: [antigravityImage], + clientRequestId: "attachment-antigravity-image-1", + })).structuredContent, + ).pipe(Effect.orDie); + const antigravityWait = yield* decodeThreadWaitResult( + (yield* invoke("t3_thread_wait", { + threadId: antigravityThreadId, + runId: antigravitySend.runId, + timeoutMs: 5_000, + })).structuredContent, + ).pipe(Effect.orDie); + expect(antigravityWait.timedOut).toBe(false); + expect( + (yield* Ref.get(capturedTurns)).find((turn) => turn.threadId === antigravityThreadId), + ).toMatchObject({ + instanceId: antigravityInstanceId, + attachments: [expect.objectContaining({ type: "image", mimeType: "image/gif" })], + }); + const unsupportedPendingId = createPendingAttachmentId(); if (unsupportedPendingId === null) { return yield* Effect.die("Expected a pending attachment id."); @@ -1982,6 +2058,24 @@ describe("orchestrator MCP toolkit", () => { const claimedBeforeUnsupported = NodeFS.readdirSync(serverConfig.attachmentsDir).filter( (entry) => !entry.startsWith("pending-"), ); + const unsupportedAntigravitySend = yield* invoke("t3_thread_send", { + threadId: antigravityThreadId, + attachments: [unsupportedFile], + clientRequestId: "attachment-antigravity-file-rejected-1", + }); + expect(unsupportedAntigravitySend.structuredContent).toMatchObject({ + code: "invalid_request", + }); + expect( + NodeFS.existsSync( + NodePath.join(serverConfig.attachmentsDir, `${unsupportedPendingId}.txt`), + ), + ).toBe(true); + expect( + NodeFS.readdirSync(serverConfig.attachmentsDir).filter( + (entry) => !entry.startsWith("pending-"), + ), + ).toEqual(claimedBeforeUnsupported); const unsupportedSend = yield* invoke("t3_thread_send", { threadId: emptyThread.threadId, attachments: [unsupportedFile], @@ -3228,11 +3322,15 @@ describe("orchestrator MCP toolkit", () => { model: codexModel, }), ]); + const serverConfigLayer = ServerConfig.layerTest(cwd, { + prefix: "t3-mcp-delegated-task-status-", + }).pipe(Layer.provide(NodeServices.layer)); const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(orchestrationLayer), Layer.provide(providerRegistryLayer), Layer.provide(unusedScheduledTaskStubLayer), + Layer.provideMerge(serverConfigLayer), Layer.provide(NodeServices.layer), ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d19d819e595c..200babd981a2 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -126,6 +126,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( getShellSnapshot: unused, ensureLegacyTranscript: unused, dispatch: unused, + getCommandReceipt: unused, getThreadProjection: unused, getCheckpointContext: unused, getThreadSnapshot: unused,