Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
115982c
feat: add opencode provider
nexxeln Apr 5, 2026
2811ba6
feat(git): add opencode text generation
nexxeln Apr 5, 2026
1e1f31c
Merge branch 'main' into nxl/opencode-adapter
juliusmarminge Apr 6, 2026
5ebbf95
Add OpenCode composer adapter support
juliusmarminge Apr 7, 2026
d362997
Reuse OpenCode server between text generation calls
juliusmarminge Apr 7, 2026
4d1cf0b
Support configured OpenCode server URLs
juliusmarminge Apr 7, 2026
ea16d83
Add OpenCode server password support
juliusmarminge Apr 7, 2026
21450f6
Merge origin/main into t3code/pr-1758/nxl/opencode-adapter
juliusmarminge Apr 10, 2026
b7f73ce
Merge origin/main into t3code/pr-1758/nxl/opencode-adapter
juliusmarminge Apr 13, 2026
8a3159c
Tighten OpenCode adapter session and permission handling
juliusmarminge Apr 13, 2026
c69f377
Harden OpenCode defaults and race cleanup
juliusmarminge Apr 13, 2026
e7dd9ba
Merge origin/main into t3code/pr-1758/nxl/opencode-adapter
juliusmarminge Apr 16, 2026
81717b0
Handle OpenCode text rollback and full-thread reverts
juliusmarminge Apr 16, 2026
4de7f3a
Log provider events with emitting thread IDs
juliusmarminge Apr 16, 2026
0827397
Merge origin/main into t3code/pr-1758/nxl/opencode-adapter
juliusmarminge Apr 17, 2026
2f09cf7
fix: address opencode adapter review feedback
juliusmarminge Apr 17, 2026
fe6d56a
Skip OpenCode version probe when provider is disabled
juliusmarminge Apr 17, 2026
a687afc
Merge remote-tracking branch 'origin/main' into nxl/opencode-adapter
juliusmarminge Apr 17, 2026
570f819
Remove stale `remove` mock from OpenCode adapter test
juliusmarminge Apr 17, 2026
b5d3ea7
Fix missing stdout/stderr in OpenCode server startup failure
juliusmarminge Apr 17, 2026
027ed78
Detect macOS quarantine when OpenCode server is SIGKILL'd
juliusmarminge Apr 17, 2026
d5c083b
Improve SIGKILL diagnostics with crash report inspection
juliusmarminge Apr 17, 2026
e2d5c2f
Harden OpenCode adapter and surface provider
juliusmarminge Apr 17, 2026
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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@effect/platform-bun": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@opencode-ai/sdk": "^1.3.15",
"@pierre/diffs": "^1.1.0-beta.16",
"effect": "catalog:",
"node-pty": "^1.1.0",
Expand Down
259 changes: 259 additions & 0 deletions apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import type { ChildProcess } from "node:child_process";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import { Duration, Effect, Layer } from "effect";
import { TestClock } from "effect/testing";
import { beforeEach, expect, vi } from "vitest";

import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { TextGeneration } from "../Services/TextGeneration.ts";
import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";

const runtimeMock = vi.hoisted(() => {
const state = {
startCalls: [] as string[],
promptUrls: [] as string[],
authHeaders: [] as Array<string | null>,
closeCalls: [] as string[],
promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined,
};

return {
state,
reset() {
state.startCalls.length = 0;
state.promptUrls.length = 0;
state.authHeaders.length = 0;
state.closeCalls.length = 0;
state.promptResult = undefined;
},
};
});

vi.mock("../../provider/opencodeRuntime.ts", async () => {
const actual = await vi.importActual<typeof import("../../provider/opencodeRuntime.ts")>(
"../../provider/opencodeRuntime.ts",
);

return {
...actual,
startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => {
const index = runtimeMock.state.startCalls.length + 1;
const url = `http://127.0.0.1:${4_300 + index}`;
runtimeMock.state.startCalls.push(binaryPath);
return {
url,
process: {} as ChildProcess,
close: () => {
runtimeMock.state.closeCalls.push(url);
},
};
}),
createOpenCodeSdkClient: vi.fn(
({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({
session: {
create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })),
prompt: vi.fn(async () => {
runtimeMock.state.promptUrls.push(baseUrl);
runtimeMock.state.authHeaders.push(
serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
);
return (
runtimeMock.state.promptResult ?? {
data: {
info: {
structured: {
subject: "Improve OpenCode reuse",
body: "Reuse one server for the full action.",
},
},
},
}
);
}),
},
}),
),
};
});

const DEFAULT_TEST_MODEL_SELECTION = {
provider: "opencode" as const,
model: "openai/gpt-5",
};

const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;

const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
Layer.provideMerge(
ServerSettingsService.layerTest({
providers: {
opencode: {
binaryPath: "fake-opencode",
},
},
}),
),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3code-opencode-text-generation-test-",
}),
),
Layer.provideMerge(NodeServices.layer),
);

const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe(
Layer.provideMerge(
ServerSettingsService.layerTest({
providers: {
opencode: {
binaryPath: "fake-opencode",
serverUrl: "http://127.0.0.1:9999",
serverPassword: "secret-password",
},
},
}),
),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3code-opencode-text-generation-existing-server-test-",
}),
),
Layer.provideMerge(NodeServices.layer),
);

beforeEach(() => {
runtimeMock.reset();
});

const advanceIdleClock = Effect.gen(function* () {
yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
yield* Effect.yieldNow;
});

it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => {
it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

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,
});
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,
});

expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
expect(runtimeMock.state.promptUrls).toEqual([
"http://127.0.0.1:4301",
"http://127.0.0.1:4301",
]);
expect(runtimeMock.state.closeCalls).toEqual([]);

yield* advanceIdleClock;

expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("starts a new server after the warm server idles out", () =>
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

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,
});

yield* advanceIdleClock;

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,
});

expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
expect(runtimeMock.state.promptUrls).toEqual([
"http://127.0.0.1:4301",
"http://127.0.0.1:4302",
]);
expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("returns a typed missing-output error when OpenCode omits info.structured", () =>
Effect.gen(function* () {
runtimeMock.state.promptResult = { data: {} };
const textGeneration = yield* TextGeneration;

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,
})
.pipe(Effect.flip);

expect(error.message).toContain("OpenCode returned no structured output.");
}),
);
});

it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
"OpenCodeTextGenerationLive with configured server URL",
(it) => {
it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

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,
});
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,
});

expect(runtimeMock.state.startCalls).toEqual([]);
expect(runtimeMock.state.promptUrls).toEqual([
"http://127.0.0.1:9999",
"http://127.0.0.1:9999",
]);
expect(runtimeMock.state.authHeaders).toEqual([
`Basic ${btoa("opencode:secret-password")}`,
`Basic ${btoa("opencode:secret-password")}`,
]);

yield* advanceIdleClock;

expect(runtimeMock.state.closeCalls).toEqual([]);
}).pipe(Effect.provide(TestClock.layer())),
);
},
);
Loading
Loading