History

Add ACP support with Cursor provider (#1355)

Julius Marminge committed 7 months ago

Commit 9c64f121 parent 0f184c298 files · +26731 −847Browse files

Showing the changes to apps/server/scripts (2 of 98 files). Show all

Unified · Split

2 files
  1. apps/server/scripts/acp-mock-agent.ts +590 −0
  2. apps/server/scripts/cursor-acp-model-mismatch-probe.ts +435 −0
apps/server/scripts/acp-mock-agent.ts+590−0
@@ -0,0 +1,590 @@
1+#!/usr/bin/env bun
2+import { appendFileSync } from "node:fs";
3+
4+import * as Effect from "effect/Effect";
5+
6+import * as NodeServices from "@effect/platform-node/NodeServices";
7+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
8+
9+import * as EffectAcpAgent from "effect-acp/agent";
10+import * as AcpError from "effect-acp/errors";
11+import type * as AcpSchema from "effect-acp/schema";
12+
13+const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
14+const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
15+const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
16+const emitInterleavedAssistantToolCalls =
17+ process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
18+const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
19+const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
20+const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
21+const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
22+const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
23+const sessionId = "mock-session-1";
24+
25+let currentModeId = "ask";
26+let currentModelId = "default";
27+let parameterizedModelPicker = false;
28+let currentReasoning = "medium";
29+let currentContext = "272k";
30+let currentFast = false;
31+const cancelledSessions = new Set<string>();
32+
33+function logExit(reason: string): void {
34+ if (!exitLogPath) {
35+ return;
36+ }
37+ appendFileSync(exitLogPath, `${reason}\n`, "utf8");
38+}
39+
40+process.once("SIGTERM", () => {
41+ logExit("SIGTERM");
42+ process.exit(0);
43+});
44+
45+process.once("SIGINT", () => {
46+ logExit("SIGINT");
47+ process.exit(0);
48+});
49+
50+process.once("exit", (code) => {
51+ logExit(`exit:${code}`);
52+});
53+
54+function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
55+ if (parameterizedModelPicker) {
56+ const baseOptions: Array<AcpSchema.SessionConfigOption> = [
57+ {
58+ id: "mode",
59+ name: "Mode",
60+ category: "mode",
61+ type: "select",
62+ currentValue: currentModeId,
63+ options: availableModes.map((mode) => ({
64+ value: mode.id,
65+ name: mode.name,
66+ ...(mode.description ? { description: mode.description } : {}),
67+ })),
68+ },
69+ {
70+ id: "model",
71+ name: "Model",
72+ category: "model",
73+ type: "select",
74+ currentValue: currentModelId,
75+ options: [
76+ { value: "default", name: "Auto" },
77+ { value: "composer-2", name: "Composer 2" },
78+ { value: "gpt-5.4", name: "GPT-5.4" },
79+ { value: "claude-opus-4-6", name: "Opus 4.6" },
80+ ],
81+ },
82+ ];
83+
84+ switch (currentModelId) {
85+ case "gpt-5.4":
86+ return [
87+ ...baseOptions,
88+ {
89+ id: "reasoning",
90+ name: "Reasoning",
91+ category: "thought_level",
92+ type: "select",
93+ currentValue: currentReasoning,
94+ options: [
95+ { value: "none", name: "None" },
96+ { value: "low", name: "Low" },
97+ { value: "medium", name: "Medium" },
98+ { value: "high", name: "High" },
99+ { value: "extra-high", name: "Extra High" },
100+ ],
101+ },
102+ {
103+ id: "context",
104+ name: "Context",
105+ category: "model_config",
106+ type: "select",
107+ currentValue: currentContext,
108+ options: [
109+ { value: "272k", name: "272K" },
110+ { value: "1m", name: "1M" },
111+ ],
112+ },
113+ {
114+ id: "fast",
115+ name: "Fast",
116+ category: "model_config",
117+ type: "select",
118+ currentValue: String(currentFast),
119+ options: [
120+ { value: "false", name: "Off" },
121+ { value: "true", name: "Fast" },
122+ ],
123+ },
124+ ];
125+ case "composer-2":
126+ return [
127+ ...baseOptions,
128+ {
129+ id: "fast",
130+ name: "Fast",
131+ category: "model_config",
132+ type: "select",
133+ currentValue: String(currentFast),
134+ options: [
135+ { value: "false", name: "Off" },
136+ { value: "true", name: "Fast" },
137+ ],
138+ },
139+ ];
140+ case "claude-opus-4-6":
141+ return [
142+ ...baseOptions,
143+ {
144+ id: "reasoning",
145+ name: "Reasoning",
146+ category: "thought_level",
147+ type: "select",
148+ currentValue: currentReasoning,
149+ options: [
150+ { value: "low", name: "Low" },
151+ { value: "medium", name: "Medium" },
152+ { value: "high", name: "High" },
153+ ],
154+ },
155+ {
156+ id: "thinking",
157+ name: "Thinking",
158+ category: "model_config",
159+ type: "boolean",
160+ currentValue: true,
161+ },
162+ ];
163+ default:
164+ return baseOptions;
165+ }
166+ }
167+
168+ return [
169+ {
170+ id: "model",
171+ name: "Model",
172+ category: "model",
173+ type: "select" as const,
174+ currentValue: currentModelId,
175+ options: [
176+ { value: "default", name: "Auto" },
177+ { value: "composer-2", name: "Composer 2" },
178+ { value: "composer-2[fast=true]", name: "Composer 2 Fast" },
179+ { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" },
180+ ],
181+ },
182+ ];
183+}
184+
185+const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [
186+ {
187+ id: "ask",
188+ name: "Ask",
189+ description: "Request permission before making any changes",
190+ },
191+ {
192+ id: "architect",
193+ name: "Architect",
194+ description: "Design and plan software systems without implementation",
195+ },
196+ {
197+ id: "code",
198+ name: "Code",
199+ description: "Write and modify code with full tool access",
200+ },
201+];
202+
203+function modeState(): AcpSchema.SessionModeState {
204+ return {
205+ currentModeId,
206+ availableModes,
207+ };
208+}
209+
210+const program = Effect.gen(function* () {
211+ const agent = yield* EffectAcpAgent.AcpAgent;
212+
213+ yield* agent.handleInitialize((request) =>
214+ Effect.sync(() => {
215+ parameterizedModelPicker =
216+ request.clientCapabilities?._meta?.parameterizedModelPicker === true;
217+ return {
218+ protocolVersion: 1,
219+ agentCapabilities: { loadSession: true },
220+ };
221+ }),
222+ );
223+
224+ yield* agent.handleAuthenticate(() => Effect.succeed({}));
225+
226+ yield* agent.handleCreateSession(() =>
227+ Effect.succeed({
228+ sessionId,
229+ modes: modeState(),
230+ configOptions: configOptions(),
231+ }),
232+ );
233+
234+ yield* agent.handleLoadSession((request) =>
235+ agent.client
236+ .sessionUpdate({
237+ sessionId: String(request.sessionId ?? sessionId),
238+ update: {
239+ sessionUpdate: "user_message_chunk",
240+ content: { type: "text", text: "replay" },
241+ },
242+ })
243+ .pipe(
244+ Effect.as({
245+ modes: modeState(),
246+ configOptions: configOptions(),
247+ }),
248+ ),
249+ );
250+
251+ yield* agent.handleSetSessionConfigOption((request) =>
252+ Effect.gen(function* () {
253+ if (exitOnSetConfigOption) {
254+ return yield* Effect.sync(() => {
255+ process.exit(7);
256+ });
257+ }
258+ if (failSetConfigOption) {
259+ return yield* AcpError.AcpRequestError.invalidParams(
260+ "Mock invalid params for session/set_config_option",
261+ {
262+ method: "session/set_config_option",
263+ params: request,
264+ },
265+ );
266+ }
267+ if (request.configId === "mode" && typeof request.value === "string") {
268+ currentModeId = request.value;
269+ }
270+ if (request.configId === "model" && typeof request.value === "string") {
271+ currentModelId = request.value;
272+ }
273+ if (request.configId === "reasoning" && typeof request.value === "string") {
274+ currentReasoning = request.value;
275+ }
276+ if (request.configId === "context" && typeof request.value === "string") {
277+ currentContext = request.value;
278+ }
279+ if (request.configId === "fast") {
280+ currentFast = request.value === true || request.value === "true";
281+ }
282+ return {
283+ configOptions: configOptions(),
284+ };
285+ }),
286+ );
287+
288+ yield* agent.handleCancel(({ sessionId }) =>
289+ Effect.sync(() => {
290+ cancelledSessions.add(String(sessionId ?? "mock-session-1"));
291+ }),
292+ );
293+
294+ yield* agent.handlePrompt((request) =>
295+ Effect.gen(function* () {
296+ const requestedSessionId = String(request.sessionId ?? sessionId);
297+
298+ if (emitInterleavedAssistantToolCalls) {
299+ const toolCallId = "tool-call-1";
300+
301+ yield* agent.client.sessionUpdate({
302+ sessionId: requestedSessionId,
303+ update: {
304+ sessionUpdate: "agent_message_chunk",
305+ content: { type: "text", text: "before tool" },
306+ },
307+ });
308+
309+ yield* agent.client.sessionUpdate({
310+ sessionId: requestedSessionId,
311+ update: {
312+ sessionUpdate: "tool_call",
313+ toolCallId,
314+ title: "Terminal",
315+ kind: "execute",
316+ status: "pending",
317+ rawInput: {
318+ command: ["echo", "hello"],
319+ },
320+ },
321+ });
322+
323+ yield* agent.client.sessionUpdate({
324+ sessionId: requestedSessionId,
325+ update: {
326+ sessionUpdate: "tool_call_update",
327+ toolCallId,
328+ status: "completed",
329+ rawOutput: {
330+ exitCode: 0,
331+ stdout: "hello",
332+ stderr: "",
333+ },
334+ },
335+ });
336+
337+ yield* agent.client.sessionUpdate({
338+ sessionId: requestedSessionId,
339+ update: {
340+ sessionUpdate: "agent_message_chunk",
341+ content: { type: "text", text: "after tool" },
342+ },
343+ });
344+
345+ return { stopReason: "end_turn" };
346+ }
347+
348+ if (emitToolCalls) {
349+ const toolCallId = "tool-call-1";
350+
351+ yield* agent.client.sessionUpdate({
352+ sessionId: requestedSessionId,
353+ update: {
354+ sessionUpdate: "tool_call",
355+ toolCallId,
356+ title: "Terminal",
357+ kind: "execute",
358+ status: "pending",
359+ rawInput: {
360+ command: ["cat", "server/package.json"],
361+ },
362+ },
363+ });
364+
365+ yield* agent.client.sessionUpdate({
366+ sessionId: requestedSessionId,
367+ update: {
368+ sessionUpdate: "tool_call_update",
369+ toolCallId,
370+ status: "in_progress",
371+ },
372+ });
373+
374+ const permission = yield* agent.client.requestPermission({
375+ sessionId: requestedSessionId,
376+ toolCall: {
377+ toolCallId,
378+ title: "`cat server/package.json`",
379+ kind: "execute",
380+ status: "pending",
381+ content: [
382+ {
383+ type: "content",
384+ content: {
385+ type: "text",
386+ text: "Not in allowlist: cat server/package.json",
387+ },
388+ },
389+ ],
390+ },
391+ options: [
392+ { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
393+ { optionId: "allow-always", name: "Allow always", kind: "allow_always" },
394+ { optionId: "reject-once", name: "Reject", kind: "reject_once" },
395+ ],
396+ });
397+
398+ const cancelled =
399+ cancelledSessions.delete(requestedSessionId) ||
400+ permission.outcome.outcome === "cancelled";
401+
402+ yield* agent.client.sessionUpdate({
403+ sessionId: requestedSessionId,
404+ update: {
405+ sessionUpdate: "tool_call_update",
406+ toolCallId,
407+ title: "Terminal",
408+ kind: "execute",
409+ status: "completed",
410+ rawOutput: {
411+ exitCode: 0,
412+ stdout: '{ "name": "t3" }',
413+ stderr: "",
414+ },
415+ },
416+ });
417+
418+ yield* agent.client.sessionUpdate({
419+ sessionId: requestedSessionId,
420+ update: {
421+ sessionUpdate: "agent_message_chunk",
422+ content: { type: "text", text: "hello from mock" },
423+ },
424+ });
425+
426+ return { stopReason: cancelled ? "cancelled" : "end_turn" };
427+ }
428+
429+ if (emitGenericToolPlaceholders) {
430+ const toolCallId = "tool-call-generic-1";
431+
432+ yield* agent.client.sessionUpdate({
433+ sessionId: requestedSessionId,
434+ update: {
435+ sessionUpdate: "tool_call",
436+ toolCallId,
437+ title: "Read File",
438+ kind: "read",
439+ status: "pending",
440+ rawInput: {},
441+ },
442+ });
443+
444+ yield* agent.client.sessionUpdate({
445+ sessionId: requestedSessionId,
446+ update: {
447+ sessionUpdate: "tool_call_update",
448+ toolCallId,
449+ status: "in_progress",
450+ },
451+ });
452+
453+ yield* agent.client.sessionUpdate({
454+ sessionId: requestedSessionId,
455+ update: {
456+ sessionUpdate: "tool_call_update",
457+ toolCallId,
458+ status: "completed",
459+ rawOutput: {
460+ content: "package.json\n",
461+ },
462+ },
463+ });
464+
465+ return { stopReason: "end_turn" };
466+ }
467+
468+ if (emitAskQuestion) {
469+ yield* agent.client.extRequest("cursor/ask_question", {
470+ toolCallId: "ask-question-tool-call-1",
471+ title: "Question",
472+ questions: [
473+ {
474+ id: "scope",
475+ prompt: "Which scope?",
476+ options: [
477+ { id: "workspace", label: "Workspace" },
478+ { id: "session", label: "Session" },
479+ ],
480+ },
481+ ],
482+ });
483+
484+ return { stopReason: "end_turn" };
485+ }
486+
487+ yield* agent.client.sessionUpdate({
488+ sessionId: requestedSessionId,
489+ update: {
490+ sessionUpdate: "plan",
491+ entries: [
492+ {
493+ content: "Inspect mock ACP state",
494+ priority: "high",
495+ status: "completed",
496+ },
497+ {
498+ content: "Implement the requested change",
499+ priority: "high",
500+ status: "in_progress",
501+ },
502+ ],
503+ },
504+ });
505+
506+ yield* agent.client.sessionUpdate({
507+ sessionId: requestedSessionId,
508+ update: {
509+ sessionUpdate: "agent_message_chunk",
510+ content: { type: "text", text: promptResponseText ?? "hello from mock" },
511+ },
512+ });
513+
514+ return { stopReason: "end_turn" };
515+ }),
516+ );
517+
518+ yield* agent.handleUnknownExtRequest((method, params) => {
519+ if (method !== "session/mode/set") {
520+ return Effect.fail(AcpError.AcpRequestError.methodNotFound(method));
521+ }
522+
523+ const nextModeId =
524+ typeof params === "object" &&
525+ params !== null &&
526+ "modeId" in params &&
527+ typeof params.modeId === "string"
528+ ? params.modeId
529+ : typeof params === "object" &&
530+ params !== null &&
531+ "mode" in params &&
532+ typeof params.mode === "string"
533+ ? params.mode
534+ : undefined;
535+ const requestedSessionId =
536+ typeof params === "object" &&
537+ params !== null &&
538+ "sessionId" in params &&
539+ typeof params.sessionId === "string"
540+ ? params.sessionId
541+ : sessionId;
542+
543+ if (typeof nextModeId === "string" && nextModeId.trim()) {
544+ currentModeId = nextModeId.trim();
545+ return agent.client
546+ .sessionUpdate({
547+ sessionId: requestedSessionId,
548+ update: {
549+ sessionUpdate: "current_mode_update",
550+ currentModeId,
551+ },
552+ })
553+ .pipe(Effect.as({}));
554+ }
555+
556+ return Effect.succeed({});
557+ });
558+
559+ return yield* Effect.never;
560+}).pipe(
561+ Effect.provide(
562+ EffectAcpAgent.layerStdio(
563+ requestLogPath
564+ ? {
565+ logIncoming: true,
566+ logger: (event) => {
567+ if (event.direction !== "incoming" || event.stage !== "raw") {
568+ return Effect.void;
569+ }
570+ if (typeof event.payload !== "string") {
571+ return Effect.void;
572+ }
573+ const payload = event.payload;
574+ return Effect.sync(() => {
575+ appendFileSync(
576+ requestLogPath,
577+ payload.endsWith("\n") ? payload : `${payload}\n`,
578+ "utf8",
579+ );
580+ });
581+ },
582+ }
583+ : {},
584+ ),
585+ ),
586+ Effect.scoped,
587+ Effect.provide(NodeServices.layer),
588+);
589+
590+NodeRuntime.runMain(program);
apps/server/scripts/cursor-acp-model-mismatch-probe.ts+435−0
@@ -0,0 +1,435 @@
1+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2+import process from "node:process";
3+import readline from "node:readline";
4+
5+type JsonPrimitive = null | boolean | number | string;
6+type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
7+
8+type JsonRpcId = number | string;
9+
10+type JsonRpcMessage = {
11+ jsonrpc?: string;
12+ id?: JsonRpcId;
13+ method?: string;
14+ params?: JsonValue;
15+ result?: JsonValue;
16+ error?: JsonValue;
17+ headers?: JsonValue;
18+};
19+
20+type SelectLeafOption = {
21+ value: string;
22+ label?: string;
23+ name?: string;
24+};
25+
26+type SelectGroupOption = {
27+ label?: string;
28+ name?: string;
29+ options: SelectLeafOption[];
30+};
31+
32+type SessionConfigOption = {
33+ id: string;
34+ name?: string;
35+ category?: string;
36+ type?: string;
37+ options?: Array<SelectLeafOption | SelectGroupOption>;
38+};
39+
40+type SessionNewResult = {
41+ sessionId: string;
42+ configOptions?: SessionConfigOption[];
43+};
44+
45+type SetConfigResult = {
46+ configOptions?: SessionConfigOption[];
47+};
48+
49+type PendingRequest = {
50+ method: string;
51+ resolve: (value: JsonValue | undefined) => void;
52+ reject: (error: Error) => void;
53+};
54+
55+const targetCwd = process.argv[2] ?? process.cwd();
56+const targetModel = process.argv[3] ?? "gpt-5.4";
57+const promptText = process.argv[4] ?? "helo";
58+const targetReasoning = process.env.CURSOR_REASONING ?? "";
59+const targetContext = process.env.CURSOR_CONTEXT ?? "";
60+const targetFast = process.env.CURSOR_FAST ?? "";
61+const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent";
62+const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000");
63+const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000");
64+
65+function logSection(title: string, value: unknown) {
66+ process.stdout.write(`\n=== ${title} ===\n`);
67+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
68+}
69+
70+function fail(message: string): never {
71+ throw new Error(message);
72+}
73+
74+function asString(value: JsonValue | undefined): string | null {
75+ return typeof value === "string" ? value : null;
76+}
77+
78+function flattenSelectValues(option: SessionConfigOption | undefined): string[] {
79+ if (!option || option.type !== "select" || !Array.isArray(option.options)) {
80+ return [];
81+ }
82+
83+ const values: string[] = [];
84+ for (const entry of option.options) {
85+ if (!entry || typeof entry !== "object") {
86+ continue;
87+ }
88+ if ("value" in entry && typeof entry.value === "string") {
89+ values.push(entry.value);
90+ continue;
91+ }
92+ if ("options" in entry && Array.isArray(entry.options)) {
93+ for (const nested of entry.options) {
94+ if (nested && typeof nested === "object" && typeof nested.value === "string") {
95+ values.push(nested.value);
96+ }
97+ }
98+ }
99+ }
100+ return values;
101+}
102+
103+function findConfigOption(
104+ configOptions: SessionConfigOption[],
105+ predicate: (option: SessionConfigOption) => boolean,
106+): SessionConfigOption | undefined {
107+ return configOptions.find(predicate);
108+}
109+
110+function matchesKeyword(option: SessionConfigOption, keyword: string): boolean {
111+ const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase();
112+ return haystack.includes(keyword.toLowerCase());
113+}
114+
115+function sleep(ms: number) {
116+ return new Promise<void>((resolve) => {
117+ setTimeout(resolve, ms);
118+ });
119+}
120+
121+class JsonRpcChild {
122+ readonly child: ChildProcessWithoutNullStreams;
123+ readonly pending = new Map<JsonRpcId, PendingRequest>();
124+ nextId = 1;
125+ closed = false;
126+
127+ constructor(bin: string, args: string[], cwd: string) {
128+ this.child = spawn(bin, args, {
129+ cwd,
130+ shell: process.platform === "win32",
131+ stdio: ["pipe", "pipe", "pipe"],
132+ env: process.env,
133+ });
134+
135+ this.child.on("exit", (code, signal) => {
136+ this.closed = true;
137+ const detail = `ACP process exited (code=${String(code)}, signal=${String(signal)})`;
138+ for (const pending of this.pending.values()) {
139+ pending.reject(new Error(`${detail} while waiting for ${pending.method}`));
140+ }
141+ this.pending.clear();
142+ });
143+
144+ this.child.on("error", (error) => {
145+ this.closed = true;
146+ for (const pending of this.pending.values()) {
147+ pending.reject(error);
148+ }
149+ this.pending.clear();
150+ });
151+
152+ const stdout = readline.createInterface({ input: this.child.stdout });
153+ stdout.on("line", (line) => {
154+ void this.handleStdoutLine(line);
155+ });
156+
157+ const stderr = readline.createInterface({ input: this.child.stderr });
158+ stderr.on("line", (line) => {
159+ process.stdout.write(`[stderr] ${line}\n`);
160+ });
161+ }
162+
163+ write(message: JsonRpcMessage) {
164+ if (this.closed) {
165+ fail("ACP process is already closed.");
166+ }
167+ const payload = JSON.stringify({
168+ jsonrpc: "2.0",
169+ headers: [],
170+ ...message,
171+ });
172+ process.stdout.write(`>>> ${payload}\n`);
173+ this.child.stdin.write(`${payload}\n`);
174+ }
175+
176+ async request(method: string, params: JsonValue, timeoutMs = requestTimeoutMs) {
177+ const id = this.nextId++;
178+
179+ const responsePromise = new Promise<JsonValue | undefined>((resolve, reject) => {
180+ const timeout = setTimeout(() => {
181+ this.pending.delete(id);
182+ reject(new Error(`Timed out waiting for ${method} response after ${timeoutMs}ms.`));
183+ }, timeoutMs);
184+
185+ this.pending.set(id, {
186+ method,
187+ resolve: (value) => {
188+ clearTimeout(timeout);
189+ resolve(value);
190+ },
191+ reject: (error) => {
192+ clearTimeout(timeout);
193+ reject(error);
194+ },
195+ });
196+ });
197+
198+ this.write({
199+ id,
200+ method,
201+ params,
202+ });
203+
204+ return responsePromise;
205+ }
206+
207+ notify(method: string, params: JsonValue) {
208+ this.write({
209+ method,
210+ params,
211+ });
212+ }
213+
214+ respond(id: JsonRpcId, result: JsonValue) {
215+ this.write({
216+ id,
217+ result,
218+ });
219+ }
220+
221+ respondError(id: JsonRpcId, code: number, message: string) {
222+ this.write({
223+ id,
224+ error: {
225+ code,
226+ message,
227+ },
228+ });
229+ }
230+
231+ async handleStdoutLine(line: string) {
232+ if (line.trim().length === 0) {
233+ return;
234+ }
235+
236+ process.stdout.write(`<<< ${line}\n`);
237+
238+ let message: JsonRpcMessage;
239+ try {
240+ message = JSON.parse(line) as JsonRpcMessage;
241+ } catch (error) {
242+ process.stdout.write(`[parse-error] ${(error as Error).message}\n`);
243+ return;
244+ }
245+
246+ if (typeof message.id !== "undefined" && !message.method) {
247+ const pending = this.pending.get(message.id);
248+ if (!pending) {
249+ return;
250+ }
251+ this.pending.delete(message.id);
252+ if (typeof message.error !== "undefined") {
253+ pending.reject(
254+ new Error(`RPC ${pending.method} failed: ${JSON.stringify(message.error, null, 2)}`),
255+ );
256+ return;
257+ }
258+ pending.resolve(message.result);
259+ return;
260+ }
261+
262+ if (message.method === "session/request_permission" && typeof message.id !== "undefined") {
263+ this.respond(message.id, {
264+ outcome: {
265+ outcome: "selected",
266+ optionId: "allow",
267+ },
268+ });
269+ return;
270+ }
271+
272+ if (typeof message.id !== "undefined" && message.id !== "") {
273+ this.respondError(
274+ message.id,
275+ -32601,
276+ `Unhandled server request: ${message.method ?? "unknown"}`,
277+ );
278+ }
279+ }
280+
281+ async close() {
282+ if (this.closed) {
283+ return;
284+ }
285+ this.child.kill("SIGTERM");
286+ await sleep(250);
287+ if (!this.closed) {
288+ this.child.kill("SIGKILL");
289+ }
290+ }
291+}
292+
293+async function setSelectOptionIfAdvertised(
294+ rpc: JsonRpcChild,
295+ sessionId: string,
296+ configOptions: SessionConfigOption[],
297+ predicate: (option: SessionConfigOption) => boolean,
298+ value: string,
299+ label: string,
300+) {
301+ if (value.length === 0) {
302+ return configOptions;
303+ }
304+
305+ const option = findConfigOption(configOptions, predicate);
306+ const values = flattenSelectValues(option);
307+ if (!option || !values.includes(value)) {
308+ logSection(`SKIP_${label}`, {
309+ requestedValue: value,
310+ availableValues: values,
311+ });
312+ return configOptions;
313+ }
314+
315+ const response = (await rpc.request("session/set_config_option", {
316+ sessionId,
317+ configId: option.id,
318+ value,
319+ })) as SetConfigResult | null | undefined;
320+
321+ logSection(`SET_${label}_RESPONSE`, response);
322+ return response?.configOptions ?? configOptions;
323+}
324+
325+async function main() {
326+ const rpc = new JsonRpcChild(agentBin, ["acp"], targetCwd);
327+
328+ try {
329+ const initializeResponse = await rpc.request("initialize", {
330+ protocolVersion: 1,
331+ clientCapabilities: {
332+ fs: { readTextFile: false, writeTextFile: false },
333+ terminal: false,
334+ _meta: {
335+ parameterizedModelPicker: true,
336+ },
337+ },
338+ clientInfo: {
339+ name: "cursor-acp-model-mismatch-probe",
340+ version: "0.0.0",
341+ },
342+ });
343+ logSection("INITIALIZE_RESPONSE", initializeResponse);
344+
345+ const authenticateResponse = await rpc.request("authenticate", {
346+ methodId: "cursor_login",
347+ });
348+ logSection("AUTHENTICATE_RESPONSE", authenticateResponse);
349+
350+ const sessionResponse = (await rpc.request("session/new", {
351+ cwd: targetCwd,
352+ mcpServers: [],
353+ })) as SessionNewResult;
354+ logSection("SESSION_NEW_RESPONSE", sessionResponse);
355+
356+ const sessionId = asString(sessionResponse.sessionId);
357+ if (!sessionId) {
358+ fail("session/new did not return a sessionId.");
359+ }
360+
361+ let configOptions = sessionResponse.configOptions ?? [];
362+ const modelConfig = findConfigOption(configOptions, (option) => option.category === "model");
363+ const advertisedModels = flattenSelectValues(modelConfig);
364+ logSection("ADVERTISED_MODEL_VALUES", advertisedModels);
365+
366+ if (!modelConfig || modelConfig.type !== "select") {
367+ fail("Cursor ACP did not expose a select-type model config option.");
368+ }
369+
370+ if (!advertisedModels.includes(targetModel)) {
371+ fail(
372+ `Cursor ACP did not advertise model ${JSON.stringify(targetModel)}. Advertised values: ${advertisedModels.join(", ")}`,
373+ );
374+ }
375+
376+ const setModelResponse = (await rpc.request("session/set_config_option", {
377+ sessionId,
378+ configId: modelConfig.id,
379+ value: targetModel,
380+ })) as SetConfigResult | null | undefined;
381+ logSection("SET_MODEL_RESPONSE", setModelResponse);
382+
383+ configOptions = setModelResponse?.configOptions ?? configOptions;
384+
385+ configOptions = await setSelectOptionIfAdvertised(
386+ rpc,
387+ sessionId,
388+ configOptions,
389+ (option) => option.category === "thought_level",
390+ targetReasoning,
391+ "REASONING",
392+ );
393+
394+ configOptions = await setSelectOptionIfAdvertised(
395+ rpc,
396+ sessionId,
397+ configOptions,
398+ (option) => option.category === "model_config" && matchesKeyword(option, "context"),
399+ targetContext,
400+ "CONTEXT",
401+ );
402+
403+ configOptions = await setSelectOptionIfAdvertised(
404+ rpc,
405+ sessionId,
406+ configOptions,
407+ (option) => option.category === "model_config" && matchesKeyword(option, "fast"),
408+ targetFast,
409+ "FAST",
410+ );
411+
412+ const promptResponse = await rpc.request("session/prompt", {
413+ sessionId,
414+ prompt: [
415+ {
416+ type: "text",
417+ text: promptText,
418+ },
419+ ],
420+ });
421+ logSection("PROMPT_RESPONSE", promptResponse);
422+
423+ await sleep(promptWaitMs);
424+ rpc.notify("session/cancel", { sessionId });
425+ } finally {
426+ await rpc.close();
427+ }
428+}
429+
430+void main().catch((error: unknown) => {
431+ process.stderr.write(
432+ `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
433+ );
434+ process.exitCode = 1;
435+});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t