Skip to content
Open
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
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: [] })),
);

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

return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe(
Effect.catchTag("SchemaError", (cause) =>
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;

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