Skip to content
Closed
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
133 changes: 131 additions & 2 deletions packages/provider-opencode/src/server/textGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
Expand All @@ -24,6 +25,12 @@ const runtimeMock = {
authHeaders: [] as Array<string | null>,
closeCalls: [] as string[],
sessionCreateCalls: 0,
sessionCreateSignals: [] as Array<AbortSignal | undefined>,
promptSignals: [] as Array<AbortSignal | undefined>,
sessionAbortIds: [] as string[],
sessionAbortSignals: [] as Array<AbortSignal | undefined>,
abortStall: false,
abortFailure: undefined as Error | undefined,
connectionError: undefined as Error | undefined,
sessionCreateError: undefined as unknown,
sessionResult: undefined as { data?: { id: string } } | undefined,
Expand All @@ -39,6 +46,12 @@ const runtimeMock = {
this.state.authHeaders.length = 0;
this.state.closeCalls.length = 0;
this.state.sessionCreateCalls = 0;
this.state.sessionCreateSignals.length = 0;
this.state.promptSignals.length = 0;
this.state.sessionAbortIds.length = 0;
this.state.sessionAbortSignals.length = 0;
this.state.abortStall = false;
this.state.abortFailure = undefined;
this.state.connectionError = undefined;
this.state.sessionCreateError = undefined;
this.state.sessionResult = undefined;
Expand Down Expand Up @@ -95,14 +108,29 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
({
session: {
create: async () => {
create: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => {
runtimeMock.state.sessionCreateCalls += 1;
runtimeMock.state.sessionCreateSignals.push(options?.signal);
if (runtimeMock.state.sessionCreateError !== undefined) {
throw runtimeMock.state.sessionCreateError;
}
return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } };
},
prompt: async (input: { readonly parts: ReadonlyArray<unknown> }) => {
abort: async (
input: { readonly sessionID: string },
options?: { readonly signal?: AbortSignal },
) => {
runtimeMock.state.sessionAbortIds.push(input.sessionID);
runtimeMock.state.sessionAbortSignals.push(options?.signal);
if (runtimeMock.state.abortStall) return new Promise<never>(() => {});
if (runtimeMock.state.abortFailure) throw runtimeMock.state.abortFailure;
return { data: true };
},
prompt: async (
input: { readonly parts: ReadonlyArray<unknown> },
options?: { readonly signal?: AbortSignal },
) => {
runtimeMock.state.promptSignals.push(options?.signal);
runtimeMock.state.promptUrls.push(baseUrl);
runtimeMock.state.promptParts.push(input.parts);
runtimeMock.state.authHeaders.push(
Expand Down Expand Up @@ -538,6 +566,107 @@ it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => {
}),
),
);
it.effect("aborts stalled session creation at the three-minute deadline", () =>
withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
runtimeMock.state.sessionResult = new Promise(() => {}) as unknown as NonNullable<
typeof runtimeMock.state.sessionResult
>;

const child = yield* textGeneration
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.forkChild);
while (runtimeMock.state.sessionCreateCalls === 0) {
yield* Effect.yieldNow;
}
yield* TestClock.adjust(Duration.seconds(181));
const error = yield* Fiber.join(child).pipe(Effect.flip);
expect(error).toBeInstanceOf(TextGenerationError);
expect(error.detail).toContain("timed out");
expect(runtimeMock.state.sessionCreateSignals.at(-1)?.aborted).toBe(true);
expect(runtimeMock.state.promptUrls).toEqual([]);
expect(runtimeMock.state.sessionAbortIds).toEqual([]);
}),
).pipe(Effect.provide(TestClock.layer())),
);

it.effect("aborts a stalled prompt at the three-minute deadline", () =>
withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
runtimeMock.state.promptResult = new Promise(() => {}) as unknown as NonNullable<
typeof runtimeMock.state.promptResult
>;

const child = yield* textGeneration
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.forkChild);
while (runtimeMock.state.promptUrls.length === 0) {
yield* Effect.yieldNow;
}
yield* TestClock.adjust(Duration.seconds(181));
const error = yield* Fiber.join(child).pipe(Effect.flip);
expect(error).toBeInstanceOf(TextGenerationError);
expect(error.detail).toContain("timed out");
expect(runtimeMock.state.promptSignals.at(-1)?.aborted).toBe(true);
expect(runtimeMock.state.sessionAbortIds).toEqual(["http://127.0.0.1:4301/session"]);
}),
).pipe(Effect.provide(TestClock.layer())),
);

it.effect("bounds a stalled server-side abort without masking the prompt timeout", () =>
withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
runtimeMock.state.abortStall = true;
runtimeMock.state.promptResult = new Promise(() => {}) as unknown as NonNullable<
typeof runtimeMock.state.promptResult
>;

const child = yield* textGeneration
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.forkChild);
while (runtimeMock.state.promptUrls.length === 0) {
yield* Effect.yieldNow;
}
yield* TestClock.adjust(Duration.seconds(181));
// An already-interrupted prompt must still enter session-abort cleanup.
while (runtimeMock.state.sessionAbortIds.length === 0) {
yield* Effect.yieldNow;
}
expect(runtimeMock.state.sessionAbortIds).toEqual(["http://127.0.0.1:9999/session"]);
expect(runtimeMock.state.sessionAbortSignals.at(-1)?.aborted).toBe(false);
yield* TestClock.adjust(Duration.seconds(10));

const error = yield* Fiber.join(child).pipe(Effect.flip);
expect(error).toBeInstanceOf(TextGenerationError);
expect(error.detail).toContain("timed out");
expect(runtimeMock.state.sessionAbortSignals.at(-1)?.aborted).toBe(true);
expect(runtimeMock.state.sessionAbortIds).toHaveLength(1);
}),
).pipe(Effect.provide(TestClock.layer())),
);

it.effect("does not mask a timed-out prompt when its session abort fails", () =>
withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
Effect.gen(function* () {
runtimeMock.state.abortFailure = new Error("synthetic session-abort failure");
runtimeMock.state.promptResult = new Promise(() => {}) as unknown as NonNullable<
typeof runtimeMock.state.promptResult
>;

const child = yield* textGeneration
.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
.pipe(Effect.forkChild);
while (runtimeMock.state.promptUrls.length === 0) {
yield* Effect.yieldNow;
}
yield* TestClock.adjust(Duration.seconds(181));
const error = yield* Fiber.join(child).pipe(Effect.flip);
expect(error).toBeInstanceOf(TextGenerationError);
expect(error.detail).toContain("timed out");
expect(runtimeMock.state.sessionAbortIds).toEqual(["http://127.0.0.1:9999/session"]);
}),
).pipe(Effect.provide(TestClock.layer())),
);
});

it.layer(layerOpenCodeTextGenerationExistingServerTest)(
Expand Down
73 changes: 59 additions & 14 deletions packages/provider-opencode/src/server/textGeneration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import { NonNegativeInt, TextGenerationError } from "@t3tools/contracts";
Expand All @@ -10,6 +12,9 @@ import * as TextGenerationOperations from "@t3tools/provider-core/server/textGen
import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";

// Match the three-minute generation budget used by the OpenCode 2 provider.
const OPENCODE_TEXT_GENERATION_TIMEOUT = "3 minutes";

const OpenCodeTextGenerationOperation = Schema.Literals([
"generateCommitMessage",
"generatePrContent",
Expand Down Expand Up @@ -186,11 +191,14 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
});
const session = yield* Effect.tryPromise({
try: () =>
client.session.create({
title: `T3 Code ${input.operation}`,
permission: [{ permission: "*", pattern: "*", action: "deny" }],
}),
try: (signal) =>
client.session.create(
{
title: `T3 Code ${input.operation}`,
permission: [{ permission: "*", pattern: "*", action: "deny" }],
},
{ signal },
),
catch: (cause) =>
new OpenCodeTextGenerationSessionRequestError({
operation: input.operation,
Expand All @@ -215,20 +223,44 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
};

const result = yield* Effect.tryPromise({
try: () =>
client.session.prompt({
sessionID: session.data.id,
model: parsedModel,
...(selectedAgent ? { agent: selectedAgent } : {}),
...(selectedVariant ? { variant: selectedVariant } : {}),
parts: [{ type: "text", text: input.prompt }, ...fileParts],
}),
try: (signal) =>
client.session.prompt(
{
sessionID: session.data.id,
model: parsedModel,
...(selectedAgent ? { agent: selectedAgent } : {}),
...(selectedVariant ? { variant: selectedVariant } : {}),
parts: [{ type: "text", text: input.prompt }, ...fileParts],
},
{ signal },
),
catch: (cause) =>
new OpenCodeTextGenerationPromptRequestError({
...promptContext,
cause,
}),
});
}).pipe(
// Request cancellation alone does not stop generation on the OpenCode server.
// This session-specific cleanup follows the approach in #10606 (yashranaway).
Effect.onInterrupt(() =>
OpenCodeRuntime.runOpenCodeSdk("session.abort", (signal) =>
client.session.abort({ sessionID: session.data.id }, { signal }),
).pipe(
// The onInterrupt finalizer is masked. Run the bounded abort
// in a fresh, interruptible child; wait for it before returning.
Effect.timeout("10 seconds"),
Effect.catch(() =>
Effect.logWarning(
"Failed to stop cancelled OpenCode text generation.",
promptContext,
),
),
Effect.asVoid,
Effect.forkChild({ startImmediately: true, uninterruptible: false }),
Effect.flatMap(Fiber.join),
),
),
);
const promptFailure = getOpenCodePromptFailure(result.data?.info?.error);
if (promptFailure) {
return yield* new OpenCodeTextGenerationPromptResponseError({
Expand Down Expand Up @@ -306,6 +338,19 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
.pipe(Effect.flatMap(runAgainstServer), Effect.scoped)
: serverOwner.withServer(runAgainstServer);
const rawOutput = yield* serverOutput.pipe(
Effect.timeoutOption(OPENCODE_TEXT_GENERATION_TIMEOUT),
Effect.flatMap(
Option.match({
onNone: () =>
Effect.fail(
new TextGenerationError({
operation: input.operation,
detail: "OpenCode request timed out.",
}),
),
onSome: (value) => Effect.succeed(value),
}),
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Effect.catchTags({
OpenCodeRuntimeError: (cause) =>
Effect.fail(
Expand Down
Loading