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
145 changes: 124 additions & 21 deletions apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts";
import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import * as Duration from "effect/Duration";
Expand All @@ -11,24 +11,30 @@ import { beforeEach, expect } from "vite-plus/test";

import * as ServerConfig from "../config.ts";
import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts";
import * as TextGeneration from "./TextGeneration.ts";
import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts";

const runtimeMock = {
state: {
startCalls: [] as string[],
promptUrls: [] as string[],
authHeaders: [] as Array<string | null>,
closeCalls: [] as string[],
sessionCreateError: undefined as unknown,
sessionResult: undefined as { data?: { id: string } } | undefined,
promptRequestError: undefined as unknown,
promptResult: undefined as
| { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } }
| { data?: { info?: { error?: unknown }; parts?: Array<unknown> } }
| undefined,
},
reset() {
this.state.startCalls.length = 0;
this.state.promptUrls.length = 0;
this.state.authHeaders.length = 0;
this.state.closeCalls.length = 0;
this.state.sessionCreateError = undefined;
this.state.sessionResult = undefined;
this.state.promptRequestError = undefined;
this.state.promptResult = undefined;
},
};
Expand Down Expand Up @@ -61,12 +67,20 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
({
session: {
create: async () => ({ data: { id: `${baseUrl}/session` } }),
create: async () => {
if (runtimeMock.state.sessionCreateError !== undefined) {
throw runtimeMock.state.sessionCreateError;
}
return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
},
prompt: async () => {
runtimeMock.state.promptUrls.push(baseUrl);
runtimeMock.state.authHeaders.push(
serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
);
if (runtimeMock.state.promptRequestError !== undefined) {
throw runtimeMock.state.promptRequestError;
}
return (
runtimeMock.state.promptResult ?? {
data: {
Expand Down Expand Up @@ -99,6 +113,13 @@ const DEFAULT_TEST_MODEL_SELECTION = {
instanceId: ProviderInstanceId.make("opencode"),
model: "openai/gpt-5",
};
const DEFAULT_COMMIT_MESSAGE_INPUT = {
cwd: process.cwd(),
branch: "feature/opencode-reuse",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
};

const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;

Expand Down Expand Up @@ -142,7 +163,7 @@ function withOpenCodeTextGeneration<A, E, R>(
effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
) {
return Effect.gen(function* () {
const textGeneration = yield* makeOpenCodeTextGeneration(settings);
const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings);
return yield* effectFn(textGeneration);
}).pipe(Effect.scoped);
}
Expand Down Expand Up @@ -221,22 +242,99 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => {
).pipe(Effect.provide(TestClock.layer())),
);

it.effect("returns a typed empty-output error when OpenCode returns no text parts", () =>
it.effect("preserves the SDK cause when session creation fails", () =>
withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
runtimeMock.state.promptResult = { data: {} };
const sdkCause = new Error("session endpoint unavailable");
runtimeMock.state.sessionCreateError = sdkCause;

const error = yield* textGeneration
.generateCommitMessage({
cwd: process.cwd(),
branch: "feature/opencode-reuse",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
})
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.flip);

expect(error).toBeInstanceOf(TextGenerationError);
expect(error.message).toContain("OpenCode session.create request failed.");
expect(error.cause).toMatchObject({
_tag: "OpenCodeTextGenerationSessionRequestError",
operation: "generateCommitMessage",
cwd: process.cwd(),
cause: sdkCause,
});
expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
}),
),
);

it.effect("reports a missing session payload without manufacturing a cause", () =>
withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
runtimeMock.state.sessionResult = {};

const error = yield* textGeneration
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.flip);

expect(error.message).toContain("OpenCode session.create returned no session payload.");
expect(error.cause).toMatchObject({
_tag: "OpenCodeTextGenerationSessionPayloadError",
operation: "generateCommitMessage",
cwd: process.cwd(),
});
expect(error.cause).not.toHaveProperty("cause");
}),
),
);

it.effect("preserves the SDK cause and request context when prompting fails", () =>
withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
const sdkCause = new Error("prompt endpoint unavailable");
runtimeMock.state.promptRequestError = sdkCause;

const error = yield* textGeneration
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.flip);

expect(error.message).toContain("OpenCode session.prompt request failed.");
expect(error.cause).toMatchObject({
_tag: "OpenCodeTextGenerationPromptRequestError",
operation: "generateCommitMessage",
cwd: process.cwd(),
sessionId: "http://127.0.0.1:4301/session",
providerId: "openai",
modelId: "gpt-5",
cause: sdkCause,
});
expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
}),
),
);

it.effect("returns a typed empty-output error for malformed and blank response parts", () =>
withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
runtimeMock.state.promptResult = {
data: {
parts: [null, { type: "tool" }, { type: "text", text: " " }],
},
};

const error = yield* textGeneration
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.flip);

expect(error.message).toContain("OpenCode returned empty output.");
expect(error.cause).toMatchObject({
_tag: "OpenCodeTextGenerationEmptyOutputError",
operation: "generateCommitMessage",
cwd: process.cwd(),
sessionId: "http://127.0.0.1:4301/session",
providerId: "openai",
modelId: "gpt-5",
responsePartCount: 3,
textPartCount: 1,
});
expect(error.cause).not.toHaveProperty("cause");
}),
),
);
Expand Down Expand Up @@ -289,16 +387,21 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => {
};

const error = yield* textGeneration
.generateCommitMessage({
cwd: process.cwd(),
branch: "feature/opencode-reuse",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
modelSelection: DEFAULT_TEST_MODEL_SELECTION,
})
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.flip);

expect(error.message).toContain("Model did not produce structured output");
expect(error.cause).toMatchObject({
_tag: "OpenCodeTextGenerationPromptResponseError",
operation: "generateCommitMessage",
cwd: process.cwd(),
sessionId: "http://127.0.0.1:4301/session",
providerId: "openai",
modelId: "gpt-5",
providerErrorName: "StructuredOutputError",
providerMessage: "Model did not produce structured output",
});
expect(error.cause).not.toHaveProperty("cause");
}),
),
);
Expand Down
Loading
Loading