Showing the changes to apps/server/scripts (2 of 98 files). Show all
Unified · Split
| @@ -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); | |
| @@ -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 | +}); | |