1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import { NonNegativeInt, TextGenerationError } from "@t3tools/contracts";
5import type { OpenCodeSettings } from "../settings.ts";
6import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
7
8import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
9import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
10import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
11import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
12
13const OpenCodeTextGenerationOperation = Schema.Literals([
14 "generateCommitMessage",
15 "generatePrContent",
16 "generateBranchName",
17 "generateThreadTitle",
18 "generateReviewGuide",
19]);
20
21const openCodeTextGenerationErrorContext = {
22 operation: OpenCodeTextGenerationOperation,
23 cwd: Schema.String,
24};
25
26export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedError<OpenCodeTextGenerationSessionRequestError>()(
27 "OpenCodeTextGenerationSessionRequestError",
28 {
29 ...openCodeTextGenerationErrorContext,
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`;
35 }
36}
37
38export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedError<OpenCodeTextGenerationSessionPayloadError>()(
39 "OpenCodeTextGenerationSessionPayloadError",
40 openCodeTextGenerationErrorContext,
41) {
42 override get message(): string {
43 return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`;
44 }
45}
46
47const openCodePromptErrorContext = {
48 ...openCodeTextGenerationErrorContext,
49 sessionId: Schema.String,
50 providerId: Schema.String,
51 modelId: Schema.String,
52};
53
54export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedError<OpenCodeTextGenerationPromptRequestError>()(
55 "OpenCodeTextGenerationPromptRequestError",
56 {
57 ...openCodePromptErrorContext,
58 cause: Schema.Defect(),
59 },
60) {
61 override get message(): string {
62 return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`;
63 }
64}
65
66export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedError<OpenCodeTextGenerationPromptResponseError>()(
67 "OpenCodeTextGenerationPromptResponseError",
68 {
69 ...openCodePromptErrorContext,
70 providerErrorName: Schema.optional(Schema.String),
71 providerMessage: Schema.String,
72 },
73) {
74 override get message(): string {
75 const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : "";
76 return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`;
77 }
78}
79
80export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedError<OpenCodeTextGenerationEmptyOutputError>()(
81 "OpenCodeTextGenerationEmptyOutputError",
82 {
83 ...openCodePromptErrorContext,
84 responsePartCount: NonNegativeInt,
85 textPartCount: NonNegativeInt,
86 },
87) {
88 override get message(): string {
89 return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`;
90 }
91}
92
93interface OpenCodePromptFailure {
94 readonly name?: string;
95 readonly message: string;
96}
97
98interface OpenCodeTextPart {
99 readonly type: "text";
100 readonly text: string;
101}
102
103function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null {
104 if (!error || typeof error !== "object") {
105 return null;
106 }
107
108 const name =
109 "name" in error && typeof error.name === "string" && error.name.trim().length > 0
110 ? error.name.trim()
111 : undefined;
112 const message =
113 "data" in error &&
114 error.data &&
115 typeof error.data === "object" &&
116 "message" in error.data &&
117 typeof error.data.message === "string"
118 ? error.data.message.trim()
119 : "";
120 if (message.length > 0) {
121 return {
122 ...(name ? { name } : {}),
123 message,
124 };
125 }
126
127 if (name) {
128 return { name, message: name };
129 }
130
131 return null;
132}
133
134function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart {
135 return (
136 part !== null &&
137 typeof part === "object" &&
138 "type" in part &&
139 part.type === "text" &&
140 "text" in part &&
141 typeof part.text === "string"
142 );
143}
144
145function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string {
146 return (parts ?? [])
147 .filter(isOpenCodeTextPart)
148 .map((part) => part.text)
149 .join("")
150 .trim();
151}
152
153export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
154 openCodeSettings: OpenCodeSettings,
155) {
156 const host = yield* ProviderHost.ProviderHost;
157 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
158 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
159
160 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(
161 input: TextGenerationOperations.Request<S>,
162 ) {
163 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
164 if (!parsedModel) {
165 return yield* new TextGenerationError({
166 operation: input.operation,
167 detail: "OpenCode model selection must use the 'provider/model' format.",
168 });
169 }
170
171 const fileParts = OpenCodeRuntime.toOpenCodeFileParts({
172 attachments: input.attachments?.filter((attachment) => attachment.type === "image"),
173 resolveAttachmentPath: host.resolveAttachmentPath,
174 });
175
176 const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")(
177 function* (
178 server: Pick<
179 OpenCodeRuntime.OpenCodeServerConnection,
180 "url" | "serverPassword" | "version"
181 >,
182 ) {
183 const client = openCodeRuntime.createOpenCodeSdkClient({
184 baseUrl: server.url,
185 directory: input.cwd,
186 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
187 });
188 const session = yield* Effect.tryPromise({
189 try: () =>
190 client.session.create({
191 title: `T3 Code ${input.operation}`,
192 permission: [{ permission: "*", pattern: "*", action: "deny" }],
193 }),
194 catch: (cause) =>
195 new OpenCodeTextGenerationSessionRequestError({
196 operation: input.operation,
197 cwd: input.cwd,
198 cause,
199 }),
200 });
201 if (!session.data) {
202 return yield* new OpenCodeTextGenerationSessionPayloadError({
203 operation: input.operation,
204 cwd: input.cwd,
205 });
206 }
207 const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
208 const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
209 const promptContext = {
210 operation: input.operation,
211 cwd: input.cwd,
212 sessionId: session.data.id,
213 providerId: parsedModel.providerID,
214 modelId: parsedModel.modelID,
215 };
216
217 const result = yield* Effect.tryPromise({
218 try: () =>
219 client.session.prompt({
220 sessionID: session.data.id,
221 model: parsedModel,
222 ...(selectedAgent ? { agent: selectedAgent } : {}),
223 ...(selectedVariant ? { variant: selectedVariant } : {}),
224 parts: [{ type: "text", text: input.prompt }, ...fileParts],
225 }),
226 catch: (cause) =>
227 new OpenCodeTextGenerationPromptRequestError({
228 ...promptContext,
229 cause,
230 }),
231 });
232 const promptFailure = getOpenCodePromptFailure(result.data?.info?.error);
233 if (promptFailure) {
234 return yield* new OpenCodeTextGenerationPromptResponseError({
235 ...promptContext,
236 ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}),
237 providerMessage: promptFailure.message,
238 });
239 }
240 const responseParts = result.data?.parts ?? [];
241 const rawText = getOpenCodeTextResponse(responseParts);
242 if (rawText.length === 0) {
243 return yield* new OpenCodeTextGenerationEmptyOutputError({
244 ...promptContext,
245 responsePartCount: responseParts.length,
246 textPartCount: responseParts.filter(isOpenCodeTextPart).length,
247 });
248 }
249 return rawText;
250 },
251 Effect.catchTags({
252 OpenCodeTextGenerationSessionRequestError: (cause) =>
253 Effect.fail(
254 new TextGenerationError({
255 operation: cause.operation,
256 detail: "OpenCode session.create request failed.",
257 cause,
258 }),
259 ),
260 OpenCodeTextGenerationSessionPayloadError: (cause) =>
261 Effect.fail(
262 new TextGenerationError({
263 operation: cause.operation,
264 detail: "OpenCode session.create returned no session payload.",
265 cause,
266 }),
267 ),
268 OpenCodeTextGenerationPromptRequestError: (cause) =>
269 Effect.fail(
270 new TextGenerationError({
271 operation: cause.operation,
272 detail: "OpenCode session.prompt request failed.",
273 cause,
274 }),
275 ),
276 OpenCodeTextGenerationPromptResponseError: (cause) =>
277 Effect.fail(
278 new TextGenerationError({
279 operation: cause.operation,
280 detail: cause.providerMessage,
281 cause,
282 }),
283 ),
284 OpenCodeTextGenerationEmptyOutputError: (cause) =>
285 Effect.fail(
286 new TextGenerationError({
287 operation: cause.operation,
288 detail: "OpenCode returned empty output.",
289 cause,
290 }),
291 ),
292 }),
293 );
294
295 const serverOutput =
296 openCodeSettings.serverUrl.length > 0
297 ? openCodeRuntime
298 .connectToOpenCodeServer({
299 binaryPath: openCodeSettings.binaryPath,
300 directory: input.cwd,
301 serverUrl: openCodeSettings.serverUrl,
302 ...(openCodeSettings.serverPassword
303 ? { serverPassword: openCodeSettings.serverPassword }
304 : {}),
305 })
306 .pipe(Effect.flatMap(runAgainstServer), Effect.scoped)
307 : serverOwner.withServer(runAgainstServer);
308 const rawOutput = yield* serverOutput.pipe(
309 Effect.catchTags({
310 OpenCodeRuntimeError: (cause) =>
311 Effect.fail(
312 new TextGenerationError({
313 operation: input.operation,
314 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
315 cause,
316 }),
317 ),
318 }),
319 );
320
321 return yield* TextGenerationOperations.decodeJsonReply(input, "OpenCode", rawOutput);
322 });
323
324 return TextGenerationOperations.fromRunner(
325 "OpenCodeTextGeneration",
326 TextGenerationOperations.withoutInvestigation("OpenCode", runOpenCodeJson),
327 );
328});