Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 56 additions & 23 deletions apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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)!, "<svg/>");
const prompts: Array<unknown> = [];
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", () =>
Expand Down
19 changes: 15 additions & 4 deletions packages/provider-opencode/src/server/v2/textGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +32,7 @@ const runOnServer = Effect.fn("Opencode2TextGeneration.runOnServer")(function* (
input: TextGenerationOperations.Request<Schema.Top>,
) {
const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost;
const fileSystem = yield* FileSystem.FileSystem;
const { client } = connection;
const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
if (parsed === null) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading