diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts
index 2cfe556ebf58f..869cd1b4fb5e7 100644
--- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts
+++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts
@@ -1,12 +1,15 @@
import { assert, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { ProviderInstanceId } from "@t3tools/contracts";
+import * as NodeURL from "node:url";
+import { ChatAttachmentId, ChatImageAttachment, ProviderInstanceId } from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import { TestClock } from "effect/testing";
+import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
import * as OpenCode2AdapterV2Testkit from "../orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts";
import * as OpenCode2Server from "@t3tools/provider-opencode/server/v2/OpenCode2Server";
@@ -16,28 +19,58 @@ import { OPENCODE2_TITLE_GENERATION } from "./OpenCode2TextGeneration.fixture.ts
const layer = TestProviderHost.layer().pipe(Layer.provideMerge(NodeServices.layer));
it.layer(layer)("OpenCode2TextGeneration", (it) => {
- it.effect("generates a title in a temporary session on the free tier and removes it", () =>
- Effect.gen(function* () {
- const server = yield* OpenCode2AdapterV2Testkit.replayServer({
- provider: "opencode",
- protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL,
- version: "2.0.18",
- scenario: "opencode2_title_generation",
- entries: [...OPENCODE2_TITLE_GENERATION, { type: "runtime_exit", status: "success" }],
- });
- const textGeneration = yield* OpenCode2TextGeneration.make().pipe(
- Effect.provideService(OpenCode2Server.OpenCode2Server, server),
- );
- const title = yield* textGeneration.generateThreadTitle({
- cwd: process.cwd(),
- message: "fix the login redirect loop after oauth",
- modelSelection: {
- instanceId: ProviderInstanceId.make("opencode"),
- model: "opencode/big-pickle",
- },
- });
- assert.equal(title.title, "Fix OAuth Login Redirect Loop");
- }).pipe(Effect.scoped),
+ it.effect.each([false, true] as const)(
+ "generates a title in a temporary session on the free tier and removes it (external: %s)",
+ (external) =>
+ Effect.gen(function* () {
+ const host = yield* ProviderHost.ProviderHost;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const image = (id: string, name: string, mimeType: string) =>
+ ChatImageAttachment.make({
+ type: "image",
+ id: ChatAttachmentId.make(id),
+ name,
+ mimeType,
+ sizeBytes: 4,
+ });
+ const screenshot = image("oc2-title-screenshot", "screen.png", "image/png");
+ const missing = image("oc2-title-missing", "missing.png", "image/png");
+ const unsupported = image("oc2-title-diagram", "diagram.svg", "image/svg+xml");
+ const screenshotPath = host.resolveAttachmentPath(screenshot)!;
+ yield* fileSystem.writeFileString(screenshotPath, "data");
+ yield* fileSystem.writeFileString(host.resolveAttachmentPath(unsupported)!, "");
+ const prompts: Array = [];
+ const server = yield* OpenCode2AdapterV2Testkit.replayServer(
+ {
+ provider: "opencode",
+ protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL,
+ version: "2.0.18",
+ scenario: "opencode2_title_generation",
+ entries: [...OPENCODE2_TITLE_GENERATION, { type: "runtime_exit", status: "success" }],
+ },
+ { external, onPrompt: (input) => prompts.push(input) },
+ );
+ const textGeneration = yield* OpenCode2TextGeneration.make().pipe(
+ Effect.provideService(OpenCode2Server.OpenCode2Server, server),
+ );
+ const title = yield* textGeneration.generateThreadTitle({
+ cwd: process.cwd(),
+ message: "fix the login redirect loop after oauth",
+ attachments: [screenshot, missing, unsupported],
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("opencode"),
+ model: "opencode/big-pickle",
+ },
+ });
+ assert.equal(title.title, "Fix OAuth Login Redirect Loop");
+ if (external) {
+ assert.notProperty(prompts[0], "files");
+ } else {
+ assert.deepInclude(prompts[0], {
+ files: [{ uri: NodeURL.pathToFileURL(screenshotPath).href, name: "screen.png" }],
+ });
+ }
+ }).pipe(Effect.scoped),
);
it.effect("keeps a provider's own error message out of the caller-visible detail", () =>
diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts
index 4897b58afdc17..64c8293771954 100644
--- a/packages/provider-opencode/src/server/v2/textGeneration.ts
+++ b/packages/provider-opencode/src/server/v2/textGeneration.ts
@@ -2,11 +2,14 @@
import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect";
import { TextGenerationError } from "@t3tools/contracts";
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
+import * as NodeURL from "node:url";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
+import { isProviderNativeImageAttachment } from "@t3tools/provider-core/server/attachmentPrompt";
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import * as OpenCode2Server from "./OpenCode2Server.ts";
import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
@@ -29,6 +32,7 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* (
input: TextGenerationOperations.Request,
) {
const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost;
+ const fileSystem = yield* FileSystem.FileSystem;
const { client } = connection;
const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
if (parsed === null) {
@@ -131,11 +135,16 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* (
.remove({ sessionID: session.id })
.pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })),
);
- const images = (input.attachments ?? []).flatMap((attachment) => {
- if (attachment.type !== "image") return [];
+ const images: Array<{ uri: string; name: string }> = [];
+ // As in the adapter: an external server may not share T3's attachment directory, and
+ // OpenCode rejects the whole prompt if an attached file is missing.
+ for (const attachment of connection.external ? [] : (input.attachments ?? [])) {
+ if (!isProviderNativeImageAttachment(attachment)) continue;
const path = resolveAttachmentPath(attachment);
- return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }];
- });
+ if (path === null) continue;
+ const exists = yield* fileSystem.exists(path).pipe(Effect.orElseSucceed(() => false));
+ if (exists) images.push({ uri: NodeURL.pathToFileURL(path).href, name: attachment.name });
+ }
yield* client.session.prompt({
sessionID: session.id,
text: input.prompt,
@@ -179,11 +188,13 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* (
export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () {
const server = yield* OpenCode2Server.OpenCode2Server;
const host = yield* ProviderHost.ProviderHost;
+ const fileSystem = yield* FileSystem.FileSystem;
const run: TextGenerationOperations.Runner = (input) =>
server
.withConnection((connection) => runOnServer(connection, input))
.pipe(
Effect.provideService(ProviderHost.ProviderHost, host),
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.mapError((cause) =>
isTextGenerationError(cause)
? cause