Unified · Split
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | type OrchestrationV2ThreadProjection, |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | 14 | import * as DateTime from "effect/DateTime"; |
| 15 | +import * as Deferred from "effect/Deferred"; | |
| 15 | 16 | import * as Effect from "effect/Effect"; |
| 16 | 17 | import * as Layer from "effect/Layer"; |
| 17 | 18 | import * as Option from "effect/Option"; |
| @@ -53,6 +54,9 @@ describe("OrchestratorMcpService", () => { · 35 unmodified lines | ||
| 53 | 54 | messages: [{ id: messageId, runId, attachments: [], text: "Accepted once." }], |
| 54 | 55 | } as unknown as OrchestrationV2ThreadProjection; |
| 55 | 56 | 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>(); | |
| 56 | 60 | const dependencies = Layer.mergeAll( |
| 57 | 61 | NodeServices.layer, |
| 58 | 62 | ServerConfig.layerTest(process.cwd(), { |
| @@ -61,11 +65,21 @@ describe("OrchestratorMcpService", () => { · 2 unmodified lines | ||
| 61 | 65 | Layer.mock(ThreadManagementService)({ |
| 62 | 66 | getThreadProjection: () => |
| 63 | 67 | 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 | + ), | |
| 65 | 77 | ), |
| 66 | 78 | 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({ | |
| 69 | 83 | commandId: CommandId.make("command:mcp-send-replay-race"), |
| 70 | 84 | threadId, |
| 71 | 85 | commandType: "message.dispatch", |
| @@ -73,8 +87,8 @@ describe("OrchestratorMcpService", () => { · 1 unmodified line | ||
| 73 | 87 | resultSequence: 2, |
| 74 | 88 | status: "accepted", |
| 75 | 89 | error: null, |
| 76 | − }), | |
| 77 | − ), | |
| 90 | + }); | |
| 91 | + }), | |
| 78 | 92 | dispatch: () => Effect.die("Accepted send replay must not dispatch again."), |
| 79 | 93 | }), |
| 80 | 94 | Layer.mock(ProviderRegistry)({ getProviders: Effect.die("Provider lookup is mutable.") }), |
| @@ -89,18 +103,29 @@ describe("OrchestratorMcpService", () => { · 8 unmodified lines | ||
| 89 | 103 | issuedAt: 1, |
| 90 | 104 | }; |
| 91 | 105 | |
| 92 | − yield* Effect.gen(function* () { | |
| 106 | + const call = Effect.gen(function* () { | |
| 93 | 107 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| 94 | − const result = yield* service.sendToThread(scope, { | |
| 108 | + return yield* service.sendToThread(scope, { | |
| 95 | 109 | threadId, |
| 96 | 110 | message: "Accepted once.", |
| 97 | 111 | clientRequestId: requestKey, |
| 98 | 112 | }); |
| 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); | |
| 103 | 113 | }).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); | |
| 104 | 129 | }), |
| 105 | 130 | ); |
| 106 | 131 | |
| @@ -79,7 +79,6 @@ import { · 78 unmodified lines | ||
| 79 | 79 | ThreadManagementService, |
| 80 | 80 | } from "../orchestration-v2/ThreadManagementService.ts"; |
| 81 | 81 | import { ProviderRegistry } from "../provider/Services/ProviderRegistry.ts"; |
| 82 | −import { isOpenCodeNativeFilePart } from "../provider/opencodeRuntime.ts"; | |
| 83 | 82 | import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; |
| 84 | 83 | import * as ServerConfig from "../config.ts"; |
| 85 | 84 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| @@ -195,8 +194,7 @@ function validateProviderAttachments( · 109 unmodified lines | ||
| 195 | 194 | (attachment.type !== "image" && attachment.type !== "file") || |
| 196 | 195 | !kinds.includes(attachment.type) || |
| 197 | 196 | (attachment.type === "image" && |
| 198 | − !isProviderSendTurnSupportedImageMimeType(attachment.mimeType)) || | |
| 199 | − (provider.driver === "opencode" && !isOpenCodeNativeFilePart(attachment)), | |
| 197 | + !isProviderSendTurnSupportedImageMimeType(attachment.mimeType)), | |
| 200 | 198 | ); |
| 201 | 199 | return unsupported === undefined |
| 202 | 200 | ? Effect.void |
| @@ -71,7 +71,7 @@ import { · 70 unmodified lines | ||
| 71 | 71 | openCodeRuntimeErrorDetail, |
| 72 | 72 | parseOpenCodeModelSlug, |
| 73 | 73 | runOpenCodeSdk, |
| 74 | − toOpenCodeFileParts, | |
| 74 | + toOpenCodePromptParts, | |
| 75 | 75 | toOpenCodePermissionReply, |
| 76 | 76 | toOpenCodeQuestionAnswers, |
| 77 | 77 | type OpenCodeRuntimeShape, |
| @@ -79,7 +79,6 @@ import { · 1 unmodified line | ||
| 79 | 79 | import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; |
| 80 | 80 | import { makeProviderFailure } from "../ProviderFailure.ts"; |
| 81 | 81 | import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts"; |
| 82 | −import { providerMessageTextWithAttachmentPaths } from "../AttachmentPrompt.ts"; | |
| 83 | 82 | import { |
| 84 | 83 | ProviderAdapterEnsureThreadError, |
| 85 | 84 | ProviderAdapterForkThreadError, |
| @@ -2629,20 +2628,16 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 2543 unmodified lines | ||
| 2629 | 2628 | }; |
| 2630 | 2629 | |
| 2631 | 2630 | const resolvePromptParts = (turnInput: ProviderAdapterV2TurnInput) => { |
| 2632 | − const text = providerMessageTextWithAttachmentPaths({ | |
| 2631 | + const parts = toOpenCodePromptParts({ | |
| 2633 | 2632 | text: turnInput.message.text, |
| 2634 | − attachments: turnInput.message.attachments, | |
| 2635 | − attachmentsDir: serverConfig.attachmentsDir, | |
| 2636 | − }).trim(); | |
| 2637 | − const files = toOpenCodeFileParts({ | |
| 2638 | 2633 | attachments: turnInput.message.attachments, |
| 2639 | 2634 | resolveAttachmentPath: (attachment) => |
| 2640 | 2635 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), |
| 2641 | 2636 | }); |
| 2642 | − if (text.length === 0 && files.length === 0) { | |
| 2637 | + if (parts.length === 0) { | |
| 2643 | 2638 | throw protocolError("OpenCode turns require text or at least one valid attachment"); |
| 2644 | 2639 | } |
| 2645 | − return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files]; | |
| 2640 | + return parts; | |
| 2646 | 2641 | }; |
| 2647 | 2642 | |
| 2648 | 2643 | const readSnapshot = Effect.fnUntraced(function* ( |
| @@ -2997,12 +2992,8 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 348 unmodified lines | ||
| 2997 | 2992 | `OpenCode model '${turn.modelSelection.model}' must use provider/model format`, |
| 2998 | 2993 | ); |
| 2999 | 2994 | } |
| 3000 | − const text = providerMessageTextWithAttachmentPaths({ | |
| 2995 | + const parts = toOpenCodePromptParts({ | |
| 3001 | 2996 | text: steerInput.message.text, |
| 3002 | − attachments: steerInput.message.attachments, | |
| 3003 | − attachmentsDir: serverConfig.attachmentsDir, | |
| 3004 | − }).trim(); | |
| 3005 | − const files = toOpenCodeFileParts({ | |
| 3006 | 2997 | attachments: steerInput.message.attachments, |
| 3007 | 2998 | resolveAttachmentPath: (attachment) => |
| 3008 | 2999 | resolveAttachmentPath({ |
| @@ -3010,13 +3001,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 1 unmodified line | ||
| 3010 | 3001 | attachment, |
| 3011 | 3002 | }), |
| 3012 | 3003 | }); |
| 3013 | − if (text.length === 0 && files.length === 0) { | |
| 3004 | + if (parts.length === 0) { | |
| 3014 | 3005 | return yield* protocolError("OpenCode steering requires text or an attachment"); |
| 3015 | 3006 | } |
| 3016 | − const parts = [ | |
| 3017 | − ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), | |
| 3018 | − ...files, | |
| 3019 | − ]; | |
| 3020 | 3007 | turn.admissionGeneration = state.nextAdmissionGeneration++; |
| 3021 | 3008 | turn.admissionMessageId = yield* makeOpenCodeMessageId(); |
| 3022 | 3009 | turn.admissionPending = true; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | parseModelsCliOutput, |
| 8 | 8 | parseSkillsCliOutput, |
| 9 | 9 | toOpenCodeFileParts, |
| 10 | + toOpenCodePromptParts, | |
| 10 | 11 | } from "./opencodeRuntime.ts"; |
| 11 | 12 | |
| 12 | 13 | describe("parseModelsCliOutput", () => { |
| @@ -318,4 +319,36 @@ describe("toOpenCodeFileParts", () => { · 305 unmodified lines | ||
| 318 | 319 | ["application/pdf", "text/markdown", "image/png"], |
| 319 | 320 | ); |
| 320 | 321 | }); |
| 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 | + }); | |
| 321 | 354 | }); |
| @@ -481,6 +481,23 @@ export function toOpenCodeFileParts(input: { · 480 unmodified lines | ||
| 481 | 481 | return parts; |
| 482 | 482 | } |
| 483 | 483 | |
| 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 | + | |
| 484 | 501 | export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { |
| 485 | 502 | if (runtimeMode === "full-access") { |
| 486 | 503 | return [ |