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