History

refactor(server): use idiomatic Effect for OpenCode SDK errors

Cursor Agent committed 3 months ago

Commit 654d5521 parent 8b546984 files · +126 −132Browse files

Unified · Split

4 files
  1. apps/server/src/provider/Layers/OpenCodeAdapter.ts +2 −1
  2. apps/server/src/provider/opencodeRuntime.ts +12 −13
  3. apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +80 −90
  4. apps/server/src/textGeneration/OpenCodeTextGeneration.ts +32 −28
apps/server/src/provider/Layers/OpenCodeAdapter.ts+2−1
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
3838 import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
3939 import {
4040 buildOpenCodePermissionRules,
41+ isOpenCodeRuntimeError,
4142 OpenCodeRuntime,
4243 OpenCodeRuntimeError,
4344 openCodeQuestionId,
@@ -131,7 +132,7 @@ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProc · 87 unmodified lines
131132 new ProviderAdapterProcessError({
132133 provider: PROVIDER,
133134 threadId,
134− detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause),
135+ detail: isOpenCodeRuntimeError(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause),
135136 cause,
136137 });
137138
apps/server/src/provider/opencodeRuntime.ts+12−13
@@ -13,14 +13,12 @@ import { · 12 unmodified lines
1313 } from "@opencode-ai/sdk/v2";
1414 import * as Cause from "effect/Cause";
1515 import * as Context from "effect/Context";
16−import * as Data from "effect/Data";
1716 import * as Deferred from "effect/Deferred";
1817 import * as Effect from "effect/Effect";
1918 import * as Exit from "effect/Exit";
2019 import * as Fiber from "effect/Fiber";
2120 import * as Layer from "effect/Layer";
2221 import * as Option from "effect/Option";
23−import * as P from "effect/Predicate";
2422 import * as Ref from "effect/Ref";
2523 import * as Result from "effect/Result";
2624 import * as Scope from "effect/Scope";
@@ -50,15 +48,16 @@ export interface OpenCodeServerConnection { · 23 unmodified lines
5048 readonly external: boolean;
5149 }
5250
53−const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";
54−export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{
55− readonly operation: string;
56− readonly cause?: unknown;
57− readonly detail: string;
58−}> {
59− static readonly is = (u: unknown): u is OpenCodeRuntimeError =>
60− P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
61−}
51+export class OpenCodeRuntimeError extends Schema.TaggedErrorClass<OpenCodeRuntimeError>()(
52+ "OpenCodeRuntimeError",
53+ {
54+ operation: Schema.String,
55+ cause: Schema.optional(Schema.Defect()),
56+ detail: Schema.String,
57+ },
58+) {}
59+
60+export const isOpenCodeRuntimeError = Schema.is(OpenCodeRuntimeError);
6261
6362 function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
6463 const result = encodeUnknownJsonStringExit(input);
@@ -66,7 +65,7 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { · 1 unmodified line
6665 }
6766
6867 export function openCodeRuntimeErrorDetail(cause: unknown): string {
69− if (OpenCodeRuntimeError.is(cause)) return cause.detail;
68+ if (isOpenCodeRuntimeError(cause)) return cause.detail;
7069 if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();
7170 if (cause && typeof cause === "object") {
7271 // SDK v2 throws { response, request, error? } shapes — extract what's useful
@@ -270,7 +269,7 @@ function ensureRuntimeError( · 197 unmodified lines
270269 detail: string,
271270 cause: unknown,
272271 ): OpenCodeRuntimeError {
273− return OpenCodeRuntimeError.is(cause)
272+ return isOpenCodeRuntimeError(cause)
274273 ? cause
275274 : new OpenCodeRuntimeError({ operation, detail, cause });
276275 }
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+80−90
@@ -1,13 +1,13 @@
11 import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
22 import * as NodeServices from "@effect/platform-node/NodeServices";
3−import { it } from "@effect/vitest";
3+import { assert, it } from "@effect/vitest";
44 import * as Duration from "effect/Duration";
55 import * as Effect from "effect/Effect";
66 import * as Layer from "effect/Layer";
77 import * as Schema from "effect/Schema";
88 import * as TestClock from "effect/testing/TestClock";
99 import * as NetService from "@t3tools/shared/Net";
10−import { beforeEach, expect } from "vite-plus/test";
10+import { beforeEach } from "vite-plus/test";
1111
1212 import * as ServerConfig from "../config.ts";
1313 import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
@@ -39,7 +39,7 @@ const runtimeMock = { · 25 unmodified lines
3939 },
4040 };
4141
42−const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
42+const OpenCodeRuntimeTestLayer = Layer.mock(OpenCodeRuntime.OpenCodeRuntime)({
4343 startOpenCodeServerProcess: ({ binaryPath }) =>
4444 Effect.gen(function* () {
4545 const index = runtimeMock.state.startCalls.length + 1;
@@ -57,13 +57,6 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { · 11 unmodified lines
5757 exitCode: Effect.never,
5858 };
5959 }),
60− connectToOpenCodeServer: ({ serverUrl }) =>
61− Effect.succeed({
62− url: serverUrl ?? "http://127.0.0.1:4301",
63− exitCode: null,
64− external: Boolean(serverUrl),
65− }),
66− runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
6760 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
6861 ({
6962 session: {
@@ -99,15 +92,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { · 29 unmodified lines
9992 },
10093 },
10194 }) as unknown as ReturnType<OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
102− loadOpenCodeInventory: () =>
103− Effect.fail(
104− new OpenCodeRuntime.OpenCodeRuntimeError({
105− operation: "loadOpenCodeInventory",
106− detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
107− cause: null,
108− }),
109− ),
110−};
95+});
11196
11297 const DEFAULT_TEST_MODEL_SELECTION = {
11398 instanceId: ProviderInstanceId.make("opencode"),
@@ -123,10 +108,7 @@ const DEFAULT_COMMIT_MESSAGE_INPUT = { · 9 unmodified lines
123108
124109 const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
125110
126−const OpenCodeTextGenerationTestLayer = Layer.succeed(
127− OpenCodeRuntime.OpenCodeRuntime,
128− OpenCodeRuntimeTestDouble,
129−).pipe(
111+const OpenCodeTextGenerationTestLayer = OpenCodeRuntimeTestLayer.pipe(
130112 Layer.provideMerge(
131113 ServerConfig.ServerConfig.layerTest(process.cwd(), {
132114 prefix: "t3code-opencode-text-generation-test-",
@@ -136,10 +118,7 @@ const OpenCodeTextGenerationTestLayer = Layer.succeed( · 3 unmodified lines
136118 Layer.provideMerge(NodeServices.layer),
137119 );
138120
139−const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed(
140− OpenCodeRuntime.OpenCodeRuntime,
141− OpenCodeRuntimeTestDouble,
142−).pipe(
121+const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeRuntimeTestLayer.pipe(
143122 Layer.provideMerge(
144123 ServerConfig.ServerConfig.layerTest(process.cwd(), {
145124 prefix: "t3code-opencode-text-generation-existing-server-test-",
@@ -197,16 +176,16 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 51 unmodified lines
197176 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
198177 });
199178
200− expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
201− expect(runtimeMock.state.promptUrls).toEqual([
179+ assert.deepStrictEqual(runtimeMock.state.startCalls, ["fake-opencode"]);
180+ assert.deepStrictEqual(runtimeMock.state.promptUrls, [
202181 "http://127.0.0.1:4301",
203182 "http://127.0.0.1:4301",
204183 ]);
205− expect(runtimeMock.state.closeCalls).toEqual([]);
184+ assert.deepStrictEqual(runtimeMock.state.closeCalls, []);
206185
207186 yield* advanceIdleClock;
208187
209− expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
188+ assert.deepStrictEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:4301"]);
210189 }),
211190 ).pipe(Effect.provide(TestClock.layer())),
212191 );
@@ -232,12 +211,15 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 19 unmodified lines
232211 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
233212 });
234213
235− expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
236− expect(runtimeMock.state.promptUrls).toEqual([
214+ assert.deepStrictEqual(runtimeMock.state.startCalls, [
215+ "fake-opencode",
216+ "fake-opencode",
217+ ]);
218+ assert.deepStrictEqual(runtimeMock.state.promptUrls, [
237219 "http://127.0.0.1:4301",
238220 "http://127.0.0.1:4302",
239221 ]);
240− expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
222+ assert.deepStrictEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:4301"]);
241223 }),
242224 ).pipe(Effect.provide(TestClock.layer())),
243225 );
@@ -252,15 +234,16 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 8 unmodified lines
252234 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
253235 .pipe(Effect.flip);
254236
255− expect(error).toBeInstanceOf(TextGenerationError);
256− expect(error.message).toContain("OpenCode session.create request failed.");
257− expect(error.cause).toMatchObject({
258− _tag: "OpenCodeTextGenerationSessionRequestError",
259− operation: "generateCommitMessage",
260− cwd: process.cwd(),
261− cause: sdkCause,
262− });
263− expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
237+ assert.instanceOf(error, TextGenerationError);
238+ assert.include(error.message, "OpenCode session.create request failed.");
239+ const cause = error.cause;
240+ assert.instanceOf(
241+ cause,
242+ OpenCodeTextGeneration.OpenCodeTextGenerationSessionRequestError,
243+ );
244+ assert.strictEqual(cause.operation, "generateCommitMessage");
245+ assert.strictEqual(cause.cwd, process.cwd());
246+ assert.strictEqual(cause.cause, sdkCause);
264247 }),
265248 ),
266249 );
@@ -274,13 +257,15 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 7 unmodified lines
274257 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
275258 .pipe(Effect.flip);
276259
277− expect(error.message).toContain("OpenCode session.create returned no session payload.");
278− expect(error.cause).toMatchObject({
279− _tag: "OpenCodeTextGenerationSessionPayloadError",
280− operation: "generateCommitMessage",
281− cwd: process.cwd(),
282− });
283− expect(error.cause).not.toHaveProperty("cause");
260+ assert.include(error.message, "OpenCode session.create returned no session payload.");
261+ const cause = error.cause;
262+ assert.instanceOf(
263+ cause,
264+ OpenCodeTextGeneration.OpenCodeTextGenerationSessionPayloadError,
265+ );
266+ assert.strictEqual(cause.operation, "generateCommitMessage");
267+ assert.strictEqual(cause.cwd, process.cwd());
268+ assert.notProperty(cause, "cause");
284269 }),
285270 ),
286271 );
@@ -295,17 +280,18 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 8 unmodified lines
295280 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
296281 .pipe(Effect.flip);
297282
298− expect(error.message).toContain("OpenCode session.prompt request failed.");
299− expect(error.cause).toMatchObject({
300− _tag: "OpenCodeTextGenerationPromptRequestError",
301− operation: "generateCommitMessage",
302− cwd: process.cwd(),
303− sessionId: "http://127.0.0.1:4301/session",
304− providerId: "openai",
305− modelId: "gpt-5",
306− cause: sdkCause,
307− });
308− expect((error.cause as { cause: unknown }).cause).toBe(sdkCause);
283+ assert.include(error.message, "OpenCode session.prompt request failed.");
284+ const cause = error.cause;
285+ assert.instanceOf(
286+ cause,
287+ OpenCodeTextGeneration.OpenCodeTextGenerationPromptRequestError,
288+ );
289+ assert.strictEqual(cause.operation, "generateCommitMessage");
290+ assert.strictEqual(cause.cwd, process.cwd());
291+ assert.strictEqual(cause.sessionId, "http://127.0.0.1:4301/session");
292+ assert.strictEqual(cause.providerId, "openai");
293+ assert.strictEqual(cause.modelId, "gpt-5");
294+ assert.strictEqual(cause.cause, sdkCause);
309295 }),
310296 ),
311297 );
@@ -323,18 +309,20 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 11 unmodified lines
323309 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
324310 .pipe(Effect.flip);
325311
326− expect(error.message).toContain("OpenCode returned empty output.");
327− expect(error.cause).toMatchObject({
328− _tag: "OpenCodeTextGenerationEmptyOutputError",
329− operation: "generateCommitMessage",
330− cwd: process.cwd(),
331− sessionId: "http://127.0.0.1:4301/session",
332− providerId: "openai",
333− modelId: "gpt-5",
334− responsePartCount: 3,
335− textPartCount: 1,
336− });
337− expect(error.cause).not.toHaveProperty("cause");
312+ assert.include(error.message, "OpenCode returned empty output.");
313+ const cause = error.cause;
314+ assert.instanceOf(
315+ cause,
316+ OpenCodeTextGeneration.OpenCodeTextGenerationEmptyOutputError,
317+ );
318+ assert.strictEqual(cause.operation, "generateCommitMessage");
319+ assert.strictEqual(cause.cwd, process.cwd());
320+ assert.strictEqual(cause.sessionId, "http://127.0.0.1:4301/session");
321+ assert.strictEqual(cause.providerId, "openai");
322+ assert.strictEqual(cause.modelId, "gpt-5");
323+ assert.strictEqual(cause.responsePartCount, 3);
324+ assert.strictEqual(cause.textPartCount, 1);
325+ assert.notProperty(cause, "cause");
338326 }),
339327 ),
340328 );
@@ -361,7 +349,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 20 unmodified lines
361349 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
362350 });
363351
364− expect(result).toEqual({
352+ assert.deepStrictEqual(result, {
365353 subject: "Tighten OpenCode parsing",
366354 body: "Handle JSON text output locally.",
367355 });
@@ -390,18 +378,20 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { · 22 unmodified lines
390378 .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT)
391379 .pipe(Effect.flip);
392380
393− expect(error.message).toContain("Model did not produce structured output");
394− expect(error.cause).toMatchObject({
395− _tag: "OpenCodeTextGenerationPromptResponseError",
396− operation: "generateCommitMessage",
397− cwd: process.cwd(),
398− sessionId: "http://127.0.0.1:4301/session",
399− providerId: "openai",
400− modelId: "gpt-5",
401− providerErrorName: "StructuredOutputError",
402− providerMessage: "Model did not produce structured output",
403− });
404− expect(error.cause).not.toHaveProperty("cause");
381+ assert.include(error.message, "Model did not produce structured output");
382+ const cause = error.cause;
383+ assert.instanceOf(
384+ cause,
385+ OpenCodeTextGeneration.OpenCodeTextGenerationPromptResponseError,
386+ );
387+ assert.strictEqual(cause.operation, "generateCommitMessage");
388+ assert.strictEqual(cause.cwd, process.cwd());
389+ assert.strictEqual(cause.sessionId, "http://127.0.0.1:4301/session");
390+ assert.strictEqual(cause.providerId, "openai");
391+ assert.strictEqual(cause.modelId, "gpt-5");
392+ assert.strictEqual(cause.providerErrorName, "StructuredOutputError");
393+ assert.strictEqual(cause.providerMessage, "Model did not produce structured output");
394+ assert.notProperty(cause, "cause");
405395 }),
406396 ),
407397 );
@@ -428,19 +418,19 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( · 20 unmodified lines
428418 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
429419 });
430420
431− expect(runtimeMock.state.startCalls).toEqual([]);
432− expect(runtimeMock.state.promptUrls).toEqual([
421+ assert.deepStrictEqual(runtimeMock.state.startCalls, []);
422+ assert.deepStrictEqual(runtimeMock.state.promptUrls, [
433423 "http://127.0.0.1:9999",
434424 "http://127.0.0.1:9999",
435425 ]);
436− expect(runtimeMock.state.authHeaders).toEqual([
426+ assert.deepStrictEqual(runtimeMock.state.authHeaders, [
437427 `Basic ${btoa("opencode:secret-password")}`,
438428 `Basic ${btoa("opencode:secret-password")}`,
439429 ]);
440430
441431 yield* advanceIdleClock;
442432
443− expect(runtimeMock.state.closeCalls).toEqual([]);
433+ assert.deepStrictEqual(runtimeMock.state.closeCalls, []);
444434 }),
445435 ).pipe(Effect.provide(TestClock.layer())),
446436 );
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+32−28
@@ -389,19 +389,21 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" · 388 unmodified lines
389389 ? { serverPassword: openCodeSettings.serverPassword }
390390 : {}),
391391 });
392− const session = yield* Effect.tryPromise({
393− try: () =>
394− client.session.create({
395− title: `T3 Code ${input.operation}`,
396− permission: [{ permission: "*", pattern: "*", action: "deny" }],
397− }),
398− catch: (cause) =>
399− new OpenCodeTextGenerationSessionRequestError({
400− operation: input.operation,
401− cwd: input.cwd,
402− cause,
403− }),
404− });
392+ const session = yield* OpenCodeRuntime.runOpenCodeSdk("session.create", () =>
393+ client.session.create({
394+ title: `T3 Code ${input.operation}`,
395+ permission: [{ permission: "*", pattern: "*", action: "deny" }],
396+ }),
397+ ).pipe(
398+ Effect.mapError(
399+ (cause) =>
400+ new OpenCodeTextGenerationSessionRequestError({
401+ operation: input.operation,
402+ cwd: input.cwd,
403+ cause: cause.cause,
404+ }),
405+ ),
406+ );
405407 if (!session.data) {
406408 return yield* new OpenCodeTextGenerationSessionPayloadError({
407409 operation: input.operation,
@@ -418,21 +420,23 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" · 10 unmodified lines
418420 modelId: parsedModel.modelID,
419421 };
420422
421− const result = yield* Effect.tryPromise({
422− try: () =>
423− client.session.prompt({
424− sessionID: session.data.id,
425− model: parsedModel,
426− ...(selectedAgent ? { agent: selectedAgent } : {}),
427− ...(selectedVariant ? { variant: selectedVariant } : {}),
428− parts: [{ type: "text", text: input.prompt }, ...fileParts],
429− }),
430− catch: (cause) =>
431− new OpenCodeTextGenerationPromptRequestError({
432− ...promptContext,
433− cause,
434− }),
435− });
423+ const result = yield* OpenCodeRuntime.runOpenCodeSdk("session.prompt", () =>
424+ client.session.prompt({
425+ sessionID: session.data.id,
426+ model: parsedModel,
427+ ...(selectedAgent ? { agent: selectedAgent } : {}),
428+ ...(selectedVariant ? { variant: selectedVariant } : {}),
429+ parts: [{ type: "text", text: input.prompt }, ...fileParts],
430+ }),
431+ ).pipe(
432+ Effect.mapError(
433+ (cause) =>
434+ new OpenCodeTextGenerationPromptRequestError({
435+ ...promptContext,
436+ cause: cause.cause,
437+ }),
438+ ),
439+ );
436440 const promptFailure = getOpenCodePromptFailure(result.data?.info?.error);
437441 if (promptFailure) {
438442 return yield* new OpenCodeTextGenerationPromptResponseError({