authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 21:04:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 21:04:13-07:00
logb6302784f8e058de4fb20dae931b4df29e9302fe
tree11ac8eeb52331ea3ba270b402d02c36ca978bfb8
parent9544e72d08755dfc5d9014efd87243f4654f239d
signature Signed by PGP key B5690EEEBB952194

[codex] Refactor review and text generation services (#3196)

Co-authored-by: codex <codex@users.noreply.github.com>

16 files changed, 609 insertions(+), 643 deletions(-)

apps/server/src/git/GitManager.test.ts+51-49
...@@ -20,27 +20,16 @@ import type {...@@ -20,27 +20,16 @@ import type {
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
2121
22import { GitCommandError, TextGenerationError } from "@t3tools/contracts";22import { GitCommandError, TextGenerationError } from "@t3tools/contracts";
23import { type GitManagerShape } from "./GitManager.ts";23import * as GitManager from "./GitManager.ts";
24import {24import * as GitHubCli from "../sourceControl/GitHubCli.ts";
25 GitHubCliError,25import * as TextGeneration from "../textGeneration/TextGeneration.ts";
26 type GitHubCliShape,
27 type GitHubPullRequestSummary,
28 GitHubCli,
29} from "../sourceControl/GitHubCli.ts";
30import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts";
31import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
32import * as VcsProcess from "../vcs/VcsProcess.ts";27import * as VcsProcess from "../vcs/VcsProcess.ts";
33import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";28import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
34import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";29import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
35import { makeGitManager } from "./GitManager.ts";30import * as ServerConfig from "../config.ts";
36import { ServerConfig } from "../config.ts";31import * as ServerSettings from "../serverSettings.ts";
37import { ServerSettingsService } from "../serverSettings.ts";32import * as ProjectSetupScriptRunner from "../project/Services/ProjectSetupScriptRunner.ts";
38import {
39 ProjectSetupScriptRunner,
40 ProjectSetupScriptRunnerError,
41 type ProjectSetupScriptRunnerInput,
42 type ProjectSetupScriptRunnerShape,
43} from "../project/Services/ProjectSetupScriptRunner.ts";
4433
45interface FakeGhScenario {34interface FakeGhScenario {
46 prListSequence?: string[];35 prListSequence?: string[];
...@@ -60,7 +49,7 @@ interface FakeGhScenario {...@@ -60,7 +49,7 @@ interface FakeGhScenario {
60 headRepositoryOwnerLogin?: string | null;49 headRepositoryOwnerLogin?: string | null;
61 };50 };
62 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;51 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
63 failWith?: GitHubCliError;52 failWith?: GitHubCli.GitHubCliError;
64}53}
6554
66function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {55function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {
...@@ -108,7 +97,7 @@ interface FakeGitTextGeneration {...@@ -108,7 +97,7 @@ interface FakeGitTextGeneration {
10897
109type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;98type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
11099
111function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null {100function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null {
112 if (!raw || typeof raw !== "object") {101 if (!raw || typeof raw !== "object") {
113 return null;102 return null;
114 }103 }
...@@ -182,13 +171,13 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {...@@ -182,13 +171,13 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {
182 if (result.status === 0) {171 if (result.status === 0) {
183 return;172 return;
184 }173 }
185 throw new GitHubCliError({174 throw new GitHubCli.GitHubCliError({
186 operation: "execute",175 operation: "execute",
187 detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,176 detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
188 });177 });
189}178}
190179
191function isGitHubCliError(error: unknown): error is GitHubCliError {180function isGitHubCliError(error: unknown): error is GitHubCli.GitHubCliError {
192 return (181 return (
193 typeof error === "object" &&182 typeof error === "object" &&
194 error !== null &&183 error !== null &&
...@@ -312,7 +301,9 @@ function configureVisibleRemoteUrlWithLocalRewrite(...@@ -312,7 +301,9 @@ function configureVisibleRemoteUrlWithLocalRewrite(
312 });301 });
313}302}
314303
315function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {304function createTextGeneration(
305 overrides: Partial<FakeGitTextGeneration> = {},
306): TextGeneration.TextGeneration["Service"] {
316 const implementation: FakeGitTextGeneration = {307 const implementation: FakeGitTextGeneration = {
317 generateCommitMessage: (input) =>308 generateCommitMessage: (input) =>
318 Effect.succeed({309 Effect.succeed({
...@@ -385,7 +376,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T...@@ -385,7 +376,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
385}376}
386377
387function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {378function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
388 service: GitHubCliShape;379 service: GitHubCli.GitHubCliShape;
389 ghCalls: string[];380 ghCalls: string[];
390} {381} {
391 const prListQueue = [...(scenario.prListSequence ?? [])];382 const prListQueue = [...(scenario.prListSequence ?? [])];
...@@ -397,7 +388,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -397,7 +388,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
397 );388 );
398 const ghCalls: string[] = [];389 const ghCalls: string[] = [];
399390
400 const execute: GitHubCliShape["execute"] = (input) => {391 const execute: GitHubCli.GitHubCliShape["execute"] = (input) => {
401 const args = [...input.args];392 const args = [...input.args];
402 ghCalls.push(args.join(" "));393 ghCalls.push(args.join(" "));
403394
...@@ -487,7 +478,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -487,7 +478,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
487 catch: (error) =>478 catch: (error) =>
488 isGitHubCliError(error)479 isGitHubCliError(error)
489 ? error480 ? error
490 : new GitHubCliError({481 : new GitHubCli.GitHubCliError({
491 operation: "execute",482 operation: "execute",
492 detail:483 detail:
493 error instanceof Error484 error instanceof Error
...@@ -503,7 +494,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -503,7 +494,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
503 const cloneUrls = scenario.repositoryCloneUrls?.[repository];494 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
504 if (!cloneUrls) {495 if (!cloneUrls) {
505 return Effect.fail(496 return Effect.fail(
506 new GitHubCliError({497 new GitHubCli.GitHubCliError({
507 operation: "execute",498 operation: "execute",
508 detail: `Unexpected repository lookup: ${repository}`,499 detail: `Unexpected repository lookup: ${repository}`,
509 }),500 }),
...@@ -523,7 +514,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -523,7 +514,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
523 }514 }
524515
525 return Effect.fail(516 return Effect.fail(
526 new GitHubCliError({517 new GitHubCli.GitHubCliError({
527 operation: "execute",518 operation: "execute",
528 detail: `Unexpected gh command: ${args.join(" ")}`,519 detail: `Unexpected gh command: ${args.join(" ")}`,
529 }),520 }),
...@@ -553,7 +544,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -553,7 +544,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
553 Effect.map((raw) =>544 Effect.map((raw) =>
554 raw545 raw
555 .map((entry) => normalizeFakePullRequestSummary(entry))546 .map((entry) => normalizeFakePullRequestSummary(entry))
556 .filter((entry): entry is GitHubPullRequestSummary => entry !== null),547 .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null),
557 ),548 ),
558 ),549 ),
559 createPullRequest: (input) =>550 createPullRequest: (input) =>
...@@ -592,7 +583,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -592,7 +583,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
592 "--json",583 "--json",
593 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",584 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
594 ],585 ],
595 }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)),586 }).pipe(
587 Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),
588 ),
596 getRepositoryCloneUrls: (input) =>589 getRepositoryCloneUrls: (input) =>
597 execute({590 execute({
598 cwd: input.cwd,591 cwd: input.cwd,
...@@ -600,7 +593,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -600,7 +593,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
600 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),593 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
601 createRepository: (input) =>594 createRepository: (input) =>
602 Effect.fail(595 Effect.fail(
603 new GitHubCliError({596 new GitHubCli.GitHubCliError({
604 operation: "createRepository",597 operation: "createRepository",
605 detail: `Unexpected repository create: ${input.repository}`,598 detail: `Unexpected repository create: ${input.repository}`,
606 }),599 }),
...@@ -616,7 +609,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -616,7 +609,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
616}609}
617610
618function runStackedAction(611function runStackedAction(
619 manager: GitManagerShape,612 manager: GitManager.GitManagerShape,
620 input: {613 input: {
621 cwd: string;614 cwd: string;
622 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";615 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
...@@ -625,7 +618,7 @@ function runStackedAction(...@@ -625,7 +618,7 @@ function runStackedAction(
625 featureBranch?: boolean;618 featureBranch?: boolean;
626 filePaths?: readonly string[];619 filePaths?: readonly string[];
627 },620 },
628 options?: Parameters<GitManagerShape["runStackedAction"]>[1],621 options?: Parameters<GitManager.GitManagerShape["runStackedAction"]>[1],
629) {622) {
630 return manager.runStackedAction(623 return manager.runStackedAction(
631 {624 {
...@@ -636,12 +629,15 @@ function runStackedAction(...@@ -636,12 +629,15 @@ function runStackedAction(
636 );629 );
637}630}
638631
639function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {632function resolvePullRequest(
633 manager: GitManager.GitManagerShape,
634 input: { cwd: string; reference: string },
635) {
640 return manager.resolvePullRequest(input);636 return manager.resolvePullRequest(input);
641}637}
642638
643function preparePullRequestThread(639function preparePullRequestThread(
644 manager: GitManagerShape,640 manager: GitManager.GitManagerShape,
645 input: GitPreparePullRequestThreadInput,641 input: GitPreparePullRequestThreadInput,
646) {642) {
647 return manager.preparePullRequestThread(input);643 return manager.preparePullRequestThread(input);
...@@ -650,20 +646,20 @@ function preparePullRequestThread(...@@ -650,20 +646,20 @@ function preparePullRequestThread(
650function makeManager(input?: {646function makeManager(input?: {
651 ghScenario?: FakeGhScenario;647 ghScenario?: FakeGhScenario;
652 textGeneration?: Partial<FakeGitTextGeneration>;648 textGeneration?: Partial<FakeGitTextGeneration>;
653 setupScriptRunner?: ProjectSetupScriptRunnerShape;649 setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunnerShape;
654}) {650}) {
655 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);651 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
656 const textGeneration = createTextGeneration(input?.textGeneration);652 const textGeneration = createTextGeneration(input?.textGeneration);
657 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {653 const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
658 prefix: "t3-git-manager-test-",654 prefix: "t3-git-manager-test-",
659 });655 });
660656
661 const serverSettingsLayer = ServerSettingsService.layerTest();657 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest();
662658
663 const vcsDriverLayer = GitVcsDriver.layer.pipe(659 const vcsDriverLayer = GitVcsDriver.layer.pipe(
664 Layer.provideMerge(VcsProcess.layer),660 Layer.provideMerge(VcsProcess.layer),
665 Layer.provideMerge(NodeServices.layer),661 Layer.provideMerge(NodeServices.layer),
666 Layer.provideMerge(ServerConfigLayer),662 Layer.provideMerge(serverConfigLayer),
667 );663 );
668 const sourceControlRegistryLayer = Layer.effect(664 const sourceControlRegistryLayer = Layer.effect(
669 SourceControlProviderRegistry.SourceControlProviderRegistry,665 SourceControlProviderRegistry.SourceControlProviderRegistry,
...@@ -676,14 +672,14 @@ function makeManager(input?: {...@@ -676,14 +672,14 @@ function makeManager(input?: {
676 discover: Effect.succeed([]),672 discover: Effect.succeed([]),
677 }),673 }),
678 ),674 ),
679 Effect.provide(Layer.succeed(GitHubCli, gitHubCli)),675 Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)),
680 ),676 ),
681 );677 );
682678
683 const managerLayer = Layer.mergeAll(679 const managerLayer = Layer.mergeAll(
684 Layer.succeed(TextGeneration, textGeneration),680 Layer.succeed(TextGeneration.TextGeneration, textGeneration),
685 Layer.succeed(681 Layer.succeed(
686 ProjectSetupScriptRunner,682 ProjectSetupScriptRunner.ProjectSetupScriptRunner,
687 input?.setupScriptRunner ?? {683 input?.setupScriptRunner ?? {
688 runForThread: () => Effect.succeed({ status: "no-script" as const }),684 runForThread: () => Effect.succeed({ status: "no-script" as const }),
689 },685 },
...@@ -692,7 +688,7 @@ function makeManager(input?: {...@@ -692,7 +688,7 @@ function makeManager(input?: {
692 serverSettingsLayer,688 serverSettingsLayer,
693 ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer));689 ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer));
694690
695 return makeGitManager().pipe(691 return GitManager.makeGitManager().pipe(
696 Effect.provide(managerLayer),692 Effect.provide(managerLayer),
697 Effect.map((manager) => ({ manager, ghCalls })),693 Effect.map((manager) => ({ manager, ghCalls })),
698 );694 );
...@@ -701,7 +697,9 @@ function makeManager(input?: {...@@ -701,7 +697,9 @@ function makeManager(input?: {
701const asThreadId = (threadId: string) => threadId as ThreadId;697const asThreadId = (threadId: string) => threadId as ThreadId;
702698
703const GitManagerTestLayer = GitVcsDriver.layer.pipe(699const GitManagerTestLayer = GitVcsDriver.layer.pipe(
704 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),700 Layer.provide(
701 ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" }),
702 ),
705 Layer.provideMerge(VcsProcess.layer),703 Layer.provideMerge(VcsProcess.layer),
706 Layer.provideMerge(NodeServices.layer),704 Layer.provideMerge(NodeServices.layer),
707);705);
...@@ -1335,7 +1333,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1335,7 +1333,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13351333
1336 const { manager } = yield* makeManager({1334 const { manager } = yield* makeManager({
1337 ghScenario: {1335 ghScenario: {
1338 failWith: new GitHubCliError({1336 failWith: new GitHubCli.GitHubCliError({
1339 operation: "execute",1337 operation: "execute",
1340 detail: "GitHub CLI (`gh`) is required but not available on PATH.",1338 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
1341 }),1339 }),
...@@ -2417,7 +2415,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2417,7 +2415,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
24172415
2418 const { manager } = yield* makeManager({2416 const { manager } = yield* makeManager({
2419 ghScenario: {2417 ghScenario: {
2420 failWith: new GitHubCliError({2418 failWith: new GitHubCli.GitHubCliError({
2421 operation: "execute",2419 operation: "execute",
2422 detail: "GitHub CLI (`gh`) is required but not available on PATH.",2420 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
2423 }),2421 }),
...@@ -2446,7 +2444,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2446,7 +2444,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
24462444
2447 const { manager } = yield* makeManager({2445 const { manager } = yield* makeManager({
2448 ghScenario: {2446 ghScenario: {
2449 failWith: new GitHubCliError({2447 failWith: new GitHubCli.GitHubCliError({
2450 operation: "execute",2448 operation: "execute",
2451 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",2449 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
2452 }),2450 }),
...@@ -2702,7 +2700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2702,7 +2700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2702 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]);2700 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]);
2703 yield* runGit(repoDir, ["checkout", "main"]);2701 yield* runGit(repoDir, ["checkout", "main"]);
27042702
2705 const setupCalls: ProjectSetupScriptRunnerInput[] = [];2703 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
2706 const { manager } = yield* makeManager({2704 const { manager } = yield* makeManager({
2707 ghScenario: {2705 ghScenario: {
2708 pullRequest: {2706 pullRequest: {
...@@ -2924,7 +2922,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -2924,7 +2922,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
2924 const worktreePath = path.join(repoDir, "..", `pr-existing-${path.basename(repoDir)}`);2922 const worktreePath = path.join(repoDir, "..", `pr-existing-${path.basename(repoDir)}`);
2925 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);2923 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);
29262924
2927 const setupCalls: ProjectSetupScriptRunnerInput[] = [];2925 const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = [];
2928 const { manager } = yield* makeManager({2926 const { manager } = yield* makeManager({
2929 ghScenario: {2927 ghScenario: {
2930 pullRequest: {2928 pullRequest: {
...@@ -3164,7 +3162,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -3164,7 +3162,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
3164 },3162 },
3165 setupScriptRunner: {3163 setupScriptRunner: {
3166 runForThread: () =>3164 runForThread: () =>
3167 Effect.fail(new ProjectSetupScriptRunnerError({ message: "terminal start failed" })),3165 Effect.fail(
3166 new ProjectSetupScriptRunner.ProjectSetupScriptRunnerError({
3167 message: "terminal start failed",
3168 }),
3169 ),
3168 },3170 },
3169 });3171 });
31703172
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+19-16
...@@ -10,16 +10,16 @@ import * as Layer from "effect/Layer";...@@ -10,16 +10,16 @@ import * as Layer from "effect/Layer";
10import * as PubSub from "effect/PubSub";10import * as PubSub from "effect/PubSub";
11import * as Stream from "effect/Stream";11import * as Stream from "effect/Stream";
1212
13import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";13import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts";
14import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";14import type * as CodexAdapter from "../Services/CodexAdapter.ts";
15import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";15import type * as CursorAdapter from "../Services/CursorAdapter.ts";
16import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";16import type * as OpenCodeAdapter from "../Services/OpenCodeAdapter.ts";
17import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";17import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
18import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";18import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
19import type { ProviderInstance } from "../ProviderDriver.ts";19import type { ProviderInstance } from "../ProviderDriver.ts";
20import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";20import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
21import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";21import type * as TextGeneration from "../../textGeneration/TextGeneration.ts";
22import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";22import * as ProviderAdapterRegistryLayer from "./ProviderAdapterRegistry.ts";
23import * as NodeServices from "@effect/platform-node/NodeServices";23import * as NodeServices from "@effect/platform-node/NodeServices";
2424
25const CODEX_DRIVER = ProviderDriverKind.make("codex");25const CODEX_DRIVER = ProviderDriverKind.make("codex");
...@@ -27,7 +27,7 @@ const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");...@@ -27,7 +27,7 @@ const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
27const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");27const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");
28const CURSOR_DRIVER = ProviderDriverKind.make("cursor");28const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
2929
30const fakeCodexAdapter: CodexAdapterShape = {30const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = {
31 provider: CODEX_DRIVER,31 provider: CODEX_DRIVER,
32 capabilities: { sessionModelSwitch: "in-session" },32 capabilities: { sessionModelSwitch: "in-session" },
33 startSession: vi.fn(),33 startSession: vi.fn(),
...@@ -44,7 +44,7 @@ const fakeCodexAdapter: CodexAdapterShape = {...@@ -44,7 +44,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
44 streamEvents: Stream.empty,44 streamEvents: Stream.empty,
45};45};
4646
47const fakeClaudeAdapter: ClaudeAdapterShape = {47const fakeClaudeAdapter: ClaudeAdapter.ClaudeAdapterShape = {
48 provider: CLAUDE_AGENT_DRIVER,48 provider: CLAUDE_AGENT_DRIVER,
49 capabilities: { sessionModelSwitch: "in-session" },49 capabilities: { sessionModelSwitch: "in-session" },
50 startSession: vi.fn(),50 startSession: vi.fn(),
...@@ -61,7 +61,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = {...@@ -61,7 +61,7 @@ const fakeClaudeAdapter: ClaudeAdapterShape = {
61 streamEvents: Stream.empty,61 streamEvents: Stream.empty,
62};62};
6363
64const fakeOpenCodeAdapter: OpenCodeAdapterShape = {64const fakeOpenCodeAdapter: OpenCodeAdapter.OpenCodeAdapterShape = {
65 provider: OPENCODE_DRIVER,65 provider: OPENCODE_DRIVER,
66 capabilities: { sessionModelSwitch: "in-session" },66 capabilities: { sessionModelSwitch: "in-session" },
67 startSession: vi.fn(),67 startSession: vi.fn(),
...@@ -78,7 +78,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = {...@@ -78,7 +78,7 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = {
78 streamEvents: Stream.empty,78 streamEvents: Stream.empty,
79};79};
8080
81const fakeCursorAdapter: CursorAdapterShape = {81const fakeCursorAdapter: CursorAdapter.CursorAdapterShape = {
82 provider: CURSOR_DRIVER,82 provider: CURSOR_DRIVER,
83 capabilities: { sessionModelSwitch: "in-session" },83 capabilities: { sessionModelSwitch: "in-session" },
84 startSession: vi.fn(),84 startSession: vi.fn(),
...@@ -124,7 +124,7 @@ const makeFakeInstance = (...@@ -124,7 +124,7 @@ const makeFakeInstance = (
124 streamChanges: Stream.empty,124 streamChanges: Stream.empty,
125 },125 },
126 adapter,126 adapter,
127 textGeneration: {} as unknown as TextGenerationShape,127 textGeneration: {} as unknown as TextGeneration.TextGeneration["Service"],
128 };128 };
129};129};
130130
...@@ -135,7 +135,7 @@ const fakeInstances: ReadonlyArray<ProviderInstance> = [...@@ -135,7 +135,7 @@ const fakeInstances: ReadonlyArray<ProviderInstance> = [
135 makeFakeInstance("cursor", fakeCursorAdapter),135 makeFakeInstance("cursor", fakeCursorAdapter),
136];136];
137137
138const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, {138const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, {
139 getInstance: (instanceId) =>139 getInstance: (instanceId) =>
140 Effect.succeed(fakeInstances.find((instance) => instance.instanceId === instanceId)),140 Effect.succeed(fakeInstances.find((instance) => instance.instanceId === instanceId)),
141 listInstances: Effect.succeed(fakeInstances),141 listInstances: Effect.succeed(fakeInstances),
...@@ -147,14 +147,17 @@ const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, {...@@ -147,14 +147,17 @@ const fakeInstanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, {
147});147});
148148
149const layer = Layer.mergeAll(149const layer = Layer.mergeAll(
150 Layer.provide(ProviderAdapterRegistryLive, fakeInstanceRegistryLayer),150 Layer.provide(
151 ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive,
152 fakeInstanceRegistryLayer,
153 ),
151 NodeServices.layer,154 NodeServices.layer,
152);155);
153156
154it.layer(layer)("ProviderAdapterRegistryLive", (it) => {157it.layer(layer)("ProviderAdapterRegistryLive", (it) => {
155 it("resolves adapters and routing metadata from provider instances", () =>158 it("resolves adapters and routing metadata from provider instances", () =>
156 Effect.gen(function* () {159 Effect.gen(function* () {
157 const registry = yield* ProviderAdapterRegistry;160 const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
158 const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER);161 const claudeInstanceId = defaultInstanceIdForDriver(CLAUDE_AGENT_DRIVER);
159162
160 const adapter = yield* registry.getByInstance(claudeInstanceId);163 const adapter = yield* registry.getByInstance(claudeInstanceId);
apps/server/src/provider/ProviderDriver.ts+2-2
...@@ -30,7 +30,7 @@ import type * as Effect from "effect/Effect";...@@ -30,7 +30,7 @@ import type * as Effect from "effect/Effect";
30import type * as Schema from "effect/Schema";30import type * as Schema from "effect/Schema";
31import type * as Scope from "effect/Scope";31import type * as Scope from "effect/Scope";
3232
33import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts";33import type * as TextGeneration from "../textGeneration/TextGeneration.ts";
34import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts";34import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts";
35import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";35import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
36import type { ServerProviderShape } from "./Services/ServerProvider.ts";36import type { ServerProviderShape } from "./Services/ServerProvider.ts";
...@@ -70,7 +70,7 @@ export interface ProviderInstance {...@@ -70,7 +70,7 @@ export interface ProviderInstance {
70 readonly enabled: boolean;70 readonly enabled: boolean;
71 readonly snapshot: ServerProviderShape;71 readonly snapshot: ServerProviderShape;
72 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;72 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
73 readonly textGeneration: TextGenerationShape;73 readonly textGeneration: TextGeneration.TextGeneration["Service"];
74}74}
7575
76export interface ProviderContinuationIdentity {76export interface ProviderContinuationIdentity {
apps/server/src/review/ReviewService.ts+14-15
...@@ -13,22 +13,21 @@ import {...@@ -13,22 +13,21 @@ import {
13 type ReviewDiffPreviewResult,13 type ReviewDiffPreviewResult,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
1515
16import { ServerConfig } from "../config.ts";16import * as ServerConfig from "../config.ts";
17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
18import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";18import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1919
20export interface ReviewServiceShape {20export class ReviewService extends Context.Service<
21 readonly getDiffPreview: (21 ReviewService,
22 input: ReviewDiffPreviewInput,22 {
23 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;23 readonly getDiffPreview: (
24}24 input: ReviewDiffPreviewInput,
2525 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;
26export class ReviewService extends Context.Service<ReviewService, ReviewServiceShape>()(26 }
27 "t3/review/ReviewService",27>()("t3/review/ReviewService") {}
28) {}28
2929export const make = Effect.gen(function* () {
30export const make = Effect.fn("makeReviewService")(function* () {30 const config = yield* ServerConfig.ServerConfig;
31 const config = yield* ServerConfig;
32 const fileSystem = yield* FileSystem.FileSystem;31 const fileSystem = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;32 const path = yield* Path.Path;
34 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;33 const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
...@@ -62,7 +61,7 @@ export const make = Effect.fn("makeReviewService")(function* () {...@@ -62,7 +61,7 @@ export const make = Effect.fn("makeReviewService")(function* () {
62 });61 });
63 });62 });
6463
65 const getDiffPreview: ReviewServiceShape["getDiffPreview"] = Effect.fn(64 const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn(
66 "ReviewService.getDiffPreview",65 "ReviewService.getDiffPreview",
67 )(function* (input) {66 )(function* (input) {
68 yield* assertWorkspaceBoundCwd(input.cwd);67 yield* assertWorkspaceBoundCwd(input.cwd);
...@@ -96,4 +95,4 @@ export const make = Effect.fn("makeReviewService")(function* () {...@@ -96,4 +95,4 @@ export const make = Effect.fn("makeReviewService")(function* () {
96 });95 });
97});96});
9897
99export const layer = Layer.effect(ReviewService, make());98export const layer = Layer.effect(ReviewService, make);
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+4-4
...@@ -9,13 +9,13 @@ import * as Schema from "effect/Schema";...@@ -9,13 +9,13 @@ import * as Schema from "effect/Schema";
9import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
10import { expect } from "vite-plus/test";10import { expect } from "vite-plus/test";
1111
12import { ServerConfig } from "../config.ts";12import * as ServerConfig from "../config.ts";
13import { type TextGenerationShape } from "./TextGeneration.ts";13import * as TextGeneration from "./TextGeneration.ts";
14import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";14import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";
15import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";15import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
16const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);16const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
1717
18const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {18const ClaudeTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
19 prefix: "t3code-claude-text-generation-test-",19 prefix: "t3code-claude-text-generation-test-",
20}).pipe(Layer.provideMerge(NodeServices.layer));20}).pipe(Layer.provideMerge(NodeServices.layer));
2121
...@@ -79,7 +79,7 @@ function withFakeClaudeEnv<A, E, R>(...@@ -79,7 +79,7 @@ function withFakeClaudeEnv<A, E, R>(
79 homeMustBe?: string;79 homeMustBe?: string;
80 claudeConfig?: Partial<ClaudeSettings>;80 claudeConfig?: Partial<ClaudeSettings>;
81 },81 },
82 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,82 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
83) {83) {
84 return Effect.gen(function* () {84 return Effect.gen(function* () {
85 const fs = yield* FileSystem.FileSystem;85 const fs = yield* FileSystem.FileSystem;
apps/server/src/textGeneration/ClaudeTextGeneration.ts+79-83
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1/**1/**
2 * ClaudeTextGeneration – Text generation layer using the Claude CLI.2 * ClaudeTextGeneration – Text generation layer using the Claude CLI.
3 *3 *
4 * Implements the same TextGenerationShape contract as CodexTextGeneration but4 * Implements the same TextGeneration service contract as CodexTextGeneration but
5 * delegates to the `claude` CLI (`claude -p`) with structured JSON output5 * delegates to the `claude` CLI (`claude -p`) with structured JSON output
6 * instead of the `codex exec` CLI.6 * instead of the `codex exec` CLI.
7 *7 *
...@@ -18,7 +18,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar...@@ -18,7 +18,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar
18import { resolveSpawnCommand } from "@t3tools/shared/shell";18import { resolveSpawnCommand } from "@t3tools/shared/shell";
1919
20import { TextGenerationError } from "@t3tools/contracts";20import { TextGenerationError } from "@t3tools/contracts";
21import { type TextGenerationShape } from "./TextGeneration.ts";21import * as TextGeneration from "./TextGeneration.ts";
22import {22import {
23 buildBranchNamePrompt,23 buildBranchNamePrompt,
24 buildCommitMessagePrompt,24 buildCommitMessagePrompt,
...@@ -260,107 +260,103 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu...@@ -260,107 +260,103 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
260 });260 });
261261
262 // ---------------------------------------------------------------------------262 // ---------------------------------------------------------------------------
263 // TextGenerationShape methods263 // TextGeneration service methods
264 // ---------------------------------------------------------------------------264 // ---------------------------------------------------------------------------
265265
266 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(266 const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
267 "ClaudeTextGeneration.generateCommitMessage",267 Effect.fn("ClaudeTextGeneration.generateCommitMessage")(function* (input) {
268 )(function* (input) {268 const { prompt, outputSchema } = buildCommitMessagePrompt({
269 const { prompt, outputSchema } = buildCommitMessagePrompt({269 branch: input.branch,
270 branch: input.branch,270 stagedSummary: input.stagedSummary,
271 stagedSummary: input.stagedSummary,271 stagedPatch: input.stagedPatch,
272 stagedPatch: input.stagedPatch,272 includeBranch: input.includeBranch === true,
273 includeBranch: input.includeBranch === true,273 });
274 });274
275 const generated = yield* runClaudeJson({
276 operation: "generateCommitMessage",
277 cwd: input.cwd,
278 prompt,
279 outputSchemaJson: outputSchema,
280 modelSelection: input.modelSelection,
281 });
275282
276 const generated = yield* runClaudeJson({283 return {
277 operation: "generateCommitMessage",284 subject: sanitizeCommitSubject(generated.subject),
278 cwd: input.cwd,285 body: generated.body.trim(),
279 prompt,286 ...("branch" in generated && typeof generated.branch === "string"
280 outputSchemaJson: outputSchema,287 ? { branch: sanitizeFeatureBranchName(generated.branch) }
281 modelSelection: input.modelSelection,288 : {}),
289 };
282 });290 });
283291
284 return {292 const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
285 subject: sanitizeCommitSubject(generated.subject),293 Effect.fn("ClaudeTextGeneration.generatePrContent")(function* (input) {
286 body: generated.body.trim(),294 const { prompt, outputSchema } = buildPrContentPrompt({
287 ...("branch" in generated && typeof generated.branch === "string"295 baseBranch: input.baseBranch,
288 ? { branch: sanitizeFeatureBranchName(generated.branch) }296 headBranch: input.headBranch,
289 : {}),297 commitSummary: input.commitSummary,
290 };298 diffSummary: input.diffSummary,
291 });299 diffPatch: input.diffPatch,
300 });
292301
293 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(302 const generated = yield* runClaudeJson({
294 "ClaudeTextGeneration.generatePrContent",303 operation: "generatePrContent",
295 )(function* (input) {304 cwd: input.cwd,
296 const { prompt, outputSchema } = buildPrContentPrompt({305 prompt,
297 baseBranch: input.baseBranch,306 outputSchemaJson: outputSchema,
298 headBranch: input.headBranch,307 modelSelection: input.modelSelection,
299 commitSummary: input.commitSummary,308 });
300 diffSummary: input.diffSummary,
301 diffPatch: input.diffPatch,
302 });
303309
304 const generated = yield* runClaudeJson({310 return {
305 operation: "generatePrContent",311 title: sanitizePrTitle(generated.title),
306 cwd: input.cwd,312 body: generated.body.trim(),
307 prompt,313 };
308 outputSchemaJson: outputSchema,
309 modelSelection: input.modelSelection,
310 });314 });
311315
312 return {316 const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
313 title: sanitizePrTitle(generated.title),317 Effect.fn("ClaudeTextGeneration.generateBranchName")(function* (input) {
314 body: generated.body.trim(),318 const { prompt, outputSchema } = buildBranchNamePrompt({
315 };319 message: input.message,
316 });320 attachments: input.attachments,
321 });
317322
318 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(323 const generated = yield* runClaudeJson({
319 "ClaudeTextGeneration.generateBranchName",324 operation: "generateBranchName",
320 )(function* (input) {325 cwd: input.cwd,
321 const { prompt, outputSchema } = buildBranchNamePrompt({326 prompt,
322 message: input.message,327 outputSchemaJson: outputSchema,
323 attachments: input.attachments,328 modelSelection: input.modelSelection,
324 });329 });
325330
326 const generated = yield* runClaudeJson({331 return {
327 operation: "generateBranchName",332 branch: sanitizeBranchFragment(generated.branch),
328 cwd: input.cwd,333 };
329 prompt,
330 outputSchemaJson: outputSchema,
331 modelSelection: input.modelSelection,
332 });334 });
333335
334 return {336 const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
335 branch: sanitizeBranchFragment(generated.branch),337 Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) {
336 };338 const { prompt, outputSchema } = buildThreadTitlePrompt({
337 });339 message: input.message,
340 attachments: input.attachments,
341 });
338342
339 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(343 const generated = yield* runClaudeJson({
340 "ClaudeTextGeneration.generateThreadTitle",344 operation: "generateThreadTitle",
341 )(function* (input) {345 cwd: input.cwd,
342 const { prompt, outputSchema } = buildThreadTitlePrompt({346 prompt,
343 message: input.message,347 outputSchemaJson: outputSchema,
344 attachments: input.attachments,348 modelSelection: input.modelSelection,
345 });349 });
346350
347 const generated = yield* runClaudeJson({351 return {
348 operation: "generateThreadTitle",352 title: sanitizeThreadTitle(generated.title),
349 cwd: input.cwd,353 };
350 prompt,
351 outputSchemaJson: outputSchema,
352 modelSelection: input.modelSelection,
353 });354 });
354355
355 return {
356 title: sanitizeThreadTitle(generated.title),
357 };
358 });
359
360 return {356 return {
361 generateCommitMessage,357 generateCommitMessage,
362 generatePrContent,358 generatePrContent,
363 generateBranchName,359 generateBranchName,
364 generateThreadTitle,360 generateThreadTitle,
365 } satisfies TextGenerationShape;361 } satisfies TextGeneration.TextGeneration["Service"];
366});362});
apps/server/src/textGeneration/CodexTextGeneration.test.ts+7-7
...@@ -11,8 +11,8 @@ import { expect } from "vite-plus/test";...@@ -11,8 +11,8 @@ import { expect } from "vite-plus/test";
1111
12import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";12import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
1313
14import { ServerConfig } from "../config.ts";14import * as ServerConfig from "../config.ts";
15import { type TextGenerationShape } from "./TextGeneration.ts";15import * as TextGeneration from "./TextGeneration.ts";
16import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";16import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
17const decodeCodexSettings = Schema.decodeSync(CodexSettings);17const decodeCodexSettings = Schema.decodeSync(CodexSettings);
1818
...@@ -21,7 +21,7 @@ const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(...@@ -21,7 +21,7 @@ const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
21 "gpt-5.4-mini",21 "gpt-5.4-mini",
22);22);
2323
24const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {24const CodexTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
25 prefix: "t3code-codex-text-generation-test-",25 prefix: "t3code-codex-text-generation-test-",
26}).pipe(Layer.provideMerge(NodeServices.layer));26}).pipe(Layer.provideMerge(NodeServices.layer));
2727
...@@ -169,7 +169,7 @@ function withFakeCodexEnv<A, E, R>(...@@ -169,7 +169,7 @@ function withFakeCodexEnv<A, E, R>(
169 stdinMustContain?: string;169 stdinMustContain?: string;
170 stdinMustNotContain?: string;170 stdinMustNotContain?: string;
171 },171 },
172 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,172 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
173) {173) {
174 return Effect.gen(function* () {174 return Effect.gen(function* () {
175 const fs = yield* FileSystem.FileSystem;175 const fs = yield* FileSystem.FileSystem;
...@@ -427,7 +427,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -427,7 +427,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
427 Effect.gen(function* () {427 Effect.gen(function* () {
428 const fs = yield* FileSystem.FileSystem;428 const fs = yield* FileSystem.FileSystem;
429 const path = yield* Path.Path;429 const path = yield* Path.Path;
430 const { attachmentsDir } = yield* ServerConfig;430 const { attachmentsDir } = yield* ServerConfig.ServerConfig;
431 const attachmentId = "thread-branch-image-attachment";431 const attachmentId = "thread-branch-image-attachment";
432 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);432 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
433 yield* fs.makeDirectory(attachmentsDir, { recursive: true });433 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
...@@ -465,7 +465,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -465,7 +465,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
465 Effect.gen(function* () {465 Effect.gen(function* () {
466 const fs = yield* FileSystem.FileSystem;466 const fs = yield* FileSystem.FileSystem;
467 const path = yield* Path.Path;467 const path = yield* Path.Path;
468 const { attachmentsDir } = yield* ServerConfig;468 const { attachmentsDir } = yield* ServerConfig.ServerConfig;
469 const attachmentId = "thread-1-attachment";469 const attachmentId = "thread-1-attachment";
470 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);470 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);
471 yield* fs.makeDirectory(attachmentsDir, { recursive: true });471 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
...@@ -514,7 +514,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {...@@ -514,7 +514,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
514 Effect.gen(function* () {514 Effect.gen(function* () {
515 const fs = yield* FileSystem.FileSystem;515 const fs = yield* FileSystem.FileSystem;
516 const path = yield* Path.Path;516 const path = yield* Path.Path;
517 const { attachmentsDir } = yield* ServerConfig;517 const { attachmentsDir } = yield* ServerConfig.ServerConfig;
518 const missingAttachmentId = "thread-missing-attachment";518 const missingAttachmentId = "thread-missing-attachment";
519 const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`);519 const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`);
520 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));520 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));
apps/server/src/textGeneration/CodexTextGeneration.ts+90-98
...@@ -12,14 +12,10 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar...@@ -12,14 +12,10 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar
12import { resolveSpawnCommand } from "@t3tools/shared/shell";12import { resolveSpawnCommand } from "@t3tools/shared/shell";
1313
14import { resolveAttachmentPath } from "../attachmentStore.ts";14import { resolveAttachmentPath } from "../attachmentStore.ts";
15import { ServerConfig } from "../config.ts";15import * as ServerConfig from "../config.ts";
16import { expandHomePath } from "../pathExpansion.ts";16import { expandHomePath } from "../pathExpansion.ts";
17import { TextGenerationError } from "@t3tools/contracts";17import { TextGenerationError } from "@t3tools/contracts";
18import {18import * as TextGeneration from "./TextGeneration.ts";
19 type BranchNameGenerationInput,
20 type ThreadTitleGenerationResult,
21 type TextGenerationShape,
22} from "./TextGeneration.ts";
23import {19import {
24 buildBranchNamePrompt,20 buildBranchNamePrompt,
25 buildCommitMessagePrompt,21 buildCommitMessagePrompt,
...@@ -50,7 +46,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -50,7 +46,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
50 const fileSystem = yield* FileSystem.FileSystem;46 const fileSystem = yield* FileSystem.FileSystem;
51 const path = yield* Path.Path;47 const path = yield* Path.Path;
52 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;48 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
53 const serverConfig = yield* Effect.service(ServerConfig);49 const serverConfig = yield* Effect.service(ServerConfig.ServerConfig);
54 const resolvedEnvironment = environment ?? process.env;50 const resolvedEnvironment = environment ?? process.env;
5551
56 type MaterializedImageAttachments = {52 type MaterializedImageAttachments = {
...@@ -121,7 +117,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -121,7 +117,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
121 | "generatePrContent"117 | "generatePrContent"
122 | "generateBranchName"118 | "generateBranchName"
123 | "generateThreadTitle",119 | "generateThreadTitle",
124 attachments: BranchNameGenerationInput["attachments"],120 attachments: TextGeneration.BranchNameGenerationInput["attachments"],
125 ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> {121 ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> {
126 if (!attachments || attachments.length === 0) {122 if (!attachments || attachments.length === 0) {
127 return { imagePaths: [] };123 return { imagePaths: [] };
...@@ -298,114 +294,110 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func...@@ -298,114 +294,110 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
298 }).pipe(Effect.ensuring(cleanup));294 }).pipe(Effect.ensuring(cleanup));
299 });295 });
300296
301 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(297 const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
302 "CodexTextGeneration.generateCommitMessage",298 Effect.fn("CodexTextGeneration.generateCommitMessage")(function* (input) {
303 )(function* (input) {299 const { prompt, outputSchema } = buildCommitMessagePrompt({
304 const { prompt, outputSchema } = buildCommitMessagePrompt({300 branch: input.branch,
305 branch: input.branch,301 stagedSummary: input.stagedSummary,
306 stagedSummary: input.stagedSummary,302 stagedPatch: input.stagedPatch,
307 stagedPatch: input.stagedPatch,303 includeBranch: input.includeBranch === true,
308 includeBranch: input.includeBranch === true,304 });
309 });
310305
311 const generated = yield* runCodexJson({306 const generated = yield* runCodexJson({
312 operation: "generateCommitMessage",307 operation: "generateCommitMessage",
313 cwd: input.cwd,308 cwd: input.cwd,
314 prompt,309 prompt,
315 outputSchemaJson: outputSchema,310 outputSchemaJson: outputSchema,
316 modelSelection: input.modelSelection,311 modelSelection: input.modelSelection,
312 });
313
314 return {
315 subject: sanitizeCommitSubject(generated.subject),
316 body: generated.body.trim(),
317 ...("branch" in generated && typeof generated.branch === "string"
318 ? { branch: sanitizeFeatureBranchName(generated.branch) }
319 : {}),
320 };
317 });321 });
318322
319 return {323 const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
320 subject: sanitizeCommitSubject(generated.subject),324 Effect.fn("CodexTextGeneration.generatePrContent")(function* (input) {
321 body: generated.body.trim(),325 const { prompt, outputSchema } = buildPrContentPrompt({
322 ...("branch" in generated && typeof generated.branch === "string"326 baseBranch: input.baseBranch,
323 ? { branch: sanitizeFeatureBranchName(generated.branch) }327 headBranch: input.headBranch,
324 : {}),328 commitSummary: input.commitSummary,
325 };329 diffSummary: input.diffSummary,
326 });330 diffPatch: input.diffPatch,
331 });
327332
328 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(333 const generated = yield* runCodexJson({
329 "CodexTextGeneration.generatePrContent",334 operation: "generatePrContent",
330 )(function* (input) {335 cwd: input.cwd,
331 const { prompt, outputSchema } = buildPrContentPrompt({336 prompt,
332 baseBranch: input.baseBranch,337 outputSchemaJson: outputSchema,
333 headBranch: input.headBranch,338 modelSelection: input.modelSelection,
334 commitSummary: input.commitSummary,339 });
335 diffSummary: input.diffSummary,
336 diffPatch: input.diffPatch,
337 });
338340
339 const generated = yield* runCodexJson({341 return {
340 operation: "generatePrContent",342 title: sanitizePrTitle(generated.title),
341 cwd: input.cwd,343 body: generated.body.trim(),
342 prompt,344 };
343 outputSchemaJson: outputSchema,
344 modelSelection: input.modelSelection,
345 });345 });
346346
347 return {347 const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
348 title: sanitizePrTitle(generated.title),348 Effect.fn("CodexTextGeneration.generateBranchName")(function* (input) {
349 body: generated.body.trim(),349 const { imagePaths } = yield* materializeImageAttachments(
350 };350 "generateBranchName",
351 });351 input.attachments,
352 );
353 const { prompt, outputSchema } = buildBranchNamePrompt({
354 message: input.message,
355 attachments: input.attachments,
356 });
352357
353 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(358 const generated = yield* runCodexJson({
354 "CodexTextGeneration.generateBranchName",359 operation: "generateBranchName",
355 )(function* (input) {360 cwd: input.cwd,
356 const { imagePaths } = yield* materializeImageAttachments(361 prompt,
357 "generateBranchName",362 outputSchemaJson: outputSchema,
358 input.attachments,363 imagePaths,
359 );364 modelSelection: input.modelSelection,
360 const { prompt, outputSchema } = buildBranchNamePrompt({365 });
361 message: input.message,
362 attachments: input.attachments,
363 });
364366
365 const generated = yield* runCodexJson({367 return {
366 operation: "generateBranchName",368 branch: sanitizeBranchFragment(generated.branch),
367 cwd: input.cwd,369 };
368 prompt,
369 outputSchemaJson: outputSchema,
370 imagePaths,
371 modelSelection: input.modelSelection,
372 });370 });
373371
374 return {372 const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
375 branch: sanitizeBranchFragment(generated.branch),373 Effect.fn("CodexTextGeneration.generateThreadTitle")(function* (input) {
376 };374 const { imagePaths } = yield* materializeImageAttachments(
377 });375 "generateThreadTitle",
376 input.attachments,
377 );
378 const { prompt, outputSchema } = buildThreadTitlePrompt({
379 message: input.message,
380 attachments: input.attachments,
381 });
378382
379 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(383 const generated = yield* runCodexJson({
380 "CodexTextGeneration.generateThreadTitle",384 operation: "generateThreadTitle",
381 )(function* (input) {385 cwd: input.cwd,
382 const { imagePaths } = yield* materializeImageAttachments(386 prompt,
383 "generateThreadTitle",387 outputSchemaJson: outputSchema,
384 input.attachments,388 imagePaths,
385 );389 modelSelection: input.modelSelection,
386 const { prompt, outputSchema } = buildThreadTitlePrompt({390 });
387 message: input.message,
388 attachments: input.attachments,
389 });
390391
391 const generated = yield* runCodexJson({392 return {
392 operation: "generateThreadTitle",393 title: sanitizeThreadTitle(generated.title),
393 cwd: input.cwd,394 } satisfies TextGeneration.ThreadTitleGenerationResult;
394 prompt,
395 outputSchemaJson: outputSchema,
396 imagePaths,
397 modelSelection: input.modelSelection,
398 });395 });
399396
400 return {
401 title: sanitizeThreadTitle(generated.title),
402 } satisfies ThreadTitleGenerationResult;
403 });
404
405 return {397 return {
406 generateCommitMessage,398 generateCommitMessage,
407 generatePrContent,399 generatePrContent,
408 generateBranchName,400 generateBranchName,
409 generateThreadTitle,401 generateThreadTitle,
410 } satisfies TextGenerationShape;402 } satisfies TextGeneration.TextGeneration["Service"];
411});403});
apps/server/src/textGeneration/CursorTextGeneration.test.ts+4-4
...@@ -16,8 +16,8 @@ import { expect } from "vite-plus/test";...@@ -16,8 +16,8 @@ import { expect } from "vite-plus/test";
1616
17import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";17import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";
1818
19import { ServerConfig } from "../config.ts";19import * as ServerConfig from "../config.ts";
20import { type TextGenerationShape } from "./TextGeneration.ts";20import * as TextGeneration from "./TextGeneration.ts";
21import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";21import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
22const decodeCursorSettings = Schema.decodeSync(CursorSettings);22const decodeCursorSettings = Schema.decodeSync(CursorSettings);
2323
...@@ -28,7 +28,7 @@ function shellSingleQuote(value: string): string {...@@ -28,7 +28,7 @@ function shellSingleQuote(value: string): string {
28 return `'${value.replaceAll("'", `'"'"'`)}'`;28 return `'${value.replaceAll("'", `'"'"'`)}'`;
29}29}
3030
31const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {31const CursorTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
32 prefix: "t3code-cursor-text-generation-test-",32 prefix: "t3code-cursor-text-generation-test-",
33}).pipe(Layer.provideMerge(NodeServices.layer));33}).pipe(Layer.provideMerge(NodeServices.layer));
3434
...@@ -56,7 +56,7 @@ function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {...@@ -56,7 +56,7 @@ function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
5656
57function withFakeAcpAgent<A, E, R>(57function withFakeAcpAgent<A, E, R>(
58 env: Record<string, string>,58 env: Record<string, string>,
59 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,59 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
60) {60) {
61 return Effect.gen(function* () {61 return Effect.gen(function* () {
62 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));62 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
apps/server/src/textGeneration/CursorTextGeneration.ts+77-81
...@@ -9,7 +9,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar...@@ -9,7 +9,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar
9import { extractJsonObject } from "@t3tools/shared/schemaJson";9import { extractJsonObject } from "@t3tools/shared/schemaJson";
1010
11import { TextGenerationError } from "@t3tools/contracts";11import { TextGenerationError } from "@t3tools/contracts";
12import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";12import * as TextGeneration from "./TextGeneration.ts";
13import {13import {
14 buildBranchNamePrompt,14 buildBranchNamePrompt,
15 buildCommitMessagePrompt,15 buildCommitMessagePrompt,
...@@ -176,104 +176,100 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu...@@ -176,104 +176,100 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
176 Effect.scoped,176 Effect.scoped,
177 );177 );
178178
179 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(179 const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
180 "CursorTextGeneration.generateCommitMessage",180 Effect.fn("CursorTextGeneration.generateCommitMessage")(function* (input) {
181 )(function* (input) {181 const { prompt, outputSchema } = buildCommitMessagePrompt({
182 const { prompt, outputSchema } = buildCommitMessagePrompt({182 branch: input.branch,
183 branch: input.branch,183 stagedSummary: input.stagedSummary,
184 stagedSummary: input.stagedSummary,184 stagedPatch: input.stagedPatch,
185 stagedPatch: input.stagedPatch,185 includeBranch: input.includeBranch === true,
186 includeBranch: input.includeBranch === true,186 });
187 });
188187
189 const generated = yield* runCursorJson({188 const generated = yield* runCursorJson({
190 operation: "generateCommitMessage",189 operation: "generateCommitMessage",
191 cwd: input.cwd,190 cwd: input.cwd,
192 prompt,191 prompt,
193 outputSchemaJson: outputSchema,192 outputSchemaJson: outputSchema,
194 modelSelection: input.modelSelection,193 modelSelection: input.modelSelection,
194 });
195
196 return {
197 subject: sanitizeCommitSubject(generated.subject),
198 body: generated.body.trim(),
199 ...("branch" in generated && typeof generated.branch === "string"
200 ? { branch: sanitizeFeatureBranchName(generated.branch) }
201 : {}),
202 };
195 });203 });
196204
197 return {205 const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
198 subject: sanitizeCommitSubject(generated.subject),206 Effect.fn("CursorTextGeneration.generatePrContent")(function* (input) {
199 body: generated.body.trim(),207 const { prompt, outputSchema } = buildPrContentPrompt({
200 ...("branch" in generated && typeof generated.branch === "string"208 baseBranch: input.baseBranch,
201 ? { branch: sanitizeFeatureBranchName(generated.branch) }209 headBranch: input.headBranch,
202 : {}),210 commitSummary: input.commitSummary,
203 };211 diffSummary: input.diffSummary,
204 });212 diffPatch: input.diffPatch,
213 });
205214
206 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(215 const generated = yield* runCursorJson({
207 "CursorTextGeneration.generatePrContent",216 operation: "generatePrContent",
208 )(function* (input) {217 cwd: input.cwd,
209 const { prompt, outputSchema } = buildPrContentPrompt({218 prompt,
210 baseBranch: input.baseBranch,219 outputSchemaJson: outputSchema,
211 headBranch: input.headBranch,220 modelSelection: input.modelSelection,
212 commitSummary: input.commitSummary,221 });
213 diffSummary: input.diffSummary,
214 diffPatch: input.diffPatch,
215 });
216222
217 const generated = yield* runCursorJson({223 return {
218 operation: "generatePrContent",224 title: sanitizePrTitle(generated.title),
219 cwd: input.cwd,225 body: generated.body.trim(),
220 prompt,226 };
221 outputSchemaJson: outputSchema,
222 modelSelection: input.modelSelection,
223 });227 });
224228
225 return {229 const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
226 title: sanitizePrTitle(generated.title),230 Effect.fn("CursorTextGeneration.generateBranchName")(function* (input) {
227 body: generated.body.trim(),231 const { prompt, outputSchema } = buildBranchNamePrompt({
228 };232 message: input.message,
229 });233 attachments: input.attachments,
234 });
230235
231 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(236 const generated = yield* runCursorJson({
232 "CursorTextGeneration.generateBranchName",237 operation: "generateBranchName",
233 )(function* (input) {238 cwd: input.cwd,
234 const { prompt, outputSchema } = buildBranchNamePrompt({239 prompt,
235 message: input.message,240 outputSchemaJson: outputSchema,
236 attachments: input.attachments,241 modelSelection: input.modelSelection,
237 });242 });
238243
239 const generated = yield* runCursorJson({244 return {
240 operation: "generateBranchName",245 branch: sanitizeBranchFragment(generated.branch),
241 cwd: input.cwd,246 };
242 prompt,
243 outputSchemaJson: outputSchema,
244 modelSelection: input.modelSelection,
245 });247 });
246248
247 return {249 const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
248 branch: sanitizeBranchFragment(generated.branch),250 Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) {
249 };251 const { prompt, outputSchema } = buildThreadTitlePrompt({
250 });252 message: input.message,
253 attachments: input.attachments,
254 });
251255
252 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(256 const generated = yield* runCursorJson({
253 "CursorTextGeneration.generateThreadTitle",257 operation: "generateThreadTitle",
254 )(function* (input) {258 cwd: input.cwd,
255 const { prompt, outputSchema } = buildThreadTitlePrompt({259 prompt,
256 message: input.message,260 outputSchemaJson: outputSchema,
257 attachments: input.attachments,261 modelSelection: input.modelSelection,
258 });262 });
259263
260 const generated = yield* runCursorJson({264 return {
261 operation: "generateThreadTitle",265 title: sanitizeThreadTitle(generated.title),
262 cwd: input.cwd,266 } satisfies TextGeneration.ThreadTitleGenerationResult;
263 prompt,
264 outputSchemaJson: outputSchema,
265 modelSelection: input.modelSelection,
266 });267 });
267268
268 return {
269 title: sanitizeThreadTitle(generated.title),
270 } satisfies ThreadTitleGenerationResult;
271 });
272
273 return {269 return {
274 generateCommitMessage,270 generateCommitMessage,
275 generatePrContent,271 generatePrContent,
276 generateBranchName,272 generateBranchName,
277 generateThreadTitle,273 generateThreadTitle,
278 } satisfies TextGenerationShape;274 } satisfies TextGeneration.TextGeneration["Service"];
279});275});
apps/server/src/textGeneration/GrokTextGeneration.test.ts+4-4
...@@ -13,8 +13,8 @@ import { createModelSelection } from "@t3tools/shared/model";...@@ -13,8 +13,8 @@ import { createModelSelection } from "@t3tools/shared/model";
13import { expect } from "vite-plus/test";13import { expect } from "vite-plus/test";
14import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts";14import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts";
1515
16import { ServerConfig } from "../config.ts";16import * as ServerConfig from "../config.ts";
17import { type TextGenerationShape } from "./TextGeneration.ts";17import * as TextGeneration from "./TextGeneration.ts";
18import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";18import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";
19const decodeGrokSettings = Schema.decodeSync(GrokSettings);19const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2020
...@@ -25,7 +25,7 @@ function shellSingleQuote(value: string): string {...@@ -25,7 +25,7 @@ function shellSingleQuote(value: string): string {
25 return `'${value.replaceAll("'", `'"'"'`)}'`;25 return `'${value.replaceAll("'", `'"'"'`)}'`;
26}26}
2727
28const GrokTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {28const GrokTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
29 prefix: "t3code-grok-text-generation-test-",29 prefix: "t3code-grok-text-generation-test-",
30}).pipe(Layer.provideMerge(NodeServices.layer));30}).pipe(Layer.provideMerge(NodeServices.layer));
3131
...@@ -53,7 +53,7 @@ function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string {...@@ -53,7 +53,7 @@ function makeAcpGrokWrapper(dir: string, env: Record<string, string>): string {
5353
54function withFakeAcpGrok<A, E, R>(54function withFakeAcpGrok<A, E, R>(
55 env: Record<string, string>,55 env: Record<string, string>,
56 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,56 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
57) {57) {
58 return Effect.gen(function* () {58 return Effect.gen(function* () {
59 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-acp-"));59 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-acp-"));
apps/server/src/textGeneration/GrokTextGeneration.ts+77-81
...@@ -10,7 +10,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar...@@ -10,7 +10,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar
10import { extractJsonObject } from "@t3tools/shared/schemaJson";10import { extractJsonObject } from "@t3tools/shared/schemaJson";
1111
12import { TextGenerationError } from "@t3tools/contracts";12import { TextGenerationError } from "@t3tools/contracts";
13import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";13import * as TextGeneration from "./TextGeneration.ts";
14import {14import {
15 buildBranchNamePrompt,15 buildBranchNamePrompt,
16 buildCommitMessagePrompt,16 buildCommitMessagePrompt,
...@@ -169,104 +169,100 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi...@@ -169,104 +169,100 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
169 Effect.scoped,169 Effect.scoped,
170 );170 );
171171
172 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(172 const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
173 "GrokTextGeneration.generateCommitMessage",173 Effect.fn("GrokTextGeneration.generateCommitMessage")(function* (input) {
174 )(function* (input) {174 const { prompt, outputSchema } = buildCommitMessagePrompt({
175 const { prompt, outputSchema } = buildCommitMessagePrompt({175 branch: input.branch,
176 branch: input.branch,176 stagedSummary: input.stagedSummary,
177 stagedSummary: input.stagedSummary,177 stagedPatch: input.stagedPatch,
178 stagedPatch: input.stagedPatch,178 includeBranch: input.includeBranch === true,
179 includeBranch: input.includeBranch === true,179 });
180 });
181180
182 const generated = yield* runGrokJson({181 const generated = yield* runGrokJson({
183 operation: "generateCommitMessage",182 operation: "generateCommitMessage",
184 cwd: input.cwd,183 cwd: input.cwd,
185 prompt,184 prompt,
186 outputSchemaJson: outputSchema,185 outputSchemaJson: outputSchema,
187 modelSelection: input.modelSelection,186 modelSelection: input.modelSelection,
187 });
188
189 return {
190 subject: sanitizeCommitSubject(generated.subject),
191 body: generated.body.trim(),
192 ...("branch" in generated && typeof generated.branch === "string"
193 ? { branch: sanitizeFeatureBranchName(generated.branch) }
194 : {}),
195 };
188 });196 });
189197
190 return {198 const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
191 subject: sanitizeCommitSubject(generated.subject),199 Effect.fn("GrokTextGeneration.generatePrContent")(function* (input) {
192 body: generated.body.trim(),200 const { prompt, outputSchema } = buildPrContentPrompt({
193 ...("branch" in generated && typeof generated.branch === "string"201 baseBranch: input.baseBranch,
194 ? { branch: sanitizeFeatureBranchName(generated.branch) }202 headBranch: input.headBranch,
195 : {}),203 commitSummary: input.commitSummary,
196 };204 diffSummary: input.diffSummary,
197 });205 diffPatch: input.diffPatch,
206 });
198207
199 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(208 const generated = yield* runGrokJson({
200 "GrokTextGeneration.generatePrContent",209 operation: "generatePrContent",
201 )(function* (input) {210 cwd: input.cwd,
202 const { prompt, outputSchema } = buildPrContentPrompt({211 prompt,
203 baseBranch: input.baseBranch,212 outputSchemaJson: outputSchema,
204 headBranch: input.headBranch,213 modelSelection: input.modelSelection,
205 commitSummary: input.commitSummary,214 });
206 diffSummary: input.diffSummary,
207 diffPatch: input.diffPatch,
208 });
209215
210 const generated = yield* runGrokJson({216 return {
211 operation: "generatePrContent",217 title: sanitizePrTitle(generated.title),
212 cwd: input.cwd,218 body: generated.body.trim(),
213 prompt,219 };
214 outputSchemaJson: outputSchema,
215 modelSelection: input.modelSelection,
216 });220 });
217221
218 return {222 const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
219 title: sanitizePrTitle(generated.title),223 Effect.fn("GrokTextGeneration.generateBranchName")(function* (input) {
220 body: generated.body.trim(),224 const { prompt, outputSchema } = buildBranchNamePrompt({
221 };225 message: input.message,
222 });226 attachments: input.attachments,
227 });
223228
224 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(229 const generated = yield* runGrokJson({
225 "GrokTextGeneration.generateBranchName",230 operation: "generateBranchName",
226 )(function* (input) {231 cwd: input.cwd,
227 const { prompt, outputSchema } = buildBranchNamePrompt({232 prompt,
228 message: input.message,233 outputSchemaJson: outputSchema,
229 attachments: input.attachments,234 modelSelection: input.modelSelection,
230 });235 });
231236
232 const generated = yield* runGrokJson({237 return {
233 operation: "generateBranchName",238 branch: sanitizeBranchFragment(generated.branch),
234 cwd: input.cwd,239 };
235 prompt,
236 outputSchemaJson: outputSchema,
237 modelSelection: input.modelSelection,
238 });240 });
239241
240 return {242 const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
241 branch: sanitizeBranchFragment(generated.branch),243 Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) {
242 };244 const { prompt, outputSchema } = buildThreadTitlePrompt({
243 });245 message: input.message,
246 attachments: input.attachments,
247 });
244248
245 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(249 const generated = yield* runGrokJson({
246 "GrokTextGeneration.generateThreadTitle",250 operation: "generateThreadTitle",
247 )(function* (input) {251 cwd: input.cwd,
248 const { prompt, outputSchema } = buildThreadTitlePrompt({252 prompt,
249 message: input.message,253 outputSchemaJson: outputSchema,
250 attachments: input.attachments,254 modelSelection: input.modelSelection,
251 });255 });
252256
253 const generated = yield* runGrokJson({257 return {
254 operation: "generateThreadTitle",258 title: sanitizeThreadTitle(generated.title),
255 cwd: input.cwd,259 } satisfies TextGeneration.ThreadTitleGenerationResult;
256 prompt,
257 outputSchemaJson: outputSchema,
258 modelSelection: input.modelSelection,
259 });260 });
260261
261 return {
262 title: sanitizeThreadTitle(generated.title),
263 } satisfies ThreadTitleGenerationResult;
264 });
265
266 return {262 return {
267 generateCommitMessage,263 generateCommitMessage,
268 generatePrContent,264 generatePrContent,
269 generateBranchName,265 generateBranchName,
270 generateThreadTitle,266 generateThreadTitle,
271 } satisfies TextGenerationShape;267 } satisfies TextGeneration.TextGeneration["Service"];
272});268});
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts+11-15
...@@ -9,13 +9,9 @@ import * as TestClock from "effect/testing/TestClock";...@@ -9,13 +9,9 @@ import * as TestClock from "effect/testing/TestClock";
9import * as NetService from "@t3tools/shared/Net";9import * as NetService from "@t3tools/shared/Net";
10import { beforeEach, expect } from "vite-plus/test";10import { beforeEach, expect } from "vite-plus/test";
1111
12import { ServerConfig } from "../config.ts";12import * as ServerConfig from "../config.ts";
13import {13import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
14 OpenCodeRuntime,14import * as TextGeneration from "./TextGeneration.ts";
15 OpenCodeRuntimeError,
16 type OpenCodeRuntimeShape,
17} from "../provider/opencodeRuntime.ts";
18import { type TextGenerationShape } from "./TextGeneration.ts";
19import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts";15import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts";
2016
21const runtimeMock = {17const runtimeMock = {
...@@ -37,7 +33,7 @@ const runtimeMock = {...@@ -37,7 +33,7 @@ const runtimeMock = {
37 },33 },
38};34};
3935
40const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {36const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
41 startOpenCodeServerProcess: ({ binaryPath }) =>37 startOpenCodeServerProcess: ({ binaryPath }) =>
42 Effect.gen(function* () {38 Effect.gen(function* () {
43 const index = runtimeMock.state.startCalls.length + 1;39 const index = runtimeMock.state.startCalls.length + 1;
...@@ -88,10 +84,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -88,10 +84,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
88 );84 );
89 },85 },
90 },86 },
91 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,87 }) as unknown as ReturnType<OpenCodeRuntime.OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
92 loadOpenCodeInventory: () =>88 loadOpenCodeInventory: () =>
93 Effect.fail(89 Effect.fail(
94 new OpenCodeRuntimeError({90 new OpenCodeRuntime.OpenCodeRuntimeError({
95 operation: "loadOpenCodeInventory",91 operation: "loadOpenCodeInventory",
96 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",92 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
97 cause: null,93 cause: null,
...@@ -107,11 +103,11 @@ const DEFAULT_TEST_MODEL_SELECTION = {...@@ -107,11 +103,11 @@ const DEFAULT_TEST_MODEL_SELECTION = {
107const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;103const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
108104
109const OpenCodeTextGenerationTestLayer = Layer.succeed(105const OpenCodeTextGenerationTestLayer = Layer.succeed(
110 OpenCodeRuntime,106 OpenCodeRuntime.OpenCodeRuntime,
111 OpenCodeRuntimeTestDouble,107 OpenCodeRuntimeTestDouble,
112).pipe(108).pipe(
113 Layer.provideMerge(109 Layer.provideMerge(
114 ServerConfig.layerTest(process.cwd(), {110 ServerConfig.ServerConfig.layerTest(process.cwd(), {
115 prefix: "t3code-opencode-text-generation-test-",111 prefix: "t3code-opencode-text-generation-test-",
116 }),112 }),
117 ),113 ),
...@@ -120,11 +116,11 @@ const OpenCodeTextGenerationTestLayer = Layer.succeed(...@@ -120,11 +116,11 @@ const OpenCodeTextGenerationTestLayer = Layer.succeed(
120);116);
121117
122const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed(118const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed(
123 OpenCodeRuntime,119 OpenCodeRuntime.OpenCodeRuntime,
124 OpenCodeRuntimeTestDouble,120 OpenCodeRuntimeTestDouble,
125).pipe(121).pipe(
126 Layer.provideMerge(122 Layer.provideMerge(
127 ServerConfig.layerTest(process.cwd(), {123 ServerConfig.ServerConfig.layerTest(process.cwd(), {
128 prefix: "t3code-opencode-text-generation-existing-server-test-",124 prefix: "t3code-opencode-text-generation-existing-server-test-",
129 }),125 }),
130 ),126 ),
...@@ -143,7 +139,7 @@ const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({...@@ -143,7 +139,7 @@ const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
143139
144function withOpenCodeTextGeneration<A, E, R>(140function withOpenCodeTextGeneration<A, E, R>(
145 settings: OpenCodeSettings,141 settings: OpenCodeSettings,
146 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,142 effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect<A, E, R>,
147) {143) {
148 return Effect.gen(function* () {144 return Effect.gen(function* () {
149 const textGeneration = yield* makeOpenCodeTextGeneration(settings);145 const textGeneration = yield* makeOpenCodeTextGeneration(settings);
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+91-102
...@@ -15,7 +15,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar...@@ -15,7 +15,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar
15import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";15import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
16import { extractJsonObject } from "@t3tools/shared/schemaJson";16import { extractJsonObject } from "@t3tools/shared/schemaJson";
1717
18import { ServerConfig } from "../config.ts";18import * as ServerConfig from "../config.ts";
19import { resolveAttachmentPath } from "../attachmentStore.ts";19import { resolveAttachmentPath } from "../attachmentStore.ts";
20import {20import {
21 buildBranchNamePrompt,21 buildBranchNamePrompt,
...@@ -23,20 +23,13 @@ import {...@@ -23,20 +23,13 @@ import {
23 buildPrContentPrompt,23 buildPrContentPrompt,
24 buildThreadTitlePrompt,24 buildThreadTitlePrompt,
25} from "./TextGenerationPrompts.ts";25} from "./TextGenerationPrompts.ts";
26import { type TextGenerationShape } from "./TextGeneration.ts";26import * as TextGeneration from "./TextGeneration.ts";
27import {27import {
28 sanitizeCommitSubject,28 sanitizeCommitSubject,
29 sanitizePrTitle,29 sanitizePrTitle,
30 sanitizeThreadTitle,30 sanitizeThreadTitle,
31} from "./TextGenerationUtils.ts";31} from "./TextGenerationUtils.ts";
32import {32import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts";
33 OpenCodeRuntime,
34 type OpenCodeServerConnection,
35 type OpenCodeServerProcess,
36 openCodeRuntimeErrorDetail,
37 parseOpenCodeModelSlug,
38 toOpenCodeFileParts,
39} from "../provider/opencodeRuntime.ts";
4033
41const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";34const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
4235
...@@ -84,7 +77,7 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str...@@ -84,7 +77,7 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str
84}77}
8578
86interface SharedOpenCodeTextGenerationServerState {79interface SharedOpenCodeTextGenerationServerState {
87 server: OpenCodeServerProcess | null;80 server: OpenCodeRuntime.OpenCodeServerProcess | null;
88 /**81 /**
89 * The scope that owns the shared server's lifetime. Closing this scope82 * The scope that owns the shared server's lifetime. Closing this scope
90 * terminates the OpenCode child process and interrupts any fibers the83 * terminates the OpenCode child process and interrupts any fibers the
...@@ -101,8 +94,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -101,8 +94,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
101 openCodeSettings: OpenCodeSettings,94 openCodeSettings: OpenCodeSettings,
102 environment?: NodeJS.ProcessEnv,95 environment?: NodeJS.ProcessEnv,
103) {96) {
104 const serverConfig = yield* ServerConfig;97 const serverConfig = yield* ServerConfig.ServerConfig;
105 const openCodeRuntime = yield* OpenCodeRuntime;98 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
106 const resolvedEnvironment = environment ?? process.env;99 const resolvedEnvironment = environment ?? process.env;
107 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>100 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
108 Scope.close(scope, Exit.void),101 Scope.close(scope, Exit.void),
...@@ -135,7 +128,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -135,7 +128,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
135 });128 });
136129
137 const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (130 const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (
138 server: OpenCodeServerProcess,131 server: OpenCodeRuntime.OpenCodeServerProcess,
139 ) {132 ) {
140 yield* cancelIdleCloseFiber();133 yield* cancelIdleCloseFiber();
141 const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe(134 const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe(
...@@ -217,7 +210,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -217,7 +210,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
217 (cause) =>210 (cause) =>
218 new TextGenerationError({211 new TextGenerationError({
219 operation: input.operation,212 operation: input.operation,
220 detail: openCodeRuntimeErrorDetail(cause),213 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
221 cause,214 cause,
222 }),215 }),
223 ),216 ),
...@@ -240,7 +233,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -240,7 +233,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
240 }),233 }),
241 );234 );
242235
243 const releaseSharedServer = (server: OpenCodeServerProcess) =>236 const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) =>
244 sharedServerMutex.withPermit(237 sharedServerMutex.withPermit(
245 Effect.gen(function* () {238 Effect.gen(function* () {
246 if (sharedServerState.server !== server) {239 if (sharedServerState.server !== server) {
...@@ -278,7 +271,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -278,7 +271,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
278 readonly modelSelection: ModelSelection;271 readonly modelSelection: ModelSelection;
279 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;272 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
280 }) {273 }) {
281 const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);274 const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
282 if (!parsedModel) {275 if (!parsedModel) {
283 return yield* new TextGenerationError({276 return yield* new TextGenerationError({
284 operation: input.operation,277 operation: input.operation,
...@@ -286,13 +279,13 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -286,13 +279,13 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
286 });279 });
287 }280 }
288281
289 const fileParts = toOpenCodeFileParts({282 const fileParts = OpenCodeRuntime.toOpenCodeFileParts({
290 attachments: input.attachments,283 attachments: input.attachments,
291 resolveAttachmentPath: (attachment) =>284 resolveAttachmentPath: (attachment) =>
292 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),285 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
293 });286 });
294287
295 const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>288 const runAgainstServer = (server: Pick<OpenCodeRuntime.OpenCodeServerConnection, "url">) =>
296 Effect.tryPromise({289 Effect.tryPromise({
297 try: async () => {290 try: async () => {
298 const client = openCodeRuntime.createOpenCodeSdkClient({291 const client = openCodeRuntime.createOpenCodeSdkClient({
...@@ -336,7 +329,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -336,7 +329,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
336 catch: (cause) =>329 catch: (cause) =>
337 new TextGenerationError({330 new TextGenerationError({
338 operation: input.operation,331 operation: input.operation,
339 detail: openCodeRuntimeErrorDetail(cause),332 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
340 cause,333 cause,
341 }),334 }),
342 });335 });
...@@ -367,102 +360,98 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"...@@ -367,102 +360,98 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
367 );360 );
368 });361 });
369362
370 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(363 const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
371 "OpenCodeTextGeneration.generateCommitMessage",364 Effect.fn("OpenCodeTextGeneration.generateCommitMessage")(function* (input) {
372 )(function* (input) {365 const { prompt, outputSchema } = buildCommitMessagePrompt({
373 const { prompt, outputSchema } = buildCommitMessagePrompt({366 branch: input.branch,
374 branch: input.branch,367 stagedSummary: input.stagedSummary,
375 stagedSummary: input.stagedSummary,368 stagedPatch: input.stagedPatch,
376 stagedPatch: input.stagedPatch,369 includeBranch: input.includeBranch === true,
377 includeBranch: input.includeBranch === true,370 });
378 });371 const generated = yield* runOpenCodeJson({
379 const generated = yield* runOpenCodeJson({372 operation: "generateCommitMessage",
380 operation: "generateCommitMessage",373 cwd: input.cwd,
381 cwd: input.cwd,374 prompt,
382 prompt,375 outputSchemaJson: outputSchema,
383 outputSchemaJson: outputSchema,376 modelSelection: input.modelSelection,
384 modelSelection: input.modelSelection,377 });
378
379 return {
380 subject: sanitizeCommitSubject(generated.subject),
381 body: generated.body.trim(),
382 ...("branch" in generated && typeof generated.branch === "string"
383 ? { branch: sanitizeFeatureBranchName(generated.branch) }
384 : {}),
385 };
385 });386 });
386387
387 return {388 const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
388 subject: sanitizeCommitSubject(generated.subject),389 Effect.fn("OpenCodeTextGeneration.generatePrContent")(function* (input) {
389 body: generated.body.trim(),390 const { prompt, outputSchema } = buildPrContentPrompt({
390 ...("branch" in generated && typeof generated.branch === "string"391 baseBranch: input.baseBranch,
391 ? { branch: sanitizeFeatureBranchName(generated.branch) }392 headBranch: input.headBranch,
392 : {}),393 commitSummary: input.commitSummary,
393 };394 diffSummary: input.diffSummary,
394 });395 diffPatch: input.diffPatch,
396 });
397 const generated = yield* runOpenCodeJson({
398 operation: "generatePrContent",
399 cwd: input.cwd,
400 prompt,
401 outputSchemaJson: outputSchema,
402 modelSelection: input.modelSelection,
403 });
395404
396 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(405 return {
397 "OpenCodeTextGeneration.generatePrContent",406 title: sanitizePrTitle(generated.title),
398 )(function* (input) {407 body: generated.body.trim(),
399 const { prompt, outputSchema } = buildPrContentPrompt({408 };
400 baseBranch: input.baseBranch,
401 headBranch: input.headBranch,
402 commitSummary: input.commitSummary,
403 diffSummary: input.diffSummary,
404 diffPatch: input.diffPatch,
405 });
406 const generated = yield* runOpenCodeJson({
407 operation: "generatePrContent",
408 cwd: input.cwd,
409 prompt,
410 outputSchemaJson: outputSchema,
411 modelSelection: input.modelSelection,
412 });409 });
413410
414 return {411 const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
415 title: sanitizePrTitle(generated.title),412 Effect.fn("OpenCodeTextGeneration.generateBranchName")(function* (input) {
416 body: generated.body.trim(),413 const { prompt, outputSchema } = buildBranchNamePrompt({
417 };414 message: input.message,
418 });415 attachments: input.attachments,
416 });
417 const generated = yield* runOpenCodeJson({
418 operation: "generateBranchName",
419 cwd: input.cwd,
420 prompt,
421 outputSchemaJson: outputSchema,
422 modelSelection: input.modelSelection,
423 attachments: input.attachments,
424 });
419425
420 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(426 return {
421 "OpenCodeTextGeneration.generateBranchName",427 branch: sanitizeBranchFragment(generated.branch),
422 )(function* (input) {428 };
423 const { prompt, outputSchema } = buildBranchNamePrompt({
424 message: input.message,
425 attachments: input.attachments,
426 });
427 const generated = yield* runOpenCodeJson({
428 operation: "generateBranchName",
429 cwd: input.cwd,
430 prompt,
431 outputSchemaJson: outputSchema,
432 modelSelection: input.modelSelection,
433 attachments: input.attachments,
434 });429 });
435430
436 return {431 const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
437 branch: sanitizeBranchFragment(generated.branch),432 Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) {
438 };433 const { prompt, outputSchema } = buildThreadTitlePrompt({
439 });434 message: input.message,
435 attachments: input.attachments,
436 });
437 const generated = yield* runOpenCodeJson({
438 operation: "generateThreadTitle",
439 cwd: input.cwd,
440 prompt,
441 outputSchemaJson: outputSchema,
442 modelSelection: input.modelSelection,
443 attachments: input.attachments,
444 });
440445
441 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(446 return {
442 "OpenCodeTextGeneration.generateThreadTitle",447 title: sanitizeThreadTitle(generated.title),
443 )(function* (input) {448 };
444 const { prompt, outputSchema } = buildThreadTitlePrompt({
445 message: input.message,
446 attachments: input.attachments,
447 });
448 const generated = yield* runOpenCodeJson({
449 operation: "generateThreadTitle",
450 cwd: input.cwd,
451 prompt,
452 outputSchemaJson: outputSchema,
453 modelSelection: input.modelSelection,
454 attachments: input.attachments,
455 });449 });
456450
457 return {
458 title: sanitizeThreadTitle(generated.title),
459 };
460 });
461
462 return {451 return {
463 generateCommitMessage,452 generateCommitMessage,
464 generatePrContent,453 generatePrContent,
465 generateBranchName,454 generateBranchName,
466 generateThreadTitle,455 generateThreadTitle,
467 } satisfies TextGenerationShape;456 } satisfies TextGeneration.TextGeneration["Service"];
468});457});
apps/server/src/textGeneration/TextGeneration.test.ts+17-16
...@@ -9,23 +9,24 @@ import { ProviderInstanceId } from "@t3tools/contracts";...@@ -9,23 +9,24 @@ import { ProviderInstanceId } from "@t3tools/contracts";
9import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
1010
11import type { ProviderInstance } from "../provider/ProviderDriver.ts";11import type { ProviderInstance } from "../provider/ProviderDriver.ts";
12import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts";12import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
13import type { TextGenerationShape } from "./TextGeneration.ts";13import * as TextGeneration from "./TextGeneration.ts";
1414
15import { makeTextGenerationFromRegistry } from "./TextGeneration.ts";15const makeStubTextGeneration = (
1616 overrides: Partial<TextGeneration.TextGeneration["Service"]>,
17const makeStubTextGeneration = (overrides: Partial<TextGenerationShape>): TextGenerationShape => ({17): TextGeneration.TextGeneration["Service"] =>
18 generateCommitMessage: () =>18 TextGeneration.TextGeneration.of({
19 Effect.die("generateCommitMessage stub not configured for this test"),19 generateCommitMessage: () =>
20 generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"),20 Effect.die("generateCommitMessage stub not configured for this test"),
21 generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"),21 generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"),
22 generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"),22 generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"),
23 ...overrides,23 generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"),
24});24 ...overrides,
25 });
2526
26const makeStubInstance = (27const makeStubInstance = (
27 instanceId: ProviderInstanceId,28 instanceId: ProviderInstanceId,
28 textGeneration: TextGenerationShape,29 textGeneration: TextGeneration.TextGeneration["Service"],
29): ProviderInstance =>30): ProviderInstance =>
30 ({31 ({
31 instanceId,32 instanceId,
...@@ -43,7 +44,7 @@ const makeStubInstance = (...@@ -43,7 +44,7 @@ const makeStubInstance = (
4344
44const makeStubRegistry = (45const makeStubRegistry = (
45 instances: ReadonlyArray<ProviderInstance>,46 instances: ReadonlyArray<ProviderInstance>,
46): ProviderInstanceRegistryShape => {47): ProviderInstanceRegistry.ProviderInstanceRegistry["Service"] => {
47 const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const));48 const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const));
48 return {49 return {
49 getInstance: (id) => Effect.succeed(byId.get(id)),50 getInstance: (id) => Effect.succeed(byId.get(id)),
...@@ -81,7 +82,7 @@ describe("makeTextGenerationFromRegistry", () => {...@@ -81,7 +82,7 @@ describe("makeTextGenerationFromRegistry", () => {
81 }),82 }),
82 );83 );
8384
84 const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work]));85 const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([personal, work]));
8586
86 const result = yield* tg.generateBranchName({87 const result = yield* tg.generateBranchName({
87 cwd: process.cwd(),88 cwd: process.cwd(),
...@@ -96,7 +97,7 @@ describe("makeTextGenerationFromRegistry", () => {...@@ -96,7 +97,7 @@ describe("makeTextGenerationFromRegistry", () => {
9697
97 it.effect("fails with TextGenerationError when the instance is unknown", () =>98 it.effect("fails with TextGenerationError when the instance is unknown", () =>
98 Effect.gen(function* () {99 Effect.gen(function* () {
99 const tg = makeTextGenerationFromRegistry(makeStubRegistry([]));100 const tg = TextGeneration.makeTextGenerationFromRegistry(makeStubRegistry([]));
100101
101 const result = yield* tg102 const result = yield* tg
102 .generateBranchName({103 .generateBranchName({
apps/server/src/textGeneration/TextGeneration.ts+62-66
...@@ -4,10 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,10 +4,7 @@ import * as Layer from "effect/Layer";
4import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts";4import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
5import { TextGenerationError } from "@t3tools/contracts";5import { TextGenerationError } from "@t3tools/contracts";
66
7import {7import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts";
8 ProviderInstanceRegistry,
9 type ProviderInstanceRegistryShape,
10} from "../provider/Services/ProviderInstanceRegistry.ts";
11import type { ProviderInstance } from "../provider/ProviderDriver.ts";8import type { ProviderInstance } from "../provider/ProviderDriver.ts";
129
13export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";10export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
...@@ -79,45 +76,44 @@ export interface TextGenerationService {...@@ -79,45 +76,44 @@ export interface TextGenerationService {
79 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;76 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
80}77}
8178
82/**
83 * TextGenerationShape - Service API for commit/PR text generation.
84 */
85export interface TextGenerationShape {
86 /**
87 * Generate a commit message from staged change context.
88 */
89 readonly generateCommitMessage: (
90 input: CommitMessageGenerationInput,
91 ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>;
92
93 /**
94 * Generate pull request title/body from branch and diff context.
95 */
96 readonly generatePrContent: (
97 input: PrContentGenerationInput,
98 ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>;
99
100 /**
101 * Generate a concise branch name from a user message.
102 */
103 readonly generateBranchName: (
104 input: BranchNameGenerationInput,
105 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
106
107 /**
108 * Generate a concise thread title from a user's first message.
109 */
110 readonly generateThreadTitle: (
111 input: ThreadTitleGenerationInput,
112 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
113}
114
115/**79/**
116 * TextGeneration - Service tag for commit and PR text generation.80 * TextGeneration - Service tag for commit and PR text generation.
117 */81 */
118export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()(82export class TextGeneration extends Context.Service<
119 "t3/textGeneration/TextGeneration",83 TextGeneration,
120) {}84 {
85 /**
86 * Generate a commit message from staged change context.
87 */
88 readonly generateCommitMessage: (
89 input: CommitMessageGenerationInput,
90 ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>;
91
92 /**
93 * Generate pull request title/body from branch and diff context.
94 */
95 readonly generatePrContent: (
96 input: PrContentGenerationInput,
97 ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>;
98
99 /**
100 * Generate a concise branch name from a user message.
101 */
102 readonly generateBranchName: (
103 input: BranchNameGenerationInput,
104 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
105
106 /**
107 * Generate a concise thread title from a user's first message.
108 */
109 readonly generateThreadTitle: (
110 input: ThreadTitleGenerationInput,
111 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
112 }
113>()("t3/textGeneration/TextGeneration") {}
114
115/** @deprecated Use `TextGeneration["Service"]`. */
116export type TextGenerationShape = TextGeneration["Service"];
121117
122type TextGenerationOp =118type TextGenerationOp =
123 | "generateCommitMessage"119 | "generateCommitMessage"
...@@ -126,7 +122,7 @@ type TextGenerationOp =...@@ -126,7 +122,7 @@ type TextGenerationOp =
126 | "generateThreadTitle";122 | "generateThreadTitle";
127123
128const resolveInstance = (124const resolveInstance = (
129 registry: ProviderInstanceRegistryShape,125 registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"],
130 operation: TextGenerationOp,126 operation: TextGenerationOp,
131 instanceId: ProviderInstanceId,127 instanceId: ProviderInstanceId,
132): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> =>128): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> =>
...@@ -144,30 +140,30 @@ const resolveInstance = (...@@ -144,30 +140,30 @@ const resolveInstance = (
144 );140 );
145141
146export const makeTextGenerationFromRegistry = (142export const makeTextGenerationFromRegistry = (
147 registry: ProviderInstanceRegistryShape,143 registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"],
148): TextGenerationShape => ({144): TextGeneration["Service"] =>
149 generateCommitMessage: (input) =>145 TextGeneration.of({
150 resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe(146 generateCommitMessage: (input) =>
151 Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)),147 resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe(
152 ),148 Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)),
153 generatePrContent: (input) =>149 ),
154 resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe(150 generatePrContent: (input) =>
155 Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)),151 resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe(
156 ),152 Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)),
157 generateBranchName: (input) =>153 ),
158 resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe(154 generateBranchName: (input) =>
159 Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)),155 resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe(
160 ),156 Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)),
161 generateThreadTitle: (input) =>157 ),
162 resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe(158 generateThreadTitle: (input) =>
163 Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)),159 resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe(
164 ),160 Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)),
161 ),
162 });
163
164export const make = Effect.gen(function* () {
165 const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
166 return makeTextGenerationFromRegistry(registry);
165});167});
166168
167export const layer = Layer.effect(169export const layer = Layer.effect(TextGeneration, make);
168 TextGeneration,
169 Effect.gen(function* () {
170 const registry = yield* ProviderInstanceRegistry;
171 return makeTextGenerationFromRegistry(registry);
172 }),
173);