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 | }); | ||