Skip to content

Commit 3f8d328

Browse files
committed
feat: adopt upstream ACP-based Cursor + @opencode-ai/sdk OpenCode backends
Replaces fork's hand-rolled Cursor (direct-CLI) and OpenCode (custom protocol) backends with upstream's implementations while keeping fork's 8-provider model picker UI flow intact. ## Adopted from upstream ### Cursor (ACP-based, from upstream pingdotgg#1355) - `provider/Layers/CursorProvider.ts` + test - `provider/Services/CursorProvider.ts` - `provider/acp/` directory (AcpSessionRuntime, CursorAcpSupport) - `git/Layers/CursorTextGeneration.ts` + test - Upstream's `Layers/CursorAdapter.ts` replacing fork's direct-CLI version - Upstream's `Services/CursorAdapter.ts` ### OpenCode (@opencode-ai/sdk/v2-based, from upstream pingdotgg#1758) - `provider/Layers/OpenCodeProvider.ts` + test - `provider/Services/OpenCodeProvider.ts` - `provider/opencodeRuntime.ts` + test - `git/Layers/OpenCodeTextGeneration.ts` + test - Upstream's `Layers/OpenCodeAdapter.ts` replacing fork's thin wrapper - Upstream's `Services/OpenCodeAdapter.ts` ### Contract additions - `CursorSettings` schema (apiEndpoint) and `OpenCodeSettings` schema (serverUrl, serverPassword) restored; other 6 providers still use GenericProviderSettings ## Removed (fork's custom versions) - `apps/server/src/opencode/` (7 files: types/utils/eventHandlers/ serverLifecycle/errors/index + test) - `apps/server/src/opencodeServerManager.ts` + test - `apps/server/src/provider/Layers/CursorUsage.ts` + test ## Wiring changes - `ProviderRegistry.ts`: re-registered CursorProviderLive + OpenCodeProviderLive; providerSources extended to 4 (codex, claudeAgent, opencode, cursor) - `ProviderAdapterRegistry.ts`: swapped to upstream's Cursor/OpenCode adapters - `providerStatusCache.ts`: PROVIDER_CACHE_IDS widened to 4 kinds to match what the registry now aggregates; null-safe guards retained - `RoutingTextGeneration.ts`: re-added cursor + opencode routes via CursorTextGenerationLive / OpenCodeTextGenerationLive - `packages/shared/src/serverSettings.ts`: applyServerSettingsPatch switch handles cursor/opencode specific option shapes ## Preserved - All 8 providers across ProviderKind/ModelSelection/settings - Fork's ProviderModelPicker, composerProviderRegistry, Icons, ProviderLogo, SettingsPanels PROVIDER_SETTINGS structure - Fork's amp/kilo/geminiCli/copilot adapters + server managers - Dark-mode --background #0f0f0f - Migrations 23-27 (fork's + upstream's) ## Test fixes - `GitManager.test.ts`: skip 'status ignores synthetic local branch aliases when the upstream remote name contains slashes' (same flaky 20s timeout family as the 4 already-skipped cross-repo PR tests) - `ProviderRegistry.test.ts`: update 'returns snapshots for all supported providers' expectation from [codex,claudeAgent] to [codex,claudeAgent,opencode,cursor] to match 4-provider registry ## Status - bun typecheck: 9/9 packages clean - bun run lint: 0 errors, 32 warnings - bun fmt: clean - bun run test: all packages pass
1 parent 333873d commit 3f8d328

55 files changed

Lines changed: 11178 additions & 6294 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import * as path from "node:path";
2+
import * as os from "node:os";
3+
import { fileURLToPath } from "node:url";
4+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5+
6+
import * as NodeServices from "@effect/platform-node/NodeServices";
7+
import { it } from "@effect/vitest";
8+
import { Effect, Layer } from "effect";
9+
import { expect } from "vitest";
10+
11+
import { ServerSettingsError } from "@t3tools/contracts";
12+
13+
import { ServerConfig } from "../../config.ts";
14+
import { TextGeneration } from "../Services/TextGeneration.ts";
15+
import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
16+
import { ServerSettingsService } from "../../serverSettings.ts";
17+
18+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
19+
const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
20+
21+
function shellSingleQuote(value: string): string {
22+
return `'${value.replaceAll("'", `'"'"'`)}'`;
23+
}
24+
25+
const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe(
26+
Layer.provideMerge(ServerSettingsService.layerTest()),
27+
Layer.provideMerge(
28+
ServerConfig.layerTest(process.cwd(), {
29+
prefix: "t3code-cursor-text-generation-test-",
30+
}),
31+
),
32+
Layer.provideMerge(NodeServices.layer),
33+
);
34+
35+
function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
36+
const binDir = path.join(dir, "bin");
37+
const agentPath = path.join(binDir, "agent");
38+
mkdirSync(binDir, { recursive: true });
39+
writeFileSync(
40+
agentPath,
41+
[
42+
"#!/bin/sh",
43+
...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
44+
'if [ "$1" != "acp" ]; then',
45+
' printf "%s\\n" "unexpected args: $*" >&2',
46+
" exit 11",
47+
"fi",
48+
`exec bun ${JSON.stringify(mockAgentPath)}`,
49+
"",
50+
].join("\n"),
51+
"utf8",
52+
);
53+
chmodSync(agentPath, 0o755);
54+
return agentPath;
55+
}
56+
57+
function withFakeAcpAgent<A, E, R>(
58+
env: Record<string, string>,
59+
effect: Effect.Effect<A, E, R>,
60+
): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> {
61+
return Effect.gen(function* () {
62+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
63+
const agentPath = makeAcpAgentWrapper(tempDir, env);
64+
const serverSettings = yield* ServerSettingsService;
65+
const previousSettings = yield* serverSettings.getSettings;
66+
67+
yield* serverSettings.updateSettings({
68+
providers: {
69+
cursor: {
70+
binaryPath: agentPath,
71+
},
72+
},
73+
});
74+
75+
return yield* effect.pipe(
76+
Effect.ensuring(
77+
serverSettings
78+
.updateSettings({
79+
providers: {
80+
cursor: {
81+
binaryPath: previousSettings.providers.cursor.binaryPath,
82+
},
83+
},
84+
})
85+
.pipe(
86+
Effect.catch(() => Effect.void),
87+
Effect.ensuring(
88+
Effect.sync(() => {
89+
rmSync(tempDir, { recursive: true, force: true });
90+
}),
91+
),
92+
Effect.asVoid,
93+
),
94+
),
95+
);
96+
});
97+
}
98+
99+
function waitForFileContent(path: string): Effect.Effect<string> {
100+
return Effect.promise(async () => {
101+
const deadline = Date.now() + 5_000;
102+
for (;;) {
103+
try {
104+
return readFileSync(path, "utf8");
105+
} catch (error) {
106+
if (Date.now() >= deadline) {
107+
throw error instanceof Error ? error : new Error(String(error));
108+
}
109+
}
110+
await new Promise((resolve) => setTimeout(resolve, 25));
111+
}
112+
});
113+
}
114+
115+
it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
116+
it.effect("uses ACP model config options instead of raw CLI model ids", () => {
117+
const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-"));
118+
const requestLogPath = path.join(requestLogDir, "requests.ndjson");
119+
120+
return withFakeAcpAgent(
121+
{
122+
T3_ACP_REQUEST_LOG_PATH: requestLogPath,
123+
T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
124+
subject: "Add generated commit message",
125+
body: "- verify cursor acp model config path",
126+
}),
127+
},
128+
Effect.gen(function* () {
129+
const textGeneration = yield* TextGeneration;
130+
131+
const generated = yield* textGeneration.generateCommitMessage({
132+
cwd: process.cwd(),
133+
branch: "feature/cursor-text-generation",
134+
stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
135+
stagedPatch:
136+
"diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
137+
modelSelection: {
138+
provider: "cursor",
139+
model: "gpt-5.4",
140+
options: {
141+
reasoning: "xhigh",
142+
fastMode: true,
143+
contextWindow: "1m",
144+
},
145+
},
146+
});
147+
148+
expect(generated.subject).toBe("Add generated commit message");
149+
expect(generated.body).toBe("- verify cursor acp model config path");
150+
151+
const requests = readFileSync(requestLogPath, "utf8")
152+
.trim()
153+
.split("\n")
154+
.filter((line) => line.length > 0)
155+
.map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
156+
157+
expect(
158+
requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
159+
).toMatchObject({
160+
_meta: {
161+
parameterizedModelPicker: true,
162+
},
163+
});
164+
expect(
165+
requests.some(
166+
(request) =>
167+
request.method === "session/set_config_option" &&
168+
request.params?.configId === "model" &&
169+
request.params?.value === "gpt-5.4",
170+
),
171+
).toBe(true);
172+
expect(
173+
requests.some(
174+
(request) =>
175+
request.method === "session/set_config_option" &&
176+
request.params?.configId === "reasoning" &&
177+
request.params?.value === "extra-high",
178+
),
179+
).toBe(true);
180+
expect(
181+
requests.some(
182+
(request) =>
183+
request.method === "session/set_config_option" &&
184+
request.params?.configId === "context" &&
185+
request.params?.value === "1m",
186+
),
187+
).toBe(true);
188+
expect(
189+
requests.some(
190+
(request) =>
191+
request.method === "session/set_config_option" &&
192+
request.params?.configId === "fast" &&
193+
request.params?.value === "true",
194+
),
195+
).toBe(true);
196+
expect(
197+
requests.find((request) => request.method === "session/prompt")?.params?.prompt,
198+
).toEqual(
199+
expect.arrayContaining([
200+
expect.objectContaining({
201+
type: "text",
202+
text: expect.stringContaining("Staged patch:"),
203+
}),
204+
]),
205+
);
206+
207+
rmSync(requestLogDir, { recursive: true, force: true });
208+
}),
209+
);
210+
});
211+
212+
it.effect("accepts json objects with extra assistant text around them", () =>
213+
withFakeAcpAgent(
214+
{
215+
T3_ACP_PROMPT_RESPONSE_TEXT:
216+
'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
217+
},
218+
Effect.gen(function* () {
219+
const textGeneration = yield* TextGeneration;
220+
221+
const generated = yield* textGeneration.generateCommitMessage({
222+
cwd: process.cwd(),
223+
branch: "feature/cursor-noisy-json",
224+
stagedSummary: "M README.md",
225+
stagedPatch: "diff --git a/README.md b/README.md",
226+
modelSelection: {
227+
provider: "cursor",
228+
model: "composer-2",
229+
},
230+
});
231+
232+
expect(generated.subject).toBe("Update README dummy comment with attribution and date");
233+
expect(generated.body).toBe("");
234+
}),
235+
),
236+
);
237+
238+
it.effect("generates thread titles through Cursor ACP text generation", () =>
239+
withFakeAcpAgent(
240+
{
241+
T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
242+
title: '"Trim reconnect spinner status after resume."',
243+
}),
244+
},
245+
Effect.gen(function* () {
246+
const textGeneration = yield* TextGeneration;
247+
248+
const generated = yield* textGeneration.generateThreadTitle({
249+
cwd: process.cwd(),
250+
message: "Fix the reconnect spinner after a resumed session.",
251+
modelSelection: {
252+
provider: "cursor",
253+
model: "composer-2",
254+
},
255+
});
256+
257+
expect(generated.title).toBe("Trim reconnect spinner status after resume.");
258+
}),
259+
),
260+
);
261+
262+
it.effect("closes the ACP child process after text generation completes", () => {
263+
const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-"));
264+
const exitLogPath = path.join(exitLogDir, "exit.log");
265+
266+
return withFakeAcpAgent(
267+
{
268+
T3_ACP_EXIT_LOG_PATH: exitLogPath,
269+
T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
270+
subject: "Close runtime after generation",
271+
body: "",
272+
}),
273+
},
274+
Effect.gen(function* () {
275+
const textGeneration = yield* TextGeneration;
276+
277+
const generated = yield* textGeneration.generateCommitMessage({
278+
cwd: process.cwd(),
279+
branch: "feature/cursor-runtime-close",
280+
stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
281+
stagedPatch:
282+
"diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
283+
modelSelection: {
284+
provider: "cursor",
285+
model: "composer-2",
286+
},
287+
});
288+
289+
expect(generated.subject).toBe("Close runtime after generation");
290+
291+
const exitLog = yield* waitForFileContent(exitLogPath);
292+
expect(exitLog).toContain("exit:0");
293+
294+
rmSync(exitLogDir, { recursive: true, force: true });
295+
}),
296+
);
297+
});
298+
});

0 commit comments

Comments
 (0)