Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -27,6 +27,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
263 changes: 263 additions & 0 deletions apps/server/src/git/Layers/OpenCodeTextGeneration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { Effect, Layer, Schema } from "effect";

import {
TextGenerationError,
type ChatAttachment,
type OpenCodeModelSelection,
} from "@t3tools/contracts";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";

import { ServerConfig } from "../../config.ts";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import {
buildBranchNamePrompt,
buildCommitMessagePrompt,
buildPrContentPrompt,
buildThreadTitlePrompt,
} from "../Prompts.ts";
import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
import {
sanitizeCommitSubject,
sanitizePrTitle,
sanitizeThreadTitle,
toJsonSchemaObject,
} from "../Utils.ts";
import {
createOpenCodeSdkClient,
parseOpenCodeModelSlug,
startOpenCodeServerProcess,
toOpenCodeFileParts,
} from "../../provider/opencodeRuntime.ts";

const makeOpenCodeTextGeneration = Effect.gen(function* () {
const serverConfig = yield* ServerConfig;
const serverSettingsService = yield* ServerSettingsService;

const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: {
readonly operation:
| "generateCommitMessage"
| "generatePrContent"
| "generateBranchName"
| "generateThreadTitle";
readonly cwd: string;
readonly prompt: string;
readonly outputSchemaJson: S;
readonly modelSelection: OpenCodeModelSelection;
readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
}) {
const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);
if (!parsedModel) {
return yield* new TextGenerationError({
operation: input.operation,
detail: "OpenCode model selection must use the 'provider/model' format.",
});
}

const settings = yield* serverSettingsService.getSettings.pipe(
Effect.map((value) => value.providers.opencode),
Effect.orElseSucceed(() => ({ enabled: true, binaryPath: "opencode", customModels: [] })),
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

const fileParts = toOpenCodeFileParts({
attachments: input.attachments,
resolveAttachmentPath: (attachment) =>
resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
});

const structuredOutput = yield* Effect.acquireUseRelease(
Effect.tryPromise({
try: () => startOpenCodeServerProcess({ binaryPath: settings.binaryPath }),
catch: (cause) =>
new TextGenerationError({
operation: input.operation,
detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.",
cause,
}),
}),
(server) =>
Effect.tryPromise({
try: async () => {
const client = createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd });
const session = await client.session.create({
title: `T3 Code ${input.operation}`,
permission: [{ permission: "*", pattern: "*", action: "deny" }],
});
if (!session.data) {
throw new Error("OpenCode session.create returned no session payload.");
}

const result = await client.session.prompt({
sessionID: session.data.id,
model: parsedModel,
...(input.modelSelection.options?.agent
? { agent: input.modelSelection.options.agent }
: {}),
...(input.modelSelection.options?.variant
? { variant: input.modelSelection.options.variant }
: {}),
format: {
type: "json_schema",
schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>,
},
parts: [{ type: "text", text: input.prompt }, ...fileParts],
});
const structured = result.data?.info.structured;
if (structured === undefined) {
throw new Error("OpenCode returned no structured output.");
}
return structured;
},
catch: (cause) =>
new TextGenerationError({
operation: input.operation,
detail:
cause instanceof Error ? cause.message : "OpenCode text generation request failed.",
cause,
}),
}),
(server) => Effect.sync(() => server.close()),
);
Comment thread
juliusmarminge marked this conversation as resolved.
Outdated

return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe(
Effect.catchTag("SchemaError", (cause) =>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Effect.fail(
new TextGenerationError({
operation: input.operation,
detail: "OpenCode returned invalid structured output.",
cause,
}),
),
),
);
});

const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
"OpenCodeTextGeneration.generateCommitMessage",
)(function* (input) {
if (input.modelSelection.provider !== "opencode") {
return yield* new TextGenerationError({
operation: "generateCommitMessage",
detail: "Invalid model selection.",
});
}

const { prompt, outputSchema } = buildCommitMessagePrompt({
branch: input.branch,
stagedSummary: input.stagedSummary,
stagedPatch: input.stagedPatch,
includeBranch: input.includeBranch === true,
});
const generated = yield* runOpenCodeJson({
operation: "generateCommitMessage",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
});

return {
subject: sanitizeCommitSubject(generated.subject),
body: generated.body.trim(),
...("branch" in generated && typeof generated.branch === "string"
? { branch: sanitizeFeatureBranchName(generated.branch) }
: {}),
};
});

const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
"OpenCodeTextGeneration.generatePrContent",
)(function* (input) {
if (input.modelSelection.provider !== "opencode") {
return yield* new TextGenerationError({
operation: "generatePrContent",
detail: "Invalid model selection.",
});
}

const { prompt, outputSchema } = buildPrContentPrompt({
baseBranch: input.baseBranch,
headBranch: input.headBranch,
commitSummary: input.commitSummary,
diffSummary: input.diffSummary,
diffPatch: input.diffPatch,
});
const generated = yield* runOpenCodeJson({
operation: "generatePrContent",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
});

return {
title: sanitizePrTitle(generated.title),
body: generated.body.trim(),
};
});

const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
"OpenCodeTextGeneration.generateBranchName",
)(function* (input) {
if (input.modelSelection.provider !== "opencode") {
return yield* new TextGenerationError({
operation: "generateBranchName",
detail: "Invalid model selection.",
});
}

const { prompt, outputSchema } = buildBranchNamePrompt({
message: input.message,
attachments: input.attachments,
});
const generated = yield* runOpenCodeJson({
operation: "generateBranchName",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
attachments: input.attachments,
});

return {
branch: sanitizeBranchFragment(generated.branch),
};
});

const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
"OpenCodeTextGeneration.generateThreadTitle",
)(function* (input) {
if (input.modelSelection.provider !== "opencode") {
return yield* new TextGenerationError({
operation: "generateThreadTitle",
detail: "Invalid model selection.",
});
}

const { prompt, outputSchema } = buildThreadTitlePrompt({
message: input.message,
attachments: input.attachments,
});
const generated = yield* runOpenCodeJson({
operation: "generateThreadTitle",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
attachments: input.attachments,
});

return {
title: sanitizeThreadTitle(generated.title),
};
});

return {
generateCommitMessage,
generatePrContent,
generateBranchName,
generateThreadTitle,
} satisfies TextGenerationShape;
});

export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration);
22 changes: 20 additions & 2 deletions apps/server/src/git/Layers/RoutingTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../Services/TextGeneration.ts";
import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";

// ---------------------------------------------------------------------------
// Internal service tags so both concrete layers can coexist.
Expand All @@ -31,16 +32,21 @@ class ClaudeTextGen extends ServiceMap.Service<ClaudeTextGen, TextGenerationShap
"t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
) {}

class OpenCodeTextGen extends ServiceMap.Service<OpenCodeTextGen, TextGenerationShape>()(
"t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
) {}

// ---------------------------------------------------------------------------
// Routing implementation
// ---------------------------------------------------------------------------

const makeRoutingTextGeneration = Effect.gen(function* () {
const codex = yield* CodexTextGen;
const claude = yield* ClaudeTextGen;
const openCode = yield* OpenCodeTextGen;
Comment thread
cursor[bot] marked this conversation as resolved.

const route = (provider?: TextGenerationProvider): TextGenerationShape =>
provider === "claudeAgent" ? claude : codex;
provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex;

return {
generateCommitMessage: (input) =>
Expand All @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect(
}),
).pipe(Layer.provide(ClaudeTextGenerationLive));

const InternalOpenCodeLayer = Layer.effect(
OpenCodeTextGen,
Effect.gen(function* () {
const svc = yield* TextGeneration;
return svc;
}),
).pipe(Layer.provide(OpenCodeTextGenerationLive));

export const RoutingTextGenerationLive = Layer.effect(
TextGeneration,
makeRoutingTextGeneration,
).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer));
).pipe(
Layer.provide(InternalCodexLayer),
Layer.provide(InternalClaudeLayer),
Layer.provide(InternalOpenCodeLayer),
);
2 changes: 1 addition & 1 deletion apps/server/src/git/Services/TextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
import type { TextGenerationError } from "@t3tools/contracts";

/** Providers that support git text generation (commit messages, PR content, branch names). */
export type TextGenerationProvider = "codex" | "claudeAgent";
export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";

export interface CommitMessageGenerationInput {
cwd: string;
Expand Down
Loading
Loading