History

fix(mcp): preserve OpenCode file attachments

Julius Marminge committed 2 months ago

Commit 0d93b711 parent 181502a5 files · +93 −33Browse files

Unified · Split

5 files
  1. apps/server/src/mcp/OrchestratorMcpService.test.ts +36 −11
  2. apps/server/src/mcp/OrchestratorMcpService.ts +1 −3
  3. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +6 −19
  4. apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +33 −0
  5. apps/server/src/provider/opencodeRuntime.ts +17 −0
apps/server/src/mcp/OrchestratorMcpService.test.ts+36−11
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 type OrchestrationV2ThreadProjection,
1313 } from "@t3tools/contracts";
1414 import * as DateTime from "effect/DateTime";
15+import * as Deferred from "effect/Deferred";
1516 import * as Effect from "effect/Effect";
1617 import * as Layer from "effect/Layer";
1718 import * as Option from "effect/Option";
@@ -53,6 +54,9 @@ describe("OrchestratorMcpService", () => { · 35 unmodified lines
5354 messages: [{ id: messageId, runId, attachments: [], text: "Accepted once." }],
5455 } as unknown as OrchestrationV2ThreadProjection;
5556 const projectionReads = yield* Ref.make(0);
57+ const committed = yield* Ref.make(false);
58+ const receiptRequested = yield* Deferred.make<void>();
59+ const releaseReceipt = yield* Deferred.make<void>();
5660 const dependencies = Layer.mergeAll(
5761 NodeServices.layer,
5862 ServerConfig.layerTest(process.cwd(), {
@@ -61,11 +65,21 @@ describe("OrchestratorMcpService", () => { · 2 unmodified lines
6165 Layer.mock(ThreadManagementService)({
6266 getThreadProjection: () =>
6367 Ref.updateAndGet(projectionReads, (count) => count + 1).pipe(
64− Effect.map((count) => (count === 1 ? staleProjection : acceptedProjection)),
68+ Effect.flatMap((count) =>
69+ count === 1
70+ ? Effect.succeed(staleProjection)
71+ : Ref.get(committed).pipe(
72+ Effect.map((isCommitted) =>
73+ isCommitted ? acceptedProjection : staleProjection,
74+ ),
75+ ),
76+ ),
6577 ),
6678 getCommandReceipt: () =>
67− Effect.succeed(
68− Option.some({
79+ Effect.gen(function* () {
80+ yield* Deferred.succeed(receiptRequested, undefined);
81+ yield* Deferred.await(releaseReceipt);
82+ return Option.some({
6983 commandId: CommandId.make("command:mcp-send-replay-race"),
7084 threadId,
7185 commandType: "message.dispatch",
@@ -73,8 +87,8 @@ describe("OrchestratorMcpService", () => { · 1 unmodified line
7387 resultSequence: 2,
7488 status: "accepted",
7589 error: null,
76− }),
77− ),
90+ });
91+ }),
7892 dispatch: () => Effect.die("Accepted send replay must not dispatch again."),
7993 }),
8094 Layer.mock(ProviderRegistry)({ getProviders: Effect.die("Provider lookup is mutable.") }),
@@ -89,18 +103,29 @@ describe("OrchestratorMcpService", () => { · 8 unmodified lines
89103 issuedAt: 1,
90104 };
91105
92− yield* Effect.gen(function* () {
106+ const call = Effect.gen(function* () {
93107 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
94− const result = yield* service.sendToThread(scope, {
108+ return yield* service.sendToThread(scope, {
95109 threadId,
96110 message: "Accepted once.",
97111 clientRequestId: requestKey,
98112 });
99− assert.equal(result.messageId, messageId);
100− assert.equal(result.runId, runId);
101− assert.equal(result.delivery, "queued");
102− assert.equal(yield* Ref.get(projectionReads), 2);
103113 }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies))));
114+ const [result] = yield* Effect.all(
115+ [
116+ call,
117+ Effect.gen(function* () {
118+ yield* Deferred.await(receiptRequested);
119+ yield* Ref.set(committed, true);
120+ yield* Deferred.succeed(releaseReceipt, undefined);
121+ }),
122+ ],
123+ { concurrency: "unbounded" },
124+ );
125+ assert.equal(result.messageId, messageId);
126+ assert.equal(result.runId, runId);
127+ assert.equal(result.delivery, "queued");
128+ assert.equal(yield* Ref.get(projectionReads), 2);
104129 }),
105130 );
106131
apps/server/src/mcp/OrchestratorMcpService.ts+1−3
@@ -79,7 +79,6 @@ import { · 78 unmodified lines
7979 ThreadManagementService,
8080 } from "../orchestration-v2/ThreadManagementService.ts";
8181 import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts";
82−import { isOpenCodeNativeFilePart } from "../provider/opencodeRuntime.ts";
8382 import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts";
8483 import * as ServerConfig from "../config.ts";
8584 import type { McpInvocationScope } from "./McpInvocationContext.ts";
@@ -195,8 +194,7 @@ function validateProviderAttachments( · 109 unmodified lines
195194 (attachment.type !== "image" && attachment.type !== "file") ||
196195 !kinds.includes(attachment.type) ||
197196 (attachment.type === "image" &&
198− !isProviderSendTurnSupportedImageMimeType(attachment.mimeType)) ||
199− (provider.driver === "opencode" && !isOpenCodeNativeFilePart(attachment)),
197+ !isProviderSendTurnSupportedImageMimeType(attachment.mimeType)),
200198 );
201199 return unsupported === undefined
202200 ? Effect.void
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+6−19
@@ -71,7 +71,7 @@ import { · 70 unmodified lines
7171 openCodeRuntimeErrorDetail,
7272 parseOpenCodeModelSlug,
7373 runOpenCodeSdk,
74− toOpenCodeFileParts,
74+ toOpenCodePromptParts,
7575 toOpenCodePermissionReply,
7676 toOpenCodeQuestionAnswers,
7777 type OpenCodeRuntimeShape,
@@ -79,7 +79,6 @@ import { · 1 unmodified line
7979 import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
8080 import { makeProviderFailure } from "../ProviderFailure.ts";
8181 import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts";
82−import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts";
8382 import {
8483 ProviderAdapterEnsureThreadError,
8584 ProviderAdapterForkThreadError,
@@ -2629,20 +2628,16 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 2543 unmodified lines
26292628 };
26302629
26312630 const resolvePromptParts = (turnInput: ProviderAdapterV2TurnInput) => {
2632− const text = providerMessageTextWithAttachmentPaths({
2631+ const parts = toOpenCodePromptParts({
26332632 text: turnInput.message.text,
2634− attachments: turnInput.message.attachments,
2635− attachmentsDir: serverConfig.attachmentsDir,
2636− }).trim();
2637− const files = toOpenCodeFileParts({
26382633 attachments: turnInput.message.attachments,
26392634 resolveAttachmentPath: (attachment) =>
26402635 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
26412636 });
2642− if (text.length === 0 && files.length === 0) {
2637+ if (parts.length === 0) {
26432638 throw protocolError("OpenCode turns require text or at least one valid attachment");
26442639 }
2645− return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files];
2640+ return parts;
26462641 };
26472642
26482643 const readSnapshot = Effect.fnUntraced(function* (
@@ -2997,12 +2992,8 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 348 unmodified lines
29972992 `OpenCode model '${turn.modelSelection.model}' must use provider/model format`,
29982993 );
29992994 }
3000− const text = providerMessageTextWithAttachmentPaths({
2995+ const parts = toOpenCodePromptParts({
30012996 text: steerInput.message.text,
3002− attachments: steerInput.message.attachments,
3003− attachmentsDir: serverConfig.attachmentsDir,
3004− }).trim();
3005− const files = toOpenCodeFileParts({
30062997 attachments: steerInput.message.attachments,
30072998 resolveAttachmentPath: (attachment) =>
30082999 resolveAttachmentPath({
@@ -3010,13 +3001,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 1 unmodified line
30103001 attachment,
30113002 }),
30123003 });
3013− if (text.length === 0 && files.length === 0) {
3004+ if (parts.length === 0) {
30143005 return yield* protocolError("OpenCode steering requires text or an attachment");
30153006 }
3016− const parts = [
3017− ...(text.length === 0 ? [] : [{ type: "text" as const, text }]),
3018− ...files,
3019− ];
30203007 turn.admissionGeneration = state.nextAdmissionGeneration++;
30213008 turn.admissionMessageId = yield* makeOpenCodeMessageId();
30223009 turn.admissionPending = true;
apps/server/src/provider/opencodeRuntime.cliParsers.test.ts+33−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 parseModelsCliOutput,
88 parseSkillsCliOutput,
99 toOpenCodeFileParts,
10+ toOpenCodePromptParts,
1011 } from "./opencodeRuntime.ts";
1112
1213 describe("parseModelsCliOutput", () => {
@@ -318,4 +319,36 @@ describe("toOpenCodeFileParts", () => { · 305 unmodified lines
318319 ["application/pdf", "text/markdown", "image/png"],
319320 );
320321 });
322+
323+ it("keeps non-native and oversized files available through resolved paths", () => {
324+ const paths = new Map([
325+ ["oversized.pdf", "/tmp/oversized.pdf"],
326+ ["archive.zip", "/tmp/archive.zip"],
327+ ["vector.svg", "/tmp/vector.svg"],
328+ ["native.png", "/tmp/native.png"],
329+ ]);
330+ const parts = toOpenCodePromptParts({
331+ text: "Inspect every attachment.",
332+ attachments: [
333+ { ...attachment("application/pdf", 25 * 1024 * 1024), name: "oversized.pdf" },
334+ { ...attachment("application/zip"), name: "archive.zip" },
335+ { ...attachment("image/svg+xml"), name: "vector.svg" },
336+ { ...attachment("image/png"), name: "native.png" },
337+ ],
338+ resolveAttachmentPath: (candidate) => paths.get(candidate.name) ?? null,
339+ });
340+
341+ NodeAssert.deepEqual(parts.slice(1), [
342+ {
343+ type: "file",
344+ mime: "image/png",
345+ filename: "native.png",
346+ url: "file:///tmp/native.png",
347+ },
348+ ]);
349+ NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /oversized\.pdf/);
350+ NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /archive\.zip/);
351+ NodeAssert.match(parts[0]?.type === "text" ? parts[0].text : "", /vector\.svg/);
352+ NodeAssert.doesNotMatch(parts[0]?.type === "text" ? parts[0].text : "", /native\.png/);
353+ });
321354 });
apps/server/src/provider/opencodeRuntime.ts+17−0
@@ -481,6 +481,23 @@ export function toOpenCodeFileParts(input: { · 480 unmodified lines
481481 return parts;
482482 }
483483
484+export function toOpenCodePromptParts(input: {
485+ readonly text: string;
486+ readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
487+ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
488+}) {
489+ const nativeFiles = toOpenCodeFileParts(input);
490+ const fallbackPaths = (input.attachments ?? []).flatMap((attachment) => {
491+ if (isOpenCodeNativeFilePart(attachment)) return [];
492+ const attachmentPath = input.resolveAttachmentPath(attachment);
493+ return attachmentPath === null
494+ ? []
495+ : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`];
496+ });
497+ const text = [input.text.trim(), fallbackPaths.join("\n")].filter(Boolean).join("\n\n");
498+ return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...nativeFiles];
499+}
500+
484501 export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {
485502 if (runtimeMode === "full-access") {
486503 return [