History

Add ACP support with Cursor provider (#1355)

Julius Marminge committed 6 months ago

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

Unified · Split

98 files
  1. apps/desktop/package.json +1 −0
  2. apps/desktop/tsdown.config.ts +1 −1
  3. apps/server/package.json +1 −0
  4. apps/server/scripts/acp-mock-agent.ts +590 −0
  5. apps/server/scripts/cursor-acp-model-mismatch-probe.ts +435 −0
  6. apps/server/src/checkpointing/Layers/CheckpointStore.ts +3 −0
  7. apps/server/src/git/Layers/ClaudeTextGeneration.ts +2 −2
  8. apps/server/src/git/Layers/CursorTextGeneration.test.ts +298 −0
  9. apps/server/src/git/Layers/CursorTextGeneration.ts +352 −0
  10. apps/server/src/git/Layers/RoutingTextGeneration.ts +22 −1
  11. apps/server/src/git/Services/TextGeneration.ts +1 −1
  12. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +1 −1
  13. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +101 −22
  14. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +566 −0
  15. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +711 −410
  16. apps/server/src/provider/Layers/ClaudeAdapter.ts +11 −10
  17. apps/server/src/provider/Layers/ClaudeProvider.ts +9 −0
  18. apps/server/src/provider/Layers/CursorAdapter.test.ts +1174 −0
  19. apps/server/src/provider/Layers/CursorAdapter.ts +1057 −0
  20. apps/server/src/provider/Layers/CursorProvider.test.ts +687 −0
  21. apps/server/src/provider/Layers/CursorProvider.ts +1136 −0
  22. apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +23 −1
  23. apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +8 −1
  24. apps/server/src/provider/Layers/ProviderRegistry.test.ts +200 −10
  25. apps/server/src/provider/Layers/ProviderRegistry.ts +136 −62
  26. apps/server/src/provider/Layers/ProviderService.test.ts +6 −2
  27. apps/server/src/provider/Layers/ProviderSessionDirectory.ts +11 −10
  28. apps/server/src/provider/Services/CursorAdapter.ts +12 −0
  29. apps/server/src/provider/Services/CursorProvider.ts +9 −0
  30. apps/server/src/provider/Services/ProviderAdapter.ts +1 −1
  31. apps/server/src/provider/acp/AcpAdapterSupport.test.ts +27 −0
  32. apps/server/src/provider/acp/AcpAdapterSupport.ts +54 −0
  33. apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +155 −0
  34. apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +242 −0
  35. apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +395 −0
  36. apps/server/src/provider/acp/AcpNativeLogging.ts +76 −0
  37. apps/server/src/provider/acp/AcpRuntimeModel.test.ts +286 −0
  38. apps/server/src/provider/acp/AcpRuntimeModel.ts +482 −0
  39. apps/server/src/provider/acp/AcpSessionRuntime.ts +722 −0
  40. apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +148 −0
  41. apps/server/src/provider/acp/CursorAcpExtension.test.ts +108 −0
  42. apps/server/src/provider/acp/CursorAcpExtension.ts +99 −0
  43. apps/server/src/provider/acp/CursorAcpSupport.test.ts +123 −0
  44. apps/server/src/provider/acp/CursorAcpSupport.ts +108 −0
  45. apps/server/src/provider/makeManagedServerProvider.test.ts +263 −0
  46. apps/server/src/provider/makeManagedServerProvider.ts +85 −4
  47. apps/server/src/provider/providerStatusCache.test.ts +30 −2
  48. apps/server/src/provider/providerStatusCache.ts +10 −0
  49. apps/server/src/server.ts +5 −0
  50. apps/server/src/serverSettings.ts +1 −1
  51. apps/server/tsdown.config.ts +1 −1
  52. apps/web/src/components/KeybindingsToast.browser.tsx +1 −0
  53. apps/web/src/components/chat/ChatComposer.tsx +1 −0
  54. apps/web/src/components/chat/MessagesTimeline.tsx +51 −20
  55. apps/web/src/components/chat/TraitsPicker.browser.tsx +152 −0
  56. apps/web/src/components/chat/TraitsPicker.tsx +146 −43
  57. apps/web/src/components/chat/composerProviderRegistry.tsx +54 −64
  58. apps/web/src/components/settings/SettingsPanels.tsx +27 −1
  59. apps/web/src/composerDraftStore.test.ts +73 −1
  60. apps/web/src/composerDraftStore.ts +64 −13
  61. apps/web/src/modelSelection.ts +13 −0
  62. apps/web/src/providerModels.ts +38 −2
  63. apps/web/src/session-logic.test.ts +193 −29
  64. apps/web/src/session-logic.ts +149 −19
  65. apps/web/src/store.ts +4 −4
  66. bun.lock +22 −0
  67. package.json +1 −0
  68. packages/contracts/src/model.ts +36 −4
  69. packages/contracts/src/orchestration.ts +14 −2
  70. packages/contracts/src/provider.test.ts +20 −0
  71. packages/contracts/src/providerRuntime.ts +10 −8
  72. packages/contracts/src/settings.ts +29 −0
  73. packages/effect-acp/package.json +55 −0
  74. packages/effect-acp/scripts/generate.ts +289 −0
  75. packages/effect-acp/src/_generated/meta.gen.ts +35 −0
  76. packages/effect-acp/src/_generated/schema.gen.ts +10375 −0
  77. packages/effect-acp/src/_internal/shared.ts +111 −0
  78. packages/effect-acp/src/_internal/stdio.ts +54 −0
  79. packages/effect-acp/src/agent.test.ts +255 −0
  80. packages/effect-acp/src/agent.ts +519 −0
  81. packages/effect-acp/src/client.test.ts +450 −0
  82. packages/effect-acp/src/client.ts +569 −0
  83. packages/effect-acp/src/errors.ts +139 −0
  84. packages/effect-acp/src/protocol.test.ts +448 −0
  85. packages/effect-acp/src/protocol.ts +536 −0
  86. packages/effect-acp/src/rpc.ts +158 −0
  87. packages/effect-acp/src/schema.ts +2 −0
  88. packages/effect-acp/src/terminal.ts +45 −0
  89. packages/effect-acp/test/examples/cursor-acp-client.example.ts +81 −0
  90. packages/effect-acp/test/fixtures/acp-mock-peer.ts +136 −0
  91. packages/effect-acp/tsconfig.json +18 −0
  92. packages/shared/package.json +4 −0
  93. packages/shared/src/model.test.ts +6 −41
  94. packages/shared/src/model.ts +32 −46
  95. packages/shared/src/serverSettings.ts +16 −7
  96. packages/shared/src/toolActivity.test.ts +57 −0
  97. packages/shared/src/toolActivity.ts +257 −0
  98. scripts/release-smoke.ts +1 −0
apps/desktop/package.json+1−0
@@ -23,6 +23,7 @@ · 22 unmodified lines
23 "@t3tools/contracts": "workspace:*",23 "@t3tools/contracts": "workspace:*",
24 "@t3tools/shared": "workspace:*",24 "@t3tools/shared": "workspace:*",
25 "@types/node": "catalog:",25 "@types/node": "catalog:",
26 "effect-acp": "workspace:*",
26 "tsdown": "catalog:",27 "tsdown": "catalog:",
27 "typescript": "catalog:",28 "typescript": "catalog:",
28 "vitest": "catalog:"29 "vitest": "catalog:"
apps/desktop/tsdown.config.ts+1−1
@@ -12,7 +12,7 @@ export default defineConfig([ · 11 unmodified lines
12 ...shared,12 ...shared,
13 entry: ["src/main.ts"],13 entry: ["src/main.ts"],
14 clean: true,14 clean: true,
15 noExternal: (id) => id.startsWith("@t3tools/"),15 noExternal: (id) => id.startsWith("@t3tools/") || id.startsWith("effect-acp"),
16 },16 },
17 {17 {
18 ...shared,18 ...shared,
apps/server/package.json+1−0
@@ -43,6 +43,7 @@ · 42 unmodified lines
43 "@t3tools/web": "workspace:*",43 "@t3tools/web": "workspace:*",
44 "@types/bun": "catalog:",44 "@types/bun": "catalog:",
45 "@types/node": "catalog:",45 "@types/node": "catalog:",
46 "effect-acp": "workspace:*",
46 "tsdown": "catalog:",47 "tsdown": "catalog:",
47 "typescript": "catalog:",48 "typescript": "catalog:",
48 "vitest": "catalog:"49 "vitest": "catalog:"
apps/server/scripts/acp-mock-agent.ts+590−0
@@ -0,0 +1,590 @@
1#!/usr/bin/env bun
2import { appendFileSync } from "node:fs";
3
4import * as Effect from "effect/Effect";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
8
9import * as EffectAcpAgent from "effect-acp/agent";
10import * as AcpError from "effect-acp/errors";
11import type * as AcpSchema from "effect-acp/schema";
12
13const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
14const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
15const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
16const emitInterleavedAssistantToolCalls =
17 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
18const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
19const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
20const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
21const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
22const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
23const sessionId = "mock-session-1";
24
25let currentModeId = "ask";
26let currentModelId = "default";
27let parameterizedModelPicker = false;
28let currentReasoning = "medium";
29let currentContext = "272k";
30let currentFast = false;
31const cancelledSessions = new Set<string>();
32
33function logExit(reason: string): void {
34 if (!exitLogPath) {
35 return;
36 }
37 appendFileSync(exitLogPath, `${reason}\n`, "utf8");
38}
39
40process.once("SIGTERM", () => {
41 logExit("SIGTERM");
42 process.exit(0);
43});
44
45process.once("SIGINT", () => {
46 logExit("SIGINT");
47 process.exit(0);
48});
49
50process.once("exit", (code) => {
51 logExit(`exit:${code}`);
52});
53
54function 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
185const 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
203function modeState(): AcpSchema.SessionModeState {
204 return {
205 currentModeId,
206 availableModes,
207 };
208}
209
210const 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
590NodeRuntime.runMain(program);
apps/server/scripts/cursor-acp-model-mismatch-probe.ts+435−0
@@ -0,0 +1,435 @@
1import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2import process from "node:process";
3import readline from "node:readline";
4
5type JsonPrimitive = null | boolean | number | string;
6type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
7
8type JsonRpcId = number | string;
9
10type JsonRpcMessage = {
11 jsonrpc?: string;
12 id?: JsonRpcId;
13 method?: string;
14 params?: JsonValue;
15 result?: JsonValue;
16 error?: JsonValue;
17 headers?: JsonValue;
18};
19
20type SelectLeafOption = {
21 value: string;
22 label?: string;
23 name?: string;
24};
25
26type SelectGroupOption = {
27 label?: string;
28 name?: string;
29 options: SelectLeafOption[];
30};
31
32type SessionConfigOption = {
33 id: string;
34 name?: string;
35 category?: string;
36 type?: string;
37 options?: Array<SelectLeafOption | SelectGroupOption>;
38};
39
40type SessionNewResult = {
41 sessionId: string;
42 configOptions?: SessionConfigOption[];
43};
44
45type SetConfigResult = {
46 configOptions?: SessionConfigOption[];
47};
48
49type PendingRequest = {
50 method: string;
51 resolve: (value: JsonValue | undefined) => void;
52 reject: (error: Error) => void;
53};
54
55const targetCwd = process.argv[2] ?? process.cwd();
56const targetModel = process.argv[3] ?? "gpt-5.4";
57const promptText = process.argv[4] ?? "helo";
58const targetReasoning = process.env.CURSOR_REASONING ?? "";
59const targetContext = process.env.CURSOR_CONTEXT ?? "";
60const targetFast = process.env.CURSOR_FAST ?? "";
61const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent";
62const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000");
63const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000");
64
65function logSection(title: string, value: unknown) {
66 process.stdout.write(`\n=== ${title} ===\n`);
67 process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
68}
69
70function fail(message: string): never {
71 throw new Error(message);
72}
73
74function asString(value: JsonValue | undefined): string | null {
75 return typeof value === "string" ? value : null;
76}
77
78function 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
103function findConfigOption(
104 configOptions: SessionConfigOption[],
105 predicate: (option: SessionConfigOption) => boolean,
106): SessionConfigOption | undefined {
107 return configOptions.find(predicate);
108}
109
110function matchesKeyword(option: SessionConfigOption, keyword: string): boolean {
111 const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase();
112 return haystack.includes(keyword.toLowerCase());
113}
114
115function sleep(ms: number) {
116 return new Promise<void>((resolve) => {
117 setTimeout(resolve, ms);
118 });
119}
120
121class 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
293async 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
325async 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
430void 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});
apps/server/src/checkpointing/Layers/CheckpointStore.ts+3−0
@@ -19,6 +19,8 @@ import { GitCore } from "../../git/Services/GitCore.ts"; · 18 unmodified lines
19import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";19import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
20import { CheckpointRef } from "@t3tools/contracts";20import { CheckpointRef } from "@t3tools/contracts";
21 21
22const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
23
22const makeCheckpointStore = Effect.gen(function* () {24const makeCheckpointStore = Effect.gen(function* () {
23 const fs = yield* FileSystem.FileSystem;25 const fs = yield* FileSystem.FileSystem;
24 const path = yield* Path.Path;26 const path = yield* Path.Path;
@@ -245,6 +247,7 @@ const makeCheckpointStore = Effect.gen(function* () { · 220 unmodified lines
245 operation,247 operation,
246 cwd: input.cwd,248 cwd: input.cwd,
247 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],249 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
250 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
248 });251 });
249 252
250 return result.stdout;253 return result.stdout;
apps/server/src/git/Layers/ClaudeTextGeneration.ts+2−2
@@ -11,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; · 10 unmodified lines
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12 12
13import { ClaudeModelSelection } from "@t3tools/contracts";13import { ClaudeModelSelection } from "@t3tools/contracts";
14import { resolveApiModelId } from "@t3tools/shared/model";
15import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
16 15
17import { TextGenerationError } from "@t3tools/contracts";16import { TextGenerationError } from "@t3tools/contracts";
@@ -30,6 +29,7 @@ import { · 12 unmodified lines
30 toJsonSchemaObject,29 toJsonSchemaObject,
31} from "../Utils.ts";30} from "../Utils.ts";
32import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";31import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
32import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts";
33import { ServerSettingsService } from "../../serverSettings.ts";33import { ServerSettingsService } from "../../serverSettings.ts";
34import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";34import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
35 35
@@ -110,7 +110,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 74 unmodified lines
110 "--json-schema",110 "--json-schema",
111 jsonSchemaStr,111 jsonSchemaStr,
112 "--model",112 "--model",
113 resolveApiModelId(modelSelection),113 resolveClaudeApiModelId(modelSelection),
114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
115 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),115 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
116 "--dangerously-skip-permissions",116 "--dangerously-skip-permissions",
apps/server/src/git/Layers/CursorTextGeneration.test.ts+298−0
@@ -0,0 +1,298 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { fileURLToPath } from "node:url";
4import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";
8import { Effect, Layer } from "effect";
9import { expect } from "vitest";
10
11import { ServerSettingsError } from "@t3tools/contracts";
12
13import { ServerConfig } from "../../config.ts";
14import { TextGeneration } from "../Services/TextGeneration.ts";
15import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
16import { ServerSettingsService } from "../../serverSettings.ts";
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
20
21function shellSingleQuote(value: string): string {
22 return `'${value.replaceAll("'", `'"'"'`)}'`;
23}
24
25const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe(
26 Layer.provideMerge(ServerSettingsService.layerTest()),
27 Layer.provideMerge(
28 ServerConfig.layerTest(process.cwd(), {
29 prefix: "t3code-cursor-text-generation-test-",
30 }),
31 ),
32 Layer.provideMerge(NodeServices.layer),
33);
34
35function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
36 const binDir = path.join(dir, "bin");
37 const agentPath = path.join(binDir, "agent");
38 mkdirSync(binDir, { recursive: true });
39 writeFileSync(
40 agentPath,
41 [
42 "#!/bin/sh",
43 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
44 'if [ "$1" != "acp" ]; then',
45 ' printf "%s\\n" "unexpected args: $*" >&2',
46 " exit 11",
47 "fi",
48 `exec bun ${JSON.stringify(mockAgentPath)}`,
49 "",
50 ].join("\n"),
51 "utf8",
52 );
53 chmodSync(agentPath, 0o755);
54 return agentPath;
55}
56
57function withFakeAcpAgent<A, E, R>(
58 env: Record<string, string>,
59 effect: Effect.Effect<A, E, R>,
60): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> {
61 return Effect.gen(function* () {
62 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
63 const agentPath = makeAcpAgentWrapper(tempDir, env);
64 const serverSettings = yield* ServerSettingsService;
65 const previousSettings = yield* serverSettings.getSettings;
66
67 yield* serverSettings.updateSettings({
68 providers: {
69 cursor: {
70 binaryPath: agentPath,
71 },
72 },
73 });
74
75 return yield* effect.pipe(
76 Effect.ensuring(
77 serverSettings
78 .updateSettings({
79 providers: {
80 cursor: {
81 binaryPath: previousSettings.providers.cursor.binaryPath,
82 },
83 },
84 })
85 .pipe(
86 Effect.catch(() => Effect.void),
87 Effect.ensuring(
88 Effect.sync(() => {
89 rmSync(tempDir, { recursive: true, force: true });
90 }),
91 ),
92 Effect.asVoid,
93 ),
94 ),
95 );
96 });
97}
98
99function waitForFileContent(path: string): Effect.Effect<string> {
100 return Effect.promise(async () => {
101 const deadline = Date.now() + 5_000;
102 for (;;) {
103 try {
104 return readFileSync(path, "utf8");
105 } catch (error) {
106 if (Date.now() >= deadline) {
107 throw error instanceof Error ? error : new Error(String(error));
108 }
109 }
110 await new Promise((resolve) => setTimeout(resolve, 25));
111 }
112 });
113}
114
115it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
116 it.effect("uses ACP model config options instead of raw CLI model ids", () => {
117 const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-"));
118 const requestLogPath = path.join(requestLogDir, "requests.ndjson");
119
120 return withFakeAcpAgent(
121 {
122 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
123 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
124 subject: "Add generated commit message",
125 body: "- verify cursor acp model config path",
126 }),
127 },
128 Effect.gen(function* () {
129 const textGeneration = yield* TextGeneration;
130
131 const generated = yield* textGeneration.generateCommitMessage({
132 cwd: process.cwd(),
133 branch: "feature/cursor-text-generation",
134 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
135 stagedPatch:
136 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
137 modelSelection: {
138 provider: "cursor",
139 model: "gpt-5.4",
140 options: {
141 reasoning: "xhigh",
142 fastMode: true,
143 contextWindow: "1m",
144 },
145 },
146 });
147
148 expect(generated.subject).toBe("Add generated commit message");
149 expect(generated.body).toBe("- verify cursor acp model config path");
150
151 const requests = readFileSync(requestLogPath, "utf8")
152 .trim()
153 .split("\n")
154 .filter((line) => line.length > 0)
155 .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
156
157 expect(
158 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
159 ).toMatchObject({
160 _meta: {
161 parameterizedModelPicker: true,
162 },
163 });
164 expect(
165 requests.some(
166 (request) =>
167 request.method === "session/set_config_option" &&
168 request.params?.configId === "model" &&
169 request.params?.value === "gpt-5.4",
170 ),
171 ).toBe(true);
172 expect(
173 requests.some(
174 (request) =>
175 request.method === "session/set_config_option" &&
176 request.params?.configId === "reasoning" &&
177 request.params?.value === "extra-high",
178 ),
179 ).toBe(true);
180 expect(
181 requests.some(
182 (request) =>
183 request.method === "session/set_config_option" &&
184 request.params?.configId === "context" &&
185 request.params?.value === "1m",
186 ),
187 ).toBe(true);
188 expect(
189 requests.some(
190 (request) =>
191 request.method === "session/set_config_option" &&
192 request.params?.configId === "fast" &&
193 request.params?.value === "true",
194 ),
195 ).toBe(true);
196 expect(
197 requests.find((request) => request.method === "session/prompt")?.params?.prompt,
198 ).toEqual(
199 expect.arrayContaining([
200 expect.objectContaining({
201 type: "text",
202 text: expect.stringContaining("Staged patch:"),
203 }),
204 ]),
205 );
206
207 rmSync(requestLogDir, { recursive: true, force: true });
208 }),
209 );
210 });
211
212 it.effect("accepts json objects with extra assistant text around them", () =>
213 withFakeAcpAgent(
214 {
215 T3_ACP_PROMPT_RESPONSE_TEXT:
216 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
217 },
218 Effect.gen(function* () {
219 const textGeneration = yield* TextGeneration;
220
221 const generated = yield* textGeneration.generateCommitMessage({
222 cwd: process.cwd(),
223 branch: "feature/cursor-noisy-json",
224 stagedSummary: "M README.md",
225 stagedPatch: "diff --git a/README.md b/README.md",
226 modelSelection: {
227 provider: "cursor",
228 model: "composer-2",
229 },
230 });
231
232 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
233 expect(generated.body).toBe("");
234 }),
235 ),
236 );
237
238 it.effect("generates thread titles through Cursor ACP text generation", () =>
239 withFakeAcpAgent(
240 {
241 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
242 title: '"Trim reconnect spinner status after resume."',
243 }),
244 },
245 Effect.gen(function* () {
246 const textGeneration = yield* TextGeneration;
247
248 const generated = yield* textGeneration.generateThreadTitle({
249 cwd: process.cwd(),
250 message: "Fix the reconnect spinner after a resumed session.",
251 modelSelection: {
252 provider: "cursor",
253 model: "composer-2",
254 },
255 });
256
257 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
258 }),
259 ),
260 );
261
262 it.effect("closes the ACP child process after text generation completes", () => {
263 const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-"));
264 const exitLogPath = path.join(exitLogDir, "exit.log");
265
266 return withFakeAcpAgent(
267 {
268 T3_ACP_EXIT_LOG_PATH: exitLogPath,
269 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
270 subject: "Close runtime after generation",
271 body: "",
272 }),
273 },
274 Effect.gen(function* () {
275 const textGeneration = yield* TextGeneration;
276
277 const generated = yield* textGeneration.generateCommitMessage({
278 cwd: process.cwd(),
279 branch: "feature/cursor-runtime-close",
280 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
281 stagedPatch:
282 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
283 modelSelection: {
284 provider: "cursor",
285 model: "composer-2",
286 },
287 });
288
289 expect(generated.subject).toBe("Close runtime after generation");
290
291 const exitLog = yield* waitForFileContent(exitLogPath);
292 expect(exitLog).toContain("exit:0");
293
294 rmSync(exitLogDir, { recursive: true, force: true });
295 }),
296 );
297 });
298});
apps/server/src/git/Layers/CursorTextGeneration.ts+352−0
@@ -0,0 +1,352 @@
1import { Effect, Layer, Option, Ref, Schema } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";
3
4import { CursorModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6
7import { TextGenerationError } from "@t3tools/contracts";
8import {
9 type ThreadTitleGenerationResult,
10 type TextGenerationShape,
11 TextGeneration,
12} from "../Services/TextGeneration.ts";
13import {
14 buildBranchNamePrompt,
15 buildCommitMessagePrompt,
16 buildPrContentPrompt,
17 buildThreadTitlePrompt,
18} from "../Prompts.ts";
19import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts";
20import {
21 applyCursorAcpModelSelection,
22 makeCursorAcpRuntime,
23} from "../../provider/acp/CursorAcpSupport.ts";
24import { ServerSettingsService } from "../../serverSettings.ts";
25
26const CURSOR_TIMEOUT_MS = 180_000;
27
28function extractJsonObject(raw: string): string {
29 const trimmed = raw.trim();
30 if (trimmed.length === 0) {
31 return trimmed;
32 }
33
34 const start = trimmed.indexOf("{");
35 if (start < 0) {
36 return trimmed;
37 }
38
39 let depth = 0;
40 let inString = false;
41 let escaping = false;
42 for (let index = start; index < trimmed.length; index += 1) {
43 const char = trimmed[index];
44 if (inString) {
45 if (escaping) {
46 escaping = false;
47 } else if (char === "\\") {
48 escaping = true;
49 } else if (char === '"') {
50 inString = false;
51 }
52 continue;
53 }
54
55 if (char === '"') {
56 inString = true;
57 continue;
58 }
59
60 if (char === "{") {
61 depth += 1;
62 continue;
63 }
64
65 if (char === "}") {
66 depth -= 1;
67 if (depth === 0) {
68 return trimmed.slice(start, index + 1);
69 }
70 }
71 }
72
73 return trimmed.slice(start);
74}
75
76function mapCursorAcpError(
77 operation:
78 | "generateCommitMessage"
79 | "generatePrContent"
80 | "generateBranchName"
81 | "generateThreadTitle",
82 detail: string,
83 cause: unknown,
84): TextGenerationError {
85 return new TextGenerationError({
86 operation,
87 detail,
88 ...(cause !== undefined ? { cause } : {}),
89 });
90}
91
92function isTextGenerationError(error: unknown): error is TextGenerationError {
93 return (
94 typeof error === "object" &&
95 error !== null &&
96 "_tag" in error &&
97 error._tag === "TextGenerationError"
98 );
99}
100
101const makeCursorTextGeneration = Effect.gen(function* () {
102 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
103 const serverSettingsService = yield* Effect.service(ServerSettingsService);
104
105 const runCursorJson = <S extends Schema.Top>({
106 operation,
107 cwd,
108 prompt,
109 outputSchemaJson,
110 modelSelection,
111 }: {
112 operation:
113 | "generateCommitMessage"
114 | "generatePrContent"
115 | "generateBranchName"
116 | "generateThreadTitle";
117 cwd: string;
118 prompt: string;
119 outputSchemaJson: S;
120 modelSelection: CursorModelSelection;
121 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
122 Effect.gen(function* () {
123 const cursorSettings = yield* Effect.map(
124 serverSettingsService.getSettings,
125 (settings) => settings.providers.cursor,
126 ).pipe(Effect.catch(() => Effect.undefined));
127
128 const outputRef = yield* Ref.make("");
129 const runtime = yield* makeCursorAcpRuntime({
130 cursorSettings,
131 childProcessSpawner: commandSpawner,
132 cwd,
133 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
134 });
135
136 yield* runtime.handleSessionUpdate((notification) => {
137 const update = notification.update;
138 if (update.sessionUpdate !== "agent_message_chunk") {
139 return Effect.void;
140 }
141 const content = update.content;
142 if (content.type !== "text") {
143 return Effect.void;
144 }
145 return Ref.update(outputRef, (current) => current + content.text);
146 });
147
148 const promptResult = yield* Effect.gen(function* () {
149 yield* runtime.start();
150 yield* Effect.ignore(runtime.setMode("ask"));
151 yield* applyCursorAcpModelSelection({
152 runtime,
153 model: modelSelection.model,
154 modelOptions: modelSelection.options,
155 mapError: ({ cause, configId, step }) =>
156 mapCursorAcpError(
157 operation,
158 step === "set-config-option"
159 ? `Failed to set Cursor ACP config option "${configId}" for text generation.`
160 : "Failed to set Cursor ACP base model for text generation.",
161 cause,
162 ),
163 });
164
165 return yield* runtime.prompt({
166 prompt: [{ type: "text", text: prompt }],
167 });
168 }).pipe(
169 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
170 Effect.flatMap(
171 Option.match({
172 onNone: () =>
173 Effect.fail(
174 new TextGenerationError({
175 operation,
176 detail: "Cursor Agent request timed out.",
177 }),
178 ),
179 onSome: (value) => Effect.succeed(value),
180 }),
181 ),
182 Effect.mapError((cause) =>
183 isTextGenerationError(cause)
184 ? cause
185 : mapCursorAcpError(operation, "Cursor ACP request failed.", cause),
186 ),
187 );
188
189 const rawResult = (yield* Ref.get(outputRef)).trim();
190 if (!rawResult) {
191 return yield* new TextGenerationError({
192 operation,
193 detail:
194 promptResult.stopReason === "cancelled"
195 ? "Cursor ACP request was cancelled."
196 : "Cursor Agent returned empty output.",
197 });
198 }
199
200 return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))(
201 extractJsonObject(rawResult),
202 ).pipe(
203 Effect.catchTag("SchemaError", (cause) =>
204 Effect.fail(
205 new TextGenerationError({
206 operation,
207 detail: "Cursor Agent returned invalid structured output.",
208 cause,
209 }),
210 ),
211 ),
212 );
213 }).pipe(
214 Effect.mapError((cause) =>
215 isTextGenerationError(cause)
216 ? cause
217 : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause),
218 ),
219 Effect.scoped,
220 );
221
222 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
223 "CursorTextGeneration.generateCommitMessage",
224 )(function* (input) {
225 const { prompt, outputSchema } = buildCommitMessagePrompt({
226 branch: input.branch,
227 stagedSummary: input.stagedSummary,
228 stagedPatch: input.stagedPatch,
229 includeBranch: input.includeBranch === true,
230 });
231
232 if (input.modelSelection.provider !== "cursor") {
233 return yield* new TextGenerationError({
234 operation: "generateCommitMessage",
235 detail: "Invalid model selection.",
236 });
237 }
238
239 const generated = yield* runCursorJson({
240 operation: "generateCommitMessage",
241 cwd: input.cwd,
242 prompt,
243 outputSchemaJson: outputSchema,
244 modelSelection: input.modelSelection,
245 });
246
247 return {
248 subject: sanitizeCommitSubject(generated.subject),
249 body: generated.body.trim(),
250 ...("branch" in generated && typeof generated.branch === "string"
251 ? { branch: sanitizeFeatureBranchName(generated.branch) }
252 : {}),
253 };
254 });
255
256 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
257 "CursorTextGeneration.generatePrContent",
258 )(function* (input) {
259 const { prompt, outputSchema } = buildPrContentPrompt({
260 baseBranch: input.baseBranch,
261 headBranch: input.headBranch,
262 commitSummary: input.commitSummary,
263 diffSummary: input.diffSummary,
264 diffPatch: input.diffPatch,
265 });
266
267 if (input.modelSelection.provider !== "cursor") {
268 return yield* new TextGenerationError({
269 operation: "generatePrContent",
270 detail: "Invalid model selection.",
271 });
272 }
273
274 const generated = yield* runCursorJson({
275 operation: "generatePrContent",
276 cwd: input.cwd,
277 prompt,
278 outputSchemaJson: outputSchema,
279 modelSelection: input.modelSelection,
280 });
281
282 return {
283 title: sanitizePrTitle(generated.title),
284 body: generated.body.trim(),
285 };
286 });
287
288 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
289 "CursorTextGeneration.generateBranchName",
290 )(function* (input) {
291 const { prompt, outputSchema } = buildBranchNamePrompt({
292 message: input.message,
293 attachments: input.attachments,
294 });
295
296 if (input.modelSelection.provider !== "cursor") {
297 return yield* new TextGenerationError({
298 operation: "generateBranchName",
299 detail: "Invalid model selection.",
300 });
301 }
302
303 const generated = yield* runCursorJson({
304 operation: "generateBranchName",
305 cwd: input.cwd,
306 prompt,
307 outputSchemaJson: outputSchema,
308 modelSelection: input.modelSelection,
309 });
310
311 return {
312 branch: sanitizeBranchFragment(generated.branch),
313 };
314 });
315
316 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
317 "CursorTextGeneration.generateThreadTitle",
318 )(function* (input) {
319 const { prompt, outputSchema } = buildThreadTitlePrompt({
320 message: input.message,
321 attachments: input.attachments,
322 });
323
324 if (input.modelSelection.provider !== "cursor") {
325 return yield* new TextGenerationError({
326 operation: "generateThreadTitle",
327 detail: "Invalid model selection.",
328 });
329 }
330
331 const generated = yield* runCursorJson({
332 operation: "generateThreadTitle",
333 cwd: input.cwd,
334 prompt,
335 outputSchemaJson: outputSchema,
336 modelSelection: input.modelSelection,
337 });
338
339 return {
340 title: sanitizeThreadTitle(generated.title),
341 } satisfies ThreadTitleGenerationResult;
342 });
343
344 return {
345 generateCommitMessage,
346 generatePrContent,
347 generateBranchName,
348 generateThreadTitle,
349 } satisfies TextGenerationShape;
350});
351
352export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration);
apps/server/src/git/Layers/RoutingTextGeneration.ts+22−1
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18} from "../Services/TextGeneration.ts";18} from "../Services/TextGeneration.ts";
19import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";19import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
20import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";20import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
21import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";22import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
22 23
23// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
@@ -32,6 +33,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 8 unmodified lines
32 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",33 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
33) {}34) {}
34 35
36class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()(
37 "t3/git/Layers/RoutingTextGeneration/CursorTextGen",
38) {}
39
35class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(40class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
36 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",41 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
37) {}42) {}
@@ -43,10 +48,17 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha · 5 unmodified lines
43const makeRoutingTextGeneration = Effect.gen(function* () {48const makeRoutingTextGeneration = Effect.gen(function* () {
44 const codex = yield* CodexTextGen;49 const codex = yield* CodexTextGen;
45 const claude = yield* ClaudeTextGen;50 const claude = yield* ClaudeTextGen;
51 const cursor = yield* CursorTextGen;
46 const openCode = yield* OpenCodeTextGen;52 const openCode = yield* OpenCodeTextGen;
47 53
48 const route = (provider?: TextGenerationProvider): TextGenerationShape =>54 const route = (provider?: TextGenerationProvider): TextGenerationShape =>
49 provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex;55 provider === "claudeAgent"
56 ? claude
57 : provider === "opencode"
58 ? openCode
59 : provider === "cursor"
60 ? cursor
61 : codex;
50 62
51 return {63 return {
52 generateCommitMessage: (input) =>64 generateCommitMessage: (input) =>
@@ -73,6 +85,14 @@ const InternalClaudeLayer = Layer.effect( · 20 unmodified lines
73 }),85 }),
74).pipe(Layer.provide(ClaudeTextGenerationLive));86).pipe(Layer.provide(ClaudeTextGenerationLive));
75 87
88const InternalCursorLayer = Layer.effect(
89 CursorTextGen,
90 Effect.gen(function* () {
91 const svc = yield* TextGeneration;
92 return svc;
93 }),
94).pipe(Layer.provide(CursorTextGenerationLive));
95
76const InternalOpenCodeLayer = Layer.effect(96const InternalOpenCodeLayer = Layer.effect(
77 OpenCodeTextGen,97 OpenCodeTextGen,
78 Effect.gen(function* () {98 Effect.gen(function* () {
@@ -87,5 +107,6 @@ export const RoutingTextGenerationLive = Layer.effect( · 8 unmodified lines
87).pipe(107).pipe(
88 Layer.provide(InternalCodexLayer),108 Layer.provide(InternalCodexLayer),
89 Layer.provide(InternalClaudeLayer),109 Layer.provide(InternalClaudeLayer),
110 Layer.provide(InternalCursorLayer),
90 Layer.provide(InternalOpenCodeLayer),111 Layer.provide(InternalOpenCodeLayer),
91);112);
apps/server/src/git/Services/TextGeneration.ts+1−1
@@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; · 12 unmodified lines
13import type { TextGenerationError } from "@t3tools/contracts";13import type { TextGenerationError } from "@t3tools/contracts";
14 14
15/** Providers that support git text generation (commit messages, PR content, branch names). */15/** Providers that support git text generation (commit messages, PR content, branch names). */
16export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";16export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
17 17
18export interface CommitMessageGenerationInput {18export interface CommitMessageGenerationInput {
19 cwd: string;19 cwd: string;
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1−1
@@ -266,7 +266,7 @@ describe("ProviderCommandReactor", () => { · 265 unmodified lines
266 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),266 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
267 Layer.provideMerge(NodeServices.layer),267 Layer.provideMerge(NodeServices.layer),
268 );268 );
269 const runtime = ManagedRuntime.make(layer);269 runtime = ManagedRuntime.make(layer);
270 270
271 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));271 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
272 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));272 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+101−22
@@ -89,9 +89,16 @@ function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolea · 88 unmodified lines
89 : false;89 : false;
90}90}
91 91
92function findProviderAdapterRequestError(
93 cause: Cause.Cause<ProviderServiceError>,
94): ProviderAdapterRequestError | undefined {
95 const failReason = cause.reasons.find(Cause.isFailReason);
96 return Schema.is(ProviderAdapterRequestError)(failReason?.error) ? failReason.error : undefined;
97}
98
92function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {99function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
93 const error = Cause.squash(cause);100 const error = findProviderAdapterRequestError(cause);
94 if (Schema.is(ProviderAdapterRequestError)(error)) {101 if (error) {
95 const detail = error.detail.toLowerCase();102 const detail = error.detail.toLowerCase();
96 return (103 return (
97 detail.includes("unknown pending approval request") ||104 detail.includes("unknown pending approval request") ||
@@ -106,8 +113,8 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService · 8 unmodified lines
106}113}
107 114
108function isUnknownPendingUserInputRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {115function isUnknownPendingUserInputRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
109 const error = Cause.squash(cause);116 const error = findProviderAdapterRequestError(cause);
110 if (Schema.is(ProviderAdapterRequestError)(error)) {117 if (error) {
111 return error.detail.toLowerCase().includes("unknown pending user-input request");118 return error.detail.toLowerCase().includes("unknown pending user-input request");
112 }119 }
113 return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request");120 return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request");
@@ -198,6 +205,17 @@ const make = Effect.gen(function* () { · 84 unmodified lines
198 createdAt: input.createdAt,205 createdAt: input.createdAt,
199 });206 });
200 207
208 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {
209 const failReason = cause.reasons.find(Cause.isFailReason);
210 const providerError = Schema.is(ProviderAdapterRequestError)(failReason?.error)
211 ? failReason.error
212 : undefined;
213 if (providerError) {
214 return providerError.detail;
215 }
216 return Cause.pretty(cause);
217 };
218
201 const setThreadSession = (input: {219 const setThreadSession = (input: {
202 readonly threadId: ThreadId;220 readonly threadId: ThreadId;
203 readonly session: OrchestrationSession;221 readonly session: OrchestrationSession;
@@ -211,7 +229,30 @@ const make = Effect.gen(function* () { · 7 unmodified lines
211 createdAt: input.createdAt,229 createdAt: input.createdAt,
212 });230 });
213 231
214 const resolveThread = Effect.fn("resolveThread")(function* (threadId: ThreadId) {232 const setThreadSessionErrorOnTurnStartFailure = Effect.fnUntraced(function* (input: {
233 readonly threadId: ThreadId;
234 readonly detail: string;
235 readonly createdAt: string;
236 }) {
237 const thread = yield* resolveThread(input.threadId);
238 const session = thread?.session;
239 if (!session) {
240 return;
241 }
242 yield* setThreadSession({
243 threadId: input.threadId,
244 session: {
245 ...session,
246 status: session.status === "stopped" ? "stopped" : "ready",
247 activeTurnId: null,
248 lastError: input.detail,
249 updatedAt: input.createdAt,
250 },
251 createdAt: input.createdAt,
252 });
253 });
254
255 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
215 const readModel = yield* orchestrationEngine.getReadModel();256 const readModel = yield* orchestrationEngine.getReadModel();
216 return readModel.threads.find((entry) => entry.id === threadId);257 return readModel.threads.find((entry) => entry.id === threadId);
217 });258 });
@@ -300,7 +341,7 @@ const make = Effect.gen(function* () { · 82 unmodified lines
300 const modelChanged =341 const modelChanged =
301 requestedModelSelection !== undefined &&342 requestedModelSelection !== undefined &&
302 requestedModelSelection.model !== activeSession?.model;343 requestedModelSelection.model !== activeSession?.model;
303 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";344 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported";
304 const previousModelSelection = threadModelSelections.get(threadId);345 const previousModelSelection = threadModelSelections.get(threadId);
305 const shouldRestartForModelSelectionChange =346 const shouldRestartForModelSelectionChange =
306 currentProvider === "claudeAgent" &&347 currentProvider === "claudeAgent" &&
@@ -350,7 +391,7 @@ const make = Effect.gen(function* () { · 43 unmodified lines
350 return startedSession.threadId;391 return startedSession.threadId;
351 });392 });
352 393
353 const sendTurnForThread = Effect.fn("sendTurnForThread")(function* (input: {394 const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: {
354 readonly threadId: ThreadId;395 readonly threadId: ThreadId;
355 readonly messageText: string;396 readonly messageText: string;
356 readonly attachments?: ReadonlyArray<ChatAttachment>;397 readonly attachments?: ReadonlyArray<ChatAttachment>;
@@ -360,7 +401,9 @@ const make = Effect.gen(function* () { · 3 unmodified lines
360 }) {401 }) {
361 const thread = yield* resolveThread(input.threadId);402 const thread = yield* resolveThread(input.threadId);
362 if (!thread) {403 if (!thread) {
363 return;404 return yield* Effect.die(
405 new Error(`Thread '${input.threadId}' was not found in read model.`),
406 );
364 }407 }
365 yield* ensureSessionForThread(408 yield* ensureSessionForThread(
366 input.threadId,409 input.threadId,
@@ -384,7 +427,7 @@ const make = Effect.gen(function* () { · 17 unmodified lines
384 const requestedModelSelection =427 const requestedModelSelection =
385 input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection;428 input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection;
386 const modelForTurn =429 const modelForTurn =
387 sessionModelSwitch === "unsupported"430 sessionModelSwitch === "unsupported" && input.modelSelection === undefined
388 ? activeSession?.model !== undefined431 ? activeSession?.model !== undefined
389 ? {432 ? {
390 ...requestedModelSelection,433 ...requestedModelSelection,
@@ -393,13 +436,13 @@ const make = Effect.gen(function* () { · 2 unmodified lines
393 : requestedModelSelection436 : requestedModelSelection
394 : input.modelSelection;437 : input.modelSelection;
395 438
396 yield* providerService.sendTurn({439 return {
397 threadId: input.threadId,440 threadId: input.threadId,
398 ...(normalizedInput ? { input: normalizedInput } : {}),441 ...(normalizedInput ? { input: normalizedInput } : {}),
399 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),442 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
400 ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),443 ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
401 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),444 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
402 });445 };
403 });446 });
404 447
405 const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fn(448 const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fn(
@@ -558,7 +601,43 @@ const make = Effect.gen(function* () { · 152 unmodified lines
558 }601 }
559 }602 }
560 603
561 yield* sendTurnForThread({604 const handleTurnStartFailure = (cause: Cause.Cause<unknown>) => {
605 if (Cause.hasInterruptsOnly(cause)) {
606 return Effect.void;
607 }
608 const detail = formatFailureDetail(cause);
609 return setThreadSessionErrorOnTurnStartFailure({
610 threadId: event.payload.threadId,
611 detail,
612 createdAt: event.payload.createdAt,
613 }).pipe(
614 Effect.flatMap(() =>
615 appendProviderFailureActivity({
616 threadId: event.payload.threadId,
617 kind: "provider.turn.start.failed",
618 summary: "Provider turn start failed",
619 detail,
620 turnId: null,
621 createdAt: event.payload.createdAt,
622 }),
623 ),
624 Effect.asVoid,
625 );
626 };
627
628 const recoverTurnStartFailure = (cause: Cause.Cause<unknown>) =>
629 handleTurnStartFailure(cause).pipe(
630 Effect.catchCause((recoveryCause) =>
631 Effect.logWarning("provider command reactor failed to recover turn start failure", {
632 eventType: event.type,
633 threadId: event.payload.threadId,
634 cause: Cause.pretty(recoveryCause),
635 originalCause: Cause.pretty(cause),
636 }),
637 ),
638 );
639
640 const sendTurnRequest = yield* buildSendTurnRequestForThread({
562 threadId: event.payload.threadId,641 threadId: event.payload.threadId,
563 messageText: message.text,642 messageText: message.text,
564 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),643 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
@@ -568,17 +647,17 @@ const make = Effect.gen(function* () { · 3 unmodified lines
568 interactionMode: event.payload.interactionMode,647 interactionMode: event.payload.interactionMode,
569 createdAt: event.payload.createdAt,648 createdAt: event.payload.createdAt,
570 }).pipe(649 }).pipe(
571 Effect.catchCause((cause) =>650 Effect.map(Option.some),
572 appendProviderFailureActivity({651 Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))),
573 threadId: event.payload.threadId,
574 kind: "provider.turn.start.failed",
575 summary: "Provider turn start failed",
576 detail: Cause.pretty(cause),
577 turnId: null,
578 createdAt: event.payload.createdAt,
579 }),
580 ),
581 );652 );
653
654 if (Option.isNone(sendTurnRequest)) {
655 return;
656 }
657
658 yield* providerService
659 .sendTurn(sendTurnRequest.value)
660 .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
582 });661 });
583 662
584 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (663 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+566−0
@@ -723,6 +723,146 @@ describe("ProviderRuntimeIngestion", () => { · 722 unmodified lines
723 expect(message?.streaming).toBe(false);723 expect(message?.streaming).toBe(false);
724 });724 });
725 725
726 it("preserves completed tool metadata on projected tool activities", async () => {
727 const harness = await createHarness();
728 const now = new Date().toISOString();
729
730 harness.emit({
731 type: "item.completed",
732 eventId: asEventId("evt-tool-completed-with-data"),
733 provider: "cursor",
734 createdAt: now,
735 threadId: asThreadId("thread-1"),
736 turnId: asTurnId("turn-tool-completed"),
737 itemId: asItemId("item-tool-completed"),
738 payload: {
739 itemType: "dynamic_tool_call",
740 status: "completed",
741 title: "Read file",
742 data: {
743 toolCallId: "tool-read-1",
744 kind: "read",
745 rawOutput: {
746 content: 'import * as Effect from "effect/Effect"\n',
747 },
748 },
749 },
750 });
751
752 const thread = await waitForThread(harness.engine, (entry) =>
753 entry.activities.some(
754 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-completed-with-data",
755 ),
756 );
757 const activity = thread.activities.find(
758 (entry: ProviderRuntimeTestActivity) => entry.id === "evt-tool-completed-with-data",
759 );
760 const payload =
761 activity?.payload && typeof activity.payload === "object"
762 ? (activity.payload as Record<string, unknown>)
763 : undefined;
764 const data =
765 payload?.data && typeof payload.data === "object"
766 ? (payload.data as Record<string, unknown>)
767 : undefined;
768 const rawOutput =
769 data?.rawOutput && typeof data.rawOutput === "object"
770 ? (data.rawOutput as Record<string, unknown>)
771 : undefined;
772
773 expect(activity?.kind).toBe("tool.completed");
774 expect(activity?.summary).toBe("Read file");
775 expect(payload?.itemType).toBe("dynamic_tool_call");
776 expect(payload?.detail).toBeUndefined();
777 expect(data?.toolCallId).toBe("tool-read-1");
778 expect(data?.kind).toBe("read");
779 expect(rawOutput?.content).toBe('import * as Effect from "effect/Effect"\n');
780 });
781
782 it("normalizes command execution activities to ran-command summaries", async () => {
783 const harness = await createHarness();
784 const now = new Date().toISOString();
785
786 harness.emit({
787 type: "item.completed",
788 eventId: asEventId("evt-command-completed"),
789 provider: "cursor",
790 createdAt: now,
791 threadId: asThreadId("thread-1"),
792 turnId: asTurnId("turn-command-completed"),
793 itemId: asItemId("item-command-completed"),
794 payload: {
795 itemType: "command_execution",
796 status: "completed",
797 title: "Ran command",
798 detail: "bun run lint",
799 data: {
800 toolCallId: "tool-command-1",
801 kind: "execute",
802 command: "bun run lint",
803 },
804 },
805 });
806
807 const thread = await waitForThread(harness.engine, (entry) =>
808 entry.activities.some(
809 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-completed",
810 ),
811 );
812 const activity = thread.activities.find(
813 (entry: ProviderRuntimeTestActivity) => entry.id === "evt-command-completed",
814 );
815 const payload =
816 activity?.payload && typeof activity.payload === "object"
817 ? (activity.payload as Record<string, unknown>)
818 : undefined;
819
820 expect(activity?.summary).toBe("Ran command");
821 expect(payload?.detail).toBe("bun run lint");
822 });
823
824 it("uses structured read-file paths when available", async () => {
825 const harness = await createHarness();
826 const now = new Date().toISOString();
827
828 harness.emit({
829 type: "item.completed",
830 eventId: asEventId("evt-read-path-completed"),
831 provider: "cursor",
832 createdAt: now,
833 threadId: asThreadId("thread-1"),
834 turnId: asTurnId("turn-read-path"),
835 itemId: asItemId("item-read-path"),
836 payload: {
837 itemType: "dynamic_tool_call",
838 status: "completed",
839 title: "Read file",
840 detail: "/tmp/app.ts",
841 data: {
842 toolCallId: "tool-read-path-1",
843 kind: "read",
844 locations: [{ path: "/tmp/app.ts" }],
845 },
846 },
847 });
848
849 const thread = await waitForThread(harness.engine, (entry) =>
850 entry.activities.some(
851 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-read-path-completed",
852 ),
853 );
854 const activity = thread.activities.find(
855 (entry: ProviderRuntimeTestActivity) => entry.id === "evt-read-path-completed",
856 );
857 const payload =
858 activity?.payload && typeof activity.payload === "object"
859 ? (activity.payload as Record<string, unknown>)
860 : undefined;
861
862 expect(activity?.summary).toBe("Read file");
863 expect(payload?.detail).toBe("/tmp/app.ts");
864 });
865
726 it("projects completed plan items into first-class proposed plans", async () => {866 it("projects completed plan items into first-class proposed plans", async () => {
727 const harness = await createHarness();867 const harness = await createHarness();
728 const now = new Date().toISOString();868 const now = new Date().toISOString();
@@ -1392,6 +1532,432 @@ describe("ProviderRuntimeIngestion", () => { · 663 unmodified lines
1392 expect(message?.streaming).toBe(false);1532 expect(message?.streaming).toBe(false);
1393 });1533 });
1394 1534
1535 it("flushes and completes buffered assistant text when an approval request opens", async () => {
1536 const harness = await createHarness();
1537 const now = new Date().toISOString();
1538
1539 harness.emit({
1540 type: "turn.started",
1541 eventId: asEventId("evt-turn-started-buffered-request-flush"),
1542 provider: "codex",
1543 createdAt: now,
1544 threadId: asThreadId("thread-1"),
1545 turnId: asTurnId("turn-buffered-request-flush"),
1546 });
1547 await waitForThread(
1548 harness.engine,
1549 (thread) =>
1550 thread.session?.status === "running" &&
1551 thread.session?.activeTurnId === "turn-buffered-request-flush",
1552 );
1553
1554 harness.emit({
1555 type: "content.delta",
1556 eventId: asEventId("evt-message-delta-buffered-request-flush"),
1557 provider: "codex",
1558 createdAt: now,
1559 threadId: asThreadId("thread-1"),
1560 turnId: asTurnId("turn-buffered-request-flush"),
1561 itemId: asItemId("item-buffered-request-flush"),
1562 payload: {
1563 streamKind: "assistant_text",
1564 delta: "visible before approval",
1565 },
1566 });
1567 harness.emit({
1568 type: "request.opened",
1569 eventId: asEventId("evt-request-opened-buffered-request-flush"),
1570 provider: "codex",
1571 createdAt: now,
1572 threadId: asThreadId("thread-1"),
1573 turnId: asTurnId("turn-buffered-request-flush"),
1574 requestId: ApprovalRequestId.make("req-buffered-request-flush"),
1575 payload: {
1576 requestType: "command_execution_approval",
1577 detail: "pwd",
1578 },
1579 });
1580
1581 const thread = await waitForThread(harness.engine, (entry) =>
1582 entry.messages.some(
1583 (message: ProviderRuntimeTestMessage) =>
1584 message.id === "assistant:item-buffered-request-flush" &&
1585 !message.streaming &&
1586 message.text === "visible before approval",
1587 ),
1588 );
1589 const message = thread.messages.find(
1590 (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-flush",
1591 );
1592 expect(message?.streaming).toBe(false);
1593 });
1594
1595 it("flushes and completes buffered assistant text when user input is requested", async () => {
1596 const harness = await createHarness();
1597 const now = new Date().toISOString();
1598
1599 harness.emit({
1600 type: "turn.started",
1601 eventId: asEventId("evt-turn-started-buffered-user-input-flush"),
1602 provider: "codex",
1603 createdAt: now,
1604 threadId: asThreadId("thread-1"),
1605 turnId: asTurnId("turn-buffered-user-input-flush"),
1606 });
1607 await waitForThread(
1608 harness.engine,
1609 (thread) =>
1610 thread.session?.status === "running" &&
1611 thread.session?.activeTurnId === "turn-buffered-user-input-flush",
1612 );
1613
1614 harness.emit({
1615 type: "content.delta",
1616 eventId: asEventId("evt-message-delta-buffered-user-input-flush"),
1617 provider: "codex",
1618 createdAt: now,
1619 threadId: asThreadId("thread-1"),
1620 turnId: asTurnId("turn-buffered-user-input-flush"),
1621 itemId: asItemId("item-buffered-user-input-flush"),
1622 payload: {
1623 streamKind: "assistant_text",
1624 delta: "visible before user input",
1625 },
1626 });
1627 harness.emit({
1628 type: "user-input.requested",
1629 eventId: asEventId("evt-user-input-requested-buffered-user-input-flush"),
1630 provider: "codex",
1631 createdAt: now,
1632 threadId: asThreadId("thread-1"),
1633 turnId: asTurnId("turn-buffered-user-input-flush"),
1634 requestId: ApprovalRequestId.make("req-buffered-user-input-flush"),
1635 payload: {
1636 questions: [
1637 {
1638 id: "choice",
1639 header: "Choice",
1640 question: "Pick one",
1641 options: [{ label: "A", description: "Option A" }],
1642 },
1643 ],
1644 },
1645 });
1646
1647 const thread = await waitForThread(harness.engine, (entry) =>
1648 entry.messages.some(
1649 (message: ProviderRuntimeTestMessage) =>
1650 message.id === "assistant:item-buffered-user-input-flush" &&
1651 !message.streaming &&
1652 message.text === "visible before user input",
1653 ),
1654 );
1655 const message = thread.messages.find(
1656 (entry: ProviderRuntimeTestMessage) =>
1657 entry.id === "assistant:item-buffered-user-input-flush",
1658 );
1659 expect(message?.streaming).toBe(false);
1660 });
1661
1662 it("does not create assistant segments for whitespace-only buffered text at approval boundaries", async () => {
1663 const harness = await createHarness();
1664 const startedAt = "2026-03-28T06:28:00.000Z";
1665 const pausedAt = "2026-03-28T06:28:01.000Z";
1666
1667 harness.emit({
1668 type: "turn.started",
1669 eventId: asEventId("evt-turn-started-buffered-whitespace-request"),
1670 provider: "codex",
1671 createdAt: startedAt,
1672 threadId: asThreadId("thread-1"),
1673 turnId: asTurnId("turn-buffered-whitespace-request"),
1674 });
1675 await waitForThread(
1676 harness.engine,
1677 (thread) =>
1678 thread.session?.status === "running" &&
1679 thread.session?.activeTurnId === "turn-buffered-whitespace-request",
1680 );
1681
1682 harness.emit({
1683 type: "content.delta",
1684 eventId: asEventId("evt-message-delta-buffered-whitespace-request"),
1685 provider: "codex",
1686 createdAt: startedAt,
1687 threadId: asThreadId("thread-1"),
1688 turnId: asTurnId("turn-buffered-whitespace-request"),
1689 itemId: asItemId("item-buffered-whitespace-request"),
1690 payload: {
1691 streamKind: "assistant_text",
1692 delta: "\n\n\n",
1693 },
1694 });
1695 harness.emit({
1696 type: "request.opened",
1697 eventId: asEventId("evt-request-opened-buffered-whitespace-request"),
1698 provider: "codex",
1699 createdAt: pausedAt,
1700 threadId: asThreadId("thread-1"),
1701 turnId: asTurnId("turn-buffered-whitespace-request"),
1702 requestId: ApprovalRequestId.make("req-buffered-whitespace-request"),
1703 payload: {
1704 requestType: "command_execution_approval",
1705 detail: "pwd",
1706 },
1707 });
1708
1709 const thread = await waitForThread(harness.engine, (entry) =>
1710 entry.activities.some(
1711 (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested",
1712 ),
1713 );
1714 expect(
1715 thread.messages.some(
1716 (message: ProviderRuntimeTestMessage) =>
1717 message.id === "assistant:item-buffered-whitespace-request",
1718 ),
1719 ).toBe(false);
1720 });
1721
1722 it("starts a new buffered assistant message segment after approval and completes without duplication", async () => {
1723 const harness = await createHarness();
1724 const startedAt = "2026-03-28T06:07:00.000Z";
1725 const pausedAt = "2026-03-28T06:07:01.000Z";
1726 const resumedAt = "2026-03-28T06:07:02.000Z";
1727 const completedAt = "2026-03-28T06:07:03.000Z";
1728
1729 harness.emit({
1730 type: "turn.started",
1731 eventId: asEventId("evt-turn-started-buffered-request-append"),
1732 provider: "codex",
1733 createdAt: startedAt,
1734 threadId: asThreadId("thread-1"),
1735 turnId: asTurnId("turn-buffered-request-append"),
1736 });
1737 await waitForThread(
1738 harness.engine,
1739 (thread) =>
1740 thread.session?.status === "running" &&
1741 thread.session?.activeTurnId === "turn-buffered-request-append",
1742 );
1743
1744 harness.emit({
1745 type: "content.delta",
1746 eventId: asEventId("evt-message-delta-buffered-request-append-initial"),
1747 provider: "codex",
1748 createdAt: startedAt,
1749 threadId: asThreadId("thread-1"),
1750 turnId: asTurnId("turn-buffered-request-append"),
1751 itemId: asItemId("item-buffered-request-append"),
1752 payload: {
1753 streamKind: "assistant_text",
1754 delta: "first half",
1755 },
1756 });
1757 harness.emit({
1758 type: "request.opened",
1759 eventId: asEventId("evt-request-opened-buffered-request-append"),
1760 provider: "codex",
1761 createdAt: pausedAt,
1762 threadId: asThreadId("thread-1"),
1763 turnId: asTurnId("turn-buffered-request-append"),
1764 requestId: ApprovalRequestId.make("req-buffered-request-append"),
1765 payload: {
1766 requestType: "command_execution_approval",
1767 detail: "pwd",
1768 },
1769 });
1770
1771 await waitForThread(harness.engine, (entry) =>
1772 entry.messages.some(
1773 (message: ProviderRuntimeTestMessage) =>
1774 message.id === "assistant:item-buffered-request-append" &&
1775 !message.streaming &&
1776 message.text === "first half",
1777 ),
1778 );
1779
1780 harness.emit({
1781 type: "content.delta",
1782 eventId: asEventId("evt-message-delta-buffered-request-append-followup"),
1783 provider: "codex",
1784 createdAt: resumedAt,
1785 threadId: asThreadId("thread-1"),
1786 turnId: asTurnId("turn-buffered-request-append"),
1787 itemId: asItemId("item-buffered-request-append"),
1788 payload: {
1789 streamKind: "assistant_text",
1790 delta: " second half",
1791 },
1792 });
1793 harness.emit({
1794 type: "item.completed",
1795 eventId: asEventId("evt-message-completed-buffered-request-append"),
1796 provider: "codex",
1797 createdAt: completedAt,
1798 threadId: asThreadId("thread-1"),
1799 turnId: asTurnId("turn-buffered-request-append"),
1800 itemId: asItemId("item-buffered-request-append"),
1801 payload: {
1802 itemType: "assistant_message",
1803 status: "completed",
1804 },
1805 });
1806
1807 const thread = await waitForThread(harness.engine, (entry) =>
1808 entry.messages.some(
1809 (message: ProviderRuntimeTestMessage) =>
1810 message.id === "assistant:item-buffered-request-append:segment:1" &&
1811 !message.streaming &&
1812 message.text === " second half",
1813 ),
1814 );
1815 const firstMessage = thread.messages.find(
1816 (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-buffered-request-append",
1817 );
1818 const resumedMessage = thread.messages.find(
1819 (entry: ProviderRuntimeTestMessage) =>
1820 entry.id === "assistant:item-buffered-request-append:segment:1",
1821 );
1822 expect(firstMessage?.text).toBe("first half");
1823 expect(firstMessage?.streaming).toBe(false);
1824 expect(resumedMessage?.text).toBe(" second half");
1825 expect(resumedMessage?.streaming).toBe(false);
1826
1827 const events = await Effect.runPromise(
1828 Stream.runCollect(harness.engine.readEvents(0)).pipe(
1829 Effect.map((chunk) => Array.from(chunk)),
1830 ),
1831 );
1832 const assistantEvents = events.filter(
1833 (event): event is Extract<(typeof events)[number], { type: "thread.message-sent" }> =>
1834 event.type === "thread.message-sent" &&
1835 event.payload.messageId.startsWith("assistant:item-buffered-request-append"),
1836 );
1837 expect(assistantEvents).toHaveLength(4);
1838 expect(assistantEvents[0]?.payload.streaming).toBe(true);
1839 expect(assistantEvents[0]?.payload.text).toBe("first half");
1840 expect(assistantEvents[1]?.payload.streaming).toBe(false);
1841 expect(assistantEvents[1]?.payload.text).toBe("");
1842 expect(assistantEvents[2]?.payload.messageId).toBe(
1843 "assistant:item-buffered-request-append:segment:1",
1844 );
1845 expect(assistantEvents[2]?.payload.streaming).toBe(true);
1846 expect(assistantEvents[2]?.payload.text).toBe(" second half");
1847 expect(assistantEvents[3]?.payload.messageId).toBe(
1848 "assistant:item-buffered-request-append:segment:1",
1849 );
1850 expect(assistantEvents[3]?.payload.streaming).toBe(false);
1851 expect(assistantEvents[3]?.payload.text).toBe("");
1852 });
1853
1854 it("starts a new streaming assistant message segment after approval", async () => {
1855 const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } });
1856 const startedAt = "2026-03-28T07:00:00.000Z";
1857 const pausedAt = "2026-03-28T07:00:01.000Z";
1858 const resumedAt = "2026-03-28T07:00:02.000Z";
1859 const completedAt = "2026-03-28T07:00:03.000Z";
1860
1861 harness.emit({
1862 type: "turn.started",
1863 eventId: asEventId("evt-turn-started-streaming-request-segment"),
1864 provider: "codex",
1865 createdAt: startedAt,
1866 threadId: asThreadId("thread-1"),
1867 turnId: asTurnId("turn-streaming-request-segment"),
1868 });
1869 await waitForThread(
1870 harness.engine,
1871 (thread) =>
1872 thread.session?.status === "running" &&
1873 thread.session?.activeTurnId === "turn-streaming-request-segment",
1874 );
1875
1876 harness.emit({
1877 type: "content.delta",
1878 eventId: asEventId("evt-message-delta-streaming-request-segment-initial"),
1879 provider: "codex",
1880 createdAt: startedAt,
1881 threadId: asThreadId("thread-1"),
1882 turnId: asTurnId("turn-streaming-request-segment"),
1883 itemId: asItemId("item-streaming-request-segment"),
1884 payload: {
1885 streamKind: "assistant_text",
1886 delta: "before approval",
1887 },
1888 });
1889 harness.emit({
1890 type: "request.opened",
1891 eventId: asEventId("evt-request-opened-streaming-request-segment"),
1892 provider: "codex",
1893 createdAt: pausedAt,
1894 threadId: asThreadId("thread-1"),
1895 turnId: asTurnId("turn-streaming-request-segment"),
1896 requestId: ApprovalRequestId.make("req-streaming-request-segment"),
1897 payload: {
1898 requestType: "command_execution_approval",
1899 detail: "pwd",
1900 },
1901 });
1902
1903 await waitForThread(harness.engine, (entry) =>
1904 entry.messages.some(
1905 (message: ProviderRuntimeTestMessage) =>
1906 message.id === "assistant:item-streaming-request-segment" &&
1907 !message.streaming &&
1908 message.text === "before approval",
1909 ),
1910 );
1911
1912 harness.emit({
1913 type: "content.delta",
1914 eventId: asEventId("evt-message-delta-streaming-request-segment-followup"),
1915 provider: "codex",
1916 createdAt: resumedAt,
1917 threadId: asThreadId("thread-1"),
1918 turnId: asTurnId("turn-streaming-request-segment"),
1919 itemId: asItemId("item-streaming-request-segment"),
1920 payload: {
1921 streamKind: "assistant_text",
1922 delta: " after approval",
1923 },
1924 });
1925 harness.emit({
1926 type: "item.completed",
1927 eventId: asEventId("evt-message-completed-streaming-request-segment"),
1928 provider: "codex",
1929 createdAt: completedAt,
1930 threadId: asThreadId("thread-1"),
1931 turnId: asTurnId("turn-streaming-request-segment"),
1932 itemId: asItemId("item-streaming-request-segment"),
1933 payload: {
1934 itemType: "assistant_message",
1935 status: "completed",
1936 },
1937 });
1938
1939 const thread = await waitForThread(harness.engine, (entry) =>
1940 entry.messages.some(
1941 (message: ProviderRuntimeTestMessage) =>
1942 message.id === "assistant:item-streaming-request-segment:segment:1" &&
1943 !message.streaming &&
1944 message.text === " after approval",
1945 ),
1946 );
1947 expect(
1948 thread.messages.find(
1949 (message: ProviderRuntimeTestMessage) =>
1950 message.id === "assistant:item-streaming-request-segment",
1951 )?.text,
1952 ).toBe("before approval");
1953 expect(
1954 thread.messages.find(
1955 (message: ProviderRuntimeTestMessage) =>
1956 message.id === "assistant:item-streaming-request-segment:segment:1",
1957 )?.text,
1958 ).toBe(" after approval");
1959 });
1960
1395 it("streams assistant deltas when thread.turn.start requests streaming mode", async () => {1961 it("streams assistant deltas when thread.turn.start requests streaming mode", async () => {
1396 const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } });1962 const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } });
1397 const now = new Date().toISOString();1963 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+711−410
@@ -32,6 +32,12 @@ const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${t · 31 unmodified lines
32const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>32const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
33 CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);33 CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
34 34
35interface AssistantSegmentState {
36 baseKey: string;
37 nextSegmentIndex: number;
38 activeMessageId: MessageId | null;
39}
40
35const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;41const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
36const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);42const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);
37const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;43const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
@@ -83,6 +89,10 @@ function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string · 45 unmodified lines
83 return trimmed;89 return trimmed;
84}90}
85 91
92function hasRenderableAssistantText(text: string | undefined): boolean {
93 return (text?.trim().length ?? 0) > 0;
94}
95
86function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string {96function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string {
87 return `plan:${threadId}:turn:${turnId}`;97 return `plan:${threadId}:turn:${turnId}`;
88}98}
@@ -98,6 +108,15 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId · 9 unmodified lines
98 return `plan:${threadId}:event:${event.eventId}`;108 return `plan:${threadId}:event:${event.eventId}`;
99}109}
100 110
111function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string {
112 return String(event.itemId ?? event.turnId ?? event.eventId);
113}
114
115function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId {
116 return MessageId.make(
117 segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`,
118 );
119}
101function buildContextWindowActivityPayload(120function buildContextWindowActivityPayload(
102 event: ProviderRuntimeEvent,121 event: ProviderRuntimeEvent,
103): ThreadTokenUsageSnapshot | undefined {122): ThreadTokenUsageSnapshot | undefined {
@@ -501,7 +520,7 @@ function runtimeEventToActivities( · 397 unmodified lines
501 return [];520 return [];
502}521}
503 522
504const make = Effect.fn("make")(function* () {523const make = Effect.gen(function* () {
505 const orchestrationEngine = yield* OrchestrationEngineService;524 const orchestrationEngine = yield* OrchestrationEngineService;
506 const providerService = yield* ProviderService;525 const providerService = yield* ProviderService;
507 const projectionTurnRepository = yield* ProjectionTurnRepository;526 const projectionTurnRepository = yield* ProjectionTurnRepository;
@@ -519,6 +538,15 @@ const make = Effect.fn("make")(function* () { · 11 unmodified lines
519 lookup: () => Effect.succeed(""),538 lookup: () => Effect.succeed(""),
520 });539 });
521 540
541 const assistantSegmentStateByTurnKey = yield* Cache.make<string, AssistantSegmentState>({
542 capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,
543 timeToLive: TURN_MESSAGE_IDS_BY_TURN_TTL,
544 lookup: () =>
545 Effect.die(
546 new Error("assistant segment state should be read through getOption before initialization"),
547 ),
548 });
549
522 const bufferedProposedPlanById = yield* Cache.make<string, { text: string; createdAt: string }>({550 const bufferedProposedPlanById = yield* Cache.make<string, { text: string; createdAt: string }>({
523 capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY,551 capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY,
524 timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL,552 timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL,
@@ -586,10 +614,86 @@ const make = Effect.fn("make")(function* () { · 61 unmodified lines
586 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>614 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
587 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));615 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
588 616
617 const getAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) =>
618 Cache.getOption(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId));
619
620 const setAssistantSegmentStateForTurn = (
621 threadId: ThreadId,
622 turnId: TurnId,
623 state: AssistantSegmentState,
624 ) => Cache.set(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId), state);
625
626 const clearAssistantSegmentStateForTurn = (threadId: ThreadId, turnId: TurnId) =>
627 Cache.invalidate(assistantSegmentStateByTurnKey, providerTurnKey(threadId, turnId));
628
629 const getActiveAssistantMessageIdForTurn = (threadId: ThreadId, turnId: TurnId) =>
630 getAssistantSegmentStateForTurn(threadId, turnId).pipe(
631 Effect.map((state) =>
632 Option.flatMap(state, (entry) =>
633 entry.activeMessageId ? Option.some(entry.activeMessageId) : Option.none(),
634 ),
635 ),
636 );
637
638 const startAssistantSegmentForTurn = (input: {
639 threadId: ThreadId;
640 turnId: TurnId;
641 baseKey: string;
642 }) =>
643 getAssistantSegmentStateForTurn(input.threadId, input.turnId).pipe(
644 Effect.flatMap((existingState) =>
645 Effect.gen(function* () {
646 const nextState = Option.match(existingState, {
647 onNone: () => ({
648 baseKey: input.baseKey,
649 nextSegmentIndex: 1,
650 activeMessageId: assistantSegmentMessageId(input.baseKey, 0),
651 }),
652 onSome: (state) => {
653 const segmentIndex = state.baseKey === input.baseKey ? state.nextSegmentIndex : 0;
654 const messageId = assistantSegmentMessageId(input.baseKey, segmentIndex);
655 return {
656 baseKey: input.baseKey,
657 nextSegmentIndex: state.baseKey === input.baseKey ? state.nextSegmentIndex + 1 : 1,
658 activeMessageId: messageId,
659 } satisfies AssistantSegmentState;
660 },
661 });
662 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, nextState);
663 return nextState.activeMessageId!;
664 }),
665 ),
666 );
667
668 const getOrCreateAssistantMessageId = (input: {
669 threadId: ThreadId;
670 event: ProviderRuntimeEvent;
671 turnId?: TurnId;
672 }) =>
673 Effect.gen(function* () {
674 if (!input.turnId) {
675 return assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(input.event), 0);
676 }
677
678 const activeMessageId = yield* getActiveAssistantMessageIdForTurn(
679 input.threadId,
680 input.turnId,
681 );
682 if (Option.isSome(activeMessageId)) {
683 return activeMessageId.value;
684 }
685
686 return yield* startAssistantSegmentForTurn({
687 threadId: input.threadId,
688 turnId: input.turnId,
689 baseKey: assistantSegmentBaseKeyFromEvent(input.event),
690 });
691 });
692
589 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>693 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>
590 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(694 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
591 Effect.flatMap(695 Effect.flatMap((existingText) =>
592 Effect.fn("appendBufferedAssistantText")(function* (existingText) {696 Effect.gen(function* () {
593 const nextText = Option.match(existingText, {697 const nextText = Option.match(existingText, {
594 onNone: () => delta,698 onNone: () => delta,
595 onSome: (text) => `${text}${delta}`,699 onSome: (text) => `${text}${delta}`,
@@ -645,48 +749,154 @@ const make = Effect.fn("make")(function* () { · 49 unmodified lines
645 const clearAssistantMessageState = (messageId: MessageId) =>749 const clearAssistantMessageState = (messageId: MessageId) =>
646 clearBufferedAssistantText(messageId);750 clearBufferedAssistantText(messageId);
647 751
648 const finalizeAssistantMessage = Effect.fn("finalizeAssistantMessage")(function* (input: {752 const flushBufferedAssistantMessage = (input: {
649 event: ProviderRuntimeEvent;753 event: ProviderRuntimeEvent;
650 threadId: ThreadId;754 threadId: ThreadId;
651 messageId: MessageId;755 messageId: MessageId;
652 turnId?: TurnId;756 turnId?: TurnId;
653 createdAt: string;757 createdAt: string;
654 commandTag: string;758 commandTag: string;
655 finalDeltaCommandTag: string;759 }) =>
656 fallbackText?: string;760 Effect.gen(function* () {
657 }) {761 const bufferedText = yield* takeBufferedAssistantText(input.messageId);
658 const bufferedText = yield* takeBufferedAssistantText(input.messageId);762 if (!hasRenderableAssistantText(bufferedText)) {
659 const text =763 return false;
660 bufferedText.length > 0764 }
661 ? bufferedText765
662 : (input.fallbackText?.trim().length ?? 0) > 0
663 ? input.fallbackText!
664 : "";
665
666 if (text.length > 0) {
667 yield* orchestrationEngine.dispatch({766 yield* orchestrationEngine.dispatch({
668 type: "thread.message.assistant.delta",767 type: "thread.message.assistant.delta",
669 commandId: providerCommandId(input.event, input.finalDeltaCommandTag),768 commandId: providerCommandId(input.event, input.commandTag),
670 threadId: input.threadId,769 threadId: input.threadId,
671 messageId: input.messageId,770 messageId: input.messageId,
672 delta: text,771 delta: bufferedText,
673 ...(input.turnId ? { turnId: input.turnId } : {}),772 ...(input.turnId ? { turnId: input.turnId } : {}),
674 createdAt: input.createdAt,773 createdAt: input.createdAt,
675 });774 });
676 }775 return true;
776 });
677 777
678 yield* orchestrationEngine.dispatch({778 const flushBufferedAssistantMessagesForTurn = (input: {
679 type: "thread.message.assistant.complete",779 event: ProviderRuntimeEvent;
680 commandId: providerCommandId(input.event, input.commandTag),780 threadId: ThreadId;
681 threadId: input.threadId,781 turnId: TurnId;
682 messageId: input.messageId,782 createdAt: string;
683 ...(input.turnId ? { turnId: input.turnId } : {}),783 commandTag: string;
684 createdAt: input.createdAt,784 }) =>
785 Effect.gen(function* () {
786 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(
787 input.threadId,
788 input.turnId,
789 );
790 const flushedMessageIds = new Set<MessageId>();
791 yield* Effect.forEach(
792 assistantMessageIds,
793 (messageId) =>
794 flushBufferedAssistantMessage({
795 event: input.event,
796 threadId: input.threadId,
797 messageId,
798 turnId: input.turnId,
799 createdAt: input.createdAt,
800 commandTag: input.commandTag,
801 }).pipe(
802 Effect.tap((flushed) =>
803 flushed ? Effect.sync(() => flushedMessageIds.add(messageId)) : Effect.void,
804 ),
805 ),
806 { concurrency: 1 },
807 ).pipe(Effect.asVoid);
808 return flushedMessageIds;
685 });809 });
686 yield* clearAssistantMessageState(input.messageId);
687 });
688 810
689 const upsertProposedPlan = Effect.fn("upsertProposedPlan")(function* (input: {811 const finalizeAssistantMessage = (input: {
812 event: ProviderRuntimeEvent;
813 threadId: ThreadId;
814 messageId: MessageId;
815 turnId?: TurnId;
816 createdAt: string;
817 commandTag: string;
818 finalDeltaCommandTag: string;
819 fallbackText?: string;
820 hasProjectedMessage?: boolean;
821 }) =>
822 Effect.gen(function* () {
823 const bufferedText = yield* takeBufferedAssistantText(input.messageId);
824 const text =
825 bufferedText.length > 0
826 ? bufferedText
827 : (input.fallbackText?.trim().length ?? 0) > 0
828 ? input.fallbackText!
829 : "";
830 const hasRenderableText = hasRenderableAssistantText(text);
831
832 if (hasRenderableText) {
833 yield* orchestrationEngine.dispatch({
834 type: "thread.message.assistant.delta",
835 commandId: providerCommandId(input.event, input.finalDeltaCommandTag),
836 threadId: input.threadId,
837 messageId: input.messageId,
838 delta: text,
839 ...(input.turnId ? { turnId: input.turnId } : {}),
840 createdAt: input.createdAt,
841 });
842 }
843
844 if (input.hasProjectedMessage || hasRenderableText) {
845 yield* orchestrationEngine.dispatch({
846 type: "thread.message.assistant.complete",
847 commandId: providerCommandId(input.event, input.commandTag),
848 threadId: input.threadId,
849 messageId: input.messageId,
850 ...(input.turnId ? { turnId: input.turnId } : {}),
851 createdAt: input.createdAt,
852 });
853 }
854 yield* clearAssistantMessageState(input.messageId);
855 });
856
857 const finalizeActiveAssistantSegmentForTurn = (input: {
858 event: ProviderRuntimeEvent;
859 threadId: ThreadId;
860 turnId: TurnId;
861 createdAt: string;
862 commandTag: string;
863 finalDeltaCommandTag: string;
864 hasProjectedMessage: boolean;
865 flushedMessageIds?: ReadonlySet<MessageId>;
866 }) =>
867 Effect.gen(function* () {
868 const activeMessageId = yield* getActiveAssistantMessageIdForTurn(
869 input.threadId,
870 input.turnId,
871 );
872 if (Option.isNone(activeMessageId)) {
873 return;
874 }
875
876 yield* finalizeAssistantMessage({
877 event: input.event,
878 threadId: input.threadId,
879 messageId: activeMessageId.value,
880 turnId: input.turnId,
881 createdAt: input.createdAt,
882 commandTag: input.commandTag,
883 finalDeltaCommandTag: input.finalDeltaCommandTag,
884 hasProjectedMessage:
885 input.hasProjectedMessage ||
886 (input.flushedMessageIds?.has(activeMessageId.value) ?? false),
887 });
888 yield* forgetAssistantMessageId(input.threadId, input.turnId, activeMessageId.value);
889
890 const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId);
891 if (Option.isSome(state)) {
892 yield* setAssistantSegmentStateForTurn(input.threadId, input.turnId, {
893 ...state.value,
894 activeMessageId: null,
895 });
896 }
897 });
898
899 const upsertProposedPlan = (input: {
690 event: ProviderRuntimeEvent;900 event: ProviderRuntimeEvent;
691 threadId: ThreadId;901 threadId: ThreadId;
692 threadProposedPlans: ReadonlyArray<{902 threadProposedPlans: ReadonlyArray<{
@@ -700,31 +910,32 @@ const make = Effect.fn("make")(function* () { · 7 unmodified lines
700 planMarkdown: string | undefined;910 planMarkdown: string | undefined;
701 createdAt: string;911 createdAt: string;
702 updatedAt: string;912 updatedAt: string;
703 }) {913 }) =>
704 const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown);914 Effect.gen(function* () {
705 if (!planMarkdown) {915 const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown);
706 return;916 if (!planMarkdown) {
707 }917 return;
918 }
708 919
709 const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId);920 const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId);
710 yield* orchestrationEngine.dispatch({921 yield* orchestrationEngine.dispatch({
711 type: "thread.proposed-plan.upsert",922 type: "thread.proposed-plan.upsert",
712 commandId: providerCommandId(input.event, "proposed-plan-upsert"),923 commandId: providerCommandId(input.event, "proposed-plan-upsert"),
713 threadId: input.threadId,924 threadId: input.threadId,
714 proposedPlan: {925 proposedPlan: {
715 id: input.planId,926 id: input.planId,
716 turnId: input.turnId ?? null,927 turnId: input.turnId ?? null,
717 planMarkdown,928 planMarkdown,
718 implementedAt: existingPlan?.implementedAt ?? null,929 implementedAt: existingPlan?.implementedAt ?? null,
719 implementationThreadId: existingPlan?.implementationThreadId ?? null,930 implementationThreadId: existingPlan?.implementationThreadId ?? null,
720 createdAt: existingPlan?.createdAt ?? input.createdAt,931 createdAt: existingPlan?.createdAt ?? input.createdAt,
721 updatedAt: input.updatedAt,932 updatedAt: input.updatedAt,
722 },933 },
723 createdAt: input.updatedAt,934 createdAt: input.updatedAt,
935 });
724 });936 });
725 });
726 937
727 const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: {938 const finalizeBufferedProposedPlan = (input: {
728 event: ProviderRuntimeEvent;939 event: ProviderRuntimeEvent;
729 threadId: ThreadId;940 threadId: ThreadId;
730 threadProposedPlans: ReadonlyArray<{941 threadProposedPlans: ReadonlyArray<{
@@ -737,65 +948,75 @@ const make = Effect.fn("make")(function* () { · 6 unmodified lines
737 turnId?: TurnId;948 turnId?: TurnId;
738 fallbackMarkdown?: string;949 fallbackMarkdown?: string;
739 updatedAt: string;950 updatedAt: string;
740 }) {951 }) =>
741 const bufferedPlan = yield* takeBufferedProposedPlan(input.planId);952 Effect.gen(function* () {
742 const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text);953 const bufferedPlan = yield* takeBufferedProposedPlan(input.planId);
743 const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown);954 const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text);
744 const planMarkdown = bufferedMarkdown ?? fallbackMarkdown;955 const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown);
745 if (!planMarkdown) {956 const planMarkdown = bufferedMarkdown ?? fallbackMarkdown;
746 return;957 if (!planMarkdown) {
747 }958 return;
959 }
748 960
749 yield* upsertProposedPlan({961 yield* upsertProposedPlan({
750 event: input.event,962 event: input.event,
751 threadId: input.threadId,963 threadId: input.threadId,
752 threadProposedPlans: input.threadProposedPlans,964 threadProposedPlans: input.threadProposedPlans,
753 planId: input.planId,965 planId: input.planId,
754 ...(input.turnId ? { turnId: input.turnId } : {}),966 ...(input.turnId ? { turnId: input.turnId } : {}),
755 planMarkdown,967 planMarkdown,
756 createdAt:968 createdAt:
757 bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0969 bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0
758 ? bufferedPlan.createdAt970 ? bufferedPlan.createdAt
759 : input.updatedAt,971 : input.updatedAt,
760 updatedAt: input.updatedAt,972 updatedAt: input.updatedAt,
973 });
974 yield* clearBufferedProposedPlan(input.planId);
761 });975 });
762 yield* clearBufferedProposedPlan(input.planId);
763 });
764 976
765 const clearTurnStateForSession = Effect.fn("clearTurnStateForSession")(function* (977 const clearTurnStateForSession = (threadId: ThreadId) =>
766 threadId: ThreadId,978 Effect.gen(function* () {
767 ) {979 const prefix = `${threadId}:`;
768 const prefix = `${threadId}:`;980 const proposedPlanPrefix = `plan:${threadId}:`;
769 const proposedPlanPrefix = `plan:${threadId}:`;981 const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));
770 const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));982 const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey));
771 const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById));983 const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById));
772 yield* Effect.forEach(984 yield* Effect.forEach(
773 turnKeys,985 turnKeys,
774 Effect.fn(function* (key) {986 (key) =>
775 if (!key.startsWith(prefix)) {987 Effect.gen(function* () {
776 return;988 if (!key.startsWith(prefix)) {
777 }989 return;
990 }
778 991
779 const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key);992 const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key);
780 if (Option.isSome(messageIds)) {993 if (Option.isSome(messageIds)) {
781 yield* Effect.forEach(messageIds.value, clearAssistantMessageState, {994 yield* Effect.forEach(messageIds.value, clearAssistantMessageState, {
782 concurrency: 1,995 concurrency: 1,
783 }).pipe(Effect.asVoid);996 }).pipe(Effect.asVoid);
784 }997 }
785 998
786 yield* Cache.invalidate(turnMessageIdsByTurnKey, key);999 yield* Cache.invalidate(turnMessageIdsByTurnKey, key);
787 }),1000 }),
788 { concurrency: 1 },1001 { concurrency: 1 },
789 ).pipe(Effect.asVoid);1002 ).pipe(Effect.asVoid);
790 yield* Effect.forEach(1003 yield* Effect.forEach(
791 proposedPlanKeys,1004 assistantSegmentKeys,
792 (key) =>1005 (key) =>
793 key.startsWith(proposedPlanPrefix)1006 key.startsWith(prefix)
794 ? Cache.invalidate(bufferedProposedPlanById, key)1007 ? Cache.invalidate(assistantSegmentStateByTurnKey, key)
795 : Effect.void,1008 : Effect.void,
796 { concurrency: 1 },1009 { concurrency: 1 },
797 ).pipe(Effect.asVoid);1010 ).pipe(Effect.asVoid);
798 });1011 yield* Effect.forEach(
1012 proposedPlanKeys,
1013 (key) =>
1014 key.startsWith(proposedPlanPrefix)
1015 ? Cache.invalidate(bufferedProposedPlanById, key)
1016 : Effect.void,
1017 { concurrency: 1 },
1018 ).pipe(Effect.asVoid);
1019 });
799 1020
800 const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn(1021 const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn(
801 "getSourceProposedPlanReferenceForPendingTurnStart",1022 "getSourceProposedPlanReferenceForPendingTurnStart",
@@ -873,353 +1094,432 @@ const make = Effect.fn("make")(function* () { · 71 unmodified lines
873 },1094 },
874 );1095 );
875 1096
876 const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* (1097 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
877 event: ProviderRuntimeEvent,1098 Effect.gen(function* () {
878 ) {1099 const readModel = yield* orchestrationEngine.getReadModel();
879 const readModel = yield* orchestrationEngine.getReadModel();1100 const thread = readModel.threads.find((entry) => entry.id === event.threadId);
880 const thread = readModel.threads.find((entry) => entry.id === event.threadId);1101 if (!thread) return;
881 if (!thread) return;
882 1102
883 const now = event.createdAt;1103 const now = event.createdAt;
884 const eventTurnId = toTurnId(event.turnId);1104 const eventTurnId = toTurnId(event.turnId);
885 const activeTurnId = thread.session?.activeTurnId ?? null;1105 const activeTurnId = thread.session?.activeTurnId ?? null;
886 1106
887 const conflictsWithActiveTurn =1107 const conflictsWithActiveTurn =
888 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);1108 activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
889 const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;1109 const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;
890 1110
891 const shouldApplyThreadLifecycle = (() => {1111 const shouldApplyThreadLifecycle = (() => {
892 if (!STRICT_PROVIDER_LIFECYCLE_GUARD) {1112 if (!STRICT_PROVIDER_LIFECYCLE_GUARD) {
893 return true;
894 }
895 switch (event.type) {
896 case "session.exited":
897 return true;
898 case "session.started":
899 case "thread.started":
900 return true;
901 case "turn.started":
902 return !conflictsWithActiveTurn;
903 case "turn.completed":
904 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
905 return false;
906 }
907 // Only the active turn may close the lifecycle state.
908 if (activeTurnId !== null && eventTurnId !== undefined) {
909 return sameId(activeTurnId, eventTurnId);
910 }
911 // If no active turn is tracked, accept completion scoped to this thread.
912 return true;1113 return true;
913 default:1114 }
914 return true;
915 }
916 })();
917 const acceptedTurnStartedSourcePlan =
918 event.type === "turn.started" && shouldApplyThreadLifecycle
919 ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId)
920 : null;
921
922 if (
923 event.type === "session.started" ||
924 event.type === "session.state.changed" ||
925 event.type === "session.exited" ||
926 event.type === "thread.started" ||
927 event.type === "turn.started" ||
928 event.type === "turn.completed"
929 ) {
930 const nextActiveTurnId =
931 event.type === "turn.started"
932 ? (eventTurnId ?? null)
933 : event.type === "turn.completed" || event.type === "session.exited"
934 ? null
935 : activeTurnId;
936 const status = (() => {
937 switch (event.type) {1115 switch (event.type) {
938 case "session.state.changed":
939 return orchestrationSessionStatusFromRuntimeState(event.payload.state);
940 case "turn.started":
941 return "running";
942 case "session.exited":1116 case "session.exited":
943 return "stopped";1117 return true;
944 case "turn.completed":
945 return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready";
946 case "session.started":1118 case "session.started":
947 case "thread.started":1119 case "thread.started":
948 // Provider thread/session start notifications can arrive during an1120 return true;
949 // active turn; preserve turn-running state in that case.1121 case "turn.started":
950 return activeTurnId !== null ? "running" : "ready";1122 return !conflictsWithActiveTurn;
1123 case "turn.completed":
1124 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
1125 return false;
1126 }
1127 // Only the active turn may close the lifecycle state.
1128 if (activeTurnId !== null && eventTurnId !== undefined) {
1129 return sameId(activeTurnId, eventTurnId);
1130 }
1131 // If no active turn is tracked, accept completion scoped to this thread.
1132 return true;
1133 default:
1134 return true;
951 }1135 }
952 })();1136 })();
953 const lastError =1137 const acceptedTurnStartedSourcePlan =
954 event.type === "session.state.changed" && event.payload.state === "error"1138 event.type === "turn.started" && shouldApplyThreadLifecycle
955 ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error")1139 ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId)
956 : event.type === "turn.completed" &&1140 : null;
957 normalizeRuntimeTurnState(event.payload.state) === "failed"1141
958 ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed")1142 if (
959 : status === "ready"1143 event.type === "session.started" ||
1144 event.type === "session.state.changed" ||
1145 event.type === "session.exited" ||
1146 event.type === "thread.started" ||
1147 event.type === "turn.started" ||
1148 event.type === "turn.completed"
1149 ) {
1150 const nextActiveTurnId =
1151 event.type === "turn.started"
1152 ? (eventTurnId ?? null)
1153 : event.type === "turn.completed" || event.type === "session.exited"
960 ? null1154 ? null
961 : (thread.session?.lastError ?? null);1155 : activeTurnId;
962 1156 const status = (() => {
963 if (shouldApplyThreadLifecycle) {1157 switch (event.type) {
964 if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) {1158 case "session.state.changed":
965 yield* markSourceProposedPlanImplemented(1159 return orchestrationSessionStatusFromRuntimeState(event.payload.state);
966 acceptedTurnStartedSourcePlan.sourceThreadId,1160 case "turn.started":
967 acceptedTurnStartedSourcePlan.sourcePlanId,1161 return "running";
968 thread.id,1162 case "session.exited":
969 now,1163 return "stopped";
970 ).pipe(1164 case "turn.completed":
971 Effect.catchCause((cause) =>1165 return normalizeRuntimeTurnState(event.payload.state) === "failed"
972 Effect.logWarning("provider runtime ingestion failed to mark source proposed plan", {1166 ? "error"
973 eventId: event.eventId,1167 : "ready";
974 eventType: event.type,1168 case "session.started":
975 cause: Cause.pretty(cause),1169 case "thread.started":
976 }),1170 // Provider thread/session start notifications can arrive during an
977 ),1171 // active turn; preserve turn-running state in that case.
978 );1172 return activeTurnId !== null ? "running" : "ready";
979 }1173 }
1174 })();
1175 const lastError =
1176 event.type === "session.state.changed" && event.payload.state === "error"
1177 ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error")
1178 : event.type === "turn.completed" &&
1179 normalizeRuntimeTurnState(event.payload.state) === "failed"
1180 ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed")
1181 : status === "ready"
1182 ? null
1183 : (thread.session?.lastError ?? null);
1184
1185 if (shouldApplyThreadLifecycle) {
1186 if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) {
1187 yield* markSourceProposedPlanImplemented(
1188 acceptedTurnStartedSourcePlan.sourceThreadId,
1189 acceptedTurnStartedSourcePlan.sourcePlanId,
1190 thread.id,
1191 now,
1192 ).pipe(
1193 Effect.catchCause((cause) =>
1194 Effect.logWarning(
1195 "provider runtime ingestion failed to mark source proposed plan",
1196 {
1197 eventId: event.eventId,
1198 eventType: event.type,
1199 cause: Cause.pretty(cause),
1200 },
1201 ),
1202 ),
1203 );
1204 }
980 1205
981 yield* orchestrationEngine.dispatch({1206 yield* orchestrationEngine.dispatch({
982 type: "thread.session.set",1207 type: "thread.session.set",
983 commandId: providerCommandId(event, "thread-session-set"),1208 commandId: providerCommandId(event, "thread-session-set"),
984 threadId: thread.id,
985 session: {
986 threadId: thread.id,1209 threadId: thread.id,
987 status,1210 session: {
988 providerName: event.provider,1211 threadId: thread.id,
989 runtimeMode: thread.session?.runtimeMode ?? "full-access",1212 status,
990 activeTurnId: nextActiveTurnId,1213 providerName: event.provider,
991 lastError,1214 runtimeMode: thread.session?.runtimeMode ?? "full-access",
992 updatedAt: now,1215 activeTurnId: nextActiveTurnId,
993 },1216 lastError,
994 createdAt: now,1217 updatedAt: now,
995 });1218 },
1219 createdAt: now,
1220 });
1221 }
996 }1222 }
997 }
998 1223
999 const assistantDelta =1224 const assistantDelta =
1000 event.type === "content.delta" && event.payload.streamKind === "assistant_text"1225 event.type === "content.delta" && event.payload.streamKind === "assistant_text"
1001 ? event.payload.delta1226 ? event.payload.delta
1002 : undefined;1227 : undefined;
1003 const proposedPlanDelta =1228 const proposedPlanDelta =
1004 event.type === "turn.proposed.delta" ? event.payload.delta : undefined;1229 event.type === "turn.proposed.delta" ? event.payload.delta : undefined;
1005 1230
1006 if (assistantDelta && assistantDelta.length > 0) {1231 if (assistantDelta && assistantDelta.length > 0) {
1007 const assistantMessageId = MessageId.make(1232 const turnId = toTurnId(event.turnId);
1008 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,1233 const assistantMessageId = yield* getOrCreateAssistantMessageId({
1009 );1234 threadId: thread.id,
1010 const turnId = toTurnId(event.turnId);1235 event,
1011 if (turnId) {1236 ...(turnId ? { turnId } : {}),
1012 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);1237 });
1013 }1238 if (turnId) {
1239 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
1240 }
1014 1241
1015 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(1242 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1016 serverSettingsService.getSettings,1243 serverSettingsService.getSettings,
1017 (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),1244 (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),
1018 );1245 );
1019 if (assistantDeliveryMode === "buffered") {1246 if (assistantDeliveryMode === "buffered") {
1020 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);1247 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
1021 if (spillChunk.length > 0) {1248 if (spillChunk.length > 0) {
1249 yield* orchestrationEngine.dispatch({
1250 type: "thread.message.assistant.delta",
1251 commandId: providerCommandId(event, "assistant-delta-buffer-spill"),
1252 threadId: thread.id,
1253 messageId: assistantMessageId,
1254 delta: spillChunk,
1255 ...(turnId ? { turnId } : {}),
1256 createdAt: now,
1257 });
1258 }
1259 } else {
1022 yield* orchestrationEngine.dispatch({1260 yield* orchestrationEngine.dispatch({
1023 type: "thread.message.assistant.delta",1261 type: "thread.message.assistant.delta",
1024 commandId: providerCommandId(event, "assistant-delta-buffer-spill"),1262 commandId: providerCommandId(event, "assistant-delta"),
1025 threadId: thread.id,1263 threadId: thread.id,
1026 messageId: assistantMessageId,1264 messageId: assistantMessageId,
1027 delta: spillChunk,1265 delta: assistantDelta,
1028 ...(turnId ? { turnId } : {}),1266 ...(turnId ? { turnId } : {}),
1029 createdAt: now,1267 createdAt: now,
1030 });1268 });
1031 }1269 }
1032 } else {1270 }
1033 yield* orchestrationEngine.dispatch({1271
1034 type: "thread.message.assistant.delta",1272 const pauseForUserTurnId =
1035 commandId: providerCommandId(event, "assistant-delta"),1273 event.type === "request.opened" || event.type === "user-input.requested"
1274 ? toTurnId(event.turnId)
1275 : undefined;
1276 if (pauseForUserTurnId) {
1277 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1278 serverSettingsService.getSettings,
1279 (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),
1280 );
1281 const flushedMessageIds =
1282 assistantDeliveryMode === "buffered"
1283 ? yield* flushBufferedAssistantMessagesForTurn({
1284 event,
1285 threadId: thread.id,
1286 turnId: pauseForUserTurnId,
1287 createdAt: now,
1288 commandTag:
1289 event.type === "request.opened"
1290 ? "assistant-delta-flush-on-request-opened"
1291 : "assistant-delta-flush-on-user-input-requested",
1292 })
1293 : new Set<MessageId>();
1294 yield* finalizeActiveAssistantSegmentForTurn({
1295 event,
1036 threadId: thread.id,1296 threadId: thread.id,
1037 messageId: assistantMessageId,1297 turnId: pauseForUserTurnId,
1038 delta: assistantDelta,
1039 ...(turnId ? { turnId } : {}),
1040 createdAt: now,1298 createdAt: now,
1299 commandTag:
1300 event.type === "request.opened"
1301 ? "assistant-complete-on-request-opened"
1302 : "assistant-complete-on-user-input-requested",
1303 finalDeltaCommandTag:
1304 event.type === "request.opened"
1305 ? "assistant-delta-finalize-on-request-opened"
1306 : "assistant-delta-finalize-on-user-input-requested",
1307 hasProjectedMessage: thread.messages.some(
1308 (entry) =>
1309 entry.role === "assistant" && entry.turnId === pauseForUserTurnId && entry.streaming,
1310 ),
1311 flushedMessageIds,
1041 });1312 });
1042 }1313 }
1043 }
1044 1314
1045 if (proposedPlanDelta && proposedPlanDelta.length > 0) {1315 if (proposedPlanDelta && proposedPlanDelta.length > 0) {
1046 const planId = proposedPlanIdFromEvent(event, thread.id);1316 const planId = proposedPlanIdFromEvent(event, thread.id);
1047 yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now);1317 yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now);
1048 }1318 }
1049 1319
1050 const assistantCompletion =1320 const assistantCompletion =
1051 event.type === "item.completed" && event.payload.itemType === "assistant_message"1321 event.type === "item.completed" && event.payload.itemType === "assistant_message"
1052 ? {1322 ? {
1053 messageId: MessageId.make(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),1323 messageId: MessageId.make(
1054 fallbackText: event.payload.detail,1324 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1055 }1325 ),
1056 : undefined;1326 fallbackText: event.payload.detail,
1057 const proposedPlanCompletion =1327 }
1058 event.type === "turn.proposed.completed"1328 : undefined;
1059 ? {1329 const proposedPlanCompletion =
1060 planId: proposedPlanIdFromEvent(event, thread.id),1330 event.type === "turn.proposed.completed"
1061 turnId: toTurnId(event.turnId),1331 ? {
1062 planMarkdown: event.payload.planMarkdown,1332 planId: proposedPlanIdFromEvent(event, thread.id),
1333 turnId: toTurnId(event.turnId),
1334 planMarkdown: event.payload.planMarkdown,
1335 }
1336 : undefined;
1337
1338 if (assistantCompletion) {
1339 const turnId = toTurnId(event.turnId);
1340 const activeAssistantMessageId = turnId
1341 ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId)
1342 : Option.none<MessageId>();
1343 const hasAssistantMessagesForTurn =
1344 turnId !== undefined
1345 ? thread.messages.some((entry) => entry.role === "assistant" && entry.turnId === turnId)
1346 : false;
1347 const assistantMessageId = Option.getOrElse(
1348 activeAssistantMessageId,
1349 () => assistantCompletion.messageId,
1350 );
1351 const existingAssistantMessage = thread.messages.find(
1352 (entry) => entry.id === assistantMessageId,
1353 );
1354 const shouldApplyFallbackCompletionText =
1355 !existingAssistantMessage || existingAssistantMessage.text.length === 0;
1356
1357 const shouldSkipRedundantCompletion =
1358 Option.isNone(activeAssistantMessageId) &&
1359 turnId !== undefined &&
1360 hasAssistantMessagesForTurn &&
1361 (assistantCompletion.fallbackText?.trim().length ?? 0) === 0;
1362
1363 if (!shouldSkipRedundantCompletion) {
1364 if (turnId && Option.isNone(activeAssistantMessageId)) {
1365 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
1063 }1366 }
1064 : undefined;
1065 1367
1066 if (assistantCompletion) {1368 yield* finalizeAssistantMessage({
1067 const assistantMessageId = assistantCompletion.messageId;1369 event,
1068 const turnId = toTurnId(event.turnId);1370 threadId: thread.id,
1069 const existingAssistantMessage = thread.messages.find(1371 messageId: assistantMessageId,
1070 (entry) => entry.id === assistantMessageId,1372 ...(turnId ? { turnId } : {}),
1071 );1373 createdAt: now,
1072 const shouldApplyFallbackCompletionText =1374 commandTag: "assistant-complete",
1073 !existingAssistantMessage || existingAssistantMessage.text.length === 0;1375 finalDeltaCommandTag: "assistant-delta-finalize",
1074 if (turnId) {1376 hasProjectedMessage: existingAssistantMessage !== undefined,
1075 yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);1377 ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText
1076 }1378 ? { fallbackText: assistantCompletion.fallbackText }
1379 : {}),
1380 });
1077 1381
1078 yield* finalizeAssistantMessage({1382 if (turnId) {
1079 event,1383 yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId);
1080 threadId: thread.id,1384 }
1081 messageId: assistantMessageId,1385 }
1082 ...(turnId ? { turnId } : {}),
1083 createdAt: now,
1084 commandTag: "assistant-complete",
1085 finalDeltaCommandTag: "assistant-delta-finalize",
1086 ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText
1087 ? { fallbackText: assistantCompletion.fallbackText }
1088 : {}),
1089 });
1090 1386
1091 if (turnId) {1387 if (turnId) {
1092 yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId);1388 yield* clearAssistantSegmentStateForTurn(thread.id, turnId);
1389 }
1093 }1390 }
1094 }
1095
1096 if (proposedPlanCompletion) {
1097 yield* finalizeBufferedProposedPlan({
1098 event,
1099 threadId: thread.id,
1100 threadProposedPlans: thread.proposedPlans,
1101 planId: proposedPlanCompletion.planId,
1102 ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}),
1103 fallbackMarkdown: proposedPlanCompletion.planMarkdown,
1104 updatedAt: now,
1105 });
1106 }
1107
1108 if (event.type === "turn.completed") {
1109 const turnId = toTurnId(event.turnId);
1110 if (turnId) {
1111 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
1112 yield* Effect.forEach(
1113 assistantMessageIds,
1114 (assistantMessageId) =>
1115 finalizeAssistantMessage({
1116 event,
1117 threadId: thread.id,
1118 messageId: assistantMessageId,
1119 turnId,
1120 createdAt: now,
1121 commandTag: "assistant-complete-finalize",
1122 finalDeltaCommandTag: "assistant-delta-finalize-fallback",
1123 }),
1124 { concurrency: 1 },
1125 ).pipe(Effect.asVoid);
1126 yield* clearAssistantMessageIdsForTurn(thread.id, turnId);
1127 1391
1392 if (proposedPlanCompletion) {
1128 yield* finalizeBufferedProposedPlan({1393 yield* finalizeBufferedProposedPlan({
1129 event,1394 event,
1130 threadId: thread.id,1395 threadId: thread.id,
1131 threadProposedPlans: thread.proposedPlans,1396 threadProposedPlans: thread.proposedPlans,
1132 planId: proposedPlanIdForTurn(thread.id, turnId),1397 planId: proposedPlanCompletion.planId,
1133 turnId,1398 ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}),
1399 fallbackMarkdown: proposedPlanCompletion.planMarkdown,
1134 updatedAt: now,1400 updatedAt: now,
1135 });1401 });
1136 }1402 }
1137 }
1138
1139 if (event.type === "session.exited") {
1140 yield* clearTurnStateForSession(thread.id);
1141 }
1142
1143 if (event.type === "runtime.error") {
1144 const runtimeErrorMessage = event.payload.message;
1145 1403
1146 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD1404 if (event.type === "turn.completed") {
1147 ? true1405 const turnId = toTurnId(event.turnId);
1148 : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId);1406 if (turnId) {
1407 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
1408 yield* Effect.forEach(
1409 assistantMessageIds,
1410 (assistantMessageId) =>
1411 finalizeAssistantMessage({
1412 event,
1413 threadId: thread.id,
1414 messageId: assistantMessageId,
1415 turnId,
1416 createdAt: now,
1417 commandTag: "assistant-complete-finalize",
1418 finalDeltaCommandTag: "assistant-delta-finalize-fallback",
1419 hasProjectedMessage: thread.messages.some(
1420 (entry) => entry.id === assistantMessageId,
1421 ),
1422 }),
1423 { concurrency: 1 },
1424 ).pipe(Effect.asVoid);
1425 yield* clearAssistantMessageIdsForTurn(thread.id, turnId);
1426 yield* clearAssistantSegmentStateForTurn(thread.id, turnId);
1149 1427
1150 if (shouldApplyRuntimeError) {1428 yield* finalizeBufferedProposedPlan({
1151 yield* orchestrationEngine.dispatch({1429 event,
1152 type: "thread.session.set",
1153 commandId: providerCommandId(event, "runtime-error-session-set"),
1154 threadId: thread.id,
1155 session: {
1156 threadId: thread.id,1430 threadId: thread.id,
1157 status: "error",1431 threadProposedPlans: thread.proposedPlans,
1158 providerName: event.provider,1432 planId: proposedPlanIdForTurn(thread.id, turnId),
1159 runtimeMode: thread.session?.runtimeMode ?? "full-access",1433 turnId,
1160 activeTurnId: eventTurnId ?? null,
1161 lastError: runtimeErrorMessage,
1162 updatedAt: now,1434 updatedAt: now,
1163 },1435 });
1164 createdAt: now,1436 }
1165 });
1166 }1437 }
1167 }
1168 1438
1169 if (event.type === "thread.metadata.updated" && event.payload.name) {1439 if (event.type === "session.exited") {
1170 yield* orchestrationEngine.dispatch({1440 yield* clearTurnStateForSession(thread.id);
1171 type: "thread.meta.update",1441 }
1172 commandId: providerCommandId(event, "thread-meta-update"),
1173 threadId: thread.id,
1174 title: event.payload.name,
1175 });
1176 }
1177 1442
1178 if (event.type === "turn.diff.updated") {1443 if (event.type === "runtime.error") {
1179 const turnId = toTurnId(event.turnId);1444 const runtimeErrorMessage = event.payload.message;
1180 if (turnId && (yield* isGitRepoForThread(thread.id))) {1445
1181 // Skip if a checkpoint already exists for this turn. A real1446 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
1182 // (non-placeholder) capture from CheckpointReactor should not1447 ? true
1183 // be clobbered, and dispatching a duplicate placeholder for the1448 : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId);
1184 // same turnId would produce an unstable checkpointTurnCount.1449
1185 if (thread.checkpoints.some((c) => c.turnId === turnId)) {1450 if (shouldApplyRuntimeError) {
1186 // Already tracked; no-op.
1187 } else {
1188 const assistantMessageId = MessageId.make(
1189 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1190 );
1191 const maxTurnCount = thread.checkpoints.reduce(
1192 (max, c) => Math.max(max, c.checkpointTurnCount),
1193 0,
1194 );
1195 yield* orchestrationEngine.dispatch({1451 yield* orchestrationEngine.dispatch({
1196 type: "thread.turn.diff.complete",1452 type: "thread.session.set",
1197 commandId: providerCommandId(event, "thread-turn-diff-complete"),1453 commandId: providerCommandId(event, "runtime-error-session-set"),
1198 threadId: thread.id,1454 threadId: thread.id,
1199 turnId,1455 session: {
1200 completedAt: now,1456 threadId: thread.id,
1201 checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`),1457 status: "error",
1202 status: "missing",1458 providerName: event.provider,
1203 files: [],1459 runtimeMode: thread.session?.runtimeMode ?? "full-access",
1204 assistantMessageId,1460 activeTurnId: eventTurnId ?? null,
1205 checkpointTurnCount: maxTurnCount + 1,1461 lastError: runtimeErrorMessage,
1462 updatedAt: now,
1463 },
1206 createdAt: now,1464 createdAt: now,
1207 });1465 });
1208 }1466 }
1209 }1467 }
1210 }
1211 1468
1212 const activities = runtimeEventToActivities(event);1469 if (event.type === "thread.metadata.updated" && event.payload.name) {
1213 yield* Effect.forEach(activities, (activity) =>1470 yield* orchestrationEngine.dispatch({
1214 orchestrationEngine.dispatch({1471 type: "thread.meta.update",
1215 type: "thread.activity.append",1472 commandId: providerCommandId(event, "thread-meta-update"),
1216 commandId: providerCommandId(event, "thread-activity-append"),1473 threadId: thread.id,
1217 threadId: thread.id,1474 title: event.payload.name,
1218 activity,1475 });
1219 createdAt: activity.createdAt,1476 }
1220 }),1477
1221 ).pipe(Effect.asVoid);1478 if (event.type === "turn.diff.updated") {
1222 });1479 const turnId = toTurnId(event.turnId);
1480 if (turnId && (yield* isGitRepoForThread(thread.id))) {
1481 // Skip if a checkpoint already exists for this turn. A real
1482 // (non-placeholder) capture from CheckpointReactor should not
1483 // be clobbered, and dispatching a duplicate placeholder for the
1484 // same turnId would produce an unstable checkpointTurnCount.
1485 if (thread.checkpoints.some((c) => c.turnId === turnId)) {
1486 // Already tracked; no-op.
1487 } else {
1488 const assistantMessageId = MessageId.make(
1489 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1490 );
1491 const maxTurnCount = thread.checkpoints.reduce(
1492 (max, c) => Math.max(max, c.checkpointTurnCount),
1493 0,
1494 );
1495 yield* orchestrationEngine.dispatch({
1496 type: "thread.turn.diff.complete",
1497 commandId: providerCommandId(event, "thread-turn-diff-complete"),
1498 threadId: thread.id,
1499 turnId,
1500 completedAt: now,
1501 checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`),
1502 status: "missing",
1503 files: [],
1504 assistantMessageId,
1505 checkpointTurnCount: maxTurnCount + 1,
1506 createdAt: now,
1507 });
1508 }
1509 }
1510 }
1511
1512 const activities = runtimeEventToActivities(event);
1513 yield* Effect.forEach(activities, (activity) =>
1514 orchestrationEngine.dispatch({
1515 type: "thread.activity.append",
1516 commandId: providerCommandId(event, "thread-activity-append"),
1517 threadId: thread.id,
1518 activity,
1519 createdAt: activity.createdAt,
1520 }),
1521 ).pipe(Effect.asVoid);
1522 });
1223 1523
1224 const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void;1524 const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void;
1225 1525
@@ -1243,21 +1543,22 @@ const make = Effect.fn("make")(function* () { · 17 unmodified lines
1243 1543
1244 const worker = yield* makeDrainableWorker(processInputSafely);1544 const worker = yield* makeDrainableWorker(processInputSafely);
1245 1545
1246 const start: ProviderRuntimeIngestionShape["start"] = Effect.fn("start")(function* () {1546 const start: ProviderRuntimeIngestionShape["start"] = () =>
1247 yield* Effect.forkScoped(1547 Effect.gen(function* () {
1248 Stream.runForEach(providerService.streamEvents, (event) =>1548 yield* Effect.forkScoped(
1249 worker.enqueue({ source: "runtime", event }),1549 Stream.runForEach(providerService.streamEvents, (event) =>
1250 ),1550 worker.enqueue({ source: "runtime", event }),
1251 );1551 ),
1252 yield* Effect.forkScoped(1552 );
1253 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {1553 yield* Effect.forkScoped(
1254 if (event.type !== "thread.turn-start-requested") {1554 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
1255 return Effect.void;1555 if (event.type !== "thread.turn-start-requested") {
1256 }1556 return Effect.void;
1257 return worker.enqueue({ source: "domain", event });1557 }
1258 }),1558 return worker.enqueue({ source: "domain", event });
1259 );1559 }),
1260 });1560 );
1561 });
1261 1562
1262 return {1563 return {
1263 start,1564 start,
@@ -1267,5 +1568,5 @@ const make = Effect.fn("make")(function* () { · 3 unmodified lines
1267 1568
1268export const ProviderRuntimeIngestionLive = Layer.effect(1569export const ProviderRuntimeIngestionLive = Layer.effect(
1269 ProviderRuntimeIngestionService,1570 ProviderRuntimeIngestionService,
1270 make(),1571 make,
1271).pipe(Layer.provide(ProjectionTurnRepositoryLive));1572).pipe(Layer.provide(ProjectionTurnRepositoryLive));
apps/server/src/provider/Layers/ClaudeAdapter.ts+11−10
@@ -42,12 +42,7 @@ import { · 41 unmodified lines
42 type UserInputQuestion,42 type UserInputQuestion,
43 ClaudeAgentEffort,43 ClaudeAgentEffort,
44} from "@t3tools/contracts";44} from "@t3tools/contracts";
45import {45import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model";
46 applyClaudePromptEffortPrefix,
47 resolveApiModelId,
48 resolveEffort,
49 trimOrNull,
50} from "@t3tools/shared/model";
51import {46import {
52 Cause,47 Cause,
53 DateTime,48 DateTime,
@@ -66,7 +61,7 @@ import { · 12 unmodified lines
66import { resolveAttachmentPath } from "../../attachmentStore.ts";61import { resolveAttachmentPath } from "../../attachmentStore.ts";
67import { ServerConfig } from "../../config.ts";62import { ServerConfig } from "../../config.ts";
68import { ServerSettingsService } from "../../serverSettings.ts";63import { ServerSettingsService } from "../../serverSettings.ts";
69import { getClaudeModelCapabilities } from "./ClaudeProvider.ts";64import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts";
70import {65import {
71 ProviderAdapterProcessError,66 ProviderAdapterProcessError,
72 ProviderAdapterRequestError,67 ProviderAdapterRequestError,
@@ -2829,7 +2824,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 2756 unmodified lines
2829 const modelSelection =2824 const modelSelection =
2830 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;2825 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2831 const caps = getClaudeModelCapabilities(modelSelection?.model);2826 const caps = getClaudeModelCapabilities(modelSelection?.model);
2832 const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined;2827 const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;
2833 const effort = (resolveEffort(caps, modelSelection?.options?.effort) ??2828 const effort = (resolveEffort(caps, modelSelection?.options?.effort) ??
2834 null) as ClaudeAgentEffort | null;2829 null) as ClaudeAgentEffort | null;
2835 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;2830 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
@@ -2853,7 +2848,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 17 unmodified lines
2853 ...(apiModelId ? { model: apiModelId } : {}),2848 ...(apiModelId ? { model: apiModelId } : {}),
2854 pathToClaudeCodeExecutable: claudeBinaryPath,2849 pathToClaudeCodeExecutable: claudeBinaryPath,
2855 settingSources: [...CLAUDE_SETTING_SOURCES],2850 settingSources: [...CLAUDE_SETTING_SOURCES],
2856 ...(effectiveEffort ? { effort: effectiveEffort } : {}),2851 // The SDK type lags the CLI here: Opus 4.7 accepts `xhigh` even though
2852 // the published `Options["effort"]` union currently stops at `max`.
2853 ...(effectiveEffort
2854 ? {
2855 effort: effectiveEffort as unknown as NonNullable<ClaudeQueryOptions["effort"]>,
2856 }
2857 : {}),
2857 ...(permissionMode ? { permissionMode } : {}),2858 ...(permissionMode ? { permissionMode } : {}),
2858 ...(permissionMode === "bypassPermissions"2859 ...(permissionMode === "bypassPermissions"
2859 ? { allowDangerouslySkipPermissions: true }2860 ? { allowDangerouslySkipPermissions: true }
@@ -3006,7 +3007,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 146 unmodified lines
3006 }3007 }
3007 3008
3008 if (modelSelection?.model) {3009 if (modelSelection?.model) {
3009 const apiModelId = resolveApiModelId(modelSelection);3010 const apiModelId = resolveClaudeApiModelId(modelSelection);
3010 if (context.currentApiModelId !== apiModelId) {3011 if (context.currentApiModelId !== apiModelId) {
3011 yield* Effect.tryPromise({3012 yield* Effect.tryPromise({
3012 try: () => context.query.setModel(apiModelId),3013 try: () => context.query.setModel(apiModelId),
apps/server/src/provider/Layers/ClaudeProvider.ts+9−0
@@ -1,5 +1,6 @@
1import type {1import type {
2 ClaudeSettings,2 ClaudeSettings,
3 ClaudeModelSelection,
3 ModelCapabilities,4 ModelCapabilities,
4 ServerProvider,5 ServerProvider,
5 ServerProviderModel,6 ServerProviderModel,
@@ -163,6 +164,14 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo · 157 unmodified lines
163 );164 );
164}165}
165 166
167export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string {
168 switch (modelSelection.options?.contextWindow) {
169 case "1m":
170 return `${modelSelection.model}[1m]`;
171 default:
172 return modelSelection.model;
173 }
174}
166export function parseClaudeAuthStatusFromOutput(result: CommandResult): {175export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
167 readonly status: Exclude<ServerProviderState, "disabled">;176 readonly status: Exclude<ServerProviderState, "disabled">;
168 readonly auth: Pick<ServerProviderAuth, "status">;177 readonly auth: Pick<ServerProviderAuth, "status">;
apps/server/src/provider/Layers/CursorAdapter.test.ts+1174−0
@@ -0,0 +1,1174 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4import { fileURLToPath } from "node:url";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";
8import { Deferred, Effect, Fiber, Layer, Stream } from "effect";
9
10import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts";
11
12import { ServerConfig } from "../../config.ts";
13import { ServerSettingsService } from "../../serverSettings.ts";
14import { CursorAdapter } from "../Services/CursorAdapter.ts";
15import { makeCursorAdapterLive } from "./CursorAdapter.ts";
16
17const __dirname = path.dirname(fileURLToPath(import.meta.url));
18const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
19const bunExe = "bun";
20
21async function makeMockAgentWrapper(
22 extraEnv?: Record<string, string>,
23 options?: { initialDelaySeconds?: number },
24) {
25 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-"));
26 const wrapperPath = path.join(dir, "fake-agent.sh");
27 const envExports = Object.entries(extraEnv ?? {})
28 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
29 .join("\n");
30 const script = `#!/bin/sh
31${envExports}
32${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""}
33exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
34`;
35 await writeFile(wrapperPath, script, "utf8");
36 await chmod(wrapperPath, 0o755);
37 return wrapperPath;
38}
39
40async function makeProbeWrapper(
41 requestLogPath: string,
42 argvLogPath: string,
43 extraEnv?: Record<string, string>,
44) {
45 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-"));
46 const wrapperPath = path.join(dir, "fake-agent.sh");
47 const envExports = Object.entries(extraEnv ?? {})
48 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
49 .join("\n");
50 const script = `#!/bin/sh
51printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)}
52printf '\n' >> ${JSON.stringify(argvLogPath)}
53export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)}
54${envExports}
55exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
56`;
57 await writeFile(wrapperPath, script, "utf8");
58 await chmod(wrapperPath, 0o755);
59 return wrapperPath;
60}
61
62async function readArgvLog(filePath: string) {
63 const raw = await readFile(filePath, "utf8");
64 return raw
65 .split("\n")
66 .map((line) => line.trim())
67 .filter((line) => line.length > 0)
68 .map((line) => line.split("\t").filter((token) => token.length > 0));
69}
70
71async function readJsonLines(filePath: string) {
72 const raw = await readFile(filePath, "utf8");
73 return raw
74 .split("\n")
75 .map((line) => line.trim())
76 .filter((line) => line.length > 0)
77 .map((line) => JSON.parse(line) as Record<string, unknown>);
78}
79
80async function waitForFileContent(filePath: string, attempts = 40) {
81 for (let attempt = 0; attempt < attempts; attempt += 1) {
82 try {
83 const raw = await readFile(filePath, "utf8");
84 if (raw.trim().length > 0) {
85 return raw;
86 }
87 } catch {}
88 await new Promise((resolve) => setTimeout(resolve, 50));
89 }
90 throw new Error(`Timed out waiting for file content at ${filePath}`);
91}
92
93const cursorAdapterTestLayer = it.layer(
94 makeCursorAdapterLive().pipe(
95 Layer.provideMerge(ServerSettingsService.layerTest()),
96 Layer.provideMerge(
97 ServerConfig.layerTest(process.cwd(), {
98 prefix: "t3code-cursor-adapter-test-",
99 }),
100 ),
101 Layer.provideMerge(NodeServices.layer),
102 ),
103);
104
105cursorAdapterTestLayer("CursorAdapterLive", (it) => {
106 it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
107 Effect.gen(function* () {
108 const adapter = yield* CursorAdapter;
109 const settings = yield* ServerSettingsService;
110 const threadId = ThreadId.make("cursor-mock-thread");
111
112 const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper());
113 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
114
115 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
116 Stream.runCollect,
117 Effect.forkChild,
118 );
119
120 const session = yield* adapter.startSession({
121 threadId,
122 provider: "cursor",
123 cwd: process.cwd(),
124 runtimeMode: "full-access",
125 modelSelection: { provider: "cursor", model: "default" },
126 });
127
128 assert.equal(session.provider, "cursor");
129 assert.deepStrictEqual(session.resumeCursor, {
130 schemaVersion: 1,
131 sessionId: "mock-session-1",
132 });
133
134 yield* adapter.sendTurn({
135 threadId,
136 input: "hello mock",
137 attachments: [],
138 });
139
140 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
141 const types = runtimeEvents.map((e) => e.type);
142
143 for (const t of [
144 "session.started",
145 "session.state.changed",
146 "thread.started",
147 "turn.started",
148 "turn.plan.updated",
149 "item.started",
150 "content.delta",
151 "item.completed",
152 "turn.completed",
153 ] as const) {
154 assert.include(types, t);
155 }
156
157 const assistantStarted = runtimeEvents.find(
158 (event) => event.type === "item.started" && event.payload.itemType === "assistant_message",
159 );
160 assert.isDefined(assistantStarted);
161
162 const delta = runtimeEvents.find((e) => e.type === "content.delta");
163 assert.isDefined(delta);
164 if (delta?.type === "content.delta") {
165 assert.equal(delta.payload.delta, "hello from mock");
166 assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/);
167 }
168
169 const assistantCompleted = runtimeEvents.find(
170 (event) =>
171 event.type === "item.completed" && event.payload.itemType === "assistant_message",
172 );
173 assert.isDefined(assistantCompleted);
174
175 const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated");
176 assert.isDefined(planUpdate);
177 if (planUpdate?.type === "turn.plan.updated") {
178 assert.deepStrictEqual(planUpdate.payload.plan, [
179 { step: "Inspect mock ACP state", status: "completed" },
180 { step: "Implement the requested change", status: "inProgress" },
181 ]);
182 }
183
184 yield* adapter.stopSession(threadId);
185 }),
186 );
187
188 it.effect("closes the ACP child process when a session stops", () =>
189 Effect.gen(function* () {
190 const adapter = yield* CursorAdapter;
191 const settings = yield* ServerSettingsService;
192 const threadId = ThreadId.make("cursor-stop-session-close");
193 const tempDir = yield* Effect.promise(() =>
194 mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")),
195 );
196 const exitLogPath = path.join(tempDir, "exit.log");
197
198 const wrapperPath = yield* Effect.promise(() =>
199 makeMockAgentWrapper({
200 T3_ACP_EXIT_LOG_PATH: exitLogPath,
201 }),
202 );
203 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
204
205 yield* adapter.startSession({
206 threadId,
207 provider: "cursor",
208 cwd: process.cwd(),
209 runtimeMode: "full-access",
210 modelSelection: { provider: "cursor", model: "default" },
211 });
212
213 yield* adapter.stopSession(threadId);
214
215 const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath));
216 assert.include(exitLog, "SIGTERM");
217 }),
218 );
219
220 it.effect(
221 "serializes concurrent startSession calls for the same thread and closes the replaced ACP session",
222 () =>
223 Effect.gen(function* () {
224 const adapter = yield* CursorAdapter;
225 const settings = yield* ServerSettingsService;
226 const threadId = ThreadId.make("cursor-concurrent-start-session");
227 const tempDir = yield* Effect.promise(() =>
228 mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")),
229 );
230 const exitLogPath = path.join(tempDir, "exit.log");
231
232 const wrapperPath = yield* Effect.promise(() =>
233 makeMockAgentWrapper(
234 {
235 T3_ACP_EXIT_LOG_PATH: exitLogPath,
236 },
237 { initialDelaySeconds: 0.2 },
238 ),
239 );
240 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
241
242 const [firstSession, secondSession] = yield* Effect.all(
243 [
244 adapter.startSession({
245 threadId,
246 provider: "cursor",
247 cwd: process.cwd(),
248 runtimeMode: "full-access",
249 modelSelection: { provider: "cursor", model: "default" },
250 }),
251 adapter.startSession({
252 threadId,
253 provider: "cursor",
254 cwd: process.cwd(),
255 runtimeMode: "full-access",
256 modelSelection: { provider: "cursor", model: "default" },
257 }),
258 ],
259 { concurrency: "unbounded" },
260 );
261
262 assert.equal(firstSession.threadId, threadId);
263 assert.equal(secondSession.threadId, threadId);
264
265 yield* adapter.stopSession(threadId);
266
267 const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath));
268 assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2);
269 }),
270 );
271
272 it.effect("rejects startSession when provider mismatches", () =>
273 Effect.gen(function* () {
274 const adapter = yield* CursorAdapter;
275 const result = yield* adapter
276 .startSession({
277 threadId: ThreadId.make("bad-provider"),
278 provider: "codex",
279 cwd: process.cwd(),
280 runtimeMode: "full-access",
281 })
282 .pipe(Effect.result);
283
284 assert.equal(result._tag, "Failure");
285 }),
286 );
287
288 it.effect("maps app plan mode onto the ACP plan session mode", () =>
289 Effect.gen(function* () {
290 const adapter = yield* CursorAdapter;
291 const serverSettings = yield* ServerSettingsService;
292 const threadId = ThreadId.make("cursor-plan-mode-probe");
293 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
294 const requestLogPath = path.join(tempDir, "requests.ndjson");
295 const argvLogPath = path.join(tempDir, "argv.txt");
296 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
297 const wrapperPath = yield* Effect.promise(() =>
298 makeProbeWrapper(requestLogPath, argvLogPath),
299 );
300 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
301
302 yield* adapter.startSession({
303 threadId,
304 provider: "cursor",
305 cwd: process.cwd(),
306 runtimeMode: "full-access",
307 modelSelection: { provider: "cursor", model: "composer-2" },
308 });
309
310 yield* adapter.sendTurn({
311 threadId,
312 input: "plan this change",
313 attachments: [],
314 interactionMode: "plan",
315 });
316 yield* adapter.stopSession(threadId);
317
318 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
319 const modeRequest = requests
320 .toReversed()
321 .find(
322 (entry) =>
323 entry.method === "session/set_mode" ||
324 (entry.method === "session/set_config_option" &&
325 (entry.params as Record<string, unknown> | undefined)?.configId === "mode"),
326 );
327 assert.isDefined(modeRequest);
328 assert.equal(
329 (modeRequest?.params as Record<string, unknown> | undefined)?.sessionId,
330 "mock-session-1",
331 );
332 assert.include(
333 ["architect", "plan"],
334 String(
335 (modeRequest?.params as Record<string, unknown> | undefined)?.modeId ??
336 (modeRequest?.params as Record<string, unknown> | undefined)?.value,
337 ),
338 );
339 }),
340 );
341
342 it.effect(
343 "applies initial model and mode configuration during startSession and skips repeating it on first send",
344 () =>
345 Effect.gen(function* () {
346 const adapter = yield* CursorAdapter;
347 const serverSettings = yield* ServerSettingsService;
348 const threadId = ThreadId.make("cursor-initial-config-probe");
349 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
350 const requestLogPath = path.join(tempDir, "requests.ndjson");
351 const argvLogPath = path.join(tempDir, "argv.txt");
352 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
353 const wrapperPath = yield* Effect.promise(() =>
354 makeProbeWrapper(requestLogPath, argvLogPath),
355 );
356 yield* serverSettings.updateSettings({
357 providers: { cursor: { binaryPath: wrapperPath } },
358 });
359
360 const modelSelection = {
361 provider: "cursor" as const,
362 model: "gpt-5.4",
363 options: {
364 reasoning: "xhigh" as const,
365 contextWindow: "1m",
366 fastMode: true,
367 },
368 };
369
370 yield* adapter.startSession({
371 threadId,
372 provider: "cursor",
373 cwd: process.cwd(),
374 runtimeMode: "full-access",
375 modelSelection,
376 });
377
378 yield* Effect.promise(() => waitForFileContent(requestLogPath));
379
380 const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath));
381 const configIdsAfterStart = requestsAfterStart.flatMap((entry) =>
382 entry.method === "session/set_config_option" &&
383 typeof (entry.params as Record<string, unknown> | undefined)?.configId === "string"
384 ? [String((entry.params as Record<string, unknown>).configId)]
385 : [],
386 );
387 assert.deepStrictEqual(configIdsAfterStart, [
388 "model",
389 "reasoning",
390 "context",
391 "fast",
392 "mode",
393 ]);
394
395 yield* adapter.sendTurn({
396 threadId,
397 input: "hello mock",
398 attachments: [],
399 modelSelection,
400 interactionMode: "default",
401 });
402 yield* adapter.stopSession(threadId);
403
404 const finalRequests = yield* Effect.promise(() => readJsonLines(requestLogPath));
405 const finalConfigIds = finalRequests.flatMap((entry) =>
406 entry.method === "session/set_config_option" &&
407 typeof (entry.params as Record<string, unknown> | undefined)?.configId === "string"
408 ? [String((entry.params as Record<string, unknown>).configId)]
409 : [],
410 );
411 assert.deepStrictEqual(finalConfigIds, ["model", "reasoning", "context", "fast", "mode"]);
412 assert.equal(finalRequests.filter((entry) => entry.method === "session/prompt").length, 1);
413 }),
414 );
415
416 it.effect(
417 "streams ACP tool calls and approvals on the active turn in approval-required mode",
418 () =>
419 Effect.gen(function* () {
420 const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS;
421 process.env.T3_ACP_EMIT_TOOL_CALLS = "1";
422
423 const adapter = yield* CursorAdapter;
424 const serverSettings = yield* ServerSettingsService;
425 const threadId = ThreadId.make("cursor-tool-call-probe");
426 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
427 const settledEventTypes = new Set<string>();
428 const settledEventsReady = yield* Deferred.make<void>();
429
430 const wrapperPath = yield* Effect.promise(() =>
431 makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }),
432 );
433 yield* serverSettings.updateSettings({
434 providers: { cursor: { binaryPath: wrapperPath } },
435 });
436
437 yield* Stream.runForEach(adapter.streamEvents, (event) =>
438 Effect.gen(function* () {
439 runtimeEvents.push(event);
440 if (String(event.threadId) !== String(threadId)) {
441 return;
442 }
443 if (event.type === "request.opened" && event.requestId) {
444 yield* adapter.respondToRequest(
445 threadId,
446 ApprovalRequestId.make(String(event.requestId)),
447 "accept",
448 );
449 }
450 if (
451 event.type === "turn.completed" ||
452 (event.type === "item.completed" && event.payload.itemType === "command_execution") ||
453 event.type === "content.delta"
454 ) {
455 settledEventTypes.add(event.type);
456 if (settledEventTypes.size === 3) {
457 yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie);
458 }
459 }
460 }),
461 ).pipe(Effect.forkChild);
462
463 const program = Effect.gen(function* () {
464 yield* adapter.startSession({
465 threadId,
466 provider: "cursor",
467 cwd: process.cwd(),
468 runtimeMode: "approval-required",
469 modelSelection: { provider: "cursor", model: "default" },
470 });
471
472 const turn = yield* adapter.sendTurn({
473 threadId,
474 input: "run a tool call",
475 attachments: [],
476 });
477 yield* Deferred.await(settledEventsReady);
478
479 const threadEvents = runtimeEvents.filter(
480 (event) => String(event.threadId) === String(threadId),
481 );
482 assert.includeMembers(
483 threadEvents.map((event) => event.type),
484 [
485 "session.started",
486 "session.state.changed",
487 "thread.started",
488 "turn.started",
489 "request.opened",
490 "request.resolved",
491 "item.updated",
492 "item.completed",
493 "content.delta",
494 "turn.completed",
495 ],
496 );
497
498 const turnEvents = threadEvents.filter(
499 (event) => String(event.turnId) === String(turn.turnId),
500 );
501 const toolUpdates = turnEvents.filter((event) => event.type === "item.updated");
502 // ACP updates can arrive either as distinct pending + in-progress events
503 // or as a single coalesced in-progress update before approval resolves.
504 assert.isAtLeast(toolUpdates.length, 1);
505 for (const toolUpdate of toolUpdates) {
506 if (toolUpdate.type !== "item.updated") {
507 continue;
508 }
509 assert.equal(toolUpdate.payload.itemType, "command_execution");
510 assert.equal(toolUpdate.payload.status, "inProgress");
511 assert.equal(toolUpdate.payload.detail, "cat server/package.json");
512 assert.equal(String(toolUpdate.itemId), "tool-call-1");
513 }
514
515 const requestOpened = turnEvents.find((event) => event.type === "request.opened");
516 assert.isDefined(requestOpened);
517 if (requestOpened?.type === "request.opened") {
518 assert.equal(String(requestOpened.turnId), String(turn.turnId));
519 assert.equal(requestOpened.payload.requestType, "exec_command_approval");
520 assert.equal(requestOpened.payload.detail, "cat server/package.json");
521 }
522
523 const requestResolved = turnEvents.find((event) => event.type === "request.resolved");
524 assert.isDefined(requestResolved);
525 if (requestResolved?.type === "request.resolved") {
526 assert.equal(String(requestResolved.turnId), String(turn.turnId));
527 assert.equal(requestResolved.payload.requestType, "exec_command_approval");
528 assert.equal(requestResolved.payload.decision, "accept");
529 }
530
531 const toolCompleted = turnEvents.find(
532 (event) =>
533 event.type === "item.completed" && event.payload.itemType === "command_execution",
534 );
535 assert.isDefined(toolCompleted);
536 if (toolCompleted?.type === "item.completed") {
537 assert.equal(String(toolCompleted.turnId), String(turn.turnId));
538 assert.equal(toolCompleted.payload.itemType, "command_execution");
539 assert.equal(toolCompleted.payload.status, "completed");
540 assert.equal(toolCompleted.payload.detail, "cat server/package.json");
541 assert.equal(String(toolCompleted.itemId), "tool-call-1");
542 }
543
544 const contentDelta = turnEvents.find((event) => event.type === "content.delta");
545 assert.isDefined(contentDelta);
546 if (contentDelta?.type === "content.delta") {
547 assert.equal(String(contentDelta.turnId), String(turn.turnId));
548 assert.equal(contentDelta.payload.delta, "hello from mock");
549 assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0");
550 }
551 });
552
553 yield* program.pipe(
554 Effect.ensuring(
555 Effect.sync(() => {
556 if (previousEmitToolCalls === undefined) {
557 delete process.env.T3_ACP_EMIT_TOOL_CALLS;
558 } else {
559 process.env.T3_ACP_EMIT_TOOL_CALLS = previousEmitToolCalls;
560 }
561 }),
562 ),
563 );
564 }).pipe(
565 Effect.provide(
566 makeCursorAdapterLive().pipe(
567 Layer.provideMerge(ServerSettingsService.layerTest()),
568 Layer.provideMerge(
569 ServerConfig.layerTest(process.cwd(), {
570 prefix: "t3code-cursor-adapter-test-",
571 }),
572 ),
573 Layer.provideMerge(NodeServices.layer),
574 ),
575 ),
576 ),
577 );
578
579 it.effect(
580 "auto-approves ACP tool permissions in full-access mode without approval runtime events",
581 () =>
582 Effect.gen(function* () {
583 const adapter = yield* CursorAdapter;
584 const serverSettings = yield* ServerSettingsService;
585 const threadId = ThreadId.make("cursor-full-access-auto-approve");
586 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
587 const settledEventTypes = new Set<string>();
588 const settledEventsReady = yield* Deferred.make<void>();
589 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
590 const requestLogPath = path.join(tempDir, "requests.ndjson");
591 const argvLogPath = path.join(tempDir, "argv.txt");
592 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
593 const wrapperPath = yield* Effect.promise(() =>
594 makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }),
595 );
596 yield* serverSettings.updateSettings({
597 providers: { cursor: { binaryPath: wrapperPath } },
598 });
599
600 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
601 Effect.gen(function* () {
602 runtimeEvents.push(event);
603 if (String(event.threadId) !== String(threadId)) {
604 return;
605 }
606 if (
607 event.type === "turn.completed" ||
608 (event.type === "item.completed" && event.payload.itemType === "command_execution") ||
609 event.type === "content.delta"
610 ) {
611 settledEventTypes.add(event.type);
612 if (settledEventTypes.size === 3) {
613 yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie);
614 }
615 }
616 }),
617 ).pipe(Effect.forkChild);
618
619 yield* adapter.startSession({
620 threadId,
621 provider: "cursor",
622 cwd: process.cwd(),
623 runtimeMode: "full-access",
624 modelSelection: { provider: "cursor", model: "default" },
625 });
626
627 const turn = yield* adapter.sendTurn({
628 threadId,
629 input: "run a tool call",
630 attachments: [],
631 });
632
633 yield* Deferred.await(settledEventsReady);
634 yield* Fiber.interrupt(runtimeEventsFiber);
635
636 const turnEvents = runtimeEvents.filter(
637 (event) =>
638 String(event.threadId) === String(threadId) &&
639 String(event.turnId) === String(turn.turnId),
640 );
641 assert.notInclude(
642 turnEvents.map((event) => event.type),
643 "request.opened",
644 );
645 assert.notInclude(
646 turnEvents.map((event) => event.type),
647 "request.resolved",
648 );
649 assert.includeMembers(
650 turnEvents.map((event) => event.type),
651 ["item.updated", "item.completed", "content.delta", "turn.completed"],
652 );
653
654 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
655 const permissionResponse = requests.find(
656 (entry) =>
657 !("method" in entry) &&
658 typeof entry.result === "object" &&
659 entry.result !== null &&
660 "outcome" in entry.result &&
661 typeof entry.result.outcome === "object" &&
662 entry.result.outcome !== null &&
663 "outcome" in entry.result.outcome &&
664 entry.result.outcome.outcome === "selected" &&
665 "optionId" in entry.result.outcome &&
666 entry.result.outcome.optionId === "allow-always",
667 );
668 assert.isDefined(permissionResponse);
669
670 yield* adapter.stopSession(threadId);
671 }),
672 );
673
674 it.effect("segments assistant messages around ACP tool activity in full-access mode", () =>
675 Effect.gen(function* () {
676 const adapter = yield* CursorAdapter;
677 const serverSettings = yield* ServerSettingsService;
678 const threadId = ThreadId.make("cursor-assistant-tool-segmentation");
679 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
680 const settledEventTypes = new Set<string>();
681 const settledEventsReady = yield* Deferred.make<void>();
682
683 const wrapperPath = yield* Effect.promise(() =>
684 makeMockAgentWrapper({ T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1" }),
685 );
686 yield* serverSettings.updateSettings({
687 providers: { cursor: { binaryPath: wrapperPath } },
688 });
689
690 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
691 Effect.gen(function* () {
692 runtimeEvents.push(event);
693 if (String(event.threadId) !== String(threadId)) {
694 return;
695 }
696 if (
697 event.type === "content.delta" ||
698 (event.type === "item.completed" && event.payload.itemType === "command_execution") ||
699 event.type === "turn.completed"
700 ) {
701 if (event.type === "content.delta") {
702 settledEventTypes.add(`delta:${event.payload.delta}`);
703 } else {
704 settledEventTypes.add(event.type);
705 }
706 if (
707 settledEventTypes.has("delta:before tool") &&
708 settledEventTypes.has("delta:after tool") &&
709 settledEventTypes.has("item.completed") &&
710 settledEventTypes.has("turn.completed")
711 ) {
712 yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie);
713 }
714 }
715 }),
716 ).pipe(Effect.forkChild);
717
718 yield* adapter.startSession({
719 threadId,
720 provider: "cursor",
721 cwd: process.cwd(),
722 runtimeMode: "full-access",
723 modelSelection: { provider: "cursor", model: "default" },
724 });
725
726 const turn = yield* adapter.sendTurn({
727 threadId,
728 input: "run an interleaved tool call",
729 attachments: [],
730 });
731
732 yield* Deferred.await(settledEventsReady);
733 yield* Fiber.interrupt(runtimeEventsFiber);
734
735 const turnEvents = runtimeEvents.filter(
736 (event) =>
737 String(event.threadId) === String(threadId) &&
738 String(event.turnId) === String(turn.turnId),
739 );
740 const firstAssistantStartIndex = turnEvents.findIndex(
741 (event) => event.type === "item.started" && event.payload.itemType === "assistant_message",
742 );
743 const firstAssistantDeltaIndex = turnEvents.findIndex(
744 (event) => event.type === "content.delta" && event.payload.delta === "before tool",
745 );
746 const assistantBoundaryIndex = turnEvents.findIndex(
747 (event) =>
748 event.type === "item.completed" && event.payload.itemType === "assistant_message",
749 );
750 const toolUpdateIndex = turnEvents.findIndex(
751 (event) => event.type === "item.updated" && event.payload.itemType === "command_execution",
752 );
753 const toolCompletedIndex = turnEvents.findIndex(
754 (event) =>
755 event.type === "item.completed" && event.payload.itemType === "command_execution",
756 );
757 const secondAssistantStartIndex = turnEvents.findIndex(
758 (event, index) =>
759 index > toolCompletedIndex &&
760 event.type === "item.started" &&
761 event.payload.itemType === "assistant_message",
762 );
763 const secondAssistantDeltaIndex = turnEvents.findIndex(
764 (event) => event.type === "content.delta" && event.payload.delta === "after tool",
765 );
766
767 assert.isAtLeast(firstAssistantStartIndex, 0);
768 assert.isAtLeast(firstAssistantDeltaIndex, 0);
769 assert.isAtLeast(assistantBoundaryIndex, 0);
770 assert.isAtLeast(toolUpdateIndex, 0);
771 assert.isAtLeast(toolCompletedIndex, 0);
772 assert.isAtLeast(secondAssistantStartIndex, 0);
773 assert.isAtLeast(secondAssistantDeltaIndex, 0);
774 assert.isBelow(firstAssistantStartIndex, firstAssistantDeltaIndex);
775 assert.isBelow(firstAssistantDeltaIndex, assistantBoundaryIndex);
776 assert.isBelow(assistantBoundaryIndex, toolUpdateIndex);
777 assert.isBelow(toolUpdateIndex, toolCompletedIndex);
778 assert.isBelow(toolCompletedIndex, secondAssistantStartIndex);
779 assert.isBelow(secondAssistantStartIndex, secondAssistantDeltaIndex);
780
781 const assistantStarts = turnEvents.filter(
782 (event) => event.type === "item.started" && event.payload.itemType === "assistant_message",
783 );
784 const assistantDeltas = turnEvents.filter((event) => event.type === "content.delta");
785 assert.lengthOf(assistantStarts, 2);
786 assert.lengthOf(assistantDeltas, 2);
787 if (
788 assistantStarts[0]?.type === "item.started" &&
789 assistantStarts[1]?.type === "item.started" &&
790 assistantDeltas[0]?.type === "content.delta" &&
791 assistantDeltas[1]?.type === "content.delta"
792 ) {
793 assert.notEqual(String(assistantStarts[0].itemId), String(assistantStarts[1].itemId));
794 assert.equal(String(assistantDeltas[0].itemId), String(assistantStarts[0].itemId));
795 assert.equal(String(assistantDeltas[1].itemId), String(assistantStarts[1].itemId));
796 }
797
798 yield* adapter.stopSession(threadId);
799 }),
800 );
801
802 it.effect("cancels pending ACP approvals and marks the turn cancelled when interrupted", () =>
803 Effect.gen(function* () {
804 const adapter = yield* CursorAdapter;
805 const serverSettings = yield* ServerSettingsService;
806 const threadId = ThreadId.make("cursor-cancel-probe");
807 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
808 const requestLogPath = path.join(tempDir, "requests.ndjson");
809 const argvLogPath = path.join(tempDir, "argv.txt");
810 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
811 const wrapperPath = yield* Effect.promise(() =>
812 makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }),
813 );
814 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
815
816 const requestResolvedReady = yield* Deferred.make<ProviderRuntimeEvent>();
817 const turnCompletedReady = yield* Deferred.make<ProviderRuntimeEvent>();
818 let interrupted = false;
819
820 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
821 Effect.gen(function* () {
822 if (String(event.threadId) !== String(threadId)) {
823 return;
824 }
825 if (event.type === "request.opened" && !interrupted) {
826 interrupted = true;
827 yield* adapter.interruptTurn(threadId);
828 return;
829 }
830 if (event.type === "request.resolved") {
831 yield* Deferred.succeed(requestResolvedReady, event).pipe(Effect.ignore);
832 return;
833 }
834 if (event.type === "turn.completed") {
835 yield* Deferred.succeed(turnCompletedReady, event).pipe(Effect.ignore);
836 }
837 }),
838 ).pipe(Effect.forkChild);
839
840 yield* adapter.startSession({
841 threadId,
842 provider: "cursor",
843 cwd: process.cwd(),
844 runtimeMode: "approval-required",
845 modelSelection: { provider: "cursor", model: "default" },
846 });
847
848 const sendTurnFiber = yield* adapter
849 .sendTurn({
850 threadId,
851 input: "cancel this turn",
852 attachments: [],
853 })
854 .pipe(Effect.forkChild);
855
856 const requestResolved = yield* Deferred.await(requestResolvedReady);
857 const turnCompleted = yield* Deferred.await(turnCompletedReady);
858 yield* Fiber.join(sendTurnFiber);
859 yield* Fiber.interrupt(runtimeEventsFiber);
860
861 assert.equal(requestResolved.type, "request.resolved");
862 if (requestResolved.type === "request.resolved") {
863 assert.equal(requestResolved.payload.decision, "cancel");
864 }
865
866 assert.equal(turnCompleted.type, "turn.completed");
867 if (turnCompleted.type === "turn.completed") {
868 assert.equal(turnCompleted.payload.state, "cancelled");
869 assert.equal(turnCompleted.payload.stopReason, "cancelled");
870 }
871
872 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
873 assert.isTrue(requests.some((entry) => entry.method === "session/cancel"));
874 assert.isTrue(
875 requests.some(
876 (entry) =>
877 !("method" in entry) &&
878 typeof entry.result === "object" &&
879 entry.result !== null &&
880 "outcome" in entry.result &&
881 typeof entry.result.outcome === "object" &&
882 entry.result.outcome !== null &&
883 "outcome" in entry.result.outcome &&
884 entry.result.outcome.outcome === "cancelled",
885 ),
886 );
887
888 yield* adapter.stopSession(threadId);
889 }),
890 );
891 it.effect("stopping a session settles pending approval waits", () =>
892 Effect.gen(function* () {
893 const adapter = yield* CursorAdapter;
894 const serverSettings = yield* ServerSettingsService;
895 const threadId = ThreadId.make("cursor-stop-pending-approval");
896 const approvalRequested = yield* Deferred.make<void>();
897
898 const wrapperPath = yield* Effect.promise(() =>
899 makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }),
900 );
901 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
902
903 yield* Stream.runForEach(adapter.streamEvents, (event) => {
904 if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") {
905 return Effect.void;
906 }
907 return Deferred.succeed(approvalRequested, undefined).pipe(Effect.ignore);
908 }).pipe(Effect.forkChild);
909
910 yield* adapter.startSession({
911 threadId,
912 provider: "cursor",
913 cwd: process.cwd(),
914 runtimeMode: "approval-required",
915 modelSelection: { provider: "cursor", model: "default" },
916 });
917
918 const sendTurnFiber = yield* adapter
919 .sendTurn({
920 threadId,
921 input: "run a tool call and then stop",
922 attachments: [],
923 })
924 .pipe(Effect.forkChild);
925
926 yield* Deferred.await(approvalRequested);
927 yield* adapter.stopSession(threadId);
928 yield* Fiber.await(sendTurnFiber);
929
930 assert.equal(yield* adapter.hasSession(threadId), false);
931 }),
932 );
933
934 it.effect("stopping a session settles pending user-input waits", () =>
935 Effect.gen(function* () {
936 const adapter = yield* CursorAdapter;
937 const serverSettings = yield* ServerSettingsService;
938 const threadId = ThreadId.make("cursor-stop-pending-user-input");
939 const userInputRequested = yield* Deferred.make<void>();
940
941 const wrapperPath = yield* Effect.promise(() =>
942 makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }),
943 );
944 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
945
946 yield* Stream.runForEach(adapter.streamEvents, (event) => {
947 if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") {
948 return Effect.void;
949 }
950 return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore);
951 }).pipe(Effect.forkChild);
952
953 yield* adapter.startSession({
954 threadId,
955 provider: "cursor",
956 cwd: process.cwd(),
957 runtimeMode: "full-access",
958 modelSelection: { provider: "cursor", model: "default" },
959 });
960
961 const sendTurnFiber = yield* adapter
962 .sendTurn({
963 threadId,
964 input: "ask me a question and then stop",
965 attachments: [],
966 })
967 .pipe(Effect.forkChild);
968
969 yield* Deferred.await(userInputRequested);
970 yield* adapter.stopSession(threadId);
971 yield* Fiber.await(sendTurnFiber);
972
973 assert.equal(yield* adapter.hasSession(threadId), false);
974 }),
975 );
976
977 it.effect("interrupting a session settles pending user-input waits", () =>
978 Effect.gen(function* () {
979 const adapter = yield* CursorAdapter;
980 const serverSettings = yield* ServerSettingsService;
981 const threadId = ThreadId.make("cursor-interrupt-pending-user-input");
982 const userInputRequested = yield* Deferred.make<void>();
983
984 const wrapperPath = yield* Effect.promise(() =>
985 makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }),
986 );
987 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
988
989 yield* Stream.runForEach(adapter.streamEvents, (event) => {
990 if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") {
991 return Effect.void;
992 }
993 return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore);
994 }).pipe(Effect.forkChild);
995
996 yield* adapter.startSession({
997 threadId,
998 provider: "cursor",
999 cwd: process.cwd(),
1000 runtimeMode: "full-access",
1001 modelSelection: { provider: "cursor", model: "default" },
1002 });
1003
1004 const sendTurnFiber = yield* adapter
1005 .sendTurn({
1006 threadId,
1007 input: "ask me a question and then interrupt",
1008 attachments: [],
1009 })
1010 .pipe(Effect.forkChild);
1011
1012 yield* Deferred.await(userInputRequested);
1013 yield* adapter.interruptTurn(threadId);
1014 yield* Fiber.await(sendTurnFiber);
1015
1016 assert.equal(yield* adapter.hasSession(threadId), true);
1017 yield* adapter.stopSession(threadId);
1018 }),
1019 );
1020
1021 it.effect("broadcasts runtime events to multiple stream consumers", () =>
1022 Effect.gen(function* () {
1023 const adapter = yield* CursorAdapter;
1024 const settings = yield* ServerSettingsService;
1025 const threadId = ThreadId.make("cursor-runtime-event-broadcast");
1026
1027 const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper());
1028 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
1029
1030 const firstConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe(
1031 Stream.runCollect,
1032 Effect.forkChild,
1033 );
1034 const secondConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe(
1035 Stream.runCollect,
1036 Effect.forkChild,
1037 );
1038
1039 yield* adapter.startSession({
1040 threadId,
1041 provider: "cursor",
1042 cwd: process.cwd(),
1043 runtimeMode: "full-access",
1044 modelSelection: { provider: "cursor", model: "default" },
1045 });
1046
1047 const firstEvents = Array.from(yield* Fiber.join(firstConsumer));
1048 const secondEvents = Array.from(yield* Fiber.join(secondConsumer));
1049
1050 assert.deepStrictEqual(
1051 firstEvents.map((event) => event.type),
1052 ["session.started", "session.state.changed", "thread.started"],
1053 );
1054 assert.deepStrictEqual(
1055 secondEvents.map((event) => event.type),
1056 ["session.started", "session.state.changed", "thread.started"],
1057 );
1058
1059 yield* adapter.stopSession(threadId);
1060 }),
1061 );
1062
1063 it.effect("switches model in-session via session/set_config_option", () =>
1064 Effect.gen(function* () {
1065 const adapter = yield* CursorAdapter;
1066 const serverSettings = yield* ServerSettingsService;
1067 const threadId = ThreadId.make("cursor-model-switch");
1068 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
1069 const requestLogPath = path.join(tempDir, "requests.ndjson");
1070 const argvLogPath = path.join(tempDir, "argv.txt");
1071 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
1072 const wrapperPath = yield* Effect.promise(() =>
1073 makeProbeWrapper(requestLogPath, argvLogPath),
1074 );
1075 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
1076
1077 yield* adapter.startSession({
1078 threadId,
1079 provider: "cursor",
1080 cwd: process.cwd(),
1081 runtimeMode: "full-access",
1082 modelSelection: { provider: "cursor", model: "composer-2" },
1083 });
1084
1085 yield* adapter.sendTurn({
1086 threadId,
1087 input: "first turn",
1088 attachments: [],
1089 });
1090
1091 yield* adapter.sendTurn({
1092 threadId,
1093 input: "second turn after switching model",
1094 attachments: [],
1095 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } },
1096 });
1097
1098 const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath));
1099 assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn");
1100 assert.deepStrictEqual(argvRuns[0], ["acp"]);
1101
1102 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
1103 const setConfigRequests = requests.filter(
1104 (entry) =>
1105 entry.method === "session/set_config_option" &&
1106 (entry.params as Record<string, unknown> | undefined)?.configId === "model",
1107 );
1108 assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option");
1109 assert.equal((setConfigRequests[0]?.params as Record<string, unknown>)?.value, "composer-2");
1110
1111 const fastConfigRequests = requests.filter(
1112 (entry) =>
1113 entry.method === "session/set_config_option" &&
1114 (entry.params as Record<string, unknown> | undefined)?.configId === "fast",
1115 );
1116 assert.isAbove(fastConfigRequests.length, 0, "should apply fast mode as a separate config");
1117 const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1];
1118 assert.equal((lastFastConfig?.params as Record<string, unknown>)?.value, "true");
1119
1120 yield* adapter.stopSession(threadId);
1121 }),
1122 );
1123
1124 it.effect("clears prior fast mode in-session when the next turn sets fastMode: false", () =>
1125 Effect.gen(function* () {
1126 const adapter = yield* CursorAdapter;
1127 const serverSettings = yield* ServerSettingsService;
1128 const threadId = ThreadId.make("cursor-fast-mode-reset");
1129 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
1130 const requestLogPath = path.join(tempDir, "requests.ndjson");
1131 const argvLogPath = path.join(tempDir, "argv.txt");
1132 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
1133 const wrapperPath = yield* Effect.promise(() =>
1134 makeProbeWrapper(requestLogPath, argvLogPath),
1135 );
1136 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
1137
1138 yield* adapter.startSession({
1139 threadId,
1140 provider: "cursor",
1141 cwd: process.cwd(),
1142 runtimeMode: "full-access",
1143 modelSelection: { provider: "cursor", model: "composer-2" },
1144 });
1145
1146 yield* adapter.sendTurn({
1147 threadId,
1148 input: "first turn with fast mode",
1149 attachments: [],
1150 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } },
1151 });
1152
1153 yield* adapter.sendTurn({
1154 threadId,
1155 input: "second turn without fast mode",
1156 attachments: [],
1157 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } },
1158 });
1159
1160 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
1161 const fastConfigRequests = requests.filter(
1162 (entry) =>
1163 entry.method === "session/set_config_option" &&
1164 (entry.params as Record<string, unknown> | undefined)?.configId === "fast",
1165 );
1166 assert.isAtLeast(fastConfigRequests.length, 2, "should set fast mode on and then off");
1167
1168 const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1];
1169 assert.equal((lastFastConfig?.params as Record<string, unknown>)?.value, "false");
1170
1171 yield* adapter.stopSession(threadId);
1172 }),
1173 );
1174});
apps/server/src/provider/Layers/CursorAdapter.ts+1057−0
@@ -0,0 +1,1057 @@
1/**
2 * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP.
3 *
4 * @module CursorAdapterLive
5 */
6import * as nodePath from "node:path";
7
8import {
9 ApprovalRequestId,
10 type CursorModelOptions,
11 EventId,
12 type ProviderApprovalDecision,
13 type ProviderInteractionMode,
14 type ProviderRuntimeEvent,
15 type ProviderSession,
16 type ProviderUserInputAnswers,
17 RuntimeRequestId,
18 type RuntimeMode,
19 type ThreadId,
20 TurnId,
21} from "@t3tools/contracts";
22import {
23 DateTime,
24 Deferred,
25 Effect,
26 Exit,
27 Fiber,
28 FileSystem,
29 Layer,
30 Option,
31 PubSub,
32 Random,
33 Scope,
34 Semaphore,
35 Stream,
36 SynchronizedRef,
37} from "effect";
38import { ChildProcessSpawner } from "effect/unstable/process";
39import type * as EffectAcpSchema from "effect-acp/schema";
40
41import { resolveAttachmentPath } from "../../attachmentStore.ts";
42import { ServerConfig } from "../../config.ts";
43import { ServerSettingsService } from "../../serverSettings.ts";
44import {
45 ProviderAdapterProcessError,
46 ProviderAdapterRequestError,
47 ProviderAdapterSessionNotFoundError,
48 ProviderAdapterValidationError,
49} from "../Errors.ts";
50import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
51import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts";
52import {
53 makeAcpAssistantItemEvent,
54 makeAcpContentDeltaEvent,
55 makeAcpPlanUpdatedEvent,
56 makeAcpRequestOpenedEvent,
57 makeAcpRequestResolvedEvent,
58 makeAcpToolCallEvent,
59} from "../acp/AcpCoreRuntimeEvents.ts";
60import {
61 type AcpSessionMode,
62 type AcpSessionModeState,
63 parsePermissionRequest,
64} from "../acp/AcpRuntimeModel.ts";
65import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts";
66import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts";
67import {
68 CursorAskQuestionRequest,
69 CursorCreatePlanRequest,
70 CursorUpdateTodosRequest,
71 extractAskQuestions,
72 extractPlanMarkdown,
73 extractTodosAsPlan,
74} from "../acp/CursorAcpExtension.ts";
75import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts";
76import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
77import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
78
79const PROVIDER = "cursor" as const;
80const CURSOR_RESUME_VERSION = 1 as const;
81const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
82const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
83const ACP_APPROVAL_MODE_ALIASES = ["ask"];
84
85export interface CursorAdapterLiveOptions {
86 readonly nativeEventLogPath?: string;
87 readonly nativeEventLogger?: EventNdjsonLogger;
88}
89
90interface PendingApproval {
91 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
92 readonly kind: string | "unknown";
93}
94
95interface PendingUserInput {
96 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
97}
98
99interface CursorSessionContext {
100 readonly threadId: ThreadId;
101 session: ProviderSession;
102 readonly scope: Scope.Closeable;
103 readonly acp: AcpSessionRuntimeShape;
104 notificationFiber: Fiber.Fiber<void, never> | undefined;
105 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
106 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
107 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
108 lastPlanFingerprint: string | undefined;
109 activeTurnId: TurnId | undefined;
110 stopped: boolean;
111}
112
113function settlePendingApprovalsAsCancelled(
114 pendingApprovals: ReadonlyMap<ApprovalRequestId, PendingApproval>,
115): Effect.Effect<void> {
116 const pendingEntries = Array.from(pendingApprovals.values());
117 return Effect.forEach(
118 pendingEntries,
119 (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore),
120 {
121 discard: true,
122 },
123 );
124}
125
126function settlePendingUserInputsAsEmptyAnswers(
127 pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>,
128): Effect.Effect<void> {
129 const pendingEntries = Array.from(pendingUserInputs.values());
130 return Effect.forEach(
131 pendingEntries,
132 (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore),
133 {
134 discard: true,
135 },
136 );
137}
138
139function isRecord(value: unknown): value is Record<string, unknown> {
140 return typeof value === "object" && value !== null && !Array.isArray(value);
141}
142
143function parseCursorResume(raw: unknown): { sessionId: string } | undefined {
144 if (!isRecord(raw)) return undefined;
145 if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined;
146 if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined;
147 return { sessionId: raw.sessionId.trim() };
148}
149
150function normalizeModeSearchText(mode: AcpSessionMode): string {
151 return [mode.id, mode.name, mode.description]
152 .filter((value): value is string => typeof value === "string" && value.length > 0)
153 .join(" ")
154 .toLowerCase()
155 .replace(/[^a-z0-9]+/g, " ")
156 .trim();
157}
158
159function findModeByAliases(
160 modes: ReadonlyArray<AcpSessionMode>,
161 aliases: ReadonlyArray<string>,
162): AcpSessionMode | undefined {
163 const normalizedAliases = aliases.map((alias) => alias.toLowerCase());
164 for (const alias of normalizedAliases) {
165 const exact = modes.find((mode) => {
166 const id = mode.id.toLowerCase();
167 const name = mode.name.toLowerCase();
168 return id === alias || name === alias;
169 });
170 if (exact) {
171 return exact;
172 }
173 }
174 for (const alias of normalizedAliases) {
175 const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias));
176 if (partial) {
177 return partial;
178 }
179 }
180 return undefined;
181}
182
183function isPlanMode(mode: AcpSessionMode): boolean {
184 return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined;
185}
186
187function resolveRequestedModeId(input: {
188 readonly interactionMode: ProviderInteractionMode | undefined;
189 readonly runtimeMode: RuntimeMode;
190 readonly modeState: AcpSessionModeState | undefined;
191}): string | undefined {
192 const modeState = input.modeState;
193 if (!modeState) {
194 return undefined;
195 }
196
197 if (input.interactionMode === "plan") {
198 return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id;
199 }
200
201 if (input.runtimeMode === "approval-required") {
202 return (
203 findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
204 findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
205 modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
206 modeState.currentModeId
207 );
208 }
209
210 return (
211 findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
212 findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
213 modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
214 modeState.currentModeId
215 );
216}
217
218function applyRequestedSessionConfiguration<E>(input: {
219 readonly runtime: AcpSessionRuntimeShape;
220 readonly runtimeMode: RuntimeMode;
221 readonly interactionMode: ProviderInteractionMode | undefined;
222 readonly modelSelection:
223 | {
224 readonly model: string;
225 readonly options?: CursorModelOptions | null | undefined;
226 }
227 | undefined;
228 readonly mapError: (context: {
229 readonly cause: import("effect-acp/errors").AcpError;
230 readonly method: "session/set_config_option" | "session/set_mode";
231 }) => E;
232}): Effect.Effect<void, E> {
233 return Effect.gen(function* () {
234 if (input.modelSelection) {
235 yield* applyCursorAcpModelSelection({
236 runtime: input.runtime,
237 model: input.modelSelection.model,
238 modelOptions: input.modelSelection.options,
239 mapError: ({ cause }) =>
240 input.mapError({
241 cause,
242 method: "session/set_config_option",
243 }),
244 });
245 }
246
247 const requestedModeId = resolveRequestedModeId({
248 interactionMode: input.interactionMode,
249 runtimeMode: input.runtimeMode,
250 modeState: yield* input.runtime.getModeState,
251 });
252 if (!requestedModeId) {
253 return;
254 }
255
256 yield* input.runtime.setMode(requestedModeId).pipe(
257 Effect.mapError((cause) =>
258 input.mapError({
259 cause,
260 method: "session/set_mode",
261 }),
262 ),
263 );
264 });
265}
266
267function selectAutoApprovedPermissionOption(
268 request: EffectAcpSchema.RequestPermissionRequest,
269): string | undefined {
270 const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always");
271 if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) {
272 return allowAlwaysOption.optionId.trim();
273 }
274
275 const allowOnceOption = request.options.find((option) => option.kind === "allow_once");
276 if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) {
277 return allowOnceOption.optionId.trim();
278 }
279
280 return undefined;
281}
282
283function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
284 return Effect.gen(function* () {
285 const fileSystem = yield* FileSystem.FileSystem;
286 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
287 const serverConfig = yield* Effect.service(ServerConfig);
288 const serverSettingsService = yield* ServerSettingsService;
289 const nativeEventLogger =
290 options?.nativeEventLogger ??
291 (options?.nativeEventLogPath !== undefined
292 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
293 stream: "native",
294 })
295 : undefined);
296 const managedNativeEventLogger =
297 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
298
299 const sessions = new Map<ThreadId, CursorSessionContext>();
300 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
301 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
302
303 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
304 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id));
305 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
306
307 const offerRuntimeEvent = (event: ProviderRuntimeEvent) =>
308 PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid);
309
310 const getThreadSemaphore = (threadId: string) =>
311 SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
312 const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr(
313 current.get(threadId),
314 );
315 return Option.match(existing, {
316 onNone: () =>
317 Semaphore.make(1).pipe(
318 Effect.map((semaphore) => {
319 const next = new Map(current);
320 next.set(threadId, semaphore);
321 return [semaphore, next] as const;
322 }),
323 ),
324 onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
325 });
326 });
327
328 const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) =>
329 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
330
331 const logNative = (
332 threadId: ThreadId,
333 method: string,
334 payload: unknown,
335 _source: "acp.jsonrpc" | "acp.cursor.extension",
336 ) =>
337 Effect.gen(function* () {
338 if (!nativeEventLogger) return;
339 const observedAt = new Date().toISOString();
340 yield* nativeEventLogger.write(
341 {
342 observedAt,
343 event: {
344 id: crypto.randomUUID(),
345 kind: "notification",
346 provider: PROVIDER,
347 createdAt: observedAt,
348 method,
349 threadId,
350 payload,
351 },
352 },
353 threadId,
354 );
355 });
356
357 const emitPlanUpdate = (
358 ctx: CursorSessionContext,
359 payload: {
360 readonly explanation?: string | null;
361 readonly plan: ReadonlyArray<{
362 readonly step: string;
363 readonly status: "pending" | "inProgress" | "completed";
364 }>;
365 },
366 rawPayload: unknown,
367 source: "acp.jsonrpc" | "acp.cursor.extension",
368 method: string,
369 ) =>
370 Effect.gen(function* () {
371 const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${JSON.stringify(payload)}`;
372 if (ctx.lastPlanFingerprint === fingerprint) {
373 return;
374 }
375 ctx.lastPlanFingerprint = fingerprint;
376 yield* offerRuntimeEvent(
377 makeAcpPlanUpdatedEvent({
378 stamp: yield* makeEventStamp(),
379 provider: PROVIDER,
380 threadId: ctx.threadId,
381 turnId: ctx.activeTurnId,
382 payload,
383 source,
384 method,
385 rawPayload,
386 }),
387 );
388 });
389
390 const requireSession = (
391 threadId: ThreadId,
392 ): Effect.Effect<CursorSessionContext, ProviderAdapterSessionNotFoundError> => {
393 const ctx = sessions.get(threadId);
394 if (!ctx || ctx.stopped) {
395 return Effect.fail(
396 new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }),
397 );
398 }
399 return Effect.succeed(ctx);
400 };
401
402 const stopSessionInternal = (ctx: CursorSessionContext) =>
403 Effect.gen(function* () {
404 if (ctx.stopped) return;
405 ctx.stopped = true;
406 yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
407 yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
408 if (ctx.notificationFiber) {
409 yield* Fiber.interrupt(ctx.notificationFiber);
410 }
411 yield* Effect.ignore(Scope.close(ctx.scope, Exit.void));
412 sessions.delete(ctx.threadId);
413 yield* offerRuntimeEvent({
414 type: "session.exited",
415 ...(yield* makeEventStamp()),
416 provider: PROVIDER,
417 threadId: ctx.threadId,
418 payload: { exitKind: "graceful" },
419 });
420 });
421
422 const startSession: CursorAdapterShape["startSession"] = (input) =>
423 withThreadLock(
424 input.threadId,
425 Effect.gen(function* () {
426 if (input.provider !== undefined && input.provider !== PROVIDER) {
427 return yield* new ProviderAdapterValidationError({
428 provider: PROVIDER,
429 operation: "startSession",
430 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
431 });
432 }
433 if (!input.cwd?.trim()) {
434 return yield* new ProviderAdapterValidationError({
435 provider: PROVIDER,
436 operation: "startSession",
437 issue: "cwd is required and must be non-empty.",
438 });
439 }
440
441 const cwd = nodePath.resolve(input.cwd.trim());
442 const cursorModelSelection =
443 input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined;
444 const existing = sessions.get(input.threadId);
445 if (existing && !existing.stopped) {
446 yield* stopSessionInternal(existing);
447 }
448
449 const cursorSettings = yield* serverSettingsService.getSettings.pipe(
450 Effect.map((settings) => settings.providers.cursor),
451 Effect.mapError(
452 (error) =>
453 new ProviderAdapterProcessError({
454 provider: PROVIDER,
455 threadId: input.threadId,
456 detail: error.message,
457 cause: error,
458 }),
459 ),
460 );
461
462 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
463 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
464 const sessionScope = yield* Scope.make("sequential");
465 let sessionScopeTransferred = false;
466 yield* Effect.addFinalizer(() =>
467 sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
468 );
469 let ctx!: CursorSessionContext;
470
471 const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId;
472 const acpNativeLoggers = makeAcpNativeLoggers({
473 nativeEventLogger,
474 provider: PROVIDER,
475 threadId: input.threadId,
476 });
477
478 const acp = yield* makeCursorAcpRuntime({
479 cursorSettings,
480 childProcessSpawner,
481 cwd,
482 ...(resumeSessionId ? { resumeSessionId } : {}),
483 clientInfo: { name: "t3-code", version: "0.0.0" },
484 ...acpNativeLoggers,
485 }).pipe(
486 Effect.provideService(Scope.Scope, sessionScope),
487 Effect.mapError(
488 (cause) =>
489 new ProviderAdapterProcessError({
490 provider: PROVIDER,
491 threadId: input.threadId,
492 detail: cause.message,
493 cause,
494 }),
495 ),
496 );
497 const started = yield* Effect.gen(function* () {
498 yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) =>
499 Effect.gen(function* () {
500 yield* logNative(
501 input.threadId,
502 "cursor/ask_question",
503 params,
504 "acp.cursor.extension",
505 );
506 const requestId = ApprovalRequestId.make(crypto.randomUUID());
507 const runtimeRequestId = RuntimeRequestId.make(requestId);
508 const answers = yield* Deferred.make<ProviderUserInputAnswers>();
509 pendingUserInputs.set(requestId, { answers });
510 yield* offerRuntimeEvent({
511 type: "user-input.requested",
512 ...(yield* makeEventStamp()),
513 provider: PROVIDER,
514 threadId: input.threadId,
515 turnId: ctx?.activeTurnId,
516 requestId: runtimeRequestId,
517 payload: { questions: extractAskQuestions(params) },
518 raw: {
519 source: "acp.cursor.extension",
520 method: "cursor/ask_question",
521 payload: params,
522 },
523 });
524 const resolved = yield* Deferred.await(answers);
525 pendingUserInputs.delete(requestId);
526 yield* offerRuntimeEvent({
527 type: "user-input.resolved",
528 ...(yield* makeEventStamp()),
529 provider: PROVIDER,
530 threadId: input.threadId,
531 turnId: ctx?.activeTurnId,
532 requestId: runtimeRequestId,
533 payload: { answers: resolved },
534 });
535 return { answers: resolved };
536 }),
537 );
538 yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) =>
539 Effect.gen(function* () {
540 yield* logNative(
541 input.threadId,
542 "cursor/create_plan",
543 params,
544 "acp.cursor.extension",
545 );
546 yield* offerRuntimeEvent({
547 type: "turn.proposed.completed",
548 ...(yield* makeEventStamp()),
549 provider: PROVIDER,
550 threadId: input.threadId,
551 turnId: ctx?.activeTurnId,
552 payload: { planMarkdown: extractPlanMarkdown(params) },
553 raw: {
554 source: "acp.cursor.extension",
555 method: "cursor/create_plan",
556 payload: params,
557 },
558 });
559 return { accepted: true } as const;
560 }),
561 );
562 yield* acp.handleExtNotification(
563 "cursor/update_todos",
564 CursorUpdateTodosRequest,
565 (params) =>
566 Effect.gen(function* () {
567 yield* logNative(
568 input.threadId,
569 "cursor/update_todos",
570 params,
571 "acp.cursor.extension",
572 );
573 if (ctx) {
574 yield* emitPlanUpdate(
575 ctx,
576 extractTodosAsPlan(params),
577 params,
578 "acp.cursor.extension",
579 "cursor/update_todos",
580 );
581 }
582 }),
583 );
584 yield* acp.handleRequestPermission((params) =>
585 Effect.gen(function* () {
586 yield* logNative(
587 input.threadId,
588 "session/request_permission",
589 params,
590 "acp.jsonrpc",
591 );
592 if (input.runtimeMode === "full-access") {
593 const autoApprovedOptionId = selectAutoApprovedPermissionOption(params);
594 if (autoApprovedOptionId !== undefined) {
595 return {
596 outcome: {
597 outcome: "selected" as const,
598 optionId: autoApprovedOptionId,
599 },
600 };
601 }
602 }
603 const permissionRequest = parsePermissionRequest(params);
604 const requestId = ApprovalRequestId.make(crypto.randomUUID());
605 const runtimeRequestId = RuntimeRequestId.make(requestId);
606 const decision = yield* Deferred.make<ProviderApprovalDecision>();
607 pendingApprovals.set(requestId, {
608 decision,
609 kind: permissionRequest.kind,
610 });
611 yield* offerRuntimeEvent(
612 makeAcpRequestOpenedEvent({
613 stamp: yield* makeEventStamp(),
614 provider: PROVIDER,
615 threadId: input.threadId,
616 turnId: ctx?.activeTurnId,
617 requestId: runtimeRequestId,
618 permissionRequest,
619 detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000),
620 args: params,
621 source: "acp.jsonrpc",
622 method: "session/request_permission",
623 rawPayload: params,
624 }),
625 );
626 const resolved = yield* Deferred.await(decision);
627 pendingApprovals.delete(requestId);
628 yield* offerRuntimeEvent(
629 makeAcpRequestResolvedEvent({
630 stamp: yield* makeEventStamp(),
631 provider: PROVIDER,
632 threadId: input.threadId,
633 turnId: ctx?.activeTurnId,
634 requestId: runtimeRequestId,
635 permissionRequest,
636 decision: resolved,
637 }),
638 );
639 return {
640 outcome:
641 resolved === "cancel"
642 ? ({ outcome: "cancelled" } as const)
643 : {
644 outcome: "selected" as const,
645 optionId: acpPermissionOutcome(resolved),
646 },
647 };
648 }),
649 );
650 return yield* acp.start();
651 }).pipe(
652 Effect.mapError((error) =>
653 mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error),
654 ),
655 );
656
657 yield* applyRequestedSessionConfiguration({
658 runtime: acp,
659 runtimeMode: input.runtimeMode,
660 interactionMode: undefined,
661 modelSelection: cursorModelSelection,
662 mapError: ({ cause, method }) =>
663 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
664 });
665
666 const now = yield* nowIso;
667 const session: ProviderSession = {
668 provider: PROVIDER,
669 status: "ready",
670 runtimeMode: input.runtimeMode,
671 cwd,
672 model: cursorModelSelection?.model,
673 threadId: input.threadId,
674 resumeCursor: {
675 schemaVersion: CURSOR_RESUME_VERSION,
676 sessionId: started.sessionId,
677 },
678 createdAt: now,
679 updatedAt: now,
680 };
681
682 ctx = {
683 threadId: input.threadId,
684 session,
685 scope: sessionScope,
686 acp,
687 notificationFiber: undefined,
688 pendingApprovals,
689 pendingUserInputs,
690 turns: [],
691 lastPlanFingerprint: undefined,
692 activeTurnId: undefined,
693 stopped: false,
694 };
695
696 const nf = yield* Stream.runDrain(
697 Stream.mapEffect(acp.getEvents(), (event) =>
698 Effect.gen(function* () {
699 switch (event._tag) {
700 case "ModeChanged":
701 return;
702 case "AssistantItemStarted":
703 yield* offerRuntimeEvent(
704 makeAcpAssistantItemEvent({
705 stamp: yield* makeEventStamp(),
706 provider: PROVIDER,
707 threadId: ctx.threadId,
708 turnId: ctx.activeTurnId,
709 itemId: event.itemId,
710 lifecycle: "item.started",
711 }),
712 );
713 return;
714 case "AssistantItemCompleted":
715 yield* offerRuntimeEvent(
716 makeAcpAssistantItemEvent({
717 stamp: yield* makeEventStamp(),
718 provider: PROVIDER,
719 threadId: ctx.threadId,
720 turnId: ctx.activeTurnId,
721 itemId: event.itemId,
722 lifecycle: "item.completed",
723 }),
724 );
725 return;
726 case "PlanUpdated":
727 yield* logNative(
728 ctx.threadId,
729 "session/update",
730 event.rawPayload,
731 "acp.jsonrpc",
732 );
733 yield* emitPlanUpdate(
734 ctx,
735 event.payload,
736 event.rawPayload,
737 "acp.jsonrpc",
738 "session/update",
739 );
740 return;
741 case "ToolCallUpdated":
742 yield* logNative(
743 ctx.threadId,
744 "session/update",
745 event.rawPayload,
746 "acp.jsonrpc",
747 );
748 yield* offerRuntimeEvent(
749 makeAcpToolCallEvent({
750 stamp: yield* makeEventStamp(),
751 provider: PROVIDER,
752 threadId: ctx.threadId,
753 turnId: ctx.activeTurnId,
754 toolCall: event.toolCall,
755 rawPayload: event.rawPayload,
756 }),
757 );
758 return;
759 case "ContentDelta":
760 yield* logNative(
761 ctx.threadId,
762 "session/update",
763 event.rawPayload,
764 "acp.jsonrpc",
765 );
766 yield* offerRuntimeEvent(
767 makeAcpContentDeltaEvent({
768 stamp: yield* makeEventStamp(),
769 provider: PROVIDER,
770 threadId: ctx.threadId,
771 turnId: ctx.activeTurnId,
772 ...(event.itemId ? { itemId: event.itemId } : {}),
773 text: event.text,
774 rawPayload: event.rawPayload,
775 }),
776 );
777 return;
778 }
779 }),
780 ),
781 ).pipe(Effect.forkChild);
782
783 ctx.notificationFiber = nf;
784 sessions.set(input.threadId, ctx);
785 sessionScopeTransferred = true;
786
787 yield* offerRuntimeEvent({
788 type: "session.started",
789 ...(yield* makeEventStamp()),
790 provider: PROVIDER,
791 threadId: input.threadId,
792 payload: { resume: started.initializeResult },
793 });
794 yield* offerRuntimeEvent({
795 type: "session.state.changed",
796 ...(yield* makeEventStamp()),
797 provider: PROVIDER,
798 threadId: input.threadId,
799 payload: { state: "ready", reason: "Cursor ACP session ready" },
800 });
801 yield* offerRuntimeEvent({
802 type: "thread.started",
803 ...(yield* makeEventStamp()),
804 provider: PROVIDER,
805 threadId: input.threadId,
806 payload: { providerThreadId: started.sessionId },
807 });
808
809 return session;
810 }).pipe(Effect.scoped),
811 );
812
813 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
814 Effect.gen(function* () {
815 const ctx = yield* requireSession(input.threadId);
816 const turnId = TurnId.make(crypto.randomUUID());
817 const turnModelSelection =
818 input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined;
819 const model = turnModelSelection?.model ?? ctx.session.model;
820 const resolvedModel = resolveCursorAcpBaseModelId(model);
821 yield* applyRequestedSessionConfiguration({
822 runtime: ctx.acp,
823 runtimeMode: ctx.session.runtimeMode,
824 interactionMode: input.interactionMode,
825 modelSelection:
826 model === undefined
827 ? undefined
828 : {
829 model,
830 options: turnModelSelection?.options,
831 },
832 mapError: ({ cause, method }) =>
833 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
834 });
835 ctx.activeTurnId = turnId;
836 ctx.lastPlanFingerprint = undefined;
837 ctx.session = {
838 ...ctx.session,
839 activeTurnId: turnId,
840 updatedAt: yield* nowIso,
841 };
842
843 yield* offerRuntimeEvent({
844 type: "turn.started",
845 ...(yield* makeEventStamp()),
846 provider: PROVIDER,
847 threadId: input.threadId,
848 turnId,
849 payload: { model: resolvedModel },
850 });
851
852 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
853 if (input.input?.trim()) {
854 promptParts.push({ type: "text", text: input.input.trim() });
855 }
856 if (input.attachments && input.attachments.length > 0) {
857 for (const attachment of input.attachments) {
858 const attachmentPath = resolveAttachmentPath({
859 attachmentsDir: serverConfig.attachmentsDir,
860 attachment,
861 });
862 if (!attachmentPath) {
863 return yield* new ProviderAdapterRequestError({
864 provider: PROVIDER,
865 method: "session/prompt",
866 detail: `Invalid attachment id '${attachment.id}'.`,
867 });
868 }
869 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
870 Effect.mapError(
871 (cause) =>
872 new ProviderAdapterRequestError({
873 provider: PROVIDER,
874 method: "session/prompt",
875 detail: cause.message,
876 cause,
877 }),
878 ),
879 );
880 promptParts.push({
881 type: "image",
882 data: Buffer.from(bytes).toString("base64"),
883 mimeType: attachment.mimeType,
884 });
885 }
886 }
887
888 if (promptParts.length === 0) {
889 return yield* new ProviderAdapterValidationError({
890 provider: PROVIDER,
891 operation: "sendTurn",
892 issue: "Turn requires non-empty text or attachments.",
893 });
894 }
895
896 const result = yield* ctx.acp
897 .prompt({
898 prompt: promptParts,
899 })
900 .pipe(
901 Effect.mapError((error) =>
902 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
903 ),
904 );
905
906 ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });
907 ctx.session = {
908 ...ctx.session,
909 activeTurnId: turnId,
910 updatedAt: yield* nowIso,
911 model: resolvedModel,
912 };
913
914 yield* offerRuntimeEvent({
915 type: "turn.completed",
916 ...(yield* makeEventStamp()),
917 provider: PROVIDER,
918 threadId: input.threadId,
919 turnId,
920 payload: {
921 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
922 stopReason: result.stopReason ?? null,
923 },
924 });
925
926 return {
927 threadId: input.threadId,
928 turnId,
929 resumeCursor: ctx.session.resumeCursor,
930 };
931 });
932
933 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) =>
934 Effect.gen(function* () {
935 const ctx = yield* requireSession(threadId);
936 yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
937 yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
938 yield* Effect.ignore(
939 ctx.acp.cancel.pipe(
940 Effect.mapError((error) =>
941 mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error),
942 ),
943 ),
944 );
945 });
946
947 const respondToRequest: CursorAdapterShape["respondToRequest"] = (
948 threadId,
949 requestId,
950 decision,
951 ) =>
952 Effect.gen(function* () {
953 const ctx = yield* requireSession(threadId);
954 const pending = ctx.pendingApprovals.get(requestId);
955 if (!pending) {
956 return yield* new ProviderAdapterRequestError({
957 provider: PROVIDER,
958 method: "session/request_permission",
959 detail: `Unknown pending approval request: ${requestId}`,
960 });
961 }
962 yield* Deferred.succeed(pending.decision, decision);
963 });
964
965 const respondToUserInput: CursorAdapterShape["respondToUserInput"] = (
966 threadId,
967 requestId,
968 answers,
969 ) =>
970 Effect.gen(function* () {
971 const ctx = yield* requireSession(threadId);
972 const pending = ctx.pendingUserInputs.get(requestId);
973 if (!pending) {
974 return yield* new ProviderAdapterRequestError({
975 provider: PROVIDER,
976 method: "cursor/ask_question",
977 detail: `Unknown pending user-input request: ${requestId}`,
978 });
979 }
980 yield* Deferred.succeed(pending.answers, answers);
981 });
982
983 const readThread: CursorAdapterShape["readThread"] = (threadId) =>
984 Effect.gen(function* () {
985 const ctx = yield* requireSession(threadId);
986 return { threadId, turns: ctx.turns };
987 });
988
989 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) =>
990 Effect.gen(function* () {
991 const ctx = yield* requireSession(threadId);
992 if (!Number.isInteger(numTurns) || numTurns < 1) {
993 return yield* new ProviderAdapterValidationError({
994 provider: PROVIDER,
995 operation: "rollbackThread",
996 issue: "numTurns must be an integer >= 1.",
997 });
998 }
999 const nextLength = Math.max(0, ctx.turns.length - numTurns);
1000 ctx.turns.splice(nextLength);
1001 return { threadId, turns: ctx.turns };
1002 });
1003
1004 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
1005 withThreadLock(
1006 threadId,
1007 Effect.gen(function* () {
1008 const ctx = yield* requireSession(threadId);
1009 yield* stopSessionInternal(ctx);
1010 }),
1011 );
1012
1013 const listSessions: CursorAdapterShape["listSessions"] = () =>
1014 Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session })));
1015
1016 const hasSession: CursorAdapterShape["hasSession"] = (threadId) =>
1017 Effect.sync(() => {
1018 const c = sessions.get(threadId);
1019 return c !== undefined && !c.stopped;
1020 });
1021
1022 const stopAll: CursorAdapterShape["stopAll"] = () =>
1023 Effect.forEach(sessions.values(), stopSessionInternal, { discard: true });
1024
1025 yield* Effect.addFinalizer(() =>
1026 Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe(
1027 Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)),
1028 Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
1029 ),
1030 );
1031
1032 const streamEvents = Stream.fromPubSub(runtimeEventPubSub);
1033
1034 return {
1035 provider: PROVIDER,
1036 capabilities: { sessionModelSwitch: "in-session" },
1037 startSession,
1038 sendTurn,
1039 interruptTurn,
1040 readThread,
1041 rollbackThread,
1042 respondToRequest,
1043 respondToUserInput,
1044 stopSession,
1045 listSessions,
1046 hasSession,
1047 stopAll,
1048 streamEvents,
1049 } satisfies CursorAdapterShape;
1050 });
1051}
1052
1053export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter());
1054
1055export function makeCursorAdapterLive(opts?: CursorAdapterLiveOptions) {
1056 return Layer.effect(CursorAdapter, makeCursorAdapter(opts));
1057}
apps/server/src/provider/Layers/CursorProvider.test.ts+687−0
@@ -0,0 +1,687 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4import { fileURLToPath } from "node:url";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect } from "effect";
8import { describe, expect, it } from "vitest";
9import type * as EffectAcpSchema from "effect-acp/schema";
10import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";
11
12import {
13 buildCursorProviderSnapshot,
14 buildCursorCapabilitiesFromConfigOptions,
15 buildCursorDiscoveredModelsFromConfigOptions,
16 discoverCursorModelCapabilitiesViaAcp,
17 discoverCursorModelsViaAcp,
18 getCursorFallbackModels,
19 getCursorParameterizedModelPickerUnsupportedMessage,
20 parseCursorAboutOutput,
21 parseCursorCliConfigChannel,
22 parseCursorVersionDate,
23 resolveCursorAcpBaseModelId,
24 resolveCursorAcpConfigUpdates,
25} from "./CursorProvider.ts";
26
27const __dirname = path.dirname(fileURLToPath(import.meta.url));
28const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
29
30async function makeMockAgentWrapper(extraEnv?: Record<string, string>) {
31 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-"));
32 const wrapperPath = path.join(dir, "fake-agent.sh");
33 const envExports = Object.entries(extraEnv ?? {})
34 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
35 .join("\n");
36 const script = `#!/bin/sh
37${envExports}
38exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@"
39`;
40 await writeFile(wrapperPath, script, "utf8");
41 await chmod(wrapperPath, 0o755);
42 return wrapperPath;
43}
44
45async function waitForFileContent(filePath: string, attempts = 40): Promise<string> {
46 for (let attempt = 0; attempt < attempts; attempt += 1) {
47 try {
48 const content = await readFile(filePath, "utf8");
49 if (content.trim().length > 0) {
50 return content;
51 }
52 } catch {}
53 await new Promise((resolve) => setTimeout(resolve, 50));
54 }
55 throw new Error(`Timed out waiting for file content at ${filePath}`);
56}
57
58const parameterizedGpt54ConfigOptions = [
59 {
60 type: "select",
61 currentValue: "gpt-5.4-medium-fast",
62 options: [{ name: "GPT-5.4", value: "gpt-5.4-medium-fast" }],
63 category: "model",
64 id: "model",
65 name: "Model",
66 },
67 {
68 type: "select",
69 currentValue: "medium",
70 options: [
71 { name: "None", value: "none" },
72 { name: "Low", value: "low" },
73 { name: "Medium", value: "medium" },
74 { name: "High", value: "high" },
75 { name: "Extra High", value: "extra-high" },
76 ],
77 category: "thought_level",
78 id: "reasoning",
79 name: "Reasoning",
80 },
81 {
82 type: "select",
83 currentValue: "272k",
84 options: [
85 { name: "272K", value: "272k" },
86 { name: "1M", value: "1m" },
87 ],
88 category: "model_config",
89 id: "context",
90 name: "Context",
91 },
92 {
93 type: "select",
94 currentValue: "false",
95 options: [
96 { name: "Off", value: "false" },
97 { name: "Fast", value: "true" },
98 ],
99 category: "model_config",
100 id: "fast",
101 name: "Fast",
102 },
103] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
104
105const parameterizedClaudeConfigOptions = [
106 {
107 type: "select",
108 currentValue: "claude-4.6-opus-high-thinking",
109 options: [{ name: "Opus 4.6", value: "claude-4.6-opus-high-thinking" }],
110 category: "model",
111 id: "model",
112 name: "Model",
113 },
114 {
115 type: "select",
116 currentValue: "high",
117 options: [
118 { name: "Low", value: "low" },
119 { name: "Medium", value: "medium" },
120 { name: "High", value: "high" },
121 ],
122 category: "thought_level",
123 id: "reasoning",
124 name: "Reasoning",
125 },
126 {
127 type: "boolean",
128 currentValue: true,
129 category: "model_config",
130 id: "thinking",
131 name: "Thinking",
132 },
133] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
134
135const parameterizedClaudeModelOptionConfigOptions = [
136 {
137 type: "select",
138 currentValue: "claude-opus-4-6",
139 options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }],
140 category: "model",
141 id: "model",
142 name: "Model",
143 },
144 {
145 type: "select",
146 currentValue: "high",
147 options: [
148 { name: "Low", value: "low" },
149 { name: "Medium", value: "medium" },
150 { name: "High", value: "high" },
151 ],
152 category: "thought_level",
153 id: "reasoning",
154 name: "Reasoning",
155 },
156 {
157 type: "select",
158 currentValue: "max",
159 options: [
160 { name: "Low", value: "low" },
161 { name: "Medium", value: "medium" },
162 { name: "High", value: "high" },
163 { name: "Max", value: "max" },
164 ],
165 category: "model_option",
166 id: "effort",
167 name: "Effort",
168 },
169 {
170 type: "select",
171 currentValue: "true",
172 options: [
173 { name: "Off", value: "false" },
174 { name: "Fast", value: "true" },
175 ],
176 category: "model_config",
177 id: "fast",
178 name: "Fast",
179 },
180 {
181 type: "select",
182 currentValue: "true",
183 options: [
184 { name: "Off", value: "false" },
185 { name: ":icon-brain:", value: "true" },
186 ],
187 category: "model_config",
188 id: "thinking",
189 name: "Thinking",
190 },
191] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
192
193const sessionNewCursorConfigOptions = [
194 {
195 type: "select",
196 currentValue: "agent",
197 options: [
198 { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" },
199 ],
200 category: "mode",
201 id: "mode",
202 name: "Mode",
203 description: "Controls how the agent executes tasks",
204 },
205 {
206 type: "select",
207 currentValue: "composer-2",
208 options: [
209 { name: "Auto", value: "default" },
210 { name: "Composer 2", value: "composer-2" },
211 { name: "GPT-5.4", value: "gpt-5.4" },
212 { name: "Sonnet 4.6", value: "claude-sonnet-4-6" },
213 { name: "Opus 4.6", value: "claude-opus-4-6" },
214 { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" },
215 ],
216 category: "model",
217 id: "model",
218 name: "Model",
219 description: "Controls which model is used for responses",
220 },
221 {
222 type: "select",
223 currentValue: "true",
224 options: [
225 { name: "Off", value: "false" },
226 { name: "Fast", value: "true" },
227 ],
228 category: "model_config",
229 id: "fast",
230 name: "Fast",
231 description: "Faster speeds.",
232 },
233] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
234
235const baseCursorSettings: CursorSettings = {
236 enabled: true,
237 binaryPath: "agent",
238 apiEndpoint: "",
239 customModels: [],
240};
241
242const emptyCapabilities = {
243 reasoningEffortLevels: [],
244 supportsFastMode: false,
245 supportsThinkingToggle: false,
246 contextWindowOptions: [],
247 promptInjectedEffortLevels: [],
248} as const;
249
250describe("getCursorFallbackModels", () => {
251 it("does not publish any built-in cursor models before ACP discovery", () => {
252 expect(
253 getCursorFallbackModels({
254 customModels: ["internal/cursor-model"],
255 }).map((model) => model.slug),
256 ).toEqual(["internal/cursor-model"]);
257 });
258});
259
260describe("buildCursorProviderSnapshot", () => {
261 it("downgrades ready status to warning when ACP model discovery times out", () => {
262 expect(
263 buildCursorProviderSnapshot({
264 checkedAt: "2026-01-01T00:00:00.000Z",
265 cursorSettings: baseCursorSettings,
266 parsed: {
267 version: "2026.04.09-f2b0fcd",
268 status: "ready",
269 auth: { status: "authenticated", type: "Team", label: "Cursor Team Subscription" },
270 },
271 discoveryWarning: "Cursor ACP model discovery timed out after 15000ms.",
272 }),
273 ).toMatchObject({
274 status: "warning",
275 message: "Cursor ACP model discovery timed out after 15000ms.",
276 models: [],
277 });
278 });
279
280 it("preserves provider error state while appending discovery warnings", () => {
281 expect(
282 buildCursorProviderSnapshot({
283 checkedAt: "2026-01-01T00:00:00.000Z",
284 cursorSettings: {
285 ...baseCursorSettings,
286 customModels: ["claude-sonnet-4-6"],
287 },
288 parsed: {
289 version: "2026.04.09-f2b0fcd",
290 status: "error",
291 auth: { status: "unauthenticated" },
292 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
293 },
294 discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.",
295 }),
296 ).toMatchObject({
297 status: "error",
298 message:
299 "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.",
300 models: [
301 {
302 slug: "claude-sonnet-4-6",
303 isCustom: true,
304 },
305 ],
306 });
307 });
308});
309
310describe("buildCursorCapabilitiesFromConfigOptions", () => {
311 it("derives model capabilities from parameterized Cursor ACP config options", () => {
312 expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({
313 reasoningEffortLevels: [
314 { value: "low", label: "Low" },
315 { value: "medium", label: "Medium", isDefault: true },
316 { value: "high", label: "High" },
317 { value: "xhigh", label: "Extra High" },
318 ],
319 supportsFastMode: true,
320 supportsThinkingToggle: false,
321 contextWindowOptions: [
322 { value: "272k", label: "272K", isDefault: true },
323 { value: "1m", label: "1M" },
324 ],
325 promptInjectedEffortLevels: [],
326 });
327 });
328
329 it("detects boolean thinking toggles from model_config options", () => {
330 expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({
331 reasoningEffortLevels: [
332 { value: "low", label: "Low" },
333 { value: "medium", label: "Medium" },
334 { value: "high", label: "High", isDefault: true },
335 ],
336 supportsFastMode: false,
337 supportsThinkingToggle: true,
338 contextWindowOptions: [],
339 promptInjectedEffortLevels: [],
340 });
341 });
342
343 it("prefers the newer model_option effort control over legacy thought_level", () => {
344 expect(
345 buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions),
346 ).toEqual({
347 reasoningEffortLevels: [
348 { value: "low", label: "Low" },
349 { value: "medium", label: "Medium" },
350 { value: "high", label: "High" },
351 { value: "max", label: "Max", isDefault: true },
352 ],
353 supportsFastMode: true,
354 supportsThinkingToggle: true,
355 contextWindowOptions: [],
356 promptInjectedEffortLevels: [],
357 });
358 });
359});
360
361describe("buildCursorDiscoveredModelsFromConfigOptions", () => {
362 it("publishes ACP model choices immediately from session/new config options", () => {
363 expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([
364 {
365 slug: "default",
366 name: "Auto",
367 isCustom: false,
368 capabilities: {
369 reasoningEffortLevels: [],
370 supportsFastMode: false,
371 supportsThinkingToggle: false,
372 contextWindowOptions: [],
373 promptInjectedEffortLevels: [],
374 },
375 },
376 {
377 slug: "composer-2",
378 name: "Composer 2",
379 isCustom: false,
380 capabilities: {
381 reasoningEffortLevels: [],
382 supportsFastMode: true,
383 supportsThinkingToggle: false,
384 contextWindowOptions: [],
385 promptInjectedEffortLevels: [],
386 },
387 },
388 {
389 slug: "gpt-5.4",
390 name: "GPT-5.4",
391 isCustom: false,
392 capabilities: {
393 reasoningEffortLevels: [],
394 supportsFastMode: false,
395 supportsThinkingToggle: false,
396 contextWindowOptions: [],
397 promptInjectedEffortLevels: [],
398 },
399 },
400 {
401 slug: "claude-sonnet-4-6",
402 name: "Sonnet 4.6",
403 isCustom: false,
404 capabilities: {
405 reasoningEffortLevels: [],
406 supportsFastMode: false,
407 supportsThinkingToggle: false,
408 contextWindowOptions: [],
409 promptInjectedEffortLevels: [],
410 },
411 },
412 {
413 slug: "claude-opus-4-6",
414 name: "Opus 4.6",
415 isCustom: false,
416 capabilities: {
417 reasoningEffortLevels: [],
418 supportsFastMode: false,
419 supportsThinkingToggle: false,
420 contextWindowOptions: [],
421 promptInjectedEffortLevels: [],
422 },
423 },
424 {
425 slug: "gpt-5.3-codex-spark",
426 name: "Codex 5.3 Spark",
427 isCustom: false,
428 capabilities: {
429 reasoningEffortLevels: [],
430 supportsFastMode: false,
431 supportsThinkingToggle: false,
432 contextWindowOptions: [],
433 promptInjectedEffortLevels: [],
434 },
435 },
436 ]);
437 });
438});
439
440describe("discoverCursorModelsViaAcp", () => {
441 it("keeps the ACP probe runtime alive long enough to discover models", async () => {
442 const wrapperPath = await makeMockAgentWrapper();
443
444 const models = await Effect.runPromise(
445 discoverCursorModelsViaAcp({
446 enabled: true,
447 binaryPath: wrapperPath,
448 apiEndpoint: "",
449 customModels: [],
450 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
451 );
452
453 expect(models.map((model) => model.slug)).toEqual([
454 "default",
455 "composer-2",
456 "gpt-5.4",
457 "claude-opus-4-6",
458 ]);
459 });
460
461 it("closes the ACP probe runtime after discovery completes", async () => {
462 const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-"));
463 const exitLogPath = path.join(tempDir, "exit.log");
464 const wrapperPath = await makeMockAgentWrapper({
465 T3_ACP_EXIT_LOG_PATH: exitLogPath,
466 });
467
468 await Effect.runPromise(
469 discoverCursorModelsViaAcp({
470 enabled: true,
471 binaryPath: wrapperPath,
472 apiEndpoint: "",
473 customModels: [],
474 }).pipe(Effect.provide(NodeServices.layer)),
475 );
476
477 const exitLog = await waitForFileContent(exitLogPath);
478 expect(exitLog).toContain("SIGTERM");
479 });
480});
481
482describe("discoverCursorModelCapabilitiesViaAcp", () => {
483 it("closes all ACP probe runtimes after capability enrichment completes", async () => {
484 const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-"));
485 const exitLogPath = path.join(tempDir, "exit.log");
486 const wrapperPath = await makeMockAgentWrapper({
487 T3_ACP_EXIT_LOG_PATH: exitLogPath,
488 });
489 const existingModels: ReadonlyArray<ServerProviderModel> = [
490 { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities },
491 { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities },
492 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities },
493 {
494 slug: "claude-opus-4-6",
495 name: "Opus 4.6",
496 isCustom: false,
497 capabilities: emptyCapabilities,
498 },
499 ];
500
501 const models = await Effect.runPromise(
502 discoverCursorModelCapabilitiesViaAcp(
503 {
504 enabled: true,
505 binaryPath: wrapperPath,
506 apiEndpoint: "",
507 customModels: [],
508 },
509 existingModels,
510 ).pipe(Effect.provide(NodeServices.layer)),
511 );
512
513 expect(models.map((model) => model.slug)).toEqual([
514 "default",
515 "composer-2",
516 "gpt-5.4",
517 "claude-opus-4-6",
518 ]);
519
520 const exitLog = await waitForFileContent(exitLogPath);
521 expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4);
522 });
523});
524
525describe("parseCursorAboutOutput", () => {
526 it("parses json about output and forwards subscription metadata", () => {
527 expect(
528 parseCursorAboutOutput({
529 code: 0,
530 stdout: JSON.stringify({
531 cliVersion: "2026.04.09-f2b0fcd",
532 subscriptionTier: "Team",
533 userEmail: "jmarminge@gmail.com",
534 }),
535 stderr: "",
536 }),
537 ).toEqual({
538 version: "2026.04.09-f2b0fcd",
539 status: "ready",
540 auth: {
541 status: "authenticated",
542 type: "Team",
543 label: "Cursor Team Subscription",
544 },
545 });
546 });
547
548 it("treats json about output with a logged-out email as unauthenticated", () => {
549 expect(
550 parseCursorAboutOutput({
551 code: 0,
552 stdout: JSON.stringify({
553 cliVersion: "2026.04.09-f2b0fcd",
554 subscriptionTier: "Team",
555 userEmail: "Not logged in",
556 }),
557 stderr: "",
558 }),
559 ).toEqual({
560 version: "2026.04.09-f2b0fcd",
561 status: "error",
562 auth: {
563 status: "unauthenticated",
564 },
565 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
566 });
567 });
568
569 it("treats json about output with a null email as unauthenticated", () => {
570 expect(
571 parseCursorAboutOutput({
572 code: 0,
573 stdout: JSON.stringify({
574 cliVersion: "2026.04.09-f2b0fcd",
575 subscriptionTier: null,
576 userEmail: null,
577 }),
578 stderr: "",
579 }),
580 ).toEqual({
581 version: "2026.04.09-f2b0fcd",
582 status: "error",
583 auth: {
584 status: "unauthenticated",
585 },
586 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
587 });
588 });
589});
590
591describe("Cursor parameterized model picker preview gating", () => {
592 it("parses Cursor CLI version dates from build versions", () => {
593 expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408);
594 expect(parseCursorVersionDate("2026.04.09")).toBe(20260409);
595 expect(parseCursorVersionDate("not-a-version")).toBeUndefined();
596 });
597
598 it("parses the Cursor CLI channel from cli-config.json", () => {
599 expect(parseCursorCliConfigChannel('{ "channel": "lab" }')).toBe("lab");
600 expect(parseCursorCliConfigChannel('{ "channel": "stable" }')).toBe("stable");
601 expect(parseCursorCliConfigChannel('{ "version": 1 }')).toBeUndefined();
602 expect(parseCursorCliConfigChannel("not-json")).toBeUndefined();
603 });
604
605 it("returns no warning when the preview requirements are met", () => {
606 expect(
607 getCursorParameterizedModelPickerUnsupportedMessage({
608 version: "2026.04.08-c4e73a3",
609 channel: "lab",
610 }),
611 ).toBeUndefined();
612 });
613
614 it("explains when the Cursor Agent version is too old", () => {
615 expect(
616 getCursorParameterizedModelPickerUnsupportedMessage({
617 version: "2026.04.07-c4e73a3",
618 channel: "lab",
619 }),
620 ).toContain("too old");
621 });
622
623 it("explains when the Cursor Agent channel is not lab", () => {
624 expect(
625 getCursorParameterizedModelPickerUnsupportedMessage({
626 version: "2026.04.08-c4e73a3",
627 channel: "stable",
628 }),
629 ).toContain("lab channel");
630 });
631});
632
633describe("resolveCursorAcpBaseModelId", () => {
634 it("drops bracket traits without rewriting raw ACP model ids", () => {
635 expect(resolveCursorAcpBaseModelId("gpt-5.4[reasoning=medium,context=272k]")).toBe("gpt-5.4");
636 expect(resolveCursorAcpBaseModelId("gpt-5.4-medium-fast")).toBe("gpt-5.4-medium-fast");
637 expect(resolveCursorAcpBaseModelId("claude-4.6-opus-high-thinking")).toBe(
638 "claude-4.6-opus-high-thinking",
639 );
640 expect(resolveCursorAcpBaseModelId("composer-2")).toBe("composer-2");
641 expect(resolveCursorAcpBaseModelId("auto")).toBe("auto");
642 });
643});
644
645describe("resolveCursorAcpConfigUpdates", () => {
646 it("maps Cursor model options onto separate ACP config option updates", () => {
647 expect(
648 resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, {
649 reasoning: "xhigh",
650 fastMode: true,
651 contextWindow: "1m",
652 }),
653 ).toEqual([
654 { configId: "reasoning", value: "extra-high" },
655 { configId: "context", value: "1m" },
656 { configId: "fast", value: "true" },
657 ]);
658 });
659
660 it("maps boolean thinking toggles when the model exposes them separately", () => {
661 expect(
662 resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, {
663 thinking: false,
664 }),
665 ).toEqual([{ configId: "thinking", value: false }]);
666 });
667
668 it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => {
669 expect(
670 resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, {
671 fastMode: false,
672 }),
673 ).toEqual([{ configId: "fast", value: "false" }]);
674 });
675
676 it("writes Cursor effort changes through the newer model_option config when available", () => {
677 expect(
678 resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, {
679 reasoning: "max",
680 thinking: false,
681 }),
682 ).toEqual([
683 { configId: "effort", value: "max" },
684 { configId: "thinking", value: "false" },
685 ]);
686 });
687});
apps/server/src/provider/Layers/CursorProvider.ts+1136−0
@@ -0,0 +1,1136 @@
1import * as nodeFs from "node:fs";
2import * as nodeOs from "node:os";
3import * as nodePath from "node:path";
4
5import type {
6 CursorModelOptions,
7 CursorSettings,
8 ModelCapabilities,
9 ServerProvider,
10 ServerProviderAuth,
11 ServerProviderModel,
12 ServerProviderState,
13 ServerSettingsError,
14} from "@t3tools/contracts";
15import type * as EffectAcpSchema from "effect-acp/schema";
16import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect";
17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18
19import {
20 buildServerProvider,
21 collectStreamAsString,
22 isCommandMissingCause,
23 providerModelsFromSettings,
24 type CommandResult,
25} from "../providerSnapshot.ts";
26import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
27import { CursorProvider } from "../Services/CursorProvider.ts";
28import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";
30
31const PROVIDER = "cursor" as const;
32const EMPTY_CAPABILITIES: ModelCapabilities = {
33 reasoningEffortLevels: [],
34 supportsFastMode: false,
35 supportsThinkingToggle: false,
36 contextWindowOptions: [],
37 promptInjectedEffortLevels: [],
38};
39
40const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
41const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";
42const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4;
43const CURSOR_REFRESH_INTERVAL = "1 hour";
44const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;
45export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {
46 _meta: {
47 parameterizedModelPicker: true,
48 },
49} satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
50
51function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider {
52 const checkedAt = new Date().toISOString();
53 const models = getCursorFallbackModels(cursorSettings);
54
55 if (!cursorSettings.enabled) {
56 return buildServerProvider({
57 provider: PROVIDER,
58 enabled: false,
59 checkedAt,
60 models,
61 probe: {
62 installed: false,
63 version: null,
64 status: "warning",
65 auth: { status: "unknown" },
66 message: "Cursor is disabled in T3 Code settings.",
67 },
68 });
69 }
70
71 return buildServerProvider({
72 provider: PROVIDER,
73 enabled: true,
74 checkedAt,
75 models,
76 probe: {
77 installed: true,
78 version: null,
79 status: "warning",
80 auth: { status: "unknown" },
81 message: "Checking Cursor Agent availability...",
82 },
83 });
84}
85
86interface CursorSessionSelectOption {
87 readonly value: string;
88 readonly name: string;
89}
90
91interface CursorAcpDiscoveredModel {
92 readonly slug: string;
93 readonly name: string;
94 readonly capabilities: ModelCapabilities;
95}
96
97function flattenSessionConfigSelectOptions(
98 configOption: EffectAcpSchema.SessionConfigOption | undefined,
99): ReadonlyArray<CursorSessionSelectOption> {
100 if (!configOption || configOption.type !== "select") {
101 return [];
102 }
103 return configOption.options.flatMap((entry) =>
104 "value" in entry
105 ? [{ value: entry.value.trim(), name: entry.name.trim() } satisfies CursorSessionSelectOption]
106 : entry.options.map(
107 (option) =>
108 ({
109 value: option.value.trim(),
110 name: option.name.trim(),
111 }) satisfies CursorSessionSelectOption,
112 ),
113 );
114}
115
116function normalizeCursorReasoningValue(value: string | null | undefined): string | undefined {
117 const normalized = value?.trim().toLowerCase();
118 switch (normalized) {
119 case "low":
120 case "medium":
121 case "high":
122 case "max":
123 return normalized;
124 case "xhigh":
125 case "extra-high":
126 case "extra high":
127 return "xhigh";
128 default:
129 return undefined;
130 }
131}
132
133function findCursorModelConfigOption(
134 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
135): EffectAcpSchema.SessionConfigOption | undefined {
136 return configOptions.find((option) => option.category === "model");
137}
138
139function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string {
140 return option.category?.trim().toLowerCase() ?? "";
141}
142
143function isCursorEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
144 const id = option.id.trim().toLowerCase();
145 const name = option.name.trim().toLowerCase();
146 return (
147 id === "effort" ||
148 id === "reasoning" ||
149 name === "effort" ||
150 name === "reasoning" ||
151 name.includes("effort") ||
152 name.includes("reasoning")
153 );
154}
155
156function findCursorEffortConfigOption(
157 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
158): EffectAcpSchema.SessionConfigOption | undefined {
159 const candidates = configOptions.filter(
160 (option) => option.type === "select" && isCursorEffortConfigOption(option),
161 );
162 return (
163 candidates.find((option) => getCursorConfigOptionCategory(option) === "model_option") ??
164 candidates.find((option) => option.id.trim().toLowerCase() === "effort") ??
165 candidates.find((option) => getCursorConfigOptionCategory(option) === "thought_level") ??
166 candidates[0]
167 );
168}
169
170function isCursorContextConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
171 const id = option.id.trim().toLowerCase();
172 const name = option.name.trim().toLowerCase();
173 return id === "context" || id === "context_size" || name.includes("context");
174}
175
176function isCursorFastConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
177 const id = option.id.trim().toLowerCase();
178 const name = option.name.trim().toLowerCase();
179 return id === "fast" || name === "fast" || name.includes("fast mode");
180}
181
182function isCursorThinkingConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
183 const id = option.id.trim().toLowerCase();
184 const name = option.name.trim().toLowerCase();
185 return id === "thinking" || name.includes("thinking");
186}
187
188function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
189 if (option.type === "boolean") {
190 return true;
191 }
192 if (option.type !== "select") {
193 return false;
194 }
195 const values = new Set(
196 flattenSessionConfigSelectOptions(option).map((entry) => entry.value.trim().toLowerCase()),
197 );
198 return values.has("true") && values.has("false");
199}
200
201export function buildCursorCapabilitiesFromConfigOptions(
202 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
203): ModelCapabilities {
204 if (!configOptions || configOptions.length === 0) {
205 return EMPTY_CAPABILITIES;
206 }
207
208 const reasoningConfig = findCursorEffortConfigOption(configOptions);
209 const reasoningEffortLevels =
210 reasoningConfig?.type === "select"
211 ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => {
212 const normalizedValue = normalizeCursorReasoningValue(entry.value);
213 if (!normalizedValue) {
214 return [];
215 }
216 return [
217 {
218 value: normalizedValue,
219 label: entry.name,
220 ...(normalizeCursorReasoningValue(reasoningConfig.currentValue) === normalizedValue
221 ? { isDefault: true }
222 : {}),
223 },
224 ];
225 })
226 : [];
227
228 const contextOption = configOptions.find(
229 (option) => option.category === "model_config" && isCursorContextConfigOption(option),
230 );
231 const contextWindowOptions =
232 contextOption?.type === "select"
233 ? flattenSessionConfigSelectOptions(contextOption).map((entry) => {
234 if (contextOption.currentValue === entry.value) {
235 return {
236 value: entry.value,
237 label: entry.name,
238 isDefault: true,
239 };
240 }
241 return {
242 value: entry.value,
243 label: entry.name,
244 };
245 })
246 : [];
247
248 const fastOption = configOptions.find(
249 (option) => option.category === "model_config" && isCursorFastConfigOption(option),
250 );
251 const thinkingOption = configOptions.find(
252 (option) => option.category === "model_config" && isCursorThinkingConfigOption(option),
253 );
254
255 return {
256 reasoningEffortLevels,
257 supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false,
258 supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false,
259 contextWindowOptions,
260 promptInjectedEffortLevels: [],
261 };
262}
263
264function buildCursorDiscoveredModels(
265 discoveredModels: ReadonlyArray<CursorAcpDiscoveredModel>,
266): ReadonlyArray<ServerProviderModel> {
267 const seen = new Set<string>();
268 return discoveredModels.flatMap((model) => {
269 if (!model.slug || seen.has(model.slug)) {
270 return [];
271 }
272 seen.add(model.slug);
273 return [
274 {
275 slug: model.slug,
276 name: model.name,
277 isCustom: false,
278 capabilities: model.capabilities,
279 } satisfies ServerProviderModel,
280 ];
281 });
282}
283
284function hasCursorModelCapabilities(model: Pick<ServerProviderModel, "capabilities">): boolean {
285 return (
286 (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 ||
287 model.capabilities?.supportsFastMode === true ||
288 model.capabilities?.supportsThinkingToggle === true ||
289 (model.capabilities?.contextWindowOptions.length ?? 0) > 0 ||
290 (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0
291 );
292}
293
294export function buildCursorDiscoveredModelsFromConfigOptions(
295 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
296): ReadonlyArray<ServerProviderModel> {
297 if (!configOptions || configOptions.length === 0) {
298 return [];
299 }
300
301 const modelOption = findCursorModelConfigOption(configOptions);
302 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
303 if (!modelOption || modelChoices.length === 0) {
304 return [];
305 }
306
307 const currentModelValue =
308 modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined;
309 const currentModelCapabilities = buildCursorCapabilitiesFromConfigOptions(configOptions);
310
311 return buildCursorDiscoveredModels(
312 modelChoices.map((modelChoice) => ({
313 slug: modelChoice.value.trim(),
314 name: modelChoice.name.trim(),
315 capabilities:
316 currentModelValue === modelChoice.value.trim()
317 ? currentModelCapabilities
318 : EMPTY_CAPABILITIES,
319 })),
320 );
321}
322
323const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) =>
324 Effect.gen(function* () {
325 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
326 const acpContext = yield* Layer.build(
327 AcpSessionRuntime.layer({
328 spawn: {
329 command: cursorSettings.binaryPath,
330 args: [
331 ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []),
332 "acp",
333 ],
334 cwd: process.cwd(),
335 },
336 cwd: process.cwd(),
337 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
338 authMethodId: "cursor_login",
339 clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
340 }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))),
341 );
342 return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext));
343 });
344
345const withCursorAcpProbeRuntime = <A, E, R>(
346 cursorSettings: CursorSettings,
347 useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>,
348) => makeCursorAcpProbeRuntime(cursorSettings).pipe(Effect.flatMap(useRuntime), Effect.scoped);
349
350function normalizeCursorConfigOptionToken(value: string | null | undefined): string {
351 return (
352 value
353 ?.trim()
354 .toLowerCase()
355 .replace(/[\s_-]+/g, "-") ?? ""
356 );
357}
358
359function findCursorSelectOptionValue(
360 configOption: EffectAcpSchema.SessionConfigOption | undefined,
361 matcher: (option: CursorSessionSelectOption) => boolean,
362): string | undefined {
363 return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value;
364}
365
366function findCursorBooleanConfigValue(
367 configOption: EffectAcpSchema.SessionConfigOption | undefined,
368 requested: boolean,
369): string | boolean | undefined {
370 if (!configOption) {
371 return undefined;
372 }
373 if (configOption.type === "boolean") {
374 return requested;
375 }
376 return findCursorSelectOptionValue(
377 configOption,
378 (option) => normalizeCursorConfigOptionToken(option.value) === String(requested),
379 );
380}
381
382export function resolveCursorAcpBaseModelId(model: string | null | undefined): string {
383 const trimmed = model?.trim();
384 const base = trimmed && trimmed.length > 0 ? trimmed : "default";
385 return base.includes("[") ? base.slice(0, base.indexOf("[")) : base;
386}
387
388export function resolveCursorAcpConfigUpdates(
389 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
390 modelOptions: CursorModelOptions | null | undefined,
391): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> {
392 if (!configOptions || configOptions.length === 0) {
393 return [];
394 }
395
396 const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = [];
397
398 const reasoningOption = findCursorEffortConfigOption(configOptions);
399 const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning);
400 if (reasoningOption && requestedReasoning) {
401 const value = findCursorSelectOptionValue(reasoningOption, (option) => {
402 const normalizedValue = normalizeCursorReasoningValue(option.value);
403 const normalizedName = normalizeCursorReasoningValue(option.name);
404 return normalizedValue === requestedReasoning || normalizedName === requestedReasoning;
405 });
406 if (value) {
407 updates.push({ configId: reasoningOption.id, value });
408 }
409 }
410
411 const contextOption = configOptions.find(
412 (option) => option.category === "model_config" && isCursorContextConfigOption(option),
413 );
414 if (contextOption && modelOptions?.contextWindow) {
415 const value = findCursorSelectOptionValue(
416 contextOption,
417 (option) =>
418 normalizeCursorConfigOptionToken(option.value) ===
419 normalizeCursorConfigOptionToken(modelOptions.contextWindow) ||
420 normalizeCursorConfigOptionToken(option.name) ===
421 normalizeCursorConfigOptionToken(modelOptions.contextWindow),
422 );
423 if (value) {
424 updates.push({ configId: contextOption.id, value });
425 }
426 }
427
428 const fastOption = configOptions.find(
429 (option) => option.category === "model_config" && isCursorFastConfigOption(option),
430 );
431 if (fastOption && typeof modelOptions?.fastMode === "boolean") {
432 const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode);
433 if (value !== undefined) {
434 updates.push({ configId: fastOption.id, value });
435 }
436 }
437
438 const thinkingOption = configOptions.find(
439 (option) => option.category === "model_config" && isCursorThinkingConfigOption(option),
440 );
441 if (thinkingOption && typeof modelOptions?.thinking === "boolean") {
442 const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking);
443 if (value !== undefined) {
444 updates.push({ configId: thinkingOption.id, value });
445 }
446 }
447
448 return updates;
449}
450
451export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) =>
452 withCursorAcpProbeRuntime(cursorSettings, (acp) =>
453 Effect.map(acp.start(), (started) =>
454 buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []),
455 ),
456 );
457
458export const discoverCursorModelCapabilitiesViaAcp = (
459 cursorSettings: CursorSettings,
460 existingModels: ReadonlyArray<ServerProviderModel>,
461) =>
462 withCursorAcpProbeRuntime(cursorSettings, (acp) =>
463 Effect.gen(function* () {
464 const started = yield* acp.start();
465 const initialConfigOptions = started.sessionSetupResult.configOptions ?? [];
466 const modelOption = findCursorModelConfigOption(initialConfigOptions);
467 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
468 if (!modelOption || modelChoices.length === 0) {
469 return [];
470 }
471
472 const currentModelValue =
473 modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined;
474 const capabilitiesBySlug = new Map<string, ModelCapabilities>();
475 if (currentModelValue) {
476 capabilitiesBySlug.set(
477 currentModelValue,
478 buildCursorCapabilitiesFromConfigOptions(initialConfigOptions),
479 );
480 }
481
482 const targetModelSlugs = new Set(
483 existingModels
484 .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model))
485 .map((model) => model.slug),
486 );
487 if (targetModelSlugs.size === 0) {
488 return buildCursorDiscoveredModels(
489 modelChoices.map((modelChoice) => ({
490 slug: modelChoice.value.trim(),
491 name: modelChoice.name.trim(),
492 capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES,
493 })),
494 );
495 }
496
497 const probedCapabilities = yield* Effect.forEach(
498 modelChoices,
499 (modelChoice) => {
500 const modelSlug = modelChoice.value.trim();
501 if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) {
502 return Effect.void.pipe(
503 Effect.as<readonly [string, ModelCapabilities] | undefined>(undefined),
504 );
505 }
506
507 return withCursorAcpProbeRuntime(cursorSettings, (probeAcp) =>
508 Effect.gen(function* () {
509 const probeStarted = yield* probeAcp.start();
510 const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? [];
511 const probeModelOption = findCursorModelConfigOption(probeConfigOptions);
512 const probeCurrentModelValue =
513 probeModelOption?.type === "select"
514 ? probeModelOption.currentValue?.trim() || undefined
515 : undefined;
516 yield* Effect.annotateCurrentSpan({
517 "cursor.acp.model.value": modelSlug,
518 "cursor.acp.model.currentValue": probeCurrentModelValue,
519 "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id,
520 });
521 const nextConfigOptions =
522 probeCurrentModelValue === modelSlug
523 ? probeConfigOptions
524 : yield* probeAcp
525 .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug)
526 .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions));
527 return [
528 modelSlug,
529 buildCursorCapabilitiesFromConfigOptions(nextConfigOptions),
530 ] as const;
531 }),
532 ).pipe(
533 Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT),
534 Effect.retry({ times: 3 }),
535 Effect.withSpan("cursor-acp-model-capability-probe"),
536 Effect.catchCause((cause) =>
537 Effect.logWarning("Cursor ACP capability probe failed", {
538 modelSlug,
539 cause: Cause.pretty(cause),
540 }),
541 ),
542 );
543 },
544 { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY },
545 );
546
547 for (const entry of probedCapabilities) {
548 if (!entry) {
549 continue;
550 }
551 capabilitiesBySlug.set(entry[0], entry[1]);
552 }
553
554 return buildCursorDiscoveredModels(
555 modelChoices.map((modelChoice) => ({
556 slug: modelChoice.value.trim(),
557 name: modelChoice.name.trim(),
558 capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES,
559 })),
560 );
561 }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})),
562 );
563
564export function getCursorFallbackModels(
565 cursorSettings: Pick<CursorSettings, "customModels">,
566): ReadonlyArray<ServerProviderModel> {
567 return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES);
568}
569
570/** Timeout for `agent about` — it's slower than a simple `--version` probe. */
571const ABOUT_TIMEOUT_MS = 8_000;
572
573/** Strip ANSI escape sequences so we can parse plain key-value lines. */
574function stripAnsi(text: string): string {
575 // eslint-disable-next-line no-control-regex
576 return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, "");
577}
578
579/**
580 * Extract a value from `agent about` key-value output.
581 * Lines look like: `CLI Version 2026.03.20-44cb435`
582 */
583function extractAboutField(plain: string, key: string): string | undefined {
584 const regex = new RegExp(`^${key}\\s{2,}(.+)$`, "mi");
585 const match = regex.exec(plain);
586 return match?.[1]?.trim();
587}
588
589export interface CursorAboutResult {
590 readonly version: string | null;
591 readonly status: Exclude<ServerProviderState, "disabled">;
592 readonly auth: ServerProviderAuth;
593 readonly message?: string;
594}
595
596function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined {
597 const parts = messages
598 .map((message) => message?.trim())
599 .filter((message): message is string => Boolean(message));
600 return parts.length > 0 ? parts.join(" ") : undefined;
601}
602
603export function buildCursorProviderSnapshot(input: {
604 readonly checkedAt: string;
605 readonly cursorSettings: CursorSettings;
606 readonly parsed: CursorAboutResult;
607 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
608 readonly discoveryWarning?: string;
609}): ServerProvider {
610 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
611 return buildServerProvider({
612 provider: PROVIDER,
613 enabled: input.cursorSettings.enabled,
614 checkedAt: input.checkedAt,
615 models: providerModelsFromSettings(
616 input.discoveredModels ?? [],
617 PROVIDER,
618 input.cursorSettings.customModels,
619 EMPTY_CAPABILITIES,
620 ),
621 probe: {
622 installed: true,
623 version: input.parsed.version,
624 status:
625 input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status,
626 auth: input.parsed.auth,
627 ...(message ? { message } : {}),
628 },
629 });
630}
631
632interface CursorAboutJsonPayload {
633 readonly cliVersion?: unknown;
634 readonly subscriptionTier?: unknown;
635 readonly userEmail?: unknown;
636}
637
638export function parseCursorVersionDate(version: string | null | undefined): number | undefined {
639 const match = version?.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})(?:\b|-|$)/);
640 if (!match) {
641 return undefined;
642 }
643 const [, year, month, day] = match;
644 return Number(`${year}${month}${day}`);
645}
646
647export function parseCursorCliConfigChannel(raw: string): string | undefined {
648 try {
649 const parsed = JSON.parse(raw) as unknown;
650 if (
651 typeof parsed === "object" &&
652 parsed !== null &&
653 "channel" in parsed &&
654 typeof parsed.channel === "string"
655 ) {
656 const channel = parsed.channel.trim().toLowerCase();
657 return channel.length > 0 ? channel : undefined;
658 }
659 } catch {
660 return undefined;
661 }
662 return undefined;
663}
664
665function toTitleCaseWords(value: string): string {
666 return value
667 .split(/[\s_-]+/g)
668 .filter((part) => part.length > 0)
669 .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
670 .join(" ");
671}
672
673function cursorSubscriptionLabel(subscriptionType: string | undefined): string | undefined {
674 const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, "");
675 if (!normalized) return undefined;
676
677 switch (normalized) {
678 case "team":
679 return "Team";
680 case "pro":
681 return "Pro";
682 case "free":
683 return "Free";
684 case "business":
685 return "Business";
686 case "enterprise":
687 return "Enterprise";
688 default:
689 return toTitleCaseWords(subscriptionType!);
690 }
691}
692
693function cursorAuthMetadata(
694 subscriptionType: string | undefined,
695): Pick<ServerProviderAuth, "label" | "type"> | undefined {
696 if (!subscriptionType) {
697 return undefined;
698 }
699 const subscriptionLabel = cursorSubscriptionLabel(subscriptionType);
700 return {
701 type: subscriptionType,
702 label: `Cursor ${subscriptionLabel ?? toTitleCaseWords(subscriptionType)} Subscription`,
703 };
704}
705
706function parseCursorAboutJsonPayload(raw: string): CursorAboutJsonPayload | undefined {
707 const trimmed = raw.trim();
708 if (!trimmed.startsWith("{")) {
709 return undefined;
710 }
711 try {
712 const parsed = JSON.parse(trimmed) as unknown;
713 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
714 return undefined;
715 }
716 return parsed as CursorAboutJsonPayload;
717 } catch {
718 return undefined;
719 }
720}
721
722function hasOwn(record: object, key: string): boolean {
723 return Object.prototype.hasOwnProperty.call(record, key);
724}
725
726function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean {
727 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
728 return (
729 lowerOutput.includes("unknown option '--format'") ||
730 lowerOutput.includes("unexpected argument '--format'") ||
731 lowerOutput.includes("unrecognized option '--format'") ||
732 lowerOutput.includes("unknown argument '--format'")
733 );
734}
735
736function readCursorCliConfigChannel(): string | undefined {
737 try {
738 const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json");
739 return parseCursorCliConfigChannel(nodeFs.readFileSync(configPath, "utf8"));
740 } catch {
741 return undefined;
742 }
743}
744
745export function getCursorParameterizedModelPickerUnsupportedMessage(input: {
746 readonly version: string | null | undefined;
747 readonly channel: string | null | undefined;
748}): string | undefined {
749 const reasons: Array<string> = [];
750 const versionDate = parseCursorVersionDate(input.version);
751 if (
752 versionDate !== undefined &&
753 versionDate < CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE
754 ) {
755 reasons.push(
756 `Cursor Agent CLI version ${input.version} is too old for Cursor ACP parameterized model picker`,
757 );
758 }
759
760 const normalizedChannel = input.channel?.trim().toLowerCase();
761 if (
762 normalizedChannel !== undefined &&
763 normalizedChannel.length > 0 &&
764 normalizedChannel !== "lab"
765 ) {
766 reasons.push(
767 `Cursor Agent CLI channel is ${JSON.stringify(input.channel)}, but parameterized model picker is only available on the lab channel`,
768 );
769 }
770
771 if (reasons.length === 0) {
772 return undefined;
773 }
774
775 return `${reasons.join(". ")}. Run \`agent set-channel lab && agent update\` and use Cursor Agent CLI 2026.04.08 or newer.`;
776}
777
778/**
779 * Parse the output of `agent about` to extract version and authentication
780 * status in a single probe.
781 *
782 * Example output (logged in):
783 * ```
784 * About Cursor CLI
785 *
786 * CLI Version 2026.03.20-44cb435
787 * User Email user@example.com
788 * ```
789 *
790 * Example output (logged out):
791 * ```
792 * About Cursor CLI
793 *
794 * CLI Version 2026.03.20-44cb435
795 * User Email Not logged in
796 * ```
797 */
798export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult {
799 const jsonPayload = parseCursorAboutJsonPayload(result.stdout);
800 if (jsonPayload) {
801 const version =
802 typeof jsonPayload.cliVersion === "string" ? jsonPayload.cliVersion.trim() : null;
803 const hasUserEmailField = hasOwn(jsonPayload, "userEmail");
804 const userEmail =
805 typeof jsonPayload.userEmail === "string" ? jsonPayload.userEmail.trim() : undefined;
806 const subscriptionType =
807 typeof jsonPayload.subscriptionTier === "string"
808 ? jsonPayload.subscriptionTier.trim()
809 : undefined;
810 const authMetadata = cursorAuthMetadata(subscriptionType);
811
812 if (hasUserEmailField && jsonPayload.userEmail == null) {
813 return {
814 version,
815 status: "error",
816 auth: { status: "unauthenticated" },
817 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
818 };
819 }
820
821 if (!userEmail) {
822 if (result.code === 0) {
823 return {
824 version,
825 status: "ready",
826 auth: {
827 status: "unknown",
828 ...authMetadata,
829 },
830 };
831 }
832 return {
833 version,
834 status: "warning",
835 auth: { status: "unknown" },
836 message: "Could not verify Cursor Agent authentication status.",
837 };
838 }
839
840 const lowerEmail = userEmail.toLowerCase();
841 if (
842 lowerEmail === "not logged in" ||
843 lowerEmail.includes("login required") ||
844 lowerEmail.includes("authentication required")
845 ) {
846 return {
847 version,
848 status: "error",
849 auth: { status: "unauthenticated" },
850 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
851 };
852 }
853
854 return {
855 version,
856 status: "ready",
857 auth: {
858 status: "authenticated",
859 ...authMetadata,
860 },
861 };
862 }
863
864 const combined = `${result.stdout}\n${result.stderr}`;
865 const lowerOutput = combined.toLowerCase();
866
867 // If the command itself isn't recognised, we're on an old CLI version.
868 if (
869 lowerOutput.includes("unknown command") ||
870 lowerOutput.includes("unrecognized command") ||
871 lowerOutput.includes("unexpected argument")
872 ) {
873 return {
874 version: null,
875 status: "warning",
876 auth: { status: "unknown" },
877 message: "The `agent about` command is unavailable in this version of the Cursor Agent CLI.",
878 };
879 }
880
881 const plain = stripAnsi(combined);
882 const version = extractAboutField(plain, "CLI Version") ?? null;
883 const userEmail = extractAboutField(plain, "User Email");
884
885 // Determine auth from the User Email field.
886 if (userEmail === undefined) {
887 // Field missing entirely — can't determine auth.
888 if (result.code === 0) {
889 return { version, status: "ready", auth: { status: "unknown" } };
890 }
891 return {
892 version,
893 status: "warning",
894 auth: { status: "unknown" },
895 message: "Could not verify Cursor Agent authentication status.",
896 };
897 }
898
899 const lowerEmail = userEmail.toLowerCase();
900 if (
901 lowerEmail === "not logged in" ||
902 lowerEmail.includes("login required") ||
903 lowerEmail.includes("authentication required")
904 ) {
905 return {
906 version,
907 status: "error",
908 auth: { status: "unauthenticated" },
909 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
910 };
911 }
912
913 // Any non-empty email value means authenticated.
914 return { version, status: "ready", auth: { status: "authenticated" } };
915}
916
917const runCursorCommand = (args: ReadonlyArray<string>) =>
918 Effect.gen(function* () {
919 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
920 const cursorSettings = yield* Effect.service(ServerSettingsService).pipe(
921 Effect.flatMap((service) => service.getSettings),
922 Effect.map((settings) => settings.providers.cursor),
923 );
924 const command = ChildProcess.make(cursorSettings.binaryPath, [...args], {
925 shell: process.platform === "win32",
926 });
927
928 const child = yield* spawner.spawn(command);
929 const [stdout, stderr, exitCode] = yield* Effect.all(
930 [
931 collectStreamAsString(child.stdout),
932 collectStreamAsString(child.stderr),
933 child.exitCode.pipe(Effect.map(Number)),
934 ],
935 { concurrency: "unbounded" },
936 );
937
938 return { stdout, stderr, code: exitCode } satisfies CommandResult;
939 }).pipe(Effect.scoped);
940
941const runCursorAboutCommand = Effect.gen(function* () {
942 const jsonResult = yield* runCursorCommand(["about", "--format", "json"]);
943 if (!isCursorAboutJsonFormatUnsupported(jsonResult)) {
944 return jsonResult;
945 }
946 return yield* runCursorCommand(["about"]);
947});
948
949export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
950 function* (): Effect.fn.Return<
951 ServerProvider,
952 ServerSettingsError,
953 ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService
954 > {
955 const cursorSettings = yield* Effect.service(ServerSettingsService).pipe(
956 Effect.flatMap((service) => service.getSettings),
957 Effect.map((settings) => settings.providers.cursor),
958 );
959 const checkedAt = new Date().toISOString();
960 const fallbackModels = getCursorFallbackModels(cursorSettings);
961
962 if (!cursorSettings.enabled) {
963 return buildServerProvider({
964 provider: PROVIDER,
965 enabled: false,
966 checkedAt,
967 models: fallbackModels,
968 probe: {
969 installed: false,
970 version: null,
971 status: "warning",
972 auth: { status: "unknown" },
973 message: "Cursor is disabled in T3 Code settings.",
974 },
975 });
976 }
977
978 // Single `agent about` probe: returns version + auth status in one call.
979 const aboutProbe = yield* runCursorAboutCommand.pipe(
980 Effect.timeoutOption(ABOUT_TIMEOUT_MS),
981 Effect.result,
982 );
983
984 if (Result.isFailure(aboutProbe)) {
985 const error = aboutProbe.failure;
986 return buildServerProvider({
987 provider: PROVIDER,
988 enabled: cursorSettings.enabled,
989 checkedAt,
990 models: fallbackModels,
991 probe: {
992 installed: !isCommandMissingCause(error),
993 version: null,
994 status: "error",
995 auth: { status: "unknown" },
996 message: isCommandMissingCause(error)
997 ? "Cursor Agent CLI (`agent`) is not installed or not on PATH."
998 : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
999 },
1000 });
1001 }
1002
1003 if (Option.isNone(aboutProbe.success)) {
1004 return buildServerProvider({
1005 provider: PROVIDER,
1006 enabled: cursorSettings.enabled,
1007 checkedAt,
1008 models: fallbackModels,
1009 probe: {
1010 installed: true,
1011 version: null,
1012 status: "error",
1013 auth: { status: "unknown" },
1014 message: "Cursor Agent CLI is installed but timed out while running `agent about`.",
1015 },
1016 });
1017 }
1018
1019 const parsed = parseCursorAboutOutput(aboutProbe.success.value);
1020 const parameterizedModelPickerUnsupportedMessage =
1021 getCursorParameterizedModelPickerUnsupportedMessage({
1022 version: parsed.version,
1023 channel: readCursorCliConfigChannel(),
1024 });
1025 if (parameterizedModelPickerUnsupportedMessage) {
1026 return buildServerProvider({
1027 provider: PROVIDER,
1028 enabled: cursorSettings.enabled,
1029 checkedAt,
1030 models: fallbackModels,
1031 probe: {
1032 installed: true,
1033 version: parsed.version,
1034 status: "error",
1035 auth: parsed.auth,
1036 message:
1037 parsed.auth.status === "unauthenticated" && parsed.message
1038 ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}`
1039 : parameterizedModelPickerUnsupportedMessage,
1040 },
1041 });
1042 }
1043 let discoveredModels = Option.none<ReadonlyArray<ServerProviderModel>>();
1044 let discoveryWarning: string | undefined;
1045 if (parsed.auth.status !== "unauthenticated") {
1046 const discoveryExit = yield* Effect.exit(
1047 discoverCursorModelsViaAcp(cursorSettings).pipe(
1048 Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
1049 ),
1050 );
1051 if (Exit.isFailure(discoveryExit)) {
1052 yield* Effect.logWarning("Cursor ACP model discovery failed", {
1053 cause: Cause.pretty(discoveryExit.cause),
1054 });
1055 discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details.";
1056 } else if (Option.isNone(discoveryExit.value)) {
1057 discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`;
1058 } else if (discoveryExit.value.value.length === 0) {
1059 discoveryWarning = "Cursor ACP model discovery returned no built-in models.";
1060 } else {
1061 discoveredModels = discoveryExit.value;
1062 }
1063 }
1064 return buildCursorProviderSnapshot({
1065 checkedAt,
1066 cursorSettings,
1067 parsed,
1068 discoveredModels: Option.getOrElse(
1069 Option.filter(discoveredModels, (models) => models.length > 0),
1070 () => [] as const,
1071 ),
1072 ...(discoveryWarning ? { discoveryWarning } : {}),
1073 });
1074 },
1075);
1076
1077export const CursorProviderLive = Layer.effect(
1078 CursorProvider,
1079 Effect.gen(function* () {
1080 const serverSettings = yield* ServerSettingsService;
1081 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1082
1083 const checkProvider = checkCursorProviderStatus().pipe(
1084 Effect.provideService(ServerSettingsService, serverSettings),
1085 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1086 );
1087
1088 return yield* makeManagedServerProvider<CursorSettings>({
1089 getSettings: serverSettings.getSettings.pipe(
1090 Effect.map((settings) => settings.providers.cursor),
1091 Effect.orDie,
1092 ),
1093 streamSettings: serverSettings.streamChanges.pipe(
1094 Stream.map((settings) => settings.providers.cursor),
1095 ),
1096 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
1097 initialSnapshot: buildInitialCursorProviderSnapshot,
1098 checkProvider,
1099 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => {
1100 if (
1101 !settings.enabled ||
1102 snapshot.auth.status === "unauthenticated" ||
1103 !snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model))
1104 ) {
1105 return Effect.void;
1106 }
1107
1108 return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe(
1109 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1110 Effect.flatMap((discoveredModels) => {
1111 if (discoveredModels.length === 0) {
1112 return Effect.void;
1113 }
1114
1115 return publishSnapshot({
1116 ...snapshot,
1117 models: providerModelsFromSettings(
1118 discoveredModels,
1119 PROVIDER,
1120 settings.customModels,
1121 EMPTY_CAPABILITIES,
1122 ),
1123 });
1124 }),
1125 Effect.catchCause((cause) =>
1126 Effect.logWarning("Cursor ACP background capability enrichment failed", {
1127 models: snapshot.models.map((model) => model.slug),
1128 cause: Cause.pretty(cause),
1129 }).pipe(Effect.asVoid),
1130 ),
1131 );
1132 },
1133 refreshInterval: CURSOR_REFRESH_INTERVAL,
1134 });
1135 }),
1136);
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+23−1
@@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; · 7 unmodified lines
8import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";8import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
9import { CodexAdapter } from "../Services/CodexAdapter.ts";9import { CodexAdapter } from "../Services/CodexAdapter.ts";
10import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";10import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
11import { CursorAdapter } from "../Services/CursorAdapter.ts";
12import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
11import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";13import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
12import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";14import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
13import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";15import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
@@ -66,6 +68,23 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { · 52 unmodified lines
66 streamEvents: Stream.empty,68 streamEvents: Stream.empty,
67};69};
68 70
71const fakeCursorAdapter: CursorAdapterShape = {
72 provider: "cursor",
73 capabilities: { sessionModelSwitch: "in-session" },
74 startSession: vi.fn(),
75 sendTurn: vi.fn(),
76 interruptTurn: vi.fn(),
77 respondToRequest: vi.fn(),
78 respondToUserInput: vi.fn(),
79 stopSession: vi.fn(),
80 listSessions: vi.fn(),
81 hasSession: vi.fn(),
82 readThread: vi.fn(),
83 rollbackThread: vi.fn(),
84 stopAll: vi.fn(),
85 streamEvents: Stream.empty,
86};
87
69const layer = it.layer(88const layer = it.layer(
70 Layer.mergeAll(89 Layer.mergeAll(
71 Layer.provide(90 Layer.provide(
@@ -74,6 +93,7 @@ const layer = it.layer( · 2 unmodified lines
74 Layer.succeed(CodexAdapter, fakeCodexAdapter),93 Layer.succeed(CodexAdapter, fakeCodexAdapter),
75 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),94 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),
76 Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter),95 Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter),
96 Layer.succeed(CursorAdapter, fakeCursorAdapter),
77 ),97 ),
78 ),98 ),
79 NodeServices.layer,99 NodeServices.layer,
@@ -87,12 +107,14 @@ layer("ProviderAdapterRegistryLive", (it) => { · 7 unmodified lines
87 const codex = yield* registry.getByProvider("codex");107 const codex = yield* registry.getByProvider("codex");
88 const claude = yield* registry.getByProvider("claudeAgent");108 const claude = yield* registry.getByProvider("claudeAgent");
89 const openCode = yield* registry.getByProvider("opencode");109 const openCode = yield* registry.getByProvider("opencode");
110 const cursor = yield* registry.getByProvider("cursor");
90 assert.equal(codex, fakeCodexAdapter);111 assert.equal(codex, fakeCodexAdapter);
91 assert.equal(claude, fakeClaudeAdapter);112 assert.equal(claude, fakeClaudeAdapter);
92 assert.equal(openCode, fakeOpenCodeAdapter);113 assert.equal(openCode, fakeOpenCodeAdapter);
114 assert.equal(cursor, fakeCursorAdapter);
93 115
94 const providers = yield* registry.listProviders();116 const providers = yield* registry.listProviders();
95 assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]);117 assert.deepEqual(providers, ["codex", "claudeAgent", "opencode", "cursor"]);
96 }),118 }),
97 );119 );
98 120
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+8−1
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
17} from "../Services/ProviderAdapterRegistry.ts";17} from "../Services/ProviderAdapterRegistry.ts";
18import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";18import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
19import { CodexAdapter } from "../Services/CodexAdapter.ts";19import { CodexAdapter } from "../Services/CodexAdapter.ts";
20import { CursorAdapter } from "../Services/CursorAdapter.ts";
20import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";21import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
21 22
22export interface ProviderAdapterRegistryLiveOptions {23export interface ProviderAdapterRegistryLiveOptions {
@@ -26,10 +27,16 @@ export interface ProviderAdapterRegistryLiveOptions { · 3 unmodified lines
26const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* (27const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* (
27 options?: ProviderAdapterRegistryLiveOptions,28 options?: ProviderAdapterRegistryLiveOptions,
28) {29) {
30 const cursorAdapterOption = yield* Effect.serviceOption(CursorAdapter);
29 const adapters =31 const adapters =
30 options?.adapters !== undefined32 options?.adapters !== undefined
31 ? options.adapters33 ? options.adapters
32 : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter];34 : [
35 yield* CodexAdapter,
36 yield* ClaudeAdapter,
37 yield* OpenCodeAdapter,
38 ...(cursorAdapterOption._tag === "Some" ? [cursorAdapterOption.value] : []),
39 ];
33 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));40 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
34 41
35 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {42 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderRegistry.test.ts+200−10
@@ -30,12 +30,18 @@ import { · 29 unmodified lines
30 readCodexConfigModelProvider,30 readCodexConfigModelProvider,
31} from "./CodexProvider.ts";31} from "./CodexProvider.ts";
32import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";32import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";
33import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts";33import {
34 haveProvidersChanged,
35 mergeProviderSnapshot,
36 ProviderRegistryLive,
37} from "./ProviderRegistry.ts";
34import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";38import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
35import { ServerConfig } from "../../config.ts";39import { ServerConfig } from "../../config.ts";
36import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";40import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
37import { ProviderRegistry } from "../Services/ProviderRegistry.ts";41import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
38 42
43process.env.T3CODE_CURSOR_ENABLED = "1";
44
39// ── Test helpers ────────────────────────────────────────────────────45// ── Test helpers ────────────────────────────────────────────────────
40 46
41const encoder = new TextEncoder();47const encoder = new TextEncoder();
@@ -577,10 +583,106 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 535 unmodified lines
577 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);583 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);
578 });584 });
579 585
580 it.effect("does not probe provider health during registry startup", () =>586 it("preserves previously discovered provider models when a refresh returns none", () => {
587 const previousProvider = {
588 provider: "cursor",
589 status: "ready",
590 enabled: true,
591 installed: true,
592 auth: { status: "authenticated" },
593 checkedAt: "2026-04-14T00:00:00.000Z",
594 version: "2026.04.09-f2b0fcd",
595 models: [
596 {
597 slug: "claude-opus-4-6",
598 name: "Opus 4.6",
599 isCustom: false,
600 capabilities: {
601 reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }],
602 supportsFastMode: true,
603 supportsThinkingToggle: true,
604 contextWindowOptions: [],
605 promptInjectedEffortLevels: [],
606 },
607 },
608 ],
609 slashCommands: [],
610 skills: [],
611 } as const satisfies ServerProvider;
612 const refreshedProvider = {
613 ...previousProvider,
614 checkedAt: "2026-04-14T00:01:00.000Z",
615 models: [],
616 } satisfies ServerProvider;
617
618 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
619 ...previousProvider.models,
620 ]);
621 });
622
623 it("fills missing capabilities from the previous provider snapshot", () => {
624 const previousProvider = {
625 provider: "cursor",
626 status: "ready",
627 enabled: true,
628 installed: true,
629 auth: { status: "authenticated" },
630 checkedAt: "2026-04-14T00:00:00.000Z",
631 version: "2026.04.09-f2b0fcd",
632 models: [
633 {
634 slug: "claude-opus-4-6",
635 name: "Opus 4.6",
636 isCustom: false,
637 capabilities: {
638 reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }],
639 supportsFastMode: true,
640 supportsThinkingToggle: true,
641 contextWindowOptions: [],
642 promptInjectedEffortLevels: [],
643 },
644 },
645 ],
646 slashCommands: [],
647 skills: [],
648 } as const satisfies ServerProvider;
649 const refreshedProvider = {
650 ...previousProvider,
651 checkedAt: "2026-04-14T00:01:00.000Z",
652 models: [
653 {
654 slug: "claude-opus-4-6",
655 name: "Opus 4.6",
656 isCustom: false,
657 capabilities: {
658 reasoningEffortLevels: [],
659 supportsFastMode: false,
660 supportsThinkingToggle: false,
661 contextWindowOptions: [],
662 promptInjectedEffortLevels: [],
663 },
664 },
665 ],
666 } satisfies ServerProvider;
667
668 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
669 ...previousProvider.models,
670 ]);
671 });
672
673 it.effect("probes enabled providers in the background during registry startup", () =>
581 Effect.gen(function* () {674 Effect.gen(function* () {
582 let spawnCount = 0;675 let spawnCount = 0;
583 const serverSettings = yield* makeMutableServerSettingsService();676 const serverSettings = yield* makeMutableServerSettingsService(
677 Schema.decodeSync(ServerSettings)(
678 deepMerge(DEFAULT_SERVER_SETTINGS, {
679 providers: {
680 claudeAgent: { enabled: false },
681 cursor: { enabled: false },
682 },
683 }),
684 ),
685 );
584 const scope = yield* Scope.make();686 const scope = yield* Scope.make();
585 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));687 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
586 const providerRegistryLayer = ProviderRegistryLive.pipe(688 const providerRegistryLayer = ProviderRegistryLive.pipe(
@@ -616,11 +718,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 29 unmodified lines
616 718
617 yield* Effect.gen(function* () {719 yield* Effect.gen(function* () {
618 const registry = yield* ProviderRegistry;720 const registry = yield* ProviderRegistry;
619 assert.deepStrictEqual(yield* registry.getProviders, []);
620 assert.strictEqual(spawnCount, 0);
621
622 const refreshed = yield* registry.refresh("codex");
623 assert.strictEqual(spawnCount > 0, true);721 assert.strictEqual(spawnCount > 0, true);
722 const refreshed = yield* Effect.gen(function* () {
723 for (let remainingAttempts = 50; remainingAttempts > 0; remainingAttempts -= 1) {
724 const providers = yield* registry.getProviders;
725 const codexProvider = providers.find((provider) => provider.provider === "codex");
726 if (codexProvider?.status === "ready") {
727 return providers;
728 }
729 yield* Effect.sleep("10 millis");
730 }
731 return yield* registry.getProviders;
732 });
624 assert.strictEqual(733 assert.strictEqual(
625 refreshed.find((provider) => provider.provider === "codex")?.status,734 refreshed.find((provider) => provider.provider === "codex")?.status,
626 "ready",735 "ready",
@@ -657,6 +766,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 30 unmodified lines
657 }766 }
658 return { stdout: "", stderr: "spawn ENOENT", code: 1 };767 return { stdout: "", stderr: "spawn ENOENT", code: 1 };
659 }768 }
769 if (joined === "about --format json") {
770 return {
771 stdout: JSON.stringify({
772 cliVersion: "2026.04.09-f2b0fcd",
773 userEmail: null,
774 }),
775 stderr: "",
776 code: 0,
777 };
778 }
779 if (joined === "about") {
780 return { stdout: "", stderr: "spawn ENOENT", code: 1 };
781 }
660 if (joined === "login status") {782 if (joined === "login status") {
661 return { stdout: "Logged in\n", stderr: "", code: 0 };783 return { stdout: "Logged in\n", stderr: "", code: 0 };
662 }784 }
@@ -671,9 +793,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 8 unmodified lines
671 yield* Effect.gen(function* () {793 yield* Effect.gen(function* () {
672 const registry = yield* ProviderRegistry;794 const registry = yield* ProviderRegistry;
673 795
674 const initial = yield* registry.getProviders;
675 assert.deepStrictEqual(initial, []);
676
677 const refreshed = yield* registry.refresh("codex");796 const refreshed = yield* registry.refresh("codex");
678 assert.strictEqual(797 assert.strictEqual(
679 refreshed.find((status) => status.provider === "codex")?.status,798 refreshed.find((status) => status.provider === "codex")?.status,
@@ -705,6 +824,77 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 25 unmodified lines
705 }),824 }),
706 );825 );
707 826
827 it.effect(
828 "keeps cursor disabled and skips probing when the provider setting is disabled",
829 () =>
830 Effect.gen(function* () {
831 const serverSettings = yield* makeMutableServerSettingsService(
832 Schema.decodeSync(ServerSettings)(
833 deepMerge(DEFAULT_SERVER_SETTINGS, {
834 providers: {
835 cursor: {
836 enabled: false,
837 },
838 },
839 }),
840 ),
841 );
842 let cursorSpawned = false;
843 const scope = yield* Scope.make();
844 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
845 const providerRegistryLayer = ProviderRegistryLive.pipe(
846 Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)),
847 Layer.provideMerge(
848 ServerConfig.layerTest(process.cwd(), {
849 prefix: "t3-provider-registry-",
850 }),
851 ),
852 Layer.provideMerge(
853 mockCommandSpawnerLayer((command, args) => {
854 if (command === "agent") {
855 cursorSpawned = true;
856 }
857 const joined = args.join(" ");
858 if (joined === "--version") {
859 return { stdout: `${command} 1.0.0\n`, stderr: "", code: 0 };
860 }
861 if (joined === "login status") {
862 return { stdout: "Logged in\n", stderr: "", code: 0 };
863 }
864 if (joined === "auth status") {
865 return { stdout: '{"authenticated":true}\n', stderr: "", code: 0 };
866 }
867 throw new Error(`Unexpected args: ${command} ${joined}`);
868 }),
869 ),
870 );
871 const runtimeServices = yield* Layer.build(
872 Layer.mergeAll(
873 Layer.succeed(ServerSettingsService, serverSettings),
874 providerRegistryLayer,
875 ),
876 ).pipe(Scope.provide(scope));
877
878 yield* Effect.gen(function* () {
879 const registry = yield* ProviderRegistry;
880 const providers = yield* registry.getProviders;
881 const cursorProvider = providers.find((provider) => provider.provider === "cursor");
882
883 assert.deepStrictEqual(
884 providers.map((provider) => provider.provider),
885 ["codex", "claudeAgent", "opencode", "cursor"],
886 );
887 assert.strictEqual(cursorProvider?.enabled, false);
888 assert.strictEqual(cursorProvider?.status, "disabled");
889 assert.strictEqual(
890 cursorProvider?.message,
891 "Cursor is disabled in T3 Code settings.",
892 );
893 assert.strictEqual(cursorSpawned, false);
894 }).pipe(Effect.provide(runtimeServices));
895 }),
896 );
897
708 it.effect("skips codex probes entirely when the provider is disabled", () =>898 it.effect("skips codex probes entirely when the provider is disabled", () =>
709 Effect.gen(function* () {899 Effect.gen(function* () {
710 const serverSettingsLayer = ServerSettingsService.layerTest({900 const serverSettingsLayer = ServerSettingsService.layerTest({
apps/server/src/provider/Layers/ProviderRegistry.ts+136−62
@@ -9,12 +9,11 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff · 8 unmodified lines
9import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
10import { ClaudeProviderLive } from "./ClaudeProvider.ts";10import { ClaudeProviderLive } from "./ClaudeProvider.ts";
11import { CodexProviderLive } from "./CodexProvider.ts";11import { CodexProviderLive } from "./CodexProvider.ts";
12import { CursorProviderLive } from "./CursorProvider.ts";
12import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";13import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
13import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts";
14import { ClaudeProvider } from "../Services/ClaudeProvider.ts";14import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
15import type { CodexProviderShape } from "../Services/CodexProvider.ts";
16import { CodexProvider } from "../Services/CodexProvider.ts";15import { CodexProvider } from "../Services/CodexProvider.ts";
17import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts";16import { CursorProvider } from "../Services/CursorProvider.ts";
18import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";17import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
19import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";18import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
20import {19import {
@@ -26,24 +25,67 @@ import { · 5 unmodified lines
26 writeProviderStatusCache,25 writeProviderStatusCache,
27} from "../providerStatusCache.ts";26} from "../providerStatusCache.ts";
28 27
28type ProviderSnapshotSource = {
29 readonly provider: ProviderKind;
30 readonly getSnapshot: Effect.Effect<ServerProvider>;
31 readonly refresh: Effect.Effect<ServerProvider>;
32 readonly streamChanges: Stream.Stream<ServerProvider>;
33};
34
29const loadProviders = (35const loadProviders = (
30 codexProvider: CodexProviderShape,36 providerSources: ReadonlyArray<ProviderSnapshotSource>,
31 claudeProvider: ClaudeProviderShape,37): Effect.Effect<ReadonlyArray<ServerProvider>> =>
32 openCodeProvider: OpenCodeProviderShape,38 Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, {
33): Effect.Effect<readonly [ServerProvider, ServerProvider, ServerProvider]> =>39 concurrency: "unbounded",
34 Effect.all(40 });
35 [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot],41
36 {42const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
37 concurrency: "unbounded",43 (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 ||
38 },44 model.capabilities?.supportsFastMode === true ||
39 );45 model.capabilities?.supportsThinkingToggle === true ||
46 (model.capabilities?.contextWindowOptions.length ?? 0) > 0 ||
47 (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0;
48
49const mergeProviderModels = (
50 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
51 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
52): ReadonlyArray<ServerProvider["models"][number]> => {
53 if (nextModels.length === 0 && previousModels.length > 0) {
54 return previousModels;
55 }
56
57 const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
58 const mergedModels = nextModels.map((model) => {
59 const previousModel = previousBySlug.get(model.slug);
60 if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) {
61 return model;
62 }
63 return {
64 ...model,
65 capabilities: previousModel.capabilities,
66 };
67 });
68 const nextSlugs = new Set(nextModels.map((model) => model.slug));
69 return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))];
70};
71
72export const mergeProviderSnapshot = (
73 previousProvider: ServerProvider | undefined,
74 nextProvider: ServerProvider,
75): ServerProvider =>
76 !previousProvider
77 ? nextProvider
78 : {
79 ...nextProvider,
80 models: mergeProviderModels(previousProvider.models, nextProvider.models),
81 };
40 82
41export const haveProvidersChanged = (83export const haveProvidersChanged = (
42 previousProviders: ReadonlyArray<ServerProvider>,84 previousProviders: ReadonlyArray<ServerProvider>,
43 nextProviders: ReadonlyArray<ServerProvider>,85 nextProviders: ReadonlyArray<ServerProvider>,
44): boolean => !Equal.equals(previousProviders, nextProviders);86): boolean => !Equal.equals(previousProviders, nextProviders);
45 87
46export const ProviderRegistryLive = Layer.effect(88const ProviderRegistryLiveBase = Layer.effect(
47 ProviderRegistry,89 ProviderRegistry,
48 Effect.gen(function* () {90 Effect.gen(function* () {
49 const codexProvider = yield* CodexProvider;91 const codexProvider = yield* CodexProvider;
@@ -52,13 +94,43 @@ export const ProviderRegistryLive = Layer.effect( · 2 unmodified lines
52 const config = yield* ServerConfig;94 const config = yield* ServerConfig;
53 const fileSystem = yield* FileSystem.FileSystem;95 const fileSystem = yield* FileSystem.FileSystem;
54 const path = yield* Path.Path;96 const path = yield* Path.Path;
97
98 const cursorProvider = yield* CursorProvider;
99
100 const providerSources = [
101 {
102 provider: "codex",
103 getSnapshot: codexProvider.getSnapshot,
104 refresh: codexProvider.refresh,
105 streamChanges: codexProvider.streamChanges,
106 },
107 {
108 provider: "claudeAgent",
109 getSnapshot: claudeProvider.getSnapshot,
110 refresh: claudeProvider.refresh,
111 streamChanges: claudeProvider.streamChanges,
112 },
113 {
114 provider: "opencode",
115 getSnapshot: openCodeProvider.getSnapshot,
116 refresh: openCodeProvider.refresh,
117 streamChanges: openCodeProvider.streamChanges,
118 },
119 {
120 provider: "cursor",
121 getSnapshot: cursorProvider.getSnapshot,
122 refresh: cursorProvider.refresh,
123 streamChanges: cursorProvider.streamChanges,
124 },
125 ] satisfies ReadonlyArray<ProviderSnapshotSource>;
126 const activeProviders = PROVIDER_CACHE_IDS;
55 const changesPubSub = yield* Effect.acquireRelease(127 const changesPubSub = yield* Effect.acquireRelease(
56 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),128 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
57 PubSub.shutdown,129 PubSub.shutdown,
58 );130 );
59 const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider);131 const fallbackProviders = yield* loadProviders(providerSources);
60 const cachePathByProvider = new Map(132 const cachePathByProvider = new Map(
61 PROVIDER_CACHE_IDS.map(133 activeProviders.map(
62 (provider) =>134 (provider) =>
63 [135 [
64 provider,136 provider,
@@ -72,8 +144,9 @@ export const ProviderRegistryLive = Layer.effect( · 7 unmodified lines
72 const fallbackByProvider = new Map(144 const fallbackByProvider = new Map(
73 fallbackProviders.map((provider) => [provider.provider, provider] as const),145 fallbackProviders.map((provider) => [provider.provider, provider] as const),
74 );146 );
147
75 const cachedProviders = yield* Effect.forEach(148 const cachedProviders = yield* Effect.forEach(
76 PROVIDER_CACHE_IDS,149 activeProviders,
77 (provider) => {150 (provider) => {
78 const filePath = cachePathByProvider.get(provider)!;151 const filePath = cachePathByProvider.get(provider)!;
79 const fallbackProvider = fallbackByProvider.get(provider)!;152 const fallbackProvider = fallbackByProvider.get(provider)!;
@@ -124,7 +197,10 @@ export const ProviderRegistryLive = Layer.effect( · 44 unmodified lines
124 );197 );
125 198
126 for (const provider of nextProviders) {199 for (const provider of nextProviders) {
127 mergedProviders.set(provider.provider, provider);200 mergedProviders.set(
201 provider.provider,
202 mergeProviderSnapshot(mergedProviders.get(provider.provider), provider),
203 );
128 }204 }
129 205
130 const providers = orderProviderSnapshots([...mergedProviders.values()]);206 const providers = orderProviderSnapshots([...mergedProviders.values()]);
@@ -155,49 +231,40 @@ export const ProviderRegistryLive = Layer.effect( · 24 unmodified lines
155 });231 });
156 232
157 const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) {233 const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) {
158 switch (provider) {234 if (provider) {
159 case "codex":235 const providerSource = providerSources.find((candidate) => candidate.provider === provider);
160 return yield* codexProvider.refresh.pipe(236 if (!providerSource) {
161 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),237 return yield* Ref.get(providersRef);
162 );238 }
163 case "claudeAgent":239 return yield* providerSource.refresh.pipe(
164 return yield* claudeProvider.refresh.pipe(240 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
165 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),241 );
166 );
167 case "opencode":
168 return yield* openCodeProvider.refresh.pipe(
169 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
170 );
171 default:
172 return yield* Effect.all(
173 [
174 codexProvider.refresh.pipe(
175 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
176 ),
177 claudeProvider.refresh.pipe(
178 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
179 ),
180 openCodeProvider.refresh.pipe(
181 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
182 ),
183 ],
184 {
185 concurrency: "unbounded",
186 discard: true,
187 },
188 ).pipe(Effect.andThen(Ref.get(providersRef)));
189 }242 }
243
244 return yield* Effect.forEach(
245 providerSources,
246 (providerSource) => providerSource.refresh.pipe(Effect.flatMap(syncProvider)),
247 {
248 concurrency: "unbounded",
249 discard: true,
250 },
251 ).pipe(Effect.andThen(Ref.get(providersRef)));
190 });252 });
191 253
192 yield* Stream.runForEach(codexProvider.streamChanges, (provider) =>254 yield* Effect.forEach(
193 syncProvider(provider),255 providerSources,
194 ).pipe(Effect.forkScoped);256 (providerSource) =>
195 yield* Stream.runForEach(claudeProvider.streamChanges, (provider) =>257 Stream.runForEach(providerSource.streamChanges, (provider) => syncProvider(provider)).pipe(
196 syncProvider(provider),258 Effect.forkScoped,
197 ).pipe(Effect.forkScoped);259 ),
198 yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) =>260 {
199 syncProvider(provider),261 concurrency: "unbounded",
200 ).pipe(Effect.forkScoped);262 discard: true,
263 },
264 );
265 yield* loadProviders(providerSources).pipe(
266 Effect.flatMap((providers) => upsertProviders(providers, { publish: false })),
267 );
201 268
202 return {269 return {
203 getProviders: Ref.get(providersRef),270 getProviders: Ref.get(providersRef),
@@ -211,8 +278,15 @@ export const ProviderRegistryLive = Layer.effect( · 7 unmodified lines
211 },278 },
212 } satisfies ProviderRegistryShape;279 } satisfies ProviderRegistryShape;
213 }),280 }),
214).pipe(281);
215 Layer.provideMerge(CodexProviderLive),282
216 Layer.provideMerge(ClaudeProviderLive),283export const ProviderRegistryLive = Layer.unwrap(
217 Layer.provideMerge(OpenCodeProviderLive),284 Effect.sync(() =>
285 ProviderRegistryLiveBase.pipe(
286 Layer.provideMerge(CursorProviderLive),
287 Layer.provideMerge(CodexProviderLive),
288 Layer.provideMerge(ClaudeProviderLive),
289 Layer.provideMerge(OpenCodeProviderLive),
290 ),
291 ),
218);292);
apps/server/src/provider/Layers/ProviderService.test.ts+6−2
@@ -245,14 +245,17 @@ const hasMetricSnapshot = ( · 244 unmodified lines
245function makeProviderServiceLayer() {245function makeProviderServiceLayer() {
246 const codex = makeFakeCodexAdapter();246 const codex = makeFakeCodexAdapter();
247 const claude = makeFakeCodexAdapter("claudeAgent");247 const claude = makeFakeCodexAdapter("claudeAgent");
248 const cursor = makeFakeCodexAdapter("cursor");
248 const registry: typeof ProviderAdapterRegistry.Service = {249 const registry: typeof ProviderAdapterRegistry.Service = {
249 getByProvider: (provider) =>250 getByProvider: (provider) =>
250 provider === "codex"251 provider === "codex"
251 ? Effect.succeed(codex.adapter)252 ? Effect.succeed(codex.adapter)
252 : provider === "claudeAgent"253 : provider === "claudeAgent"
253 ? Effect.succeed(claude.adapter)254 ? Effect.succeed(claude.adapter)
254 : Effect.fail(new ProviderUnsupportedError({ provider })),255 : provider === "cursor"
255 listProviders: () => Effect.succeed(["codex", "claudeAgent"]),256 ? Effect.succeed(cursor.adapter)
257 : Effect.fail(new ProviderUnsupportedError({ provider })),
258 listProviders: () => Effect.succeed(["codex", "claudeAgent", "cursor"]),
256 };259 };
257 260
258 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);261 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
@@ -279,6 +282,7 @@ function makeProviderServiceLayer() { · 20 unmodified lines
279 return {282 return {
280 codex,283 codex,
281 claude,284 claude,
285 cursor,
282 layer,286 layer,
283 };287 };
284}288}
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+11−10
@@ -1,5 +1,5 @@
1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";1import { ProviderKind, type ThreadId } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";2import { Effect, Layer, Option, Schema } from "effect";
3 3
4import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts";4import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts";
5import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";5import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
@@ -24,14 +24,15 @@ function decodeProviderKind( · 18 unmodified lines
24 providerName: string,24 providerName: string,
25 operation: string,25 operation: string,
26): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {26): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
27 if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {27 return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe(
28 return Effect.succeed(providerName);28 Effect.mapError(
29 }29 (cause) =>
30 return Effect.fail(30 new ProviderSessionDirectoryPersistenceError({
31 new ProviderSessionDirectoryPersistenceError({31 operation,
32 operation,32 detail: `Unknown persisted provider '${providerName}'.`,
33 detail: `Unknown persisted provider '${providerName}'.`,33 cause,
34 }),34 }),
35 ),
35 );36 );
36}37}
37 38
apps/server/src/provider/Services/CursorAdapter.ts+12−0
@@ -0,0 +1,12 @@
1import { Context } from "effect";
2
3import type { ProviderAdapterError } from "../Errors.ts";
4import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
5
6export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
7 readonly provider: "cursor";
8}
9
10export class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()(
11 "t3/provider/Services/CursorAdapter",
12) {}
apps/server/src/provider/Services/CursorProvider.ts+9−0
@@ -0,0 +1,9 @@
1import { Context } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider.ts";
4
5export interface CursorProviderShape extends ServerProviderShape {}
6
7export class CursorProvider extends Context.Service<CursorProvider, CursorProviderShape>()(
8 "t3/provider/Services/CursorProvider",
9) {}
apps/server/src/provider/Services/ProviderAdapter.ts+1−1
@@ -23,7 +23,7 @@ import type { · 22 unmodified lines
23import type { Effect } from "effect";23import type { Effect } from "effect";
24import type { Stream } from "effect";24import type { Stream } from "effect";
25 25
26export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported";26export type ProviderSessionModelSwitchMode = "in-session" | "unsupported";
27 27
28export interface ProviderAdapterCapabilities {28export interface ProviderAdapterCapabilities {
29 /**29 /**
apps/server/src/provider/acp/AcpAdapterSupport.test.ts+27−0
@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vitest";
2import * as EffectAcpErrors from "effect-acp/errors";
3
4import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts";
5
6describe("AcpAdapterSupport", () => {
7 it("maps ACP approval decisions to permission outcomes", () => {
8 expect(acpPermissionOutcome("accept")).toBe("allow-once");
9 expect(acpPermissionOutcome("acceptForSession")).toBe("allow-always");
10 expect(acpPermissionOutcome("decline")).toBe("reject-once");
11 });
12
13 it("maps ACP request errors to provider adapter request errors", () => {
14 const error = mapAcpToAdapterError(
15 "cursor",
16 "thread-1" as never,
17 "session/prompt",
18 new EffectAcpErrors.AcpRequestError({
19 code: -32602,
20 errorMessage: "Invalid params",
21 }),
22 );
23
24 expect(error._tag).toBe("ProviderAdapterRequestError");
25 expect(error.message).toContain("Invalid params");
26 });
27});
apps/server/src/provider/acp/AcpAdapterSupport.ts+54−0
@@ -0,0 +1,54 @@
1import {
2 type ProviderApprovalDecision,
3 type ProviderKind,
4 type ThreadId,
5} from "@t3tools/contracts";
6import { Schema } from "effect";
7import * as EffectAcpErrors from "effect-acp/errors";
8
9import {
10 ProviderAdapterRequestError,
11 ProviderAdapterSessionClosedError,
12 type ProviderAdapterError,
13} from "../Errors.ts";
14
15export function mapAcpToAdapterError(
16 provider: ProviderKind,
17 threadId: ThreadId,
18 method: string,
19 error: EffectAcpErrors.AcpError,
20): ProviderAdapterError {
21 if (Schema.is(EffectAcpErrors.AcpProcessExitedError)(error)) {
22 return new ProviderAdapterSessionClosedError({
23 provider,
24 threadId,
25 cause: error,
26 });
27 }
28 if (Schema.is(EffectAcpErrors.AcpRequestError)(error)) {
29 return new ProviderAdapterRequestError({
30 provider,
31 method,
32 detail: error.message,
33 cause: error,
34 });
35 }
36 return new ProviderAdapterRequestError({
37 provider,
38 method,
39 detail: error.message,
40 cause: error,
41 });
42}
43
44export function acpPermissionOutcome(decision: ProviderApprovalDecision): string {
45 switch (decision) {
46 case "acceptForSession":
47 return "allow-always";
48 case "accept":
49 return "allow-once";
50 case "decline":
51 default:
52 return "reject-once";
53 }
54}
apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts+155−0
@@ -0,0 +1,155 @@
1import { RuntimeRequestId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import {
5 makeAcpAssistantItemEvent,
6 makeAcpContentDeltaEvent,
7 makeAcpPlanUpdatedEvent,
8 makeAcpRequestOpenedEvent,
9 makeAcpRequestResolvedEvent,
10 makeAcpToolCallEvent,
11} from "./AcpCoreRuntimeEvents.ts";
12
13describe("AcpCoreRuntimeEvents", () => {
14 it("maps ACP permission requests to canonical runtime events", () => {
15 const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" };
16 const turnId = TurnId.make("turn-1");
17 const permissionRequest = {
18 kind: "execute" as const,
19 detail: "cat package.json",
20 toolCall: {
21 toolCallId: "tool-1",
22 kind: "execute",
23 status: "pending" as const,
24 command: "cat package.json",
25 detail: "cat package.json",
26 data: { toolCallId: "tool-1", kind: "execute" },
27 },
28 };
29
30 expect(
31 makeAcpRequestOpenedEvent({
32 stamp,
33 provider: "cursor",
34 threadId: "thread-1" as never,
35 turnId,
36 requestId: RuntimeRequestId.make("request-1"),
37 permissionRequest,
38 detail: "cat package.json",
39 args: { command: ["cat", "package.json"] },
40 source: "acp.jsonrpc",
41 method: "session/request_permission",
42 rawPayload: { sessionId: "session-1" },
43 }),
44 ).toMatchObject({
45 type: "request.opened",
46 payload: {
47 requestType: "exec_command_approval",
48 detail: "cat package.json",
49 },
50 });
51
52 expect(
53 makeAcpRequestResolvedEvent({
54 stamp,
55 provider: "cursor",
56 threadId: "thread-1" as never,
57 turnId,
58 requestId: RuntimeRequestId.make("request-1"),
59 permissionRequest,
60 decision: "accept",
61 }),
62 ).toMatchObject({
63 type: "request.resolved",
64 payload: {
65 requestType: "exec_command_approval",
66 decision: "accept",
67 },
68 });
69 });
70
71 it("maps ACP core plan, tool-call, and content updates", () => {
72 const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" };
73 const turnId = TurnId.make("turn-1");
74
75 expect(
76 makeAcpPlanUpdatedEvent({
77 stamp,
78 provider: "cursor",
79 threadId: "thread-1" as never,
80 turnId,
81 payload: {
82 plan: [{ step: "Inspect state", status: "inProgress" }],
83 },
84 source: "acp.cursor.extension",
85 method: "cursor/update_todos",
86 rawPayload: { todos: [] },
87 }),
88 ).toMatchObject({
89 type: "turn.plan.updated",
90 raw: {
91 method: "cursor/update_todos",
92 },
93 });
94
95 expect(
96 makeAcpToolCallEvent({
97 stamp,
98 provider: "cursor",
99 threadId: "thread-1" as never,
100 turnId,
101 toolCall: {
102 toolCallId: "tool-1",
103 kind: "execute",
104 status: "completed",
105 title: "Terminal",
106 detail: "bun run test",
107 data: { command: "bun run test" },
108 },
109 rawPayload: { sessionId: "session-1" },
110 }),
111 ).toMatchObject({
112 type: "item.completed",
113 payload: {
114 itemType: "command_execution",
115 status: "completed",
116 },
117 });
118
119 expect(
120 makeAcpContentDeltaEvent({
121 stamp,
122 provider: "cursor",
123 threadId: "thread-1" as never,
124 turnId,
125 itemId: "assistant:session-1:segment:0",
126 text: "hello",
127 rawPayload: { sessionId: "session-1" },
128 }),
129 ).toMatchObject({
130 type: "content.delta",
131 itemId: "assistant:session-1:segment:0",
132 payload: {
133 delta: "hello",
134 },
135 });
136
137 expect(
138 makeAcpAssistantItemEvent({
139 stamp,
140 provider: "cursor",
141 threadId: "thread-1" as never,
142 turnId,
143 itemId: "assistant:session-1:segment:0",
144 lifecycle: "item.started",
145 }),
146 ).toMatchObject({
147 type: "item.started",
148 itemId: "assistant:session-1:segment:0",
149 payload: {
150 itemType: "assistant_message",
151 status: "inProgress",
152 },
153 });
154 });
155});
apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts+242−0
@@ -0,0 +1,242 @@
1import {
2 type RuntimeEventRawSource,
3 RuntimeItemId,
4 type CanonicalRequestType,
5 type EventId,
6 type ProviderApprovalDecision,
7 type ProviderKind,
8 type ProviderRuntimeEvent,
9 type RuntimeRequestId,
10 type ThreadId,
11 type ToolLifecycleItemType,
12 type TurnId,
13} from "@t3tools/contracts";
14
15import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts";
16
17type AcpAdapterRawSource = Extract<
18 RuntimeEventRawSource,
19 "acp.jsonrpc" | `acp.${string}.extension`
20>;
21
22interface AcpEventStamp {
23 readonly eventId: EventId;
24 readonly createdAt: string;
25}
26
27type AcpCanonicalRequestType = Extract<
28 CanonicalRequestType,
29 "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown"
30>;
31
32function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType {
33 switch (kind) {
34 case "execute":
35 return "exec_command_approval";
36 case "read":
37 return "file_read_approval";
38 case "edit":
39 case "delete":
40 case "move":
41 return "file_change_approval";
42 default:
43 return "unknown";
44 }
45}
46
47function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType {
48 switch (kind) {
49 case "execute":
50 return "command_execution";
51 case "edit":
52 case "delete":
53 case "move":
54 return "file_change";
55 case "search":
56 case "fetch":
57 return "web_search";
58 default:
59 return "dynamic_tool_call";
60 }
61}
62
63function runtimeItemStatusFromAcpToolStatus(
64 status: AcpToolCallState["status"],
65): "inProgress" | "completed" | "failed" | undefined {
66 switch (status) {
67 case "pending":
68 case "inProgress":
69 return "inProgress";
70 case "completed":
71 return "completed";
72 case "failed":
73 return "failed";
74 default:
75 return undefined;
76 }
77}
78
79export function makeAcpRequestOpenedEvent(input: {
80 readonly stamp: AcpEventStamp;
81 readonly provider: ProviderKind;
82 readonly threadId: ThreadId;
83 readonly turnId: TurnId | undefined;
84 readonly requestId: RuntimeRequestId;
85 readonly permissionRequest: AcpPermissionRequest;
86 readonly detail: string;
87 readonly args: unknown;
88 readonly source: AcpAdapterRawSource;
89 readonly method: string;
90 readonly rawPayload: unknown;
91}): ProviderRuntimeEvent {
92 return {
93 type: "request.opened",
94 ...input.stamp,
95 provider: input.provider,
96 threadId: input.threadId,
97 turnId: input.turnId,
98 requestId: input.requestId,
99 payload: {
100 requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind),
101 detail: input.detail,
102 args: input.args,
103 },
104 raw: {
105 source: input.source,
106 method: input.method,
107 payload: input.rawPayload,
108 },
109 };
110}
111
112export function makeAcpRequestResolvedEvent(input: {
113 readonly stamp: AcpEventStamp;
114 readonly provider: ProviderKind;
115 readonly threadId: ThreadId;
116 readonly turnId: TurnId | undefined;
117 readonly requestId: RuntimeRequestId;
118 readonly permissionRequest: AcpPermissionRequest;
119 readonly decision: ProviderApprovalDecision;
120}): ProviderRuntimeEvent {
121 return {
122 type: "request.resolved",
123 ...input.stamp,
124 provider: input.provider,
125 threadId: input.threadId,
126 turnId: input.turnId,
127 requestId: input.requestId,
128 payload: {
129 requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind),
130 decision: input.decision,
131 },
132 };
133}
134
135export function makeAcpPlanUpdatedEvent(input: {
136 readonly stamp: AcpEventStamp;
137 readonly provider: ProviderKind;
138 readonly threadId: ThreadId;
139 readonly turnId: TurnId | undefined;
140 readonly payload: AcpPlanUpdate;
141 readonly source: AcpAdapterRawSource;
142 readonly method: string;
143 readonly rawPayload: unknown;
144}): ProviderRuntimeEvent {
145 return {
146 type: "turn.plan.updated",
147 ...input.stamp,
148 provider: input.provider,
149 threadId: input.threadId,
150 turnId: input.turnId,
151 payload: input.payload,
152 raw: {
153 source: input.source,
154 method: input.method,
155 payload: input.rawPayload,
156 },
157 };
158}
159
160export function makeAcpToolCallEvent(input: {
161 readonly stamp: AcpEventStamp;
162 readonly provider: ProviderKind;
163 readonly threadId: ThreadId;
164 readonly turnId: TurnId | undefined;
165 readonly toolCall: AcpToolCallState;
166 readonly rawPayload: unknown;
167}): ProviderRuntimeEvent {
168 const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status);
169 return {
170 type:
171 input.toolCall.status === "completed" || input.toolCall.status === "failed"
172 ? "item.completed"
173 : "item.updated",
174 ...input.stamp,
175 provider: input.provider,
176 threadId: input.threadId,
177 turnId: input.turnId,
178 itemId: RuntimeItemId.make(input.toolCall.toolCallId),
179 payload: {
180 itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind),
181 ...(runtimeStatus ? { status: runtimeStatus } : {}),
182 ...(input.toolCall.title ? { title: input.toolCall.title } : {}),
183 ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}),
184 ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}),
185 },
186 raw: {
187 source: "acp.jsonrpc",
188 method: "session/update",
189 payload: input.rawPayload,
190 },
191 };
192}
193
194export function makeAcpAssistantItemEvent(input: {
195 readonly stamp: AcpEventStamp;
196 readonly provider: ProviderKind;
197 readonly threadId: ThreadId;
198 readonly turnId: TurnId | undefined;
199 readonly itemId: string;
200 readonly lifecycle: "item.started" | "item.completed";
201}): ProviderRuntimeEvent {
202 return {
203 type: input.lifecycle,
204 ...input.stamp,
205 provider: input.provider,
206 threadId: input.threadId,
207 turnId: input.turnId,
208 itemId: RuntimeItemId.make(input.itemId),
209 payload: {
210 itemType: "assistant_message",
211 status: input.lifecycle === "item.completed" ? "completed" : "inProgress",
212 },
213 };
214}
215
216export function makeAcpContentDeltaEvent(input: {
217 readonly stamp: AcpEventStamp;
218 readonly provider: ProviderKind;
219 readonly threadId: ThreadId;
220 readonly turnId: TurnId | undefined;
221 readonly itemId?: string;
222 readonly text: string;
223 readonly rawPayload: unknown;
224}): ProviderRuntimeEvent {
225 return {
226 type: "content.delta",
227 ...input.stamp,
228 provider: input.provider,
229 threadId: input.threadId,
230 turnId: input.turnId,
231 ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),
232 payload: {
233 streamKind: "assistant_text",
234 delta: input.text,
235 },
236 raw: {
237 source: "acp.jsonrpc",
238 method: "session/update",
239 payload: input.rawPayload,
240 },
241 };
242}
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+395−0
@@ -0,0 +1,395 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { fileURLToPath } from "node:url";
4import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";
8import { Effect, Stream } from "effect";
9import { describe, expect } from "vitest";
10
11import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts";
12import type * as EffectAcpProtocol from "effect-acp/protocol";
13
14const __dirname = path.dirname(fileURLToPath(import.meta.url));
15const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
16const bunExe = "bun";
17
18describe("AcpSessionRuntime", () => {
19 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {
20 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
21 return Effect.gen(function* () {
22 const runtime = yield* AcpSessionRuntime;
23 yield* runtime.start();
24
25 const initializeStarted = requestEvents.find(
26 (event) => event.method === "initialize" && event.status === "started",
27 );
28 expect(initializeStarted?.payload).toMatchObject({
29 protocolVersion: 1,
30 clientCapabilities: {
31 fs: { readTextFile: false, writeTextFile: false },
32 terminal: false,
33 _meta: { parameterizedModelPicker: true },
34 },
35 });
36 }).pipe(
37 Effect.provide(
38 AcpSessionRuntime.layer({
39 spawn: {
40 command: bunExe,
41 args: [mockAgentPath],
42 },
43 cwd: process.cwd(),
44 clientCapabilities: {
45 _meta: {
46 parameterizedModelPicker: true,
47 },
48 },
49 clientInfo: { name: "t3-test", version: "0.0.0" },
50 authMethodId: "test",
51 requestLogger: (event) =>
52 Effect.sync(() => {
53 requestEvents.push(event);
54 }),
55 }),
56 ),
57 Effect.scoped,
58 Effect.provide(NodeServices.layer),
59 );
60 });
61
62 it.effect("starts a session, prompts, and emits normalized events against the mock agent", () =>
63 Effect.gen(function* () {
64 const runtime = yield* AcpSessionRuntime;
65 const started = yield* runtime.start();
66
67 expect(started.initializeResult).toMatchObject({ protocolVersion: 1 });
68 expect(started.sessionId).toBe("mock-session-1");
69
70 const promptResult = yield* runtime.prompt({
71 prompt: [{ type: "text", text: "hi" }],
72 });
73 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
74
75 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
76 expect(notes).toHaveLength(4);
77 expect(notes.map((note) => note._tag)).toEqual([
78 "PlanUpdated",
79 "AssistantItemStarted",
80 "ContentDelta",
81 "AssistantItemCompleted",
82 ]);
83 const planUpdate = notes.find((note) => note._tag === "PlanUpdated");
84 expect(planUpdate?._tag).toBe("PlanUpdated");
85 if (planUpdate?._tag === "PlanUpdated") {
86 expect(planUpdate.payload.plan).toHaveLength(2);
87 }
88 const assistantStart = notes[1];
89 const assistantDelta = notes[2];
90 if (
91 assistantStart?._tag === "AssistantItemStarted" &&
92 assistantDelta?._tag === "ContentDelta"
93 ) {
94 expect(assistantDelta.itemId).toBe(assistantStart.itemId);
95 }
96 }).pipe(
97 Effect.provide(
98 AcpSessionRuntime.layer({
99 spawn: {
100 command: bunExe,
101 args: [mockAgentPath],
102 },
103 cwd: process.cwd(),
104 clientInfo: { name: "t3-test", version: "0.0.0" },
105 authMethodId: "test",
106 }),
107 ),
108 Effect.scoped,
109 Effect.provide(NodeServices.layer),
110 ),
111 );
112
113 it.effect("segments assistant text around ACP tool calls", () =>
114 Effect.gen(function* () {
115 const runtime = yield* AcpSessionRuntime;
116 yield* runtime.start();
117
118 const promptResult = yield* runtime.prompt({
119 prompt: [{ type: "text", text: "hi" }],
120 });
121 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
122
123 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7)));
124 expect(notes.map((note) => note._tag)).toEqual([
125 "AssistantItemStarted",
126 "ContentDelta",
127 "AssistantItemCompleted",
128 "ToolCallUpdated",
129 "ToolCallUpdated",
130 "AssistantItemStarted",
131 "ContentDelta",
132 ]);
133
134 const firstStarted = notes[0];
135 const firstDelta = notes[1];
136 const firstCompleted = notes[2];
137 const secondStarted = notes[5];
138 const secondDelta = notes[6];
139 expect(firstStarted?._tag).toBe("AssistantItemStarted");
140 expect(firstCompleted?._tag).toBe("AssistantItemCompleted");
141 expect(secondStarted?._tag).toBe("AssistantItemStarted");
142 if (
143 firstStarted?._tag === "AssistantItemStarted" &&
144 firstDelta?._tag === "ContentDelta" &&
145 firstCompleted?._tag === "AssistantItemCompleted" &&
146 secondStarted?._tag === "AssistantItemStarted" &&
147 secondDelta?._tag === "ContentDelta"
148 ) {
149 expect(firstDelta.itemId).toBe(firstStarted.itemId);
150 expect(firstCompleted.itemId).toBe(firstStarted.itemId);
151 expect(secondStarted.itemId).not.toBe(firstStarted.itemId);
152 expect(secondDelta.itemId).toBe(secondStarted.itemId);
153 }
154 }).pipe(
155 Effect.provide(
156 AcpSessionRuntime.layer({
157 spawn: {
158 command: bunExe,
159 args: [mockAgentPath],
160 env: {
161 T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1",
162 },
163 },
164 cwd: process.cwd(),
165 clientInfo: { name: "t3-test", version: "0.0.0" },
166 authMethodId: "test",
167 }),
168 ),
169 Effect.scoped,
170 Effect.provide(NodeServices.layer),
171 ),
172 );
173
174 it.effect("suppresses generic placeholder tool updates until completion", () =>
175 Effect.gen(function* () {
176 const runtime = yield* AcpSessionRuntime;
177 yield* runtime.start();
178
179 const promptResult = yield* runtime.prompt({
180 prompt: [{ type: "text", text: "hi" }],
181 });
182 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
183
184 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1)));
185 expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]);
186 const toolCall = notes[0];
187 expect(toolCall?._tag).toBe("ToolCallUpdated");
188 if (toolCall?._tag === "ToolCallUpdated") {
189 expect(toolCall.toolCall.status).toBe("completed");
190 expect(toolCall.toolCall.title).toBe("Read file");
191 }
192 }).pipe(
193 Effect.provide(
194 AcpSessionRuntime.layer({
195 spawn: {
196 command: bunExe,
197 args: [mockAgentPath],
198 env: {
199 T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1",
200 },
201 },
202 cwd: process.cwd(),
203 clientInfo: { name: "t3-test", version: "0.0.0" },
204 authMethodId: "test",
205 }),
206 ),
207 Effect.scoped,
208 Effect.provide(NodeServices.layer),
209 ),
210 );
211
212 it.effect("logs ACP requests from the shared runtime", () => {
213 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
214 return Effect.gen(function* () {
215 const runtime = yield* AcpSessionRuntime;
216 yield* runtime.start();
217
218 yield* runtime.setModel("composer-2");
219 yield* runtime.prompt({
220 prompt: [{ type: "text", text: "hi" }],
221 });
222
223 expect(
224 requestEvents.some(
225 (event) => event.method === "session/set_config_option" && event.status === "started",
226 ),
227 ).toBe(true);
228 expect(
229 requestEvents.some(
230 (event) => event.method === "session/set_config_option" && event.status === "succeeded",
231 ),
232 ).toBe(true);
233 expect(
234 requestEvents.some(
235 (event) => event.method === "session/prompt" && event.status === "started",
236 ),
237 ).toBe(true);
238 expect(
239 requestEvents.some(
240 (event) => event.method === "session/prompt" && event.status === "succeeded",
241 ),
242 ).toBe(true);
243 }).pipe(
244 Effect.provide(
245 AcpSessionRuntime.layer({
246 authMethodId: "test",
247 spawn: {
248 command: bunExe,
249 args: [mockAgentPath],
250 },
251 cwd: process.cwd(),
252 clientInfo: { name: "t3-test", version: "0.0.0" },
253 requestLogger: (event) =>
254 Effect.sync(() => {
255 requestEvents.push(event);
256 }),
257 }),
258 ),
259 Effect.scoped,
260 Effect.provide(NodeServices.layer),
261 );
262 });
263
264 it.effect("skips no-op session config writes when the requested value is already active", () => {
265 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
266 return Effect.gen(function* () {
267 const runtime = yield* AcpSessionRuntime;
268 yield* runtime.start();
269
270 yield* runtime.setConfigOption("model", "default");
271 yield* runtime.setMode("ask");
272
273 expect(
274 requestEvents.some(
275 (event) => event.method === "session/set_config_option" && event.status === "started",
276 ),
277 ).toBe(false);
278 }).pipe(
279 Effect.provide(
280 AcpSessionRuntime.layer({
281 authMethodId: "test",
282 spawn: {
283 command: bunExe,
284 args: [mockAgentPath],
285 },
286 cwd: process.cwd(),
287 clientInfo: { name: "t3-test", version: "0.0.0" },
288 requestLogger: (event) =>
289 Effect.sync(() => {
290 requestEvents.push(event);
291 }),
292 }),
293 ),
294 Effect.scoped,
295 Effect.provide(NodeServices.layer),
296 );
297 });
298
299 it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => {
300 const protocolEvents: Array<EffectAcpProtocol.AcpProtocolLogEvent> = [];
301 return Effect.gen(function* () {
302 const runtime = yield* AcpSessionRuntime;
303 yield* runtime.start();
304
305 yield* runtime.prompt({
306 prompt: [{ type: "text", text: "hi" }],
307 });
308
309 expect(
310 protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"),
311 ).toBe(true);
312 expect(
313 protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"),
314 ).toBe(true);
315 expect(
316 protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"),
317 ).toBe(true);
318 expect(
319 protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"),
320 ).toBe(true);
321 }).pipe(
322 Effect.provide(
323 AcpSessionRuntime.layer({
324 authMethodId: "test",
325 spawn: {
326 command: bunExe,
327 args: [mockAgentPath],
328 },
329 cwd: process.cwd(),
330 clientInfo: { name: "t3-test", version: "0.0.0" },
331 protocolLogging: {
332 logIncoming: true,
333 logOutgoing: true,
334 logger: (event) =>
335 Effect.sync(() => {
336 protocolEvents.push(event);
337 }),
338 },
339 }),
340 ),
341 Effect.scoped,
342 Effect.provide(NodeServices.layer),
343 );
344 });
345
346 it.effect("rejects invalid config option values before sending session/set_config_option", () => {
347 const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-"));
348 const requestLogPath = path.join(tempDir, "requests.ndjson");
349 return Effect.gen(function* () {
350 const runtime = yield* AcpSessionRuntime;
351 yield* runtime.start();
352
353 const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip);
354 expect(error._tag).toBe("AcpRequestError");
355 if (error._tag === "AcpRequestError") {
356 expect(error.code).toBe(-32602);
357 expect(error.message).toContain(
358 'Invalid value "composer-2[fast=false]" for session config option "model"',
359 );
360 expect(error.message).toContain("composer-2[fast=true]");
361 }
362
363 const recordedRequests = readFileSync(requestLogPath, "utf8")
364 .trim()
365 .split("\n")
366 .filter((line) => line.length > 0)
367 .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } });
368 expect(
369 recordedRequests.some(
370 (message) =>
371 message.method === "session/set_config_option" &&
372 message.params?.value === "composer-2[fast=false]",
373 ),
374 ).toBe(false);
375 }).pipe(
376 Effect.provide(
377 AcpSessionRuntime.layer({
378 authMethodId: "test",
379 spawn: {
380 command: bunExe,
381 args: [mockAgentPath],
382 env: {
383 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
384 },
385 },
386 cwd: process.cwd(),
387 clientInfo: { name: "t3-test", version: "0.0.0" },
388 }),
389 ),
390 Effect.scoped,
391 Effect.provide(NodeServices.layer),
392 Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))),
393 );
394 });
395});
apps/server/src/provider/acp/AcpNativeLogging.ts+76−0
@@ -0,0 +1,76 @@
1import type { ProviderKind, ThreadId } from "@t3tools/contracts";
2import { Cause, Effect } from "effect";
3import type * as EffectAcpProtocol from "effect-acp/protocol";
4
5import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts";
6import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts";
7
8function writeNativeAcpLog(input: {
9 readonly nativeEventLogger: EventNdjsonLogger | undefined;
10 readonly provider: ProviderKind;
11 readonly threadId: ThreadId;
12 readonly kind: "request" | "protocol";
13 readonly payload: unknown;
14}): Effect.Effect<void, never> {
15 return Effect.gen(function* () {
16 if (!input.nativeEventLogger) return;
17 const observedAt = new Date().toISOString();
18 yield* input.nativeEventLogger.write(
19 {
20 observedAt,
21 event: {
22 id: crypto.randomUUID(),
23 kind: input.kind,
24 provider: input.provider,
25 createdAt: observedAt,
26 threadId: input.threadId,
27 payload: input.payload,
28 },
29 },
30 input.threadId,
31 );
32 });
33}
34
35function formatRequestLogPayload(event: AcpSessionRequestLogEvent) {
36 return {
37 method: event.method,
38 status: event.status,
39 request: event.payload,
40 ...(event.result !== undefined ? { result: event.result } : {}),
41 ...(event.cause !== undefined ? { cause: Cause.pretty(event.cause) } : {}),
42 };
43}
44
45export function makeAcpNativeLoggers(input: {
46 readonly nativeEventLogger: EventNdjsonLogger | undefined;
47 readonly provider: ProviderKind;
48 readonly threadId: ThreadId;
49}): Pick<AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> {
50 return {
51 requestLogger: (event) =>
52 writeNativeAcpLog({
53 nativeEventLogger: input.nativeEventLogger,
54 provider: input.provider,
55 threadId: input.threadId,
56 kind: "request",
57 payload: formatRequestLogPayload(event),
58 }),
59 ...(input.nativeEventLogger
60 ? {
61 protocolLogging: {
62 logIncoming: true,
63 logOutgoing: true,
64 logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) =>
65 writeNativeAcpLog({
66 nativeEventLogger: input.nativeEventLogger,
67 provider: input.provider,
68 threadId: input.threadId,
69 kind: "protocol",
70 payload: event,
71 }),
72 } satisfies NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>,
73 }
74 : {}),
75 };
76}
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+286−0
@@ -0,0 +1,286 @@
1import { describe, expect, it } from "vitest";
2
3import type * as EffectAcpSchema from "effect-acp/schema";
4
5import {
6 extractModelConfigId,
7 mergeToolCallState,
8 parsePermissionRequest,
9 parseSessionModeState,
10 parseSessionUpdateEvent,
11} from "./AcpRuntimeModel.ts";
12
13describe("AcpRuntimeModel", () => {
14 it("parses session mode state from typed ACP session setup responses", () => {
15 const modeState = parseSessionModeState({
16 sessionId: "session-1",
17 modes: {
18 currentModeId: " code ",
19 availableModes: [
20 { id: " ask ", name: " Ask ", description: " Request approval " },
21 { id: " code ", name: " Code " },
22 ],
23 },
24 configOptions: [],
25 } satisfies EffectAcpSchema.NewSessionResponse);
26
27 expect(modeState).toEqual({
28 currentModeId: "code",
29 availableModes: [
30 { id: "ask", name: "Ask", description: "Request approval" },
31 { id: "code", name: "Code" },
32 ],
33 });
34 });
35
36 it("extracts the model config id from typed ACP config options", () => {
37 const modelConfigId = extractModelConfigId({
38 sessionId: "session-1",
39 configOptions: [
40 {
41 id: "approval",
42 name: "Approval Mode",
43 category: "permission",
44 type: "select",
45 currentValue: "ask",
46 options: [{ value: "ask", name: "Ask" }],
47 },
48 {
49 id: "model",
50 name: "Model",
51 category: "model",
52 type: "select",
53 currentValue: "default",
54 options: [{ value: "default", name: "Auto" }],
55 },
56 ],
57 } satisfies EffectAcpSchema.NewSessionResponse);
58
59 expect(modelConfigId).toBe("model");
60 });
61
62 it("projects typed ACP tool call updates into runtime events", () => {
63 const created = parseSessionUpdateEvent({
64 sessionId: "session-1",
65 update: {
66 sessionUpdate: "tool_call",
67 toolCallId: "tool-1",
68 title: "Terminal",
69 kind: "execute",
70 status: "pending",
71 rawInput: {
72 executable: "bun",
73 args: ["run", "typecheck"],
74 },
75 content: [
76 {
77 type: "content",
78 content: {
79 type: "text",
80 text: "Running checks",
81 },
82 },
83 ],
84 },
85 } satisfies EffectAcpSchema.SessionNotification);
86
87 expect(created.events).toEqual([
88 {
89 _tag: "ToolCallUpdated",
90 toolCall: {
91 toolCallId: "tool-1",
92 kind: "execute",
93 title: "Ran command",
94 status: "pending",
95 command: "bun run typecheck",
96 detail: "bun run typecheck",
97 data: {
98 toolCallId: "tool-1",
99 kind: "execute",
100 command: "bun run typecheck",
101 rawInput: {
102 executable: "bun",
103 args: ["run", "typecheck"],
104 },
105 content: [
106 {
107 type: "content",
108 content: {
109 type: "text",
110 text: "Running checks",
111 },
112 },
113 ],
114 },
115 },
116 rawPayload: {
117 sessionId: "session-1",
118 update: {
119 sessionUpdate: "tool_call",
120 toolCallId: "tool-1",
121 title: "Terminal",
122 kind: "execute",
123 status: "pending",
124 rawInput: {
125 executable: "bun",
126 args: ["run", "typecheck"],
127 },
128 content: [
129 {
130 type: "content",
131 content: {
132 type: "text",
133 text: "Running checks",
134 },
135 },
136 ],
137 },
138 },
139 },
140 ]);
141
142 const updated = parseSessionUpdateEvent({
143 sessionId: "session-1",
144 update: {
145 sessionUpdate: "tool_call_update",
146 toolCallId: "tool-1",
147 status: "completed",
148 rawOutput: { exitCode: 0 },
149 },
150 } satisfies EffectAcpSchema.SessionNotification);
151
152 expect(updated.events).toHaveLength(1);
153 expect(updated.events[0]?._tag).toBe("ToolCallUpdated");
154 const createdEvent = created.events[0];
155 const updatedEvent = updated.events[0];
156 if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") {
157 expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({
158 toolCallId: "tool-1",
159 status: "completed",
160 title: "Ran command",
161 detail: "bun run typecheck",
162 command: "bun run typecheck",
163 });
164 }
165 });
166
167 it("trims padded current mode updates before emitting a mode change", () => {
168 const result = parseSessionUpdateEvent({
169 sessionId: "session-1",
170 update: {
171 sessionUpdate: "current_mode_update",
172 currentModeId: " code ",
173 },
174 } satisfies EffectAcpSchema.SessionNotification);
175
176 expect(result.modeId).toBe("code");
177 expect(result.events).toEqual([
178 {
179 _tag: "ModeChanged",
180 modeId: "code",
181 },
182 ]);
183 });
184
185 it("projects typed ACP plan and content updates", () => {
186 const planResult = parseSessionUpdateEvent({
187 sessionId: "session-1",
188 update: {
189 sessionUpdate: "plan",
190 entries: [
191 { content: " Inspect state ", priority: "high", status: "completed" },
192 { content: "", priority: "medium", status: "in_progress" },
193 ],
194 },
195 } satisfies EffectAcpSchema.SessionNotification);
196
197 expect(planResult.events).toEqual([
198 {
199 _tag: "PlanUpdated",
200 payload: {
201 plan: [
202 { step: "Inspect state", status: "completed" },
203 { step: "Step 2", status: "inProgress" },
204 ],
205 },
206 rawPayload: {
207 sessionId: "session-1",
208 update: {
209 sessionUpdate: "plan",
210 entries: [
211 { content: " Inspect state ", priority: "high", status: "completed" },
212 { content: "", priority: "medium", status: "in_progress" },
213 ],
214 },
215 },
216 },
217 ]);
218
219 const contentResult = parseSessionUpdateEvent({
220 sessionId: "session-1",
221 update: {
222 sessionUpdate: "agent_message_chunk",
223 content: {
224 type: "text",
225 text: "hello from acp",
226 },
227 },
228 } satisfies EffectAcpSchema.SessionNotification);
229
230 expect(contentResult.events).toEqual([
231 {
232 _tag: "ContentDelta",
233 text: "hello from acp",
234 rawPayload: {
235 sessionId: "session-1",
236 update: {
237 sessionUpdate: "agent_message_chunk",
238 content: {
239 type: "text",
240 text: "hello from acp",
241 },
242 },
243 },
244 },
245 ]);
246 });
247
248 it("keeps permission request parsing compatible with loose extension payloads", () => {
249 const request = parsePermissionRequest({
250 sessionId: "session-1",
251 options: [
252 {
253 optionId: "allow-once",
254 name: "Allow once",
255 kind: "allow_once",
256 },
257 ],
258 toolCall: {
259 toolCallId: "tool-1",
260 title: "`cat package.json`",
261 kind: "execute",
262 status: "pending",
263 content: [
264 {
265 type: "content",
266 content: {
267 type: "text",
268 text: "Not in allowlist",
269 },
270 },
271 ],
272 },
273 });
274
275 expect(request).toMatchObject({
276 kind: "execute",
277 detail: "cat package.json",
278 toolCall: {
279 toolCallId: "tool-1",
280 kind: "execute",
281 status: "pending",
282 command: "cat package.json",
283 },
284 });
285 });
286});
apps/server/src/provider/acp/AcpRuntimeModel.ts+482−0
@@ -0,0 +1,482 @@
1import type * as EffectAcpSchema from "effect-acp/schema";
2import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity";
3import type { ToolLifecycleItemType } from "@t3tools/contracts";
4
5function isRecord(value: unknown): value is Record<string, unknown> {
6 return typeof value === "object" && value !== null && !Array.isArray(value);
7}
8
9export interface AcpSessionMode {
10 readonly id: string;
11 readonly name: string;
12 readonly description?: string;
13}
14
15export interface AcpSessionModeState {
16 readonly currentModeId: string;
17 readonly availableModes: ReadonlyArray<AcpSessionMode>;
18}
19
20export interface AcpToolCallState {
21 readonly toolCallId: string;
22 readonly kind?: string;
23 readonly title?: string;
24 readonly status?: "pending" | "inProgress" | "completed" | "failed";
25 readonly command?: string;
26 readonly detail?: string;
27 readonly data: Record<string, unknown>;
28}
29
30export interface AcpPlanUpdate {
31 readonly explanation?: string | null;
32 readonly plan: ReadonlyArray<{
33 readonly step: string;
34 readonly status: "pending" | "inProgress" | "completed";
35 }>;
36}
37
38export interface AcpPermissionRequest {
39 readonly kind: string | "unknown";
40 readonly detail?: string;
41 readonly toolCall?: AcpToolCallState;
42}
43
44export type AcpParsedSessionEvent =
45 | {
46 readonly _tag: "ModeChanged";
47 readonly modeId: string;
48 }
49 | {
50 readonly _tag: "AssistantItemStarted";
51 readonly itemId: string;
52 }
53 | {
54 readonly _tag: "AssistantItemCompleted";
55 readonly itemId: string;
56 }
57 | {
58 readonly _tag: "PlanUpdated";
59 readonly payload: AcpPlanUpdate;
60 readonly rawPayload: unknown;
61 }
62 | {
63 readonly _tag: "ToolCallUpdated";
64 readonly toolCall: AcpToolCallState;
65 readonly rawPayload: unknown;
66 }
67 | {
68 readonly _tag: "ContentDelta";
69 readonly itemId?: string;
70 readonly text: string;
71 readonly rawPayload: unknown;
72 };
73
74type AcpSessionSetupResponse =
75 | EffectAcpSchema.LoadSessionResponse
76 | EffectAcpSchema.NewSessionResponse
77 | EffectAcpSchema.ResumeSessionResponse;
78
79type AcpToolCallUpdate = Extract<
80 EffectAcpSchema.SessionNotification["update"],
81 { readonly sessionUpdate: "tool_call" | "tool_call_update" }
82>;
83
84export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined {
85 const configOptions = sessionResponse.configOptions;
86 if (!configOptions) return undefined;
87 for (const opt of configOptions) {
88 if (opt.category === "model" && opt.id.trim().length > 0) {
89 return opt.id.trim();
90 }
91 }
92 return undefined;
93}
94
95export function findSessionConfigOption(
96 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
97 configId: string,
98): EffectAcpSchema.SessionConfigOption | undefined {
99 if (!configOptions) {
100 return undefined;
101 }
102 const normalizedConfigId = configId.trim();
103 if (!normalizedConfigId) {
104 return undefined;
105 }
106 return configOptions.find((option) => option.id.trim() === normalizedConfigId);
107}
108
109export function collectSessionConfigOptionValues(
110 configOption: EffectAcpSchema.SessionConfigOption,
111): ReadonlyArray<string> {
112 if (configOption.type !== "select") {
113 return [];
114 }
115 return configOption.options.flatMap((entry) =>
116 "value" in entry ? [entry.value] : entry.options.map((option) => option.value),
117 );
118}
119
120export function parseSessionModeState(
121 sessionResponse: AcpSessionSetupResponse,
122): AcpSessionModeState | undefined {
123 const modes = sessionResponse.modes;
124 if (!modes) return undefined;
125 const currentModeId = modes.currentModeId.trim();
126 if (!currentModeId) {
127 return undefined;
128 }
129 const availableModes = modes.availableModes
130 .map((mode) => {
131 const id = mode.id.trim();
132 const name = mode.name.trim();
133 if (!id || !name) {
134 return undefined;
135 }
136 const description = mode.description?.trim() || undefined;
137 return description !== undefined
138 ? ({ id, name, description } satisfies AcpSessionMode)
139 : ({ id, name } satisfies AcpSessionMode);
140 })
141 .filter((mode): mode is AcpSessionMode => mode !== undefined);
142 if (availableModes.length === 0) {
143 return undefined;
144 }
145 return {
146 currentModeId,
147 availableModes,
148 };
149}
150
151function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" {
152 switch (raw) {
153 case "completed":
154 return "completed";
155 case "in_progress":
156 case "inProgress":
157 return "inProgress";
158 default:
159 return "pending";
160 }
161}
162
163function normalizeToolCallStatus(
164 raw: unknown,
165 fallback?: "pending" | "inProgress" | "completed" | "failed",
166): "pending" | "inProgress" | "completed" | "failed" | undefined {
167 switch (raw) {
168 case "pending":
169 return "pending";
170 case "in_progress":
171 case "inProgress":
172 return "inProgress";
173 case "completed":
174 return "completed";
175 case "failed":
176 return "failed";
177 default:
178 return fallback;
179 }
180}
181
182function normalizeCommandValue(value: unknown): string | undefined {
183 if (typeof value === "string" && value.trim().length > 0) {
184 return value.trim();
185 }
186 if (!Array.isArray(value)) {
187 return undefined;
188 }
189 const parts = value
190 .map((entry) => (typeof entry === "string" && entry.trim().length > 0 ? entry.trim() : null))
191 .filter((entry): entry is string => entry !== null);
192 return parts.length > 0 ? parts.join(" ") : undefined;
193}
194
195function extractCommandFromTitle(title: string | undefined): string | undefined {
196 if (!title) {
197 return undefined;
198 }
199 const match = /`([^`]+)`/.exec(title);
200 return match?.[1]?.trim() || undefined;
201}
202
203function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined {
204 if (isRecord(rawInput)) {
205 const directCommand = normalizeCommandValue(rawInput.command);
206 if (directCommand) {
207 return directCommand;
208 }
209 const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : "";
210 const args = normalizeCommandValue(rawInput.args);
211 if (executable && args) {
212 return `${executable} ${args}`;
213 }
214 if (executable) {
215 return executable;
216 }
217 }
218 return extractCommandFromTitle(title);
219}
220
221function extractTextContentFromToolCallContent(
222 content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined,
223): string | undefined {
224 if (!content) return undefined;
225 const chunks = content
226 .map((entry) => {
227 if (entry.type !== "content") {
228 return undefined;
229 }
230 const nestedContent = entry.content;
231 if (nestedContent.type !== "text") {
232 return undefined;
233 }
234 return nestedContent.text.trim().length > 0 ? nestedContent.text.trim() : undefined;
235 })
236 .filter((entry): entry is string => entry !== undefined);
237 return chunks.length > 0 ? chunks.join("\n") : undefined;
238}
239
240function normalizeToolKind(kind: unknown): string | undefined {
241 return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined;
242}
243
244function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType {
245 switch (kind) {
246 case "execute":
247 return "command_execution";
248 case "edit":
249 case "delete":
250 case "move":
251 return "file_change";
252 case "search":
253 case "fetch":
254 return "web_search";
255 default:
256 return "dynamic_tool_call";
257 }
258}
259
260function makeToolCallState(
261 input: {
262 readonly toolCallId: string;
263 readonly title?: string | null | undefined;
264 readonly kind?: EffectAcpSchema.ToolKind | null | undefined;
265 readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined;
266 readonly rawInput?: unknown;
267 readonly rawOutput?: unknown;
268 readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined;
269 readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined;
270 },
271 options?: {
272 readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed";
273 },
274): AcpToolCallState | undefined {
275 const toolCallId = input.toolCallId.trim();
276 if (!toolCallId) {
277 return undefined;
278 }
279 const title = input.title?.trim() || undefined;
280 const command = extractToolCallCommand(input.rawInput, title);
281 const textContent = extractTextContentFromToolCallContent(input.content);
282 const normalizedTitle =
283 title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call"
284 ? title
285 : undefined;
286 const data: Record<string, unknown> = { toolCallId };
287 const kind = normalizeToolKind(input.kind);
288 if (kind) {
289 data.kind = kind;
290 }
291 if (command) {
292 data.command = command;
293 }
294 if (input.rawInput !== undefined) {
295 data.rawInput = input.rawInput;
296 }
297 if (input.rawOutput !== undefined) {
298 data.rawOutput = input.rawOutput;
299 }
300 if (input.content !== undefined) {
301 data.content = input.content;
302 }
303 if (input.locations !== undefined) {
304 data.locations = input.locations;
305 }
306 const fallbackDetail = command ?? normalizedTitle ?? textContent;
307 const hasPresentationSeed =
308 title !== undefined ||
309 kind !== undefined ||
310 command !== undefined ||
311 normalizedTitle !== undefined ||
312 textContent !== undefined;
313 const presentation = hasPresentationSeed
314 ? deriveToolActivityPresentation({
315 itemType: canonicalItemTypeFromAcpToolKind(kind),
316 title,
317 detail: fallbackDetail,
318 data,
319 fallbackSummary: title ?? "Tool",
320 })
321 : undefined;
322 const status = normalizeToolCallStatus(input.status, options?.fallbackStatus);
323 return {
324 toolCallId,
325 ...(kind ? { kind } : {}),
326 ...(presentation?.summary ? { title: presentation.summary } : {}),
327 ...(status ? { status } : {}),
328 ...(command ? { command } : {}),
329 ...(presentation?.detail ? { detail: presentation.detail } : {}),
330 data,
331 };
332}
333
334function parseTypedToolCallState(
335 event: AcpToolCallUpdate,
336 options?: {
337 readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed";
338 },
339): AcpToolCallState | undefined {
340 return makeToolCallState(
341 {
342 toolCallId: event.toolCallId,
343 title: event.title,
344 kind: event.kind,
345 status: event.status,
346 rawInput: event.rawInput,
347 rawOutput: event.rawOutput,
348 content: event.content,
349 locations: event.locations,
350 },
351 options,
352 );
353}
354
355export function mergeToolCallState(
356 previous: AcpToolCallState | undefined,
357 next: AcpToolCallState,
358): AcpToolCallState {
359 const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined;
360 const kind = nextKind ?? previous?.kind;
361 const title = next.title ?? previous?.title;
362 const status = next.status ?? previous?.status;
363 const command = next.command ?? previous?.command;
364 const detail = next.detail ?? previous?.detail;
365 return {
366 toolCallId: next.toolCallId,
367 ...(kind ? { kind } : {}),
368 ...(title ? { title } : {}),
369 ...(status ? { status } : {}),
370 ...(command ? { command } : {}),
371 ...(detail ? { detail } : {}),
372 data: {
373 ...previous?.data,
374 ...next.data,
375 },
376 };
377}
378
379export function parsePermissionRequest(
380 params: EffectAcpSchema.RequestPermissionRequest,
381): AcpPermissionRequest {
382 const toolCall = makeToolCallState(
383 {
384 toolCallId: params.toolCall.toolCallId,
385 title: params.toolCall.title,
386 kind: params.toolCall.kind,
387 status: params.toolCall.status,
388 rawInput: params.toolCall.rawInput,
389 rawOutput: params.toolCall.rawOutput,
390 content: params.toolCall.content,
391 locations: params.toolCall.locations,
392 },
393 { fallbackStatus: "pending" },
394 );
395 const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown";
396 const detail =
397 toolCall?.command ??
398 toolCall?.title ??
399 toolCall?.detail ??
400 (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined);
401 return {
402 kind,
403 ...(detail ? { detail } : {}),
404 ...(toolCall ? { toolCall } : {}),
405 };
406}
407
408export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): {
409 readonly modeId?: string;
410 readonly events: ReadonlyArray<AcpParsedSessionEvent>;
411} {
412 const upd = params.update;
413 const events: Array<AcpParsedSessionEvent> = [];
414 let modeId: string | undefined;
415
416 switch (upd.sessionUpdate) {
417 case "current_mode_update": {
418 modeId = upd.currentModeId.trim();
419 if (modeId) {
420 events.push({
421 _tag: "ModeChanged",
422 modeId,
423 });
424 }
425 break;
426 }
427 case "plan": {
428 const plan = upd.entries.map((entry, index) => ({
429 step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`,
430 status: normalizePlanStepStatus(entry.status),
431 }));
432 if (plan.length > 0) {
433 events.push({
434 _tag: "PlanUpdated",
435 payload: {
436 plan,
437 },
438 rawPayload: params,
439 });
440 }
441 break;
442 }
443 case "tool_call": {
444 const toolCall = parseTypedToolCallState(upd, {
445 fallbackStatus: "pending",
446 });
447 if (toolCall) {
448 events.push({
449 _tag: "ToolCallUpdated",
450 toolCall,
451 rawPayload: params,
452 });
453 }
454 break;
455 }
456 case "tool_call_update": {
457 const toolCall = parseTypedToolCallState(upd);
458 if (toolCall) {
459 events.push({
460 _tag: "ToolCallUpdated",
461 toolCall,
462 rawPayload: params,
463 });
464 }
465 break;
466 }
467 case "agent_message_chunk": {
468 if (upd.content.type === "text" && upd.content.text.length > 0) {
469 events.push({
470 _tag: "ContentDelta",
471 text: upd.content.text,
472 rawPayload: params,
473 });
474 }
475 break;
476 }
477 default:
478 break;
479 }
480
481 return { ...(modeId !== undefined ? { modeId } : {}), events };
482}
apps/server/src/provider/acp/AcpSessionRuntime.ts+722−0
@@ -0,0 +1,722 @@
1import { Cause, Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Context, Stream } from "effect";
2import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3import * as EffectAcpClient from "effect-acp/client";
4import * as EffectAcpErrors from "effect-acp/errors";
5import type * as EffectAcpSchema from "effect-acp/schema";
6import type * as EffectAcpProtocol from "effect-acp/protocol";
7
8import {
9 collectSessionConfigOptionValues,
10 extractModelConfigId,
11 findSessionConfigOption,
12 mergeToolCallState,
13 parseSessionModeState,
14 parseSessionUpdateEvent,
15 type AcpParsedSessionEvent,
16 type AcpSessionModeState,
17 type AcpToolCallState,
18} from "./AcpRuntimeModel.ts";
19
20export interface AcpSpawnInput {
21 readonly command: string;
22 readonly args: ReadonlyArray<string>;
23 readonly cwd?: string;
24 readonly env?: Readonly<Record<string, string>>;
25}
26
27export interface AcpSessionRuntimeOptions {
28 readonly spawn: AcpSpawnInput;
29 readonly cwd: string;
30 readonly resumeSessionId?: string;
31 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
32 readonly clientInfo: {
33 readonly name: string;
34 readonly version: string;
35 };
36 readonly authMethodId: string;
37 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
38 readonly protocolLogging?: {
39 readonly logIncoming?: boolean;
40 readonly logOutgoing?: boolean;
41 readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
42 };
43}
44
45export interface AcpSessionRequestLogEvent {
46 readonly method: string;
47 readonly payload: unknown;
48 readonly status: "started" | "succeeded" | "failed";
49 readonly result?: unknown;
50 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;
51}
52
53export interface AcpSessionRuntimeStartResult {
54 readonly sessionId: string;
55 readonly initializeResult: EffectAcpSchema.InitializeResponse;
56 readonly sessionSetupResult:
57 | EffectAcpSchema.LoadSessionResponse
58 | EffectAcpSchema.NewSessionResponse
59 | EffectAcpSchema.ResumeSessionResponse;
60 readonly modelConfigId: string | undefined;
61}
62
63export interface AcpSessionRuntimeShape {
64 readonly handleRequestPermission: EffectAcpClient.AcpClientShape["handleRequestPermission"];
65 readonly handleElicitation: EffectAcpClient.AcpClientShape["handleElicitation"];
66 readonly handleReadTextFile: EffectAcpClient.AcpClientShape["handleReadTextFile"];
67 readonly handleWriteTextFile: EffectAcpClient.AcpClientShape["handleWriteTextFile"];
68 readonly handleCreateTerminal: EffectAcpClient.AcpClientShape["handleCreateTerminal"];
69 readonly handleTerminalOutput: EffectAcpClient.AcpClientShape["handleTerminalOutput"];
70 readonly handleTerminalWaitForExit: EffectAcpClient.AcpClientShape["handleTerminalWaitForExit"];
71 readonly handleTerminalKill: EffectAcpClient.AcpClientShape["handleTerminalKill"];
72 readonly handleTerminalRelease: EffectAcpClient.AcpClientShape["handleTerminalRelease"];
73 readonly handleSessionUpdate: EffectAcpClient.AcpClientShape["handleSessionUpdate"];
74 readonly handleElicitationComplete: EffectAcpClient.AcpClientShape["handleElicitationComplete"];
75 readonly handleUnknownExtRequest: EffectAcpClient.AcpClientShape["handleUnknownExtRequest"];
76 readonly handleUnknownExtNotification: EffectAcpClient.AcpClientShape["handleUnknownExtNotification"];
77 readonly handleExtRequest: EffectAcpClient.AcpClientShape["handleExtRequest"];
78 readonly handleExtNotification: EffectAcpClient.AcpClientShape["handleExtNotification"];
79 readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
80 readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>;
81 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;
82 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
83 readonly prompt: (
84 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
85 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
86 readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>;
87 readonly setMode: (
88 modeId: string,
89 ) => Effect.Effect<EffectAcpSchema.SetSessionModeResponse, EffectAcpErrors.AcpError>;
90 readonly setConfigOption: (
91 configId: string,
92 value: string | boolean,
93 ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>;
94 readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
95 readonly request: (
96 method: string,
97 payload: unknown,
98 ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;
99 readonly notify: (
100 method: string,
101 payload: unknown,
102 ) => Effect.Effect<void, EffectAcpErrors.AcpError>;
103}
104
105interface AcpStartedState extends AcpSessionRuntimeStartResult {}
106
107type AcpStartState =
108 | { readonly _tag: "NotStarted" }
109 | {
110 readonly _tag: "Starting";
111 readonly deferred: Deferred.Deferred<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
112 }
113 | { readonly _tag: "Started"; readonly result: AcpStartedState };
114
115interface AcpAssistantSegmentState {
116 readonly nextSegmentIndex: number;
117 readonly activeItemId?: string;
118}
119
120interface EnsureActiveAssistantSegmentResult {
121 readonly itemId: string;
122 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
123}
124
125export class AcpSessionRuntime extends Context.Service<AcpSessionRuntime, AcpSessionRuntimeShape>()(
126 "t3/provider/acp/AcpSessionRuntime",
127) {
128 static layer(
129 options: AcpSessionRuntimeOptions,
130 ): Layer.Layer<
131 AcpSessionRuntime,
132 EffectAcpErrors.AcpError,
133 ChildProcessSpawner.ChildProcessSpawner
134 > {
135 return Layer.effect(AcpSessionRuntime, makeAcpSessionRuntime(options));
136 }
137}
138
139const makeAcpSessionRuntime = (
140 options: AcpSessionRuntimeOptions,
141): Effect.Effect<
142 AcpSessionRuntimeShape,
143 EffectAcpErrors.AcpError,
144 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
145> =>
146 Effect.gen(function* () {
147 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
148 const runtimeScope = yield* Scope.Scope;
149 const eventQueue = yield* Queue.unbounded<AcpParsedSessionEvent>();
150 const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined);
151 const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallState>());
152 const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });
153 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
154 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });
155
156 const logRequest = (event: AcpSessionRequestLogEvent) =>
157 options.requestLogger ? options.requestLogger(event) : Effect.void;
158
159 const runLoggedRequest = <A>(
160 method: string,
161 payload: unknown,
162 effect: Effect.Effect<A, EffectAcpErrors.AcpError>,
163 ): Effect.Effect<A, EffectAcpErrors.AcpError> =>
164 logRequest({ method, payload, status: "started" }).pipe(
165 Effect.flatMap(() =>
166 effect.pipe(
167 Effect.tap((result) =>
168 logRequest({
169 method,
170 payload,
171 status: "succeeded",
172 result,
173 }),
174 ),
175 Effect.onError((cause) =>
176 logRequest({
177 method,
178 payload,
179 status: "failed",
180 cause,
181 }),
182 ),
183 ),
184 ),
185 );
186
187 const child = yield* spawner
188 .spawn(
189 ChildProcess.make(options.spawn.command, [...options.spawn.args], {
190 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
191 ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}),
192 shell: process.platform === "win32",
193 }),
194 )
195 .pipe(
196 Effect.provideService(Scope.Scope, runtimeScope),
197 Effect.mapError(
198 (cause) =>
199 new EffectAcpErrors.AcpSpawnError({
200 command: options.spawn.command,
201 cause,
202 }),
203 ),
204 );
205
206 const acpContext = yield* Layer.build(
207 EffectAcpClient.layerChildProcess(child, {
208 ...(options.protocolLogging?.logIncoming !== undefined
209 ? { logIncoming: options.protocolLogging.logIncoming }
210 : {}),
211 ...(options.protocolLogging?.logOutgoing !== undefined
212 ? { logOutgoing: options.protocolLogging.logOutgoing }
213 : {}),
214 ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}),
215 }),
216 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));
217
218 const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext));
219
220 yield* acp.handleSessionUpdate((notification) =>
221 handleSessionUpdate({
222 queue: eventQueue,
223 modeStateRef,
224 toolCallsRef,
225 assistantSegmentRef,
226 params: notification,
227 }),
228 );
229
230 const initializeClientCapabilities = {
231 fs: {
232 readTextFile: false,
233 writeTextFile: false,
234 ...options.clientCapabilities?.fs,
235 },
236 terminal: options.clientCapabilities?.terminal ?? false,
237 ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}),
238 ...(options.clientCapabilities?.elicitation
239 ? { elicitation: options.clientCapabilities.elicitation }
240 : {}),
241 ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}),
242 } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
243
244 const getStartedState = Effect.gen(function* () {
245 const state = yield* Ref.get(startStateRef);
246 if (state._tag === "Started") {
247 return state.result;
248 }
249 return yield* new EffectAcpErrors.AcpTransportError({
250 detail: "ACP session runtime has not been started",
251 cause: new Error("ACP session runtime has not been started"),
252 });
253 });
254
255 const validateConfigOptionValue = (
256 configId: string,
257 value: string | boolean,
258 ): Effect.Effect<void, EffectAcpErrors.AcpError> =>
259 Effect.gen(function* () {
260 const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId);
261 if (!configOption) {
262 return;
263 }
264 if (configOption.type === "boolean") {
265 if (typeof value === "boolean") {
266 return;
267 }
268 return yield* new EffectAcpErrors.AcpRequestError({
269 code: -32602,
270 errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected boolean`,
271 data: {
272 configId: configOption.id,
273 expectedType: "boolean",
274 receivedValue: value,
275 },
276 });
277 }
278 if (typeof value !== "string") {
279 return yield* new EffectAcpErrors.AcpRequestError({
280 code: -32602,
281 errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected string`,
282 data: {
283 configId: configOption.id,
284 expectedType: "string",
285 receivedValue: value,
286 },
287 });
288 }
289 const allowedValues = collectSessionConfigOptionValues(configOption);
290 if (allowedValues.includes(value)) {
291 return;
292 }
293 return yield* new EffectAcpErrors.AcpRequestError({
294 code: -32602,
295 errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`,
296 data: {
297 configId: configOption.id,
298 allowedValues,
299 receivedValue: value,
300 },
301 });
302 });
303
304 const updateConfigOptions = (
305 response:
306 | EffectAcpSchema.SetSessionConfigOptionResponse
307 | EffectAcpSchema.LoadSessionResponse
308 | EffectAcpSchema.NewSessionResponse
309 | EffectAcpSchema.ResumeSessionResponse,
310 ): Effect.Effect<void> => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response));
311
312 const updateCurrentModeId = (modeId: string): Effect.Effect<void> =>
313 Ref.update(modeStateRef, (current) =>
314 current ? { ...current, currentModeId: modeId } : current,
315 );
316
317 const setConfigOption = (
318 configId: string,
319 value: string | boolean,
320 ): Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError> =>
321 validateConfigOptionValue(configId, value).pipe(
322 Effect.flatMap(() => getStartedState),
323 Effect.flatMap((started) =>
324 Ref.get(configOptionsRef).pipe(
325 Effect.flatMap((configOptions) => {
326 const existing = findSessionConfigOption(configOptions, configId);
327 if (existing && configOptionCurrentValueMatches(existing, value)) {
328 return Effect.succeed({
329 configOptions,
330 } satisfies EffectAcpSchema.SetSessionConfigOptionResponse);
331 }
332 const requestPayload =
333 typeof value === "boolean"
334 ? ({
335 sessionId: started.sessionId,
336 configId,
337 type: "boolean",
338 value,
339 } satisfies EffectAcpSchema.SetSessionConfigOptionRequest)
340 : ({
341 sessionId: started.sessionId,
342 configId,
343 value: String(value),
344 } satisfies EffectAcpSchema.SetSessionConfigOptionRequest);
345 return runLoggedRequest(
346 "session/set_config_option",
347 requestPayload,
348 acp.agent.setSessionConfigOption(requestPayload),
349 ).pipe(Effect.tap((response) => updateConfigOptions(response)));
350 }),
351 ),
352 ),
353 );
354
355 const startOnce = Effect.gen(function* () {
356 const initializePayload = {
357 protocolVersion: 1,
358 clientCapabilities: initializeClientCapabilities,
359 clientInfo: options.clientInfo,
360 } satisfies EffectAcpSchema.InitializeRequest;
361
362 const initializeResult = yield* runLoggedRequest(
363 "initialize",
364 initializePayload,
365 acp.agent.initialize(initializePayload),
366 );
367
368 const authenticatePayload = {
369 methodId: options.authMethodId,
370 } satisfies EffectAcpSchema.AuthenticateRequest;
371
372 yield* runLoggedRequest(
373 "authenticate",
374 authenticatePayload,
375 acp.agent.authenticate(authenticatePayload),
376 );
377
378 let sessionId: string;
379 let sessionSetupResult:
380 | EffectAcpSchema.LoadSessionResponse
381 | EffectAcpSchema.NewSessionResponse
382 | EffectAcpSchema.ResumeSessionResponse;
383 if (options.resumeSessionId) {
384 const loadPayload = {
385 sessionId: options.resumeSessionId,
386 cwd: options.cwd,
387 mcpServers: [],
388 } satisfies EffectAcpSchema.LoadSessionRequest;
389 const resumed = yield* runLoggedRequest(
390 "session/load",
391 loadPayload,
392 acp.agent.loadSession(loadPayload),
393 ).pipe(Effect.exit);
394 if (Exit.isSuccess(resumed)) {
395 sessionId = options.resumeSessionId;
396 sessionSetupResult = resumed.value;
397 } else {
398 const createPayload = {
399 cwd: options.cwd,
400 mcpServers: [],
401 } satisfies EffectAcpSchema.NewSessionRequest;
402 const created = yield* runLoggedRequest(
403 "session/new",
404 createPayload,
405 acp.agent.createSession(createPayload),
406 );
407 sessionId = created.sessionId;
408 sessionSetupResult = created;
409 }
410 } else {
411 const createPayload = {
412 cwd: options.cwd,
413 mcpServers: [],
414 } satisfies EffectAcpSchema.NewSessionRequest;
415 const created = yield* runLoggedRequest(
416 "session/new",
417 createPayload,
418 acp.agent.createSession(createPayload),
419 );
420 sessionId = created.sessionId;
421 sessionSetupResult = created;
422 }
423
424 yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
425 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
426
427 const nextState = {
428 sessionId,
429 initializeResult,
430 sessionSetupResult,
431 modelConfigId: extractModelConfigId(sessionSetupResult),
432 } satisfies AcpStartedState;
433 return nextState;
434 });
435
436 const start = Effect.gen(function* () {
437 const deferred = yield* Deferred.make<
438 AcpSessionRuntimeStartResult,
439 EffectAcpErrors.AcpError
440 >();
441 const effect = yield* Ref.modify(startStateRef, (state) => {
442 switch (state._tag) {
443 case "Started":
444 return [Effect.succeed(state.result), state] as const;
445 case "Starting":
446 return [Deferred.await(state.deferred), state] as const;
447 case "NotStarted":
448 return [
449 startOnce.pipe(
450 Effect.tap((result) =>
451 Ref.set(startStateRef, { _tag: "Started", result }).pipe(
452 Effect.andThen(Deferred.succeed(deferred, result)),
453 ),
454 ),
455 Effect.onError((cause) =>
456 Deferred.failCause(deferred, cause).pipe(
457 Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })),
458 ),
459 ),
460 ),
461 { _tag: "Starting", deferred } satisfies AcpStartState,
462 ] as const;
463 }
464 });
465 return yield* effect;
466 });
467
468 return {
469 handleRequestPermission: acp.handleRequestPermission,
470 handleElicitation: acp.handleElicitation,
471 handleReadTextFile: acp.handleReadTextFile,
472 handleWriteTextFile: acp.handleWriteTextFile,
473 handleCreateTerminal: acp.handleCreateTerminal,
474 handleTerminalOutput: acp.handleTerminalOutput,
475 handleTerminalWaitForExit: acp.handleTerminalWaitForExit,
476 handleTerminalKill: acp.handleTerminalKill,
477 handleTerminalRelease: acp.handleTerminalRelease,
478 handleSessionUpdate: acp.handleSessionUpdate,
479 handleElicitationComplete: acp.handleElicitationComplete,
480 handleUnknownExtRequest: acp.handleUnknownExtRequest,
481 handleUnknownExtNotification: acp.handleUnknownExtNotification,
482 handleExtRequest: acp.handleExtRequest,
483 handleExtNotification: acp.handleExtNotification,
484 start: () => start,
485 getEvents: () => Stream.fromQueue(eventQueue),
486 getModeState: Ref.get(modeStateRef),
487 getConfigOptions: Ref.get(configOptionsRef),
488 prompt: (payload) =>
489 getStartedState.pipe(
490 Effect.flatMap((started) => {
491 const requestPayload = {
492 sessionId: started.sessionId,
493 ...payload,
494 } satisfies EffectAcpSchema.PromptRequest;
495 return closeActiveAssistantSegment({
496 queue: eventQueue,
497 assistantSegmentRef,
498 }).pipe(
499 Effect.andThen(
500 runLoggedRequest(
501 "session/prompt",
502 requestPayload,
503 acp.agent.prompt(requestPayload),
504 ),
505 ),
506 Effect.tap(() =>
507 closeActiveAssistantSegment({
508 queue: eventQueue,
509 assistantSegmentRef,
510 }),
511 ),
512 );
513 }),
514 ),
515 cancel: getStartedState.pipe(
516 Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })),
517 ),
518 setMode: (modeId) =>
519 Ref.get(modeStateRef).pipe(
520 Effect.flatMap((modeState) => {
521 if (modeState?.currentModeId === modeId) {
522 return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse);
523 }
524 return setConfigOption("mode", modeId).pipe(
525 Effect.tap(() => updateCurrentModeId(modeId)),
526 Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse),
527 );
528 }),
529 ),
530 setConfigOption,
531 setModel: (model) =>
532 getStartedState.pipe(
533 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
534 Effect.asVoid,
535 ),
536 request: (method, payload) =>
537 runLoggedRequest(method, payload, acp.raw.request(method, payload)),
538 notify: acp.raw.notify,
539 } satisfies AcpSessionRuntimeShape;
540 });
541
542function sessionConfigOptionsFromSetup(
543 response:
544 | {
545 readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null;
546 }
547 | undefined,
548): ReadonlyArray<EffectAcpSchema.SessionConfigOption> {
549 return response?.configOptions ?? [];
550}
551
552function configOptionCurrentValueMatches(
553 configOption: EffectAcpSchema.SessionConfigOption,
554 value: string | boolean,
555): boolean {
556 const currentValue = configOption.currentValue;
557 if (configOption.type === "boolean") {
558 return currentValue === value;
559 }
560 if (typeof currentValue !== "string") {
561 return false;
562 }
563 return currentValue.trim() === String(value).trim();
564}
565
566const handleSessionUpdate = ({
567 queue,
568 modeStateRef,
569 toolCallsRef,
570 assistantSegmentRef,
571 params,
572}: {
573 readonly queue: Queue.Queue<AcpParsedSessionEvent>;
574 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
575 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>;
576 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
577 readonly params: EffectAcpSchema.SessionNotification;
578}): Effect.Effect<void> =>
579 Effect.gen(function* () {
580 const parsed = parseSessionUpdateEvent(params);
581 if (parsed.modeId) {
582 yield* Ref.update(modeStateRef, (current) =>
583 current === undefined ? current : updateModeState(current, parsed.modeId!),
584 );
585 }
586 for (const event of parsed.events) {
587 if (event._tag === "ToolCallUpdated") {
588 yield* closeActiveAssistantSegment({
589 queue,
590 assistantSegmentRef,
591 });
592 const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => {
593 const previous = current.get(event.toolCall.toolCallId);
594 const nextToolCall = mergeToolCallState(previous, event.toolCall);
595 const next = new Map(current);
596 if (nextToolCall.status === "completed" || nextToolCall.status === "failed") {
597 next.delete(nextToolCall.toolCallId);
598 } else {
599 next.set(nextToolCall.toolCallId, nextToolCall);
600 }
601 return [{ previous, merged: nextToolCall }, next] as const;
602 });
603 if (!shouldEmitToolCallUpdate(previous, merged)) {
604 continue;
605 }
606 yield* Queue.offer(queue, {
607 _tag: "ToolCallUpdated",
608 toolCall: merged,
609 rawPayload: event.rawPayload,
610 });
611 continue;
612 }
613 if (event._tag === "ContentDelta") {
614 if (event.text.trim().length === 0) {
615 const assistantSegmentState = yield* Ref.get(assistantSegmentRef);
616 if (!assistantSegmentState.activeItemId) {
617 continue;
618 }
619 }
620 const itemId = yield* ensureActiveAssistantSegment({
621 queue,
622 assistantSegmentRef,
623 sessionId: params.sessionId,
624 });
625 yield* Queue.offer(queue, {
626 ...event,
627 itemId,
628 });
629 continue;
630 }
631 yield* Queue.offer(queue, event);
632 }
633 });
634
635function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState {
636 const normalized = nextModeId.trim();
637 if (!normalized) {
638 return modeState;
639 }
640 return modeState.availableModes.some((mode) => mode.id === normalized)
641 ? {
642 ...modeState,
643 currentModeId: normalized,
644 }
645 : modeState;
646}
647
648function shouldEmitToolCallUpdate(
649 previous: AcpToolCallState | undefined,
650 next: AcpToolCallState,
651): boolean {
652 if (next.status === "completed" || next.status === "failed") {
653 return true;
654 }
655 if (!next.detail) {
656 return false;
657 }
658 return previous === undefined || previous.title !== next.title || previous.detail !== next.detail;
659}
660
661const assistantItemId = (sessionId: string, segmentIndex: number) =>
662 `assistant:${sessionId}:segment:${segmentIndex}`;
663
664const ensureActiveAssistantSegment = ({
665 queue,
666 assistantSegmentRef,
667 sessionId,
668}: {
669 readonly queue: Queue.Queue<AcpParsedSessionEvent>;
670 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
671 readonly sessionId: string;
672}) =>
673 Ref.modify<AcpAssistantSegmentState, EnsureActiveAssistantSegmentResult>(
674 assistantSegmentRef,
675 (current) => {
676 if (current.activeItemId) {
677 return [{ itemId: current.activeItemId }, current] as const;
678 }
679 const itemId = assistantItemId(sessionId, current.nextSegmentIndex);
680 return [
681 {
682 itemId,
683 startedEvent: {
684 _tag: "AssistantItemStarted",
685 itemId,
686 } satisfies Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>,
687 },
688 {
689 nextSegmentIndex: current.nextSegmentIndex + 1,
690 activeItemId: itemId,
691 } satisfies AcpAssistantSegmentState,
692 ] as const;
693 },
694 ).pipe(
695 Effect.flatMap((result) =>
696 result.startedEvent
697 ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId))
698 : Effect.succeed(result.itemId),
699 ),
700 );
701
702const closeActiveAssistantSegment = ({
703 queue,
704 assistantSegmentRef,
705}: {
706 readonly queue: Queue.Queue<AcpParsedSessionEvent>;
707 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
708}) =>
709 Ref.modify(assistantSegmentRef, (current) => {
710 if (!current.activeItemId) {
711 return [undefined, current] as const;
712 }
713 return [
714 {
715 _tag: "AssistantItemCompleted",
716 itemId: current.activeItemId,
717 } satisfies AcpParsedSessionEvent,
718 {
719 nextSegmentIndex: current.nextSegmentIndex,
720 } satisfies AcpAssistantSegmentState,
721 ] as const;
722 }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void)));
apps/server/src/provider/acp/CursorAcpCliProbe.test.ts+148−0
@@ -0,0 +1,148 @@
1/**
2 * Optional integration check against a real `agent acp` install.
3 * Enable with: T3_CURSOR_ACP_PROBE=1 bun run test --filter CursorAcpCliProbe
4 */
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";
7import { Effect } from "effect";
8import { describe, expect } from "vitest";
9import type * as EffectAcpSchema from "effect-acp/schema";
10
11import { AcpSessionRuntime } from "./AcpSessionRuntime.ts";
12
13describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => {
14 it.effect("initialize and authenticate against real agent acp", () =>
15 Effect.gen(function* () {
16 const runtime = yield* AcpSessionRuntime;
17 const started = yield* runtime.start();
18 expect(started.initializeResult).toBeDefined();
19 }).pipe(
20 Effect.provide(
21 AcpSessionRuntime.layer({
22 spawn: {
23 command: "agent",
24 args: ["acp"],
25 cwd: process.cwd(),
26 },
27 cwd: process.cwd(),
28 clientCapabilities: {
29 _meta: {
30 parameterizedModelPicker: true,
31 },
32 },
33 clientInfo: { name: "t3-probe", version: "0.0.0" },
34 authMethodId: "cursor_login",
35 }),
36 ),
37 Effect.scoped,
38 Effect.provide(NodeServices.layer),
39 ),
40 );
41
42 it.effect("session/new returns configOptions with a model selector", () =>
43 Effect.gen(function* () {
44 const runtime = yield* AcpSessionRuntime;
45 const started = yield* runtime.start();
46 const result = started.sessionSetupResult;
47 console.log("session/new result:", JSON.stringify(result, null, 2));
48
49 expect(typeof started.sessionId).toBe("string");
50
51 const configOptions = result.configOptions;
52 console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2));
53
54 if (Array.isArray(configOptions)) {
55 const modelConfig = configOptions.find((opt) => opt.category === "model");
56 const parameterizedOptions = configOptions.filter(
57 (opt) =>
58 opt.category === "thought_level" ||
59 opt.category === "model_option" ||
60 opt.category === "model_config",
61 );
62 console.log("Model config option:", JSON.stringify(modelConfig, null, 2));
63 console.log(
64 "Parameterized model config options:",
65 JSON.stringify(parameterizedOptions, null, 2),
66 );
67 expect(modelConfig).toBeDefined();
68 expect(typeof modelConfig?.id).toBe("string");
69 }
70 }).pipe(
71 Effect.provide(
72 AcpSessionRuntime.layer({
73 authMethodId: "cursor_login",
74 spawn: {
75 command: "agent",
76 args: ["acp"],
77 cwd: process.cwd(),
78 },
79 cwd: process.cwd(),
80 clientCapabilities: {
81 _meta: {
82 parameterizedModelPicker: true,
83 },
84 },
85 clientInfo: { name: "t3-probe", version: "0.0.0" },
86 }),
87 ),
88 Effect.scoped,
89 Effect.provide(NodeServices.layer),
90 ),
91 );
92
93 it.effect("session/set_config_option switches the model in-session", () =>
94 Effect.gen(function* () {
95 const runtime = yield* AcpSessionRuntime;
96 const started = yield* runtime.start();
97 const newResult = started.sessionSetupResult;
98
99 const configOptions = newResult.configOptions;
100 let modelConfigId = "model";
101 if (Array.isArray(configOptions)) {
102 const modelConfig = configOptions.find((opt) => opt.category === "model");
103 if (typeof modelConfig?.id === "string") {
104 modelConfigId = modelConfig.id;
105 }
106 }
107
108 const setResult: EffectAcpSchema.SetSessionConfigOptionResponse =
109 yield* runtime.setConfigOption(modelConfigId, "gpt-5.4");
110
111 console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2));
112
113 if (Array.isArray(setResult.configOptions)) {
114 const modelConfig = setResult.configOptions.find((opt) => opt.category === "model");
115 const parameterizedOptions = setResult.configOptions.filter(
116 (opt) =>
117 opt.category === "thought_level" ||
118 opt.category === "model_option" ||
119 opt.category === "model_config",
120 );
121 if (modelConfig?.type === "select") {
122 expect(modelConfig.currentValue).toBe("gpt-5.4");
123 }
124 expect(parameterizedOptions.length).toBeGreaterThan(0);
125 }
126 }).pipe(
127 Effect.provide(
128 AcpSessionRuntime.layer({
129 authMethodId: "cursor_login",
130 spawn: {
131 command: "agent",
132 args: ["acp"],
133 cwd: process.cwd(),
134 },
135 cwd: process.cwd(),
136 clientCapabilities: {
137 _meta: {
138 parameterizedModelPicker: true,
139 },
140 },
141 clientInfo: { name: "t3-probe", version: "0.0.0" },
142 }),
143 ),
144 Effect.scoped,
145 Effect.provide(NodeServices.layer),
146 ),
147 );
148});
apps/server/src/provider/acp/CursorAcpExtension.test.ts+108−0
@@ -0,0 +1,108 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 extractAskQuestions,
5 extractPlanMarkdown,
6 extractTodosAsPlan,
7} from "./CursorAcpExtension.ts";
8
9describe("CursorAcpExtension", () => {
10 it("extracts ask-question prompts from the real Cursor ACP payload shape", () => {
11 const questions = extractAskQuestions({
12 toolCallId: "ask-1",
13 title: "Need input",
14 questions: [
15 {
16 id: "language",
17 prompt: "Which language should I use?",
18 options: [
19 { id: "ts", label: "TypeScript" },
20 { id: "rs", label: "Rust" },
21 ],
22 allowMultiple: false,
23 },
24 ],
25 });
26
27 expect(questions).toEqual([
28 {
29 id: "language",
30 header: "Question",
31 question: "Which language should I use?",
32 multiSelect: false,
33 options: [
34 { label: "TypeScript", description: "TypeScript" },
35 { label: "Rust", description: "Rust" },
36 ],
37 },
38 ]);
39 });
40
41 it("defaults ask-question multi-select to false when Cursor omits allowMultiple", () => {
42 const questions = extractAskQuestions({
43 toolCallId: "ask-2",
44 questions: [
45 {
46 id: "mode",
47 prompt: "Which mode should I use?",
48 options: [
49 { id: "agent", label: "Agent" },
50 { id: "plan", label: "Plan" },
51 ],
52 },
53 ],
54 });
55
56 expect(questions).toEqual([
57 {
58 id: "mode",
59 header: "Question",
60 question: "Which mode should I use?",
61 multiSelect: false,
62 options: [
63 { label: "Agent", description: "Agent" },
64 { label: "Plan", description: "Plan" },
65 ],
66 },
67 ]);
68 });
69
70 it("extracts plan markdown from the real Cursor create-plan payload shape", () => {
71 const planMarkdown = extractPlanMarkdown({
72 toolCallId: "plan-1",
73 name: "Refactor parser",
74 overview: "Tighten ACP parsing",
75 plan: "# Plan\n\n1. Add schemas\n2. Remove casts",
76 todos: [
77 { id: "t1", content: "Add schemas", status: "in_progress" },
78 { id: "t2", content: "Remove casts", status: "pending" },
79 ],
80 isProject: false,
81 });
82
83 expect(planMarkdown).toBe("# Plan\n\n1. Add schemas\n2. Remove casts");
84 });
85
86 it("projects todo updates into a plan shape and drops invalid entries", () => {
87 expect(
88 extractTodosAsPlan({
89 toolCallId: "todos-1",
90 todos: [
91 { id: "1", content: "Inspect state", status: "completed" },
92 { id: "2", content: " Apply fix ", status: "in_progress" },
93 { id: "3", title: "Fallback title", status: "pending" },
94 { id: "4", content: "Unknown status", status: "weird_status" },
95 { id: "5", content: " " },
96 ],
97 merge: true,
98 }),
99 ).toEqual({
100 plan: [
101 { step: "Inspect state", status: "completed" },
102 { step: "Apply fix", status: "inProgress" },
103 { step: "Fallback title", status: "pending" },
104 { step: "Unknown status", status: "pending" },
105 ],
106 });
107 });
108});
apps/server/src/provider/acp/CursorAcpExtension.ts+99−0
@@ -0,0 +1,99 @@
1/**
2 * Public Docs: https://cursor.com/docs/cli/acp#cursor-extension-methods
3 * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md
4 */
5import type { UserInputQuestion } from "@t3tools/contracts";
6import { Schema } from "effect";
7
8const CursorAskQuestionOption = Schema.Struct({
9 id: Schema.String,
10 label: Schema.String,
11});
12
13const CursorAskQuestion = Schema.Struct({
14 id: Schema.String,
15 prompt: Schema.String,
16 options: Schema.Array(CursorAskQuestionOption),
17 allowMultiple: Schema.optional(Schema.Boolean),
18});
19
20export const CursorAskQuestionRequest = Schema.Struct({
21 toolCallId: Schema.String,
22 title: Schema.optional(Schema.String),
23 questions: Schema.Array(CursorAskQuestion),
24});
25
26const CursorTodoStatus = Schema.String;
27
28const CursorTodo = Schema.Struct({
29 id: Schema.optional(Schema.String),
30 content: Schema.optional(Schema.String),
31 title: Schema.optional(Schema.String),
32 status: Schema.optional(CursorTodoStatus),
33});
34
35const CursorPlanPhase = Schema.Struct({
36 name: Schema.String,
37 todos: Schema.Array(CursorTodo),
38});
39
40export const CursorCreatePlanRequest = Schema.Struct({
41 toolCallId: Schema.String,
42 name: Schema.optional(Schema.String),
43 overview: Schema.optional(Schema.String),
44 plan: Schema.String,
45 todos: Schema.Array(CursorTodo),
46 isProject: Schema.optional(Schema.Boolean),
47 phases: Schema.optional(Schema.Array(CursorPlanPhase)),
48});
49
50export const CursorUpdateTodosRequest = Schema.Struct({
51 toolCallId: Schema.String,
52 todos: Schema.Array(CursorTodo),
53 merge: Schema.Boolean,
54});
55
56export function extractAskQuestions(
57 params: typeof CursorAskQuestionRequest.Type,
58): ReadonlyArray<UserInputQuestion> {
59 return params.questions.map((question) => ({
60 id: question.id,
61 header: "Question",
62 question: question.prompt,
63 multiSelect: question.allowMultiple === true,
64 options:
65 question.options.length > 0
66 ? question.options.map((option) => ({
67 label: option.label,
68 description: option.label,
69 }))
70 : [{ label: "OK", description: "Continue" }],
71 }));
72}
73
74export function extractPlanMarkdown(params: typeof CursorCreatePlanRequest.Type): string {
75 return params.plan || "# Plan\n\n(Cursor did not supply plan text.)";
76}
77
78export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type): {
79 readonly explanation?: string;
80 readonly plan: ReadonlyArray<{
81 readonly step: string;
82 readonly status: "pending" | "inProgress" | "completed";
83 }>;
84} {
85 const plan = params.todos.flatMap((todo) => {
86 const step = todo.content?.trim() ?? todo.title?.trim() ?? "";
87 if (step === "") {
88 return [];
89 }
90 const status: "pending" | "inProgress" | "completed" =
91 todo.status === "completed"
92 ? "completed"
93 : todo.status === "in_progress" || todo.status === "inProgress"
94 ? "inProgress"
95 : "pending";
96 return [{ step, status }];
97 });
98 return { plan };
99}
apps/server/src/provider/acp/CursorAcpSupport.test.ts+123−0
@@ -0,0 +1,123 @@
1import { Effect } from "effect";
2import type * as EffectAcpSchema from "effect-acp/schema";
3import { describe, expect, it } from "vitest";
4
5import { applyCursorAcpModelSelection, buildCursorAcpSpawnInput } from "./CursorAcpSupport.ts";
6
7const parameterizedGpt54ConfigOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> = [
8 {
9 id: "model",
10 name: "Model",
11 category: "model",
12 type: "select",
13 currentValue: "gpt-5.4-medium-fast",
14 options: [{ value: "gpt-5.4-medium-fast", name: "GPT-5.4" }],
15 },
16 {
17 id: "reasoning",
18 name: "Reasoning",
19 category: "thought_level",
20 type: "select",
21 currentValue: "medium",
22 options: [
23 { value: "low", name: "Low" },
24 { value: "medium", name: "Medium" },
25 { value: "high", name: "High" },
26 { value: "extra-high", name: "Extra High" },
27 ],
28 },
29 {
30 id: "context",
31 name: "Context",
32 category: "model_config",
33 type: "select",
34 currentValue: "272k",
35 options: [
36 { value: "272k", name: "272K" },
37 { value: "1m", name: "1M" },
38 ],
39 },
40 {
41 id: "fast",
42 name: "Fast",
43 category: "model_config",
44 type: "select",
45 currentValue: "false",
46 options: [
47 { value: "false", name: "Off" },
48 { value: "true", name: "Fast" },
49 ],
50 },
51];
52
53describe("buildCursorAcpSpawnInput", () => {
54 it("builds the default Cursor ACP command", () => {
55 expect(buildCursorAcpSpawnInput(undefined, "/tmp/project")).toEqual({
56 command: "agent",
57 args: ["acp"],
58 cwd: "/tmp/project",
59 });
60 });
61
62 it("includes the configured api endpoint when present", () => {
63 expect(
64 buildCursorAcpSpawnInput(
65 {
66 binaryPath: "/usr/local/bin/agent",
67 apiEndpoint: "http://localhost:3000",
68 },
69 "/tmp/project",
70 ),
71 ).toEqual({
72 command: "/usr/local/bin/agent",
73 args: ["-e", "http://localhost:3000", "acp"],
74 cwd: "/tmp/project",
75 });
76 });
77});
78
79describe("applyCursorAcpModelSelection", () => {
80 it("sets the base model before applying separate config options", async () => {
81 const calls: Array<
82 | { readonly type: "model"; readonly value: string }
83 | { readonly type: "config"; readonly configId: string; readonly value: string | boolean }
84 > = [];
85
86 const runtime = {
87 getConfigOptions: Effect.succeed(parameterizedGpt54ConfigOptions),
88 setModel: (value: string) =>
89 Effect.sync(() => {
90 calls.push({ type: "model", value });
91 }),
92 setConfigOption: (configId: string, value: string | boolean) =>
93 Effect.sync(() => {
94 calls.push({ type: "config", configId, value });
95 }),
96 };
97
98 await Effect.runPromise(
99 applyCursorAcpModelSelection({
100 runtime,
101 model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]",
102 modelOptions: {
103 reasoning: "xhigh",
104 contextWindow: "1m",
105 fastMode: true,
106 },
107 mapError: ({ step, configId, cause }) =>
108 new Error(
109 step === "set-config-option"
110 ? `failed to set config option ${configId}: ${cause.message}`
111 : `failed to set model: ${cause.message}`,
112 ),
113 }),
114 );
115
116 expect(calls).toEqual([
117 { type: "model", value: "gpt-5.4-medium-fast" },
118 { type: "config", configId: "reasoning", value: "extra-high" },
119 { type: "config", configId: "context", value: "1m" },
120 { type: "config", configId: "fast", value: "true" },
121 ]);
122 });
123});
apps/server/src/provider/acp/CursorAcpSupport.ts+108−0
@@ -0,0 +1,108 @@
1import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts";
2import { Effect, Layer, Scope } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import type * as EffectAcpErrors from "effect-acp/errors";
5
6import {
7 CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
8 resolveCursorAcpBaseModelId,
9 resolveCursorAcpConfigUpdates,
10} from "../Layers/CursorProvider.ts";
11import {
12 AcpSessionRuntime,
13 type AcpSessionRuntimeOptions,
14 type AcpSessionRuntimeShape,
15 type AcpSpawnInput,
16} from "./AcpSessionRuntime.ts";
17
18type CursorAcpRuntimeCursorSettings = Pick<CursorSettings, "apiEndpoint" | "binaryPath">;
19
20export interface CursorAcpRuntimeInput extends Omit<
21 AcpSessionRuntimeOptions,
22 "authMethodId" | "clientCapabilities" | "spawn"
23> {
24 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
25 readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined;
26}
27
28export interface CursorAcpModelSelectionErrorContext {
29 readonly cause: EffectAcpErrors.AcpError;
30 readonly step: "set-config-option" | "set-model";
31 readonly configId?: string;
32}
33
34export function buildCursorAcpSpawnInput(
35 cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined,
36 cwd: string,
37): AcpSpawnInput {
38 return {
39 command: cursorSettings?.binaryPath || "agent",
40 args: [
41 ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []),
42 "acp",
43 ],
44 cwd,
45 };
46}
47
48export const makeCursorAcpRuntime = (
49 input: CursorAcpRuntimeInput,
50): Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> =>
51 Effect.gen(function* () {
52 const acpContext = yield* Layer.build(
53 AcpSessionRuntime.layer({
54 ...input,
55 spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd),
56 authMethodId: "cursor_login",
57 clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
58 }).pipe(
59 Layer.provide(
60 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
61 ),
62 ),
63 );
64 return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext));
65 });
66
67interface CursorAcpModelSelectionRuntime {
68 readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"];
69 readonly setConfigOption: (
70 configId: string,
71 value: string | boolean,
72 ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;
73 readonly setModel: (model: string) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;
74}
75
76export function applyCursorAcpModelSelection<E>(input: {
77 readonly runtime: CursorAcpModelSelectionRuntime;
78 readonly model: string | null | undefined;
79 readonly modelOptions: CursorModelOptions | null | undefined;
80 readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E;
81}): Effect.Effect<void, E> {
82 return Effect.gen(function* () {
83 yield* input.runtime.setModel(resolveCursorAcpBaseModelId(input.model)).pipe(
84 Effect.mapError((cause) =>
85 input.mapError({
86 cause,
87 step: "set-model",
88 }),
89 ),
90 );
91
92 const configUpdates = resolveCursorAcpConfigUpdates(
93 yield* input.runtime.getConfigOptions,
94 input.modelOptions,
95 );
96 for (const update of configUpdates) {
97 yield* input.runtime.setConfigOption(update.configId, update.value).pipe(
98 Effect.mapError((cause) =>
99 input.mapError({
100 cause,
101 step: "set-config-option",
102 configId: update.configId,
103 }),
104 ),
105 );
106 }
107 });
108}
apps/server/src/provider/makeManagedServerProvider.test.ts+263−0
@@ -0,0 +1,263 @@
1import { describe, it, assert } from "@effect/vitest";
2import type { ServerProvider } from "@t3tools/contracts";
3import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect";
4
5import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";
6
7interface TestSettings {
8 readonly enabled: boolean;
9}
10
11const initialSnapshot: ServerProvider = {
12 provider: "codex",
13 enabled: true,
14 installed: true,
15 version: null,
16 status: "warning",
17 auth: { status: "unknown" },
18 checkedAt: "2026-04-10T00:00:00.000Z",
19 message: "Checking provider availability...",
20 models: [],
21 slashCommands: [],
22 skills: [],
23};
24
25const refreshedSnapshot: ServerProvider = {
26 provider: "codex",
27 enabled: true,
28 installed: true,
29 version: "1.0.0",
30 status: "ready",
31 auth: { status: "authenticated" },
32 checkedAt: "2026-04-10T00:00:01.000Z",
33 models: [],
34 slashCommands: [],
35 skills: [],
36};
37
38const enrichedSnapshot: ServerProvider = {
39 ...refreshedSnapshot,
40 checkedAt: "2026-04-10T00:00:02.000Z",
41 models: [
42 {
43 slug: "composer-2",
44 name: "Composer 2",
45 isCustom: false,
46 capabilities: {
47 reasoningEffortLevels: [],
48 supportsFastMode: true,
49 supportsThinkingToggle: false,
50 contextWindowOptions: [],
51 promptInjectedEffortLevels: [],
52 },
53 },
54 ],
55};
56
57const refreshedSnapshotSecond: ServerProvider = {
58 ...refreshedSnapshot,
59 checkedAt: "2026-04-10T00:00:03.000Z",
60 message: "Refreshed provider availability again.",
61};
62
63const enrichedSnapshotSecond: ServerProvider = {
64 ...refreshedSnapshotSecond,
65 checkedAt: "2026-04-10T00:00:04.000Z",
66 models: [
67 {
68 slug: "gpt-5.4",
69 name: "GPT-5.4",
70 isCustom: false,
71 capabilities: {
72 reasoningEffortLevels: [],
73 supportsFastMode: false,
74 supportsThinkingToggle: false,
75 contextWindowOptions: [],
76 promptInjectedEffortLevels: [],
77 },
78 },
79 ],
80};
81
82describe("makeManagedServerProvider", () => {
83 it.effect(
84 "runs the initial provider check in the background and streams the refreshed snapshot",
85 () =>
86 Effect.scoped(
87 Effect.gen(function* () {
88 const checkCalls = yield* Ref.make(0);
89 const releaseCheck = yield* Deferred.make<void>();
90 const provider = yield* makeManagedServerProvider<TestSettings>({
91 getSettings: Effect.succeed({ enabled: true }),
92 streamSettings: Stream.empty,
93 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
94 initialSnapshot: () => initialSnapshot,
95 checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe(
96 Effect.flatMap(() => Deferred.await(releaseCheck)),
97 Effect.as(refreshedSnapshot),
98 ),
99 refreshInterval: "1 hour",
100 });
101
102 const initial = yield* provider.getSnapshot;
103 assert.deepStrictEqual(initial, initialSnapshot);
104
105 const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe(
106 Stream.runCollect,
107 Effect.forkChild,
108 );
109 yield* Effect.yieldNow;
110
111 yield* Deferred.succeed(releaseCheck, undefined);
112
113 const updates = Array.from(yield* Fiber.join(updatesFiber));
114 const latest = yield* provider.getSnapshot;
115
116 assert.deepStrictEqual(updates, [refreshedSnapshot]);
117 assert.deepStrictEqual(latest, refreshedSnapshot);
118 assert.strictEqual(yield* Ref.get(checkCalls), 1);
119 }),
120 ),
121 );
122
123 it.effect("reruns the provider check when streamed settings change", () =>
124 Effect.scoped(
125 Effect.gen(function* () {
126 const settingsRef = yield* Ref.make<TestSettings>({ enabled: true });
127 const settingsChanges = yield* PubSub.unbounded<TestSettings>();
128 const checkCalls = yield* Ref.make(0);
129 const releaseInitialCheck = yield* Deferred.make<void>();
130 const releaseSettingsCheck = yield* Deferred.make<void>();
131 const provider = yield* makeManagedServerProvider<TestSettings>({
132 getSettings: Ref.get(settingsRef),
133 streamSettings: Stream.fromPubSub(settingsChanges),
134 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
135 initialSnapshot: () => initialSnapshot,
136 checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
137 Effect.flatMap((count) =>
138 count === 1
139 ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot))
140 : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)),
141 ),
142 ),
143 refreshInterval: "1 hour",
144 });
145
146 const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe(
147 Stream.runCollect,
148 Effect.forkChild,
149 );
150 yield* Effect.yieldNow;
151
152 yield* Deferred.succeed(releaseInitialCheck, undefined);
153 yield* Ref.set(settingsRef, { enabled: false });
154 yield* PubSub.publish(settingsChanges, { enabled: false });
155 yield* Deferred.succeed(releaseSettingsCheck, undefined);
156
157 const updates = Array.from(yield* Fiber.join(updatesFiber));
158 const latest = yield* provider.getSnapshot;
159
160 assert.deepStrictEqual(updates, [refreshedSnapshot, refreshedSnapshotSecond]);
161 assert.deepStrictEqual(latest, refreshedSnapshotSecond);
162 assert.strictEqual(yield* Ref.get(checkCalls), 2);
163 }),
164 ),
165 );
166
167 it.effect("streams supplemental snapshot updates after the base provider check completes", () =>
168 Effect.scoped(
169 Effect.gen(function* () {
170 const releaseEnrichment = yield* Deferred.make<void>();
171 const releaseCheck = yield* Deferred.make<void>();
172 const provider = yield* makeManagedServerProvider<TestSettings>({
173 getSettings: Effect.succeed({ enabled: true }),
174 streamSettings: Stream.empty,
175 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
176 initialSnapshot: () => initialSnapshot,
177 checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)),
178 enrichSnapshot: ({ publishSnapshot }) =>
179 Deferred.await(releaseEnrichment).pipe(
180 Effect.flatMap(() => publishSnapshot(enrichedSnapshot)),
181 ),
182 refreshInterval: "1 hour",
183 });
184
185 const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe(
186 Stream.runCollect,
187 Effect.forkChild,
188 );
189 yield* Effect.yieldNow;
190
191 yield* Deferred.succeed(releaseCheck, undefined);
192
193 yield* Deferred.succeed(releaseEnrichment, undefined);
194
195 const updates = Array.from(yield* Fiber.join(updatesFiber));
196 const latest = yield* provider.getSnapshot;
197
198 assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]);
199 assert.deepStrictEqual(latest, enrichedSnapshot);
200 }),
201 ),
202 );
203
204 it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () =>
205 Effect.scoped(
206 Effect.gen(function* () {
207 const publishCallbacks: Array<(snapshot: ServerProvider) => Effect.Effect<void>> = [];
208 const refreshCount = yield* Ref.make(0);
209 const firstCallbackReady = yield* Deferred.make<void>();
210 const secondCallbackReady = yield* Deferred.make<void>();
211 const allowFirstRefresh = yield* Deferred.make<void>();
212 const provider = yield* makeManagedServerProvider<TestSettings>({
213 getSettings: Effect.succeed({ enabled: true }),
214 streamSettings: Stream.empty,
215 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
216 initialSnapshot: () => initialSnapshot,
217 checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe(
218 Effect.flatMap((count) =>
219 count === 1
220 ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot))
221 : Effect.succeed(refreshedSnapshotSecond),
222 ),
223 ),
224 enrichSnapshot: ({ publishSnapshot }) =>
225 Effect.gen(function* () {
226 publishCallbacks.push(publishSnapshot);
227 if (publishCallbacks.length === 1) {
228 yield* Deferred.succeed(firstCallbackReady, undefined).pipe(Effect.ignore);
229 } else if (publishCallbacks.length === 2) {
230 yield* Deferred.succeed(secondCallbackReady, undefined).pipe(Effect.ignore);
231 }
232 }),
233 refreshInterval: "1 hour",
234 });
235
236 const updatesFiber = yield* Stream.take(provider.streamChanges, 3).pipe(
237 Stream.runCollect,
238 Effect.forkChild,
239 );
240 yield* Effect.yieldNow;
241
242 yield* Deferred.succeed(allowFirstRefresh, undefined);
243 yield* Deferred.await(firstCallbackReady);
244
245 yield* provider.refresh;
246 yield* Deferred.await(secondCallbackReady);
247
248 yield* publishCallbacks[0]!(enrichedSnapshot);
249 yield* publishCallbacks[1]!(enrichedSnapshotSecond);
250
251 const updates = Array.from(yield* Fiber.join(updatesFiber));
252 const latest = yield* provider.getSnapshot;
253
254 assert.deepStrictEqual(updates, [
255 refreshedSnapshot,
256 refreshedSnapshotSecond,
257 enrichedSnapshotSecond,
258 ]);
259 assert.deepStrictEqual(latest, enrichedSnapshotSecond);
260 }),
261 ),
262 );
263});
apps/server/src/provider/makeManagedServerProvider.ts+85−4
@@ -1,10 +1,15 @@
1import type { ServerProvider } from "@t3tools/contracts";1import type { ServerProvider } from "@t3tools/contracts";
2import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect";2import { Duration, Effect, Equal, Fiber, PubSub, Ref, Scope, Stream } from "effect";
3import * as Semaphore from "effect/Semaphore";3import * as Semaphore from "effect/Semaphore";
4 4
5import type { ServerProviderShape } from "./Services/ServerProvider.ts";5import type { ServerProviderShape } from "./Services/ServerProvider.ts";
6import { ServerSettingsError } from "@t3tools/contracts";6import { ServerSettingsError } from "@t3tools/contracts";
7 7
8interface ProviderSnapshotState {
9 readonly snapshot: ServerProvider;
10 readonly enrichmentGeneration: number;
11}
12
8export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <13export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
9 Settings,14 Settings,
10>(input: {15>(input: {
@@ -13,6 +18,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 2 unmodified lines
13 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;18 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
14 readonly initialSnapshot: (settings: Settings) => ServerProvider;19 readonly initialSnapshot: (settings: Settings) => ServerProvider;
15 readonly checkProvider: Effect.Effect<ServerProvider, ServerSettingsError>;20 readonly checkProvider: Effect.Effect<ServerProvider, ServerSettingsError>;
21 readonly enrichSnapshot?: (input: {
22 readonly settings: Settings;
23 readonly snapshot: ServerProvider;
24 readonly getSnapshot: Effect.Effect<ServerProvider>;
25 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
26 }) => Effect.Effect<void>;
16 readonly refreshInterval?: Duration.Input;27 readonly refreshInterval?: Duration.Input;
17}): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope> {28}): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope> {
18 const refreshSemaphore = yield* Semaphore.make(1);29 const refreshSemaphore = yield* Semaphore.make(1);
@@ -22,8 +33,61 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 3 unmodified lines
22 );33 );
23 const initialSettings = yield* input.getSettings;34 const initialSettings = yield* input.getSettings;
24 const initialSnapshot = input.initialSnapshot(initialSettings);35 const initialSnapshot = input.initialSnapshot(initialSettings);
25 const snapshotRef = yield* Ref.make(initialSnapshot);36 const snapshotStateRef = yield* Ref.make<ProviderSnapshotState>({
37 snapshot: initialSnapshot,
38 enrichmentGeneration: 0,
39 });
26 const settingsRef = yield* Ref.make(initialSettings);40 const settingsRef = yield* Ref.make(initialSettings);
41 const enrichmentFiberRef = yield* Ref.make<Fiber.Fiber<void, unknown> | null>(null);
42 const scope = yield* Effect.scope;
43
44 const publishEnrichedSnapshot = Effect.fn("publishEnrichedSnapshot")(function* (
45 generation: number,
46 nextSnapshot: ServerProvider,
47 ) {
48 const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => {
49 if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) {
50 return [null, state] as const;
51 }
52 return [
53 nextSnapshot,
54 {
55 ...state,
56 snapshot: nextSnapshot,
57 },
58 ] as const;
59 });
60 if (snapshotToPublish === null) {
61 return;
62 }
63 yield* PubSub.publish(changesPubSub, snapshotToPublish);
64 });
65
66 const restartSnapshotEnrichment = Effect.fn("restartSnapshotEnrichment")(function* (
67 settings: Settings,
68 snapshot: ServerProvider,
69 generation: number,
70 ) {
71 const previousFiber = yield* Ref.getAndSet(enrichmentFiberRef, null);
72 if (previousFiber) {
73 yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore);
74 }
75
76 if (!input.enrichSnapshot) {
77 return;
78 }
79
80 const fiber = yield* input
81 .enrichSnapshot({
82 settings,
83 snapshot,
84 getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)),
85 publishSnapshot: (nextSnapshot) => publishEnrichedSnapshot(generation, nextSnapshot),
86 })
87 .pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope));
88
89 yield* Ref.set(enrichmentFiberRef, fiber);
90 });
27 91
28 const applySnapshotBase = Effect.fn("applySnapshot")(function* (92 const applySnapshotBase = Effect.fn("applySnapshot")(function* (
29 nextSettings: Settings,93 nextSettings: Settings,
@@ -33,13 +97,25 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 3 unmodified lines
33 const previousSettings = yield* Ref.get(settingsRef);97 const previousSettings = yield* Ref.get(settingsRef);
34 if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) {98 if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) {
35 yield* Ref.set(settingsRef, nextSettings);99 yield* Ref.set(settingsRef, nextSettings);
36 return yield* Ref.get(snapshotRef);100 return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot));
37 }101 }
38 102
39 const nextSnapshot = yield* input.checkProvider;103 const nextSnapshot = yield* input.checkProvider;
104 const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => {
105 const generation = input.enrichSnapshot
106 ? state.enrichmentGeneration + 1
107 : state.enrichmentGeneration;
108 return [
109 generation,
110 {
111 snapshot: nextSnapshot,
112 enrichmentGeneration: generation,
113 },
114 ] as const;
115 });
40 yield* Ref.set(settingsRef, nextSettings);116 yield* Ref.set(settingsRef, nextSettings);
41 yield* Ref.set(snapshotRef, nextSnapshot);
42 yield* PubSub.publish(changesPubSub, nextSnapshot);117 yield* PubSub.publish(changesPubSub, nextSnapshot);
118 yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration);
43 return nextSnapshot;119 return nextSnapshot;
44 });120 });
45 const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) =>121 const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) =>
@@ -61,6 +137,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 15 unmodified lines
61 ),137 ),
62 ).pipe(Effect.forkScoped);138 ).pipe(Effect.forkScoped);
63 139
140 yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe(
141 Effect.ignoreCause({ log: true }),
142 Effect.forkScoped,
143 );
144
64 return {145 return {
65 getSnapshot: input.getSettings.pipe(146 getSnapshot: input.getSettings.pipe(
66 Effect.flatMap(applySnapshot),147 Effect.flatMap(applySnapshot),
apps/server/src/provider/providerStatusCache.test.ts+30−2
@@ -73,10 +73,23 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 72 unmodified lines
73 }),73 }),
74 );74 );
75 75
76 it("hydrates cached provider status onto current settings-derived models", () => {76 it("hydrates cached provider status while preserving current settings-derived models", () => {
77 const cachedCodex = makeProvider("codex", {77 const cachedCodex = makeProvider("codex", {
78 checkedAt: "2026-04-10T12:00:00.000Z",78 checkedAt: "2026-04-10T12:00:00.000Z",
79 models: [],79 models: [
80 {
81 slug: "gpt-5-mini",
82 name: "GPT-5 Mini",
83 isCustom: false,
84 capabilities: {
85 reasoningEffortLevels: [],
86 supportsFastMode: false,
87 supportsThinkingToggle: false,
88 contextWindowOptions: [],
89 promptInjectedEffortLevels: [],
90 },
91 },
92 ],
80 message: "Cached message",93 message: "Cached message",
81 skills: [94 skills: [
82 {95 {
@@ -112,6 +125,21 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 29 unmodified lines
112 }),125 }),
113 {126 {
114 ...fallbackCodex,127 ...fallbackCodex,
128 models: [
129 ...fallbackCodex.models,
130 {
131 slug: "gpt-5-mini",
132 name: "GPT-5 Mini",
133 isCustom: false,
134 capabilities: {
135 reasoningEffortLevels: [],
136 supportsFastMode: false,
137 supportsThinkingToggle: false,
138 contextWindowOptions: [],
139 promptInjectedEffortLevels: [],
140 },
141 },
142 ],
115 installed: cachedCodex.installed,143 installed: cachedCodex.installed,
116 version: cachedCodex.version,144 version: cachedCodex.version,
117 status: cachedCodex.status,145 status: cachedCodex.status,
apps/server/src/provider/providerStatusCache.ts+10−0
@@ -6,6 +6,7 @@ export const PROVIDER_CACHE_IDS = [ · 5 unmodified lines
6 "codex",6 "codex",
7 "claudeAgent",7 "claudeAgent",
8 "opencode",8 "opencode",
9 "cursor",
9] as const satisfies ReadonlyArray<ServerProvider["provider"]>;10] as const satisfies ReadonlyArray<ServerProvider["provider"]>;
10 11
11const decodeProviderStatusCache = Schema.decodeUnknownEffect(12const decodeProviderStatusCache = Schema.decodeUnknownEffect(
@@ -17,6 +18,14 @@ const providerOrderRank = (provider: ServerProvider["provider"]): number => { · 5 unmodified lines
17 return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;18 return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;
18};19};
19 20
21const mergeProviderModels = (
22 fallbackModels: ReadonlyArray<ServerProvider["models"][number]>,
23 cachedModels: ReadonlyArray<ServerProvider["models"][number]>,
24): ReadonlyArray<ServerProvider["models"][number]> => {
25 const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug));
26 return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))];
27};
28
20export const orderProviderSnapshots = (29export const orderProviderSnapshots = (
21 providers: ReadonlyArray<ServerProvider>,30 providers: ReadonlyArray<ServerProvider>,
22): ReadonlyArray<ServerProvider> =>31): ReadonlyArray<ServerProvider> =>
@@ -38,6 +47,7 @@ export const hydrateCachedProvider = (input: { · 15 unmodified lines
38 const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider;47 const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider;
39 const hydratedProvider: ServerProvider = {48 const hydratedProvider: ServerProvider = {
40 ...fallbackWithoutMessage,49 ...fallbackWithoutMessage,
50 models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models),
41 installed: input.cachedProvider.installed,51 installed: input.cachedProvider.installed,
42 version: input.cachedProvider.version,52 version: input.cachedProvider.version,
43 status: input.cachedProvider.status,53 status: input.cachedProvider.status,
apps/server/src/server.ts+5−0
@@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD · 20 unmodified lines
21import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";21import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";
22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";
23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";
24import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts";
24import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts";25import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts";
25import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";26import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
26import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";27import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";
@@ -160,10 +161,14 @@ const ProviderLayerLive = Layer.unwrap( · 133 unmodified lines
160 const openCodeAdapterLayer = makeOpenCodeAdapterLive(161 const openCodeAdapterLayer = makeOpenCodeAdapterLive(
161 nativeEventLogger ? { nativeEventLogger } : undefined,162 nativeEventLogger ? { nativeEventLogger } : undefined,
162 );163 );
164 const cursorAdapterLayer = makeCursorAdapterLive(
165 nativeEventLogger ? { nativeEventLogger } : undefined,
166 );
163 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(167 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
164 Layer.provide(codexAdapterLayer),168 Layer.provide(codexAdapterLayer),
165 Layer.provide(claudeAdapterLayer),169 Layer.provide(claudeAdapterLayer),
166 Layer.provide(openCodeAdapterLayer),170 Layer.provide(openCodeAdapterLayer),
171 Layer.provide(cursorAdapterLayer),
167 Layer.provideMerge(ProviderSessionDirectoryLayerLive),172 Layer.provideMerge(ProviderSessionDirectoryLayerLive),
168 );173 );
169 return makeProviderServiceLive(174 return makeProviderServiceLive(
apps/server/src/serverSettings.ts+1−1
@@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< · 104 unmodified lines
105 105
106const ServerSettingsJson = fromLenientJson(ServerSettings);106const ServerSettingsJson = fromLenientJson(ServerSettings);
107 107
108const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"];108const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"];
109 109
110/**110/**
111 * Ensure the `textGenerationModelSelection` points to an enabled provider.111 * Ensure the `textGenerationModelSelection` points to an enabled provider.
apps/server/tsdown.config.ts+1−1
@@ -9,7 +9,7 @@ export default defineConfig({ · 8 unmodified lines
9 outDir: "dist",9 outDir: "dist",
10 sourcemap: true,10 sourcemap: true,
11 clean: true,11 clean: true,
12 noExternal: (id) => id.startsWith("@t3tools/"),12 noExternal: (id) => id.startsWith("@t3tools/") || id.startsWith("effect-acp"),
13 inlineOnly: false,13 inlineOnly: false,
14 banner: {14 banner: {
15 js: "#!/usr/bin/env node\n",15 js: "#!/usr/bin/env node\n",
apps/web/src/components/KeybindingsToast.browser.tsx+1−0
@@ -99,6 +99,7 @@ function createBaseServerConfig(): ServerConfig { · 98 unmodified lines
99 providers: {99 providers: {
100 codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },100 codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },
101 claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },101 claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },
102 cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] },
102 opencode: {103 opencode: {
103 enabled: true,104 enabled: true,
104 binaryPath: "",105 binaryPath: "",
apps/web/src/components/chat/ChatComposer.tsx+1−0
@@ -612,6 +612,7 @@ export const ChatComposer = memo( · 611 unmodified lines
612 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],612 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],
613 opencode:613 opencode:
614 providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [],614 providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [],
615 cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [],
615 }),616 }),
616 [providerStatuses],617 [providerStatuses],
617 );618 );
apps/web/src/components/chat/MessagesTimeline.tsx+51−20
@@ -938,7 +938,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 937 unmodified lines
938 const iconConfig = workToneIcon(workEntry.tone);938 const iconConfig = workToneIcon(workEntry.tone);
939 const EntryIcon = workEntryIcon(workEntry);939 const EntryIcon = workEntryIcon(workEntry);
940 const heading = toolWorkEntryHeading(workEntry);940 const heading = toolWorkEntryHeading(workEntry);
941 const preview = workEntryPreview(workEntry, workspaceRoot);941 const rawPreview = workEntryPreview(workEntry, workspaceRoot);
942 const preview =
943 rawPreview &&
944 normalizeCompactToolLabel(rawPreview).toLowerCase() ===
945 normalizeCompactToolLabel(heading).toLowerCase()
946 ? null
947 : rawPreview;
942 const rawCommand = workEntryRawCommand(workEntry);948 const rawCommand = workEntryRawCommand(workEntry);
943 const displayText = preview ? `${heading} - ${preview}` : heading;949 const displayText = preview ? `${heading} - ${preview}` : heading;
944 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;950 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
@@ -953,20 +959,20 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 8 unmodified lines
953 <EntryIcon className="size-3" />959 <EntryIcon className="size-3" />
954 </span>960 </span>
955 <div className="min-w-0 flex-1 overflow-hidden">961 <div className="min-w-0 flex-1 overflow-hidden">
956 <div className="max-w-full">962 {rawCommand ? (
957 <p963 <div className="max-w-full">
958 className={cn(964 <p
959 "truncate text-xs leading-5",965 className={cn(
960 workToneClass(workEntry.tone),966 "truncate text-xs leading-5",
961 preview ? "text-muted-foreground/70" : "",967 workToneClass(workEntry.tone),
962 )}968 preview ? "text-muted-foreground/70" : "",
963 title={rawCommand ? undefined : displayText}969 )}
964 >970 title={displayText}
965 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>971 >
966 {heading}972 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
967 </span>973 {heading}
968 {preview &&974 </span>
969 (rawCommand ? (975 {preview && (
970 <Tooltip>976 <Tooltip>
971 <TooltipTrigger977 <TooltipTrigger
972 closeDelay={0}978 closeDelay={0}
@@ -988,11 +994,36 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 15 unmodified lines
988 </div>994 </div>
989 </TooltipPopup>995 </TooltipPopup>
990 </Tooltip>996 </Tooltip>
991 ) : (997 )}
992 <span className="text-muted-foreground/55"> - {preview}</span>998 </p>
993 ))}999 </div>
994 </p>1000 ) : (
995 </div>1001 <Tooltip>
1002 <TooltipTrigger
1003 className="block min-w-0 w-full text-left"
1004 title={displayText}
1005 aria-label={displayText}
1006 >
1007 <p
1008 className={cn(
1009 "truncate text-[11px] leading-5",
1010 workToneClass(workEntry.tone),
1011 preview ? "text-muted-foreground/70" : "",
1012 )}
1013 >
1014 <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
1015 {heading}
1016 </span>
1017 {preview && <span className="text-muted-foreground/55"> - {preview}</span>}
1018 </p>
1019 </TooltipTrigger>
1020 <TooltipPopup className="max-w-[min(720px,calc(100vw-2rem))]">
1021 <p className="whitespace-pre-wrap wrap-break-word text-xs leading-5">
1022 {displayText}
1023 </p>
1024 </TooltipPopup>
1025 </Tooltip>
1026 )}
996 </div>1027 </div>
997 </div>1028 </div>
998 {hasChangedFiles && !previewIsChangedFiles && (1029 {hasChangedFiles && !previewIsChangedFiles && (
apps/web/src/components/chat/TraitsPicker.browser.tsx+152−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
4 type ModelSelection,4 type ModelSelection,
5 ClaudeModelOptions,5 ClaudeModelOptions,
6 CodexModelOptions,6 CodexModelOptions,
7 CursorModelOptions,
7 DEFAULT_MODEL_BY_PROVIDER,8 DEFAULT_MODEL_BY_PROVIDER,
8 DEFAULT_SERVER_SETTINGS,9 DEFAULT_SERVER_SETTINGS,
9 OpenCodeModelOptions,10 OpenCodeModelOptions,
@@ -475,6 +476,90 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 465 unmodified lines
475 };476 };
476}477}
477 478
479async function mountCursorPicker(props: { model?: string; options?: CursorModelOptions }) {
480 const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.cursor;
481 const cursorThreadId = ThreadId.make("thread-cursor-traits");
482 const cursorThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, cursorThreadId);
483 const cursorThreadKey = scopedThreadKey(cursorThreadRef);
484 const host = document.createElement("div");
485 document.body.append(host);
486
487 useComposerDraftStore.setState({
488 draftsByThreadKey: {
489 [cursorThreadKey]: {
490 prompt: "",
491 images: [],
492 nonPersistedImageIds: [],
493 persistedAttachments: [],
494 terminalContexts: [],
495 modelSelectionByProvider: {
496 cursor: {
497 provider: "cursor",
498 model,
499 ...(props.options ? { options: props.options } : {}),
500 },
501 },
502 activeProvider: "cursor",
503 runtimeMode: null,
504 interactionMode: null,
505 },
506 },
507 draftThreadsByThreadKey: {},
508 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
509 });
510
511 const screen = await render(
512 <TraitsPicker
513 provider="cursor"
514 models={[
515 {
516 slug: "composer-2",
517 name: "Composer 2",
518 isCustom: false,
519 capabilities: {
520 reasoningEffortLevels: [],
521 supportsFastMode: true,
522 supportsThinkingToggle: false,
523 contextWindowOptions: [],
524 promptInjectedEffortLevels: [],
525 },
526 },
527 {
528 slug: "claude-opus-4-6",
529 name: "Opus 4.6",
530 isCustom: false,
531 capabilities: {
532 reasoningEffortLevels: [],
533 supportsFastMode: true,
534 supportsThinkingToggle: false,
535 contextWindowOptions: [
536 { value: "200k", label: "200K", isDefault: true },
537 { value: "1m", label: "1M" },
538 ],
539 promptInjectedEffortLevels: [],
540 },
541 },
542 ]}
543 threadRef={cursorThreadRef}
544 model={model}
545 prompt=""
546 modelOptions={props.options}
547 onPromptChange={() => {}}
548 />,
549 { container: host },
550 );
551
552 const cleanup = async () => {
553 await screen.unmount();
554 host.remove();
555 };
556
557 return {
558 [Symbol.asyncDispose]: cleanup,
559 cleanup,
560 };
561}
562
478describe("TraitsPicker (Codex)", () => {563describe("TraitsPicker (Codex)", () => {
479 afterEach(() => {564 afterEach(() => {
480 document.body.innerHTML = "";565 document.body.innerHTML = "";
@@ -628,3 +713,70 @@ describe("TraitsPicker (OpenCode)", () => { · 147 unmodified lines
628 });713 });
629 });714 });
630});715});
716
717describe("TraitsPicker (Cursor)", () => {
718 afterEach(() => {
719 document.body.innerHTML = "";
720 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
721 useComposerDraftStore.setState({
722 draftsByThreadKey: {},
723 draftThreadsByThreadKey: {},
724 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
725 stickyModelSelectionByProvider: {},
726 });
727 });
728
729 it("uses the selected fast mode menu label for the trigger in fast-only state", async () => {
730 await using _ = await mountCursorPicker({
731 model: "composer-2",
732 options: { fastMode: false },
733 });
734
735 await vi.waitFor(() => {
736 const text = document.body.textContent ?? "";
737 expect(text).toContain("Normal");
738 });
739
740 await page.getByRole("button").click();
741
742 await vi.waitFor(() => {
743 const text = document.body.textContent ?? "";
744 expect(text).toContain("Fast Mode");
745 expect(text).toContain("off");
746 expect(text).toContain("on");
747 });
748 });
749
750 it("shows Normal for Cursor Opus 4.6 when fast mode and context window are both at defaults", async () => {
751 await using _ = await mountCursorPicker({
752 model: "claude-opus-4-6",
753 options: { fastMode: false },
754 });
755
756 await vi.waitFor(() => {
757 const text = document.body.textContent ?? "";
758 expect(text).toContain("Normal");
759 });
760
761 await page.getByRole("button").click();
762
763 await vi.waitFor(() => {
764 const text = document.body.textContent ?? "";
765 expect(text).toContain("Fast Mode");
766 expect(text).toContain("Context Window");
767 expect(text).toContain("200K (default)");
768 expect(text).toContain("1M");
769 });
770 });
771
772 it("shows Normal · 1M for Cursor Opus 4.6 when fast mode is off and context window is overridden", async () => {
773 await using _ = await mountCursorPicker({
774 model: "claude-opus-4-6",
775 options: { fastMode: false, contextWindow: "1m" },
776 });
777
778 await vi.waitFor(() => {
779 expect(document.body.textContent ?? "").toContain("Normal · 1M");
780 });
781 });
782});
apps/web/src/components/chat/TraitsPicker.tsx+146−43
@@ -1,6 +1,7 @@
1import {1import {
2 type ClaudeModelOptions,2 type ClaudeModelOptions,
3 type CodexModelOptions,3 type CodexModelOptions,
4 type CursorModelOptions,
4 type OpenCodeModelOptions,5 type OpenCodeModelOptions,
5 type ProviderKind,6 type ProviderKind,
6 type ProviderModelOptions,7 type ProviderModelOptions,
@@ -60,12 +61,22 @@ function getRawEffort( · 53 unmodified lines
60 if (provider === "codex") {61 if (provider === "codex") {
61 return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort);62 return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort);
62 }63 }
64 if (provider === "cursor") {
65 return trimOrNull((modelOptions as CursorModelOptions | undefined)?.reasoning);
66 }
63 if (provider === "opencode") {67 if (provider === "opencode") {
64 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant);68 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant);
65 }69 }
66 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);70 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);
67}71}
68 72
73function getEffortKey(provider: ProviderKind): string {
74 if (provider === "codex") return "reasoningEffort";
75 if (provider === "cursor") return "reasoning";
76 if (provider === "opencode") return "variant";
77 return "effort";
78}
79
69function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null {80function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null {
70 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent);81 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent);
71}82}
@@ -87,8 +98,8 @@ function getRawContextWindow( · 15 unmodified lines
87 provider: ProviderKind,98 provider: ProviderKind,
88 modelOptions: ProviderOptions | null | undefined,99 modelOptions: ProviderOptions | null | undefined,
89): string | null {100): string | null {
90 if (provider === "claudeAgent") {101 if (modelOptions && "contextWindow" in modelOptions) {
91 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.contextWindow);102 return trimOrNull(modelOptions.contextWindow);
92 }103 }
93 return null;104 return null;
94}105}
@@ -98,16 +109,7 @@ function buildNextOptions( · 3 unmodified lines
98 modelOptions: ProviderOptions | null | undefined,109 modelOptions: ProviderOptions | null | undefined,
99 patch: Record<string, unknown>,110 patch: Record<string, unknown>,
100): ProviderOptions {111): ProviderOptions {
101 if (provider === "codex") {112 return { ...(modelOptions as Record<string, unknown> | undefined), ...patch } as ProviderOptions;
102 return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions;
103 }
104 if (provider === "opencode") {
105 return {
106 ...(modelOptions as OpenCodeModelOptions | undefined),
107 ...patch,
108 } as OpenCodeModelOptions;
109 }
110 return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions;
111}113}
112 114
113function getSelectedTraits(115function getSelectedTraits(
@@ -137,7 +139,9 @@ function getSelectedTraits( · 23 unmodified lines
137 139
138 // Thinking toggle (only for models that support it)140 // Thinking toggle (only for models that support it)
139 const thinkingEnabled = caps.supportsThinkingToggle141 const thinkingEnabled = caps.supportsThinkingToggle
140 ? ((modelOptions as ClaudeModelOptions | undefined)?.thinking ?? true)142 ? modelOptions && "thinking" in modelOptions
143 ? modelOptions.thinking === true
144 : null
141 : null;145 : null;
142 146
143 // Fast mode147 // Fast mode
@@ -185,6 +189,51 @@ function getSelectedTraits( · 41 unmodified lines
185 };189 };
186}190}
187 191
192function getTraitsSectionVisibility(input: {
193 provider: ProviderKind;
194 models: ReadonlyArray<ServerProviderModel>;
195 model: string | null | undefined;
196 prompt: string;
197 modelOptions: ProviderOptions | null | undefined;
198 allowPromptInjectedEffort?: boolean;
199}) {
200 const selected = getSelectedTraits(
201 input.provider,
202 input.models,
203 input.model,
204 input.prompt,
205 input.modelOptions,
206 input.allowPromptInjectedEffort ?? true,
207 );
208
209 const showEffort = selected.effort !== null;
210 const showThinking = selected.thinkingEnabled !== null;
211 const showFastMode = selected.caps.supportsFastMode;
212 const showContextWindow = selected.contextWindowOptions.length > 1;
213 const showAgent = selected.agentOptions.length > 0;
214
215 return {
216 ...selected,
217 showEffort,
218 showThinking,
219 showFastMode,
220 showContextWindow,
221 showAgent,
222 hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow || showAgent,
223 };
224}
225
226export function shouldRenderTraitsControls(input: {
227 provider: ProviderKind;
228 models: ReadonlyArray<ServerProviderModel>;
229 model: string | null | undefined;
230 prompt: string;
231 modelOptions: ProviderOptions | null | undefined;
232 allowPromptInjectedEffort?: boolean;
233}): boolean {
234 return getTraitsSectionVisibility(input).hasAnyControls;
235}
236
188export interface TraitsMenuContentProps {237export interface TraitsMenuContentProps {
189 provider: ProviderKind;238 provider: ProviderKind;
190 models: ReadonlyArray<ServerProviderModel>;239 models: ReadonlyArray<ServerProviderModel>;
@@ -219,10 +268,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 28 unmodified lines
219 return;268 return;
220 }269 }
221 setProviderModelOptions(threadTarget, provider, nextOptions, {270 setProviderModelOptions(threadTarget, provider, nextOptions, {
271 model,
222 persistSticky: true,272 persistSticky: true,
223 });273 });
224 },274 },
225 [persistence, provider, setProviderModelOptions],275 [model, persistence, provider, setProviderModelOptions],
226 );276 );
227 const {277 const {
228 caps,278 caps,
@@ -234,10 +284,22 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 5 unmodified lines
234 contextWindow,284 contextWindow,
235 defaultContextWindow,285 defaultContextWindow,
236 ultrathinkPromptControlled,286 ultrathinkPromptControlled,
287 showEffort,
288 showThinking,
289 showFastMode,
290 showContextWindow,
237 ultrathinkInBodyText,291 ultrathinkInBodyText,
238 agentOptions,292 agentOptions,
239 selectedAgent,293 selectedAgent,
240 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);294 hasAnyControls,
295 } = getTraitsSectionVisibility({
296 provider,
297 models,
298 model,
299 prompt,
300 modelOptions,
301 allowPromptInjectedEffort,
302 });
241 const defaultEffort = getDefaultEffort(caps);303 const defaultEffort = getDefaultEffort(caps);
242 304
243 const handleEffortChange = useCallback(305 const handleEffortChange = useCallback(
@@ -262,7 +324,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 18 unmodified lines
262 const stripped = prompt.replace(/^Ultrathink:\s*/i, "");324 const stripped = prompt.replace(/^Ultrathink:\s*/i, "");
263 onPromptChange(stripped);325 onPromptChange(stripped);
264 }326 }
265 const effortKey = provider === "codex" ? "reasoningEffort" : "effort";327 const effortKey = getEffortKey(provider);
266 updateModelOptions(328 updateModelOptions(
267 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),329 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),
268 );330 );
@@ -280,18 +342,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 11 unmodified lines
280 ],342 ],
281 );343 );
282 344
283 if (345 if (!hasAnyControls) {
284 effort === null &&
285 thinkingEnabled === null &&
286 contextWindowOptions.length <= 1 &&
287 agentOptions.length === 0
288 ) {
289 return null;346 return null;
290 }347 }
291 348
292 return (349 return (
293 <>350 <>
294 {effort ? (351 {showEffort ? (
295 <>352 <>
296 <MenuGroup>353 <MenuGroup>
297 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">354 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">
@@ -321,7 +378,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 23 unmodified lines
321 </MenuRadioGroup>378 </MenuRadioGroup>
322 </MenuGroup>379 </MenuGroup>
323 </>380 </>
324 ) : thinkingEnabled !== null ? (381 ) : showThinking ? (
325 <MenuGroup>382 <MenuGroup>
326 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>383 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
327 <MenuRadioGroup384 <MenuRadioGroup
@@ -337,9 +394,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 9 unmodified lines
337 </MenuRadioGroup>394 </MenuRadioGroup>
338 </MenuGroup>395 </MenuGroup>
339 ) : null}396 ) : null}
340 {caps.supportsFastMode ? (397 {showFastMode ? (
341 <>398 <>
342 <MenuDivider />399 {showEffort || showThinking ? <MenuDivider /> : null}
343 <MenuGroup>400 <MenuGroup>
344 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>401 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
345 <MenuRadioGroup402 <MenuRadioGroup
@@ -356,9 +413,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 10 unmodified lines
356 </MenuGroup>413 </MenuGroup>
357 </>414 </>
358 ) : null}415 ) : null}
359 {contextWindowOptions.length > 1 ? (416 {showContextWindow ? (
360 <>417 <>
361 <MenuDivider />418 {showEffort || showThinking || showFastMode ? <MenuDivider /> : null}
362 <MenuGroup>419 <MenuGroup>
363 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">420 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
364 Context Window421 Context Window
@@ -431,30 +488,76 @@ export const TraitsPicker = memo(function TraitsPicker({ · 66 unmodified lines
431 contextWindow,488 contextWindow,
432 defaultContextWindow,489 defaultContextWindow,
433 ultrathinkPromptControlled,490 ultrathinkPromptControlled,
434 selectedAgentLabel,491 showEffort,
435 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);492 showThinking,
493 showContextWindow,
494 } = getTraitsSectionVisibility({
495 provider,
496 models,
497 model,
498 prompt,
499 modelOptions,
500 allowPromptInjectedEffort,
501 });
502 const { selectedAgentLabel } = getSelectedTraits(
503 provider,
504 models,
505 model,
506 prompt,
507 modelOptions,
508 allowPromptInjectedEffort,
509 );
436 510
437 const effortLabel = effort511 const effortLabel = effort
438 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)512 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
439 : null;513 : null;
514 const primaryTraitLabel = ultrathinkPromptControlled
515 ? "Ultrathink"
516 : effortLabel
517 ? effortLabel
518 : thinkingEnabled === null
519 ? null
520 : `Thinking ${thinkingEnabled ? "On" : "Off"}`;
440 const contextWindowLabel =521 const contextWindowLabel =
441 contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow522 showContextWindow && contextWindow !== defaultContextWindow
442 ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null)523 ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null)
443 : null;524 : null;
444 const triggerLabel = [525 const fastOnlyControl =
445 ultrathinkPromptControlled526 caps.supportsFastMode && !showEffort && !showThinking && !showContextWindow;
446 ? "Ultrathink"527 if (
447 : effortLabel528 !shouldRenderTraitsControls({
448 ? effortLabel529 provider,
449 : thinkingEnabled === null530 models,
450 ? null531 model,
451 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,532 prompt,
452 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),533 modelOptions,
534 allowPromptInjectedEffort,
535 })
536 ) {
537 return null;
538 }
539
540 const selectedTriggerTraits = [
541 primaryTraitLabel,
542 ...(caps.supportsFastMode &&
543 (fastModeEnabled || (primaryTraitLabel === null && contextWindowLabel !== null))
544 ? [fastModeEnabled ? "Fast" : "Normal"]
545 : []),
453 ...(contextWindowLabel ? [contextWindowLabel] : []),546 ...(contextWindowLabel ? [contextWindowLabel] : []),
454 ...(selectedAgentLabel ? [selectedAgentLabel] : []),547 ...(selectedAgentLabel ? [selectedAgentLabel] : []),
455 ]548 ].filter(Boolean);
456 .filter(Boolean)549 const triggerLabel = fastOnlyControl
457 .join(" · ");550 ? fastModeEnabled
551 ? "Fast"
552 : "Normal"
553 : selectedTriggerTraits.length > 0
554 ? selectedTriggerTraits.join(" · ")
555 : caps.supportsFastMode
556 ? "Normal"
557 : defaultContextWindow
558 ? (contextWindowOptions.find((option) => option.value === defaultContextWindow)?.label ??
559 defaultContextWindow)
560 : (selectedAgentLabel ?? "");
458 561
459 const isCodexStyle = provider === "codex";562 const isCodexStyle = provider === "codex";
460 563
apps/web/src/components/chat/composerProviderRegistry.tsx+54−64
@@ -14,7 +14,7 @@ import type { ReactNode } from "react"; · 13 unmodified lines
14 14
15import type { DraftId } from "../../composerDraftStore";15import type { DraftId } from "../../composerDraftStore";
16import { getProviderModelCapabilities } from "../../providerModels";16import { getProviderModelCapabilities } from "../../providerModels";
17import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";17import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
18 18
19export type ComposerProviderStateInput = {19export type ComposerProviderStateInput = {
20 provider: ProviderKind;20 provider: ProviderKind;
@@ -33,6 +33,16 @@ export type ComposerProviderState = { · 12 unmodified lines
33 modelPickerIconClassName?: string;33 modelPickerIconClassName?: string;
34};34};
35 35
36type TraitsRenderInput = {
37 threadRef?: ScopedThreadRef;
38 draftId?: DraftId;
39 model: string;
40 models: ReadonlyArray<ServerProviderModel>;
41 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
42 prompt: string;
43 onPromptChange: (prompt: string) => void;
44};
45
36export type ComposerProviderControls = {46export type ComposerProviderControls = {
37 showInteractionModeToggle: boolean;47 showInteractionModeToggle: boolean;
38};48};
@@ -40,24 +50,8 @@ export type ComposerProviderControls = { · 1 unmodified line
40type ProviderRegistryEntry = {50type ProviderRegistryEntry = {
41 controls: ComposerProviderControls;51 controls: ComposerProviderControls;
42 getState: (input: ComposerProviderStateInput) => ComposerProviderState;52 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
43 renderTraitsMenuContent: (input: {53 renderTraitsMenuContent: (input: TraitsRenderInput) => ReactNode;
44 threadRef?: ScopedThreadRef;54 renderTraitsPicker: (input: TraitsRenderInput) => ReactNode;
45 draftId?: DraftId;
46 model: string;
47 models: ReadonlyArray<ServerProviderModel>;
48 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
49 prompt: string;
50 onPromptChange: (prompt: string) => void;
51 }) => ReactNode;
52 renderTraitsPicker: (input: {
53 threadRef?: ScopedThreadRef;
54 draftId?: DraftId;
55 model: string;
56 models: ReadonlyArray<ServerProviderModel>;
57 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
58 prompt: string;
59 onPromptChange: (prompt: string) => void;
60 }) => ReactNode;
61};55};
62 56
63function hasComposerTraitsTarget(input: {57function hasComposerTraitsTarget(input: {
@@ -67,6 +61,39 @@ function hasComposerTraitsTarget(input: { · 3 unmodified lines
67 return input.threadRef !== undefined || input.draftId !== undefined;61 return input.threadRef !== undefined || input.draftId !== undefined;
68}62}
69 63
64function renderTraitsControl(
65 Component: typeof TraitsMenuContent | typeof TraitsPicker,
66 provider: ProviderKind,
67 input: TraitsRenderInput,
68): ReactNode {
69 const { threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = input;
70 if (
71 !hasComposerTraitsTarget({ threadRef, draftId }) ||
72 !shouldRenderTraitsControls({
73 provider,
74 models,
75 model,
76 modelOptions,
77 prompt,
78 })
79 ) {
80 return null;
81 }
82
83 return (
84 <Component
85 provider={provider}
86 models={models}
87 {...(threadRef ? { threadRef } : {})}
88 {...(draftId ? { draftId } : {})}
89 model={model}
90 modelOptions={modelOptions}
91 prompt={prompt}
92 onPromptChange={onPromptChange}
93 />
94 );
95}
96
70function getProviderStateFromCapabilities(97function getProviderStateFromCapabilities(
71 input: ComposerProviderStateInput,98 input: ComposerProviderStateInput,
72): ComposerProviderState {99): ComposerProviderState {
@@ -78,9 +105,11 @@ function getProviderStateFromCapabilities( · 5 unmodified lines
78 ? providerOptions.effort105 ? providerOptions.effort
79 : "reasoningEffort" in providerOptions106 : "reasoningEffort" in providerOptions
80 ? providerOptions.reasoningEffort107 ? providerOptions.reasoningEffort
81 : "variant" in providerOptions108 : "reasoning" in providerOptions
82 ? providerOptions.variant109 ? providerOptions.reasoning
83 : null110 : "variant" in providerOptions
111 ? providerOptions.variant
112 : null
84 : null;113 : null;
85 const normalizedOptions = normalizeProviderModelOptionsWithCapabilities(114 const normalizedOptions = normalizeProviderModelOptionsWithCapabilities(
86 provider,115 provider,
@@ -122,54 +151,15 @@ function createProviderRegistryEntry( · 35 unmodified lines
122 ...controls,151 ...controls,
123 },152 },
124 getState: (input) => getProviderStateFromCapabilities(input),153 getState: (input) => getProviderStateFromCapabilities(input),
125 renderTraitsMenuContent: ({154 renderTraitsMenuContent: (input) => renderTraitsControl(TraitsMenuContent, provider, input),
126 threadRef,155 renderTraitsPicker: (input) => renderTraitsControl(TraitsPicker, provider, input),
127 draftId,
128 model,
129 models,
130 modelOptions,
131 prompt,
132 onPromptChange,
133 }) =>
134 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
135 <TraitsMenuContent
136 provider={provider}
137 models={models}
138 {...(threadRef ? { threadRef } : {})}
139 {...(draftId ? { draftId } : {})}
140 model={model}
141 modelOptions={modelOptions}
142 prompt={prompt}
143 onPromptChange={onPromptChange}
144 />
145 ),
146 renderTraitsPicker: ({
147 threadRef,
148 draftId,
149 model,
150 models,
151 modelOptions,
152 prompt,
153 onPromptChange,
154 }) =>
155 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
156 <TraitsPicker
157 provider={provider}
158 models={models}
159 {...(threadRef ? { threadRef } : {})}
160 {...(draftId ? { draftId } : {})}
161 model={model}
162 modelOptions={modelOptions}
163 prompt={prompt}
164 onPromptChange={onPromptChange}
165 />
166 ),
167 };156 };
168}157}
169 158
170const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {159const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
171 codex: createProviderRegistryEntry("codex"),160 codex: createProviderRegistryEntry("codex"),
172 claudeAgent: createProviderRegistryEntry("claudeAgent"),161 claudeAgent: createProviderRegistryEntry("claudeAgent"),
162 cursor: createProviderRegistryEntry("cursor"),
173 opencode: createProviderRegistryEntry("opencode", {163 opencode: createProviderRegistryEntry("opencode", {
174 showInteractionModeToggle: false,164 showInteractionModeToggle: false,
175 }),165 }),
apps/web/src/components/settings/SettingsPanels.tsx+27−1
@@ -56,6 +56,7 @@ import { · 55 unmodified lines
56} from "../../store";56} from "../../store";
57import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";57import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
58import { cn } from "../../lib/utils";58import { cn } from "../../lib/utils";
59import { Badge } from "../ui/badge";
59import { Button } from "../ui/button";60import { Button } from "../ui/button";
60import { Collapsible, CollapsibleContent } from "../ui/collapsible";61import { Collapsible, CollapsibleContent } from "../ui/collapsible";
61import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";62import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
@@ -103,6 +104,7 @@ const TIMESTAMP_FORMAT_LABELS = { · 41 unmodified lines
103type InstallProviderSettings = {104type InstallProviderSettings = {
104 provider: ProviderKind;105 provider: ProviderKind;
105 title: string;106 title: string;
107 badgeLabel?: string;
106 binaryPlaceholder: string;108 binaryPlaceholder: string;
107 binaryDescription: ReactNode;109 binaryDescription: ReactNode;
108 serverUrlPlaceholder?: string;110 serverUrlPlaceholder?: string;
@@ -130,6 +132,13 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ · 21 unmodified lines
130 binaryPlaceholder: "Claude binary path",132 binaryPlaceholder: "Claude binary path",
131 binaryDescription: "Path to the Claude binary",133 binaryDescription: "Path to the Claude binary",
132 },134 },
135 {
136 provider: "cursor",
137 title: "Cursor",
138 badgeLabel: "Early Access",
139 binaryPlaceholder: "Cursor agent binary path",
140 binaryDescription: "Path to the Cursor agent binary",
141 },
133 {142 {
134 provider: "opencode",143 provider: "opencode",
135 title: "OpenCode",144 title: "OpenCode",
@@ -530,6 +539,11 @@ export function GeneralSettingsPanel() { · 394 unmodified lines
530 settings.providers.claudeAgent.customModels.length > 0 ||539 settings.providers.claudeAgent.customModels.length > 0 ||
531 settings.providers.claudeAgent.launchArgs !== "",540 settings.providers.claudeAgent.launchArgs !== "",
532 ),541 ),
542 cursor: Boolean(
543 settings.providers.cursor.binaryPath !==
544 DEFAULT_UNIFIED_SETTINGS.providers.cursor.binaryPath ||
545 settings.providers.cursor.customModels.length > 0,
546 ),
533 opencode: Boolean(547 opencode: Boolean(
534 settings.providers.opencode.binaryPath !==548 settings.providers.opencode.binaryPath !==
535 DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath ||549 DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath ||
@@ -545,6 +559,7 @@ export function GeneralSettingsPanel() { · 9 unmodified lines
545 >({559 >({
546 codex: "",560 codex: "",
547 claudeAgent: "",561 claudeAgent: "",
562 cursor: "",
548 opencode: "",563 opencode: "",
549 });564 });
550 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<565 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
@@ -572,6 +587,11 @@ export function GeneralSettingsPanel() { · 21 unmodified lines
572 const availableEditors = useServerAvailableEditors();587 const availableEditors = useServerAvailableEditors();
573 const observability = useServerObservability();588 const observability = useServerObservability();
574 const serverProviders = useServerProviders();589 const serverProviders = useServerProviders();
590 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
591 (providerSettings) =>
592 providerSettings.provider !== "cursor" ||
593 serverProviders.some((provider) => provider.provider === "cursor"),
594 );
575 const codexHomePath = settings.providers.codex.homePath;595 const codexHomePath = settings.providers.codex.homePath;
576 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;596 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
577 const diagnosticsDescription = (() => {597 const diagnosticsDescription = (() => {
@@ -736,7 +756,7 @@ export function GeneralSettingsPanel() { · 158 unmodified lines
736 [settings, updateSettings],756 [settings, updateSettings],
737 );757 );
738 758
739 const providerCards = PROVIDER_SETTINGS.map((providerSettings) => {759 const providerCards = visibleProviderSettings.map((providerSettings) => {
740 const liveProvider = serverProviders.find(760 const liveProvider = serverProviders.find(
741 (candidate) => candidate.provider === providerSettings.provider,761 (candidate) => candidate.provider === providerSettings.provider,
742 );762 );
@@ -756,6 +776,7 @@ export function GeneralSettingsPanel() { · 13 unmodified lines
756 return {776 return {
757 provider: providerSettings.provider,777 provider: providerSettings.provider,
758 title: providerSettings.title,778 title: providerSettings.title,
779 badgeLabel: providerSettings.badgeLabel,
759 binaryPlaceholder: providerSettings.binaryPlaceholder,780 binaryPlaceholder: providerSettings.binaryPlaceholder,
760 binaryDescription: providerSettings.binaryDescription,781 binaryDescription: providerSettings.binaryDescription,
761 serverUrlPlaceholder: providerSettings.serverUrlPlaceholder,782 serverUrlPlaceholder: providerSettings.serverUrlPlaceholder,
@@ -1153,6 +1174,11 @@ export function GeneralSettingsPanel() { · 391 unmodified lines
1153 className={cn("size-2 shrink-0 rounded-full", providerCard.statusStyle.dot)}1174 className={cn("size-2 shrink-0 rounded-full", providerCard.statusStyle.dot)}
1154 />1175 />
1155 <h3 className="text-sm font-medium text-foreground">{providerDisplayName}</h3>1176 <h3 className="text-sm font-medium text-foreground">{providerDisplayName}</h3>
1177 {providerCard.badgeLabel ? (
1178 <Badge variant="warning" size="sm" className="shrink-0">
1179 {providerCard.badgeLabel}
1180 </Badge>
1181 ) : null}
1156 {providerCard.versionLabel ? (1182 {providerCard.versionLabel ? (
1157 <code className="text-xs text-muted-foreground">1183 <code className="text-xs text-muted-foreground">
1158 {providerCard.versionLabel}1184 {providerCard.versionLabel}
apps/web/src/composerDraftStore.test.ts+73−1
@@ -91,7 +91,7 @@ function resetComposerDraftStore() { · 90 unmodified lines
91}91}
92 92
93function modelSelection(93function modelSelection(
94 provider: "codex" | "claudeAgent",94 provider: "codex" | "claudeAgent" | "cursor",
95 model: string,95 model: string,
96 options?: ModelSelection["options"],96 options?: ModelSelection["options"],
97): ModelSelection {97): ModelSelection {
@@ -1002,6 +1002,60 @@ describe("composerDraftStore modelSelection", () => { · 904 unmodified lines
1002 );1002 );
1003 });1003 });
1004 1004
1005 it("keeps explicit Cursor reset overrides on the selection", () => {
1006 const store = useComposerDraftStore.getState();
1007
1008 store.setModelSelection(
1009 threadRef,
1010 modelSelection("cursor", "claude-opus-4-6", {
1011 reasoning: "xhigh",
1012 fastMode: true,
1013 thinking: false,
1014 }),
1015 );
1016
1017 store.setProviderModelOptions(threadRef, "cursor", {
1018 reasoning: "medium",
1019 fastMode: false,
1020 thinking: true,
1021 });
1022
1023 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual(
1024 modelSelection("cursor", "claude-opus-4-6", {
1025 reasoning: "medium",
1026 fastMode: false,
1027 thinking: true,
1028 }),
1029 );
1030 });
1031
1032 it("preserves the selected Cursor model when only traits change", () => {
1033 const store = useComposerDraftStore.getState();
1034
1035 store.setProviderModelOptions(
1036 threadRef,
1037 "cursor",
1038 {
1039 reasoning: "high",
1040 },
1041 {
1042 model: "gpt-5.4",
1043 persistSticky: true,
1044 },
1045 );
1046
1047 expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.cursor).toEqual(
1048 modelSelection("cursor", "gpt-5.4", {
1049 reasoning: "high",
1050 }),
1051 );
1052 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual(
1053 modelSelection("cursor", "gpt-5.4", {
1054 reasoning: "high",
1055 }),
1056 );
1057 });
1058
1005 it("updates only the draft when sticky persistence is omitted", () => {1059 it("updates only the draft when sticky persistence is omitted", () => {
1006 const store = useComposerDraftStore.getState();1060 const store = useComposerDraftStore.getState();
1007 1061
@@ -1174,6 +1228,24 @@ describe("composerDraftStore sticky composer settings", () => { · 166 unmodified lines
1174 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");1228 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
1175 });1229 });
1176 1230
1231 it("drops empty cursor model options when normalizing sticky state", () => {
1232 const store = useComposerDraftStore.getState();
1233
1234 store.setStickyModelSelection(
1235 modelSelection("cursor", "gpt-5.4", {
1236 reasoning: undefined,
1237 fastMode: undefined,
1238 thinking: undefined,
1239 contextWindow: undefined,
1240 }),
1241 );
1242
1243 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual(
1244 modelSelection("cursor", "gpt-5.4"),
1245 );
1246 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("cursor");
1247 });
1248
1177 it("applies sticky activeProvider to new drafts", () => {1249 it("applies sticky activeProvider to new drafts", () => {
1178 const store = useComposerDraftStore.getState();1250 const store = useComposerDraftStore.getState();
1179 const threadId = ThreadId.make("thread-sticky-active-provider");1251 const threadId = ThreadId.make("thread-sticky-active-provider");
apps/web/src/composerDraftStore.ts+64−13
@@ -1,7 +1,10 @@
1import {1import {
2 CURSOR_REASONING_OPTIONS,
3 DEFAULT_MODEL_BY_PROVIDER,
4 type CursorModelOptions,
5 type CursorReasoningOption,
2 ClaudeAgentEffort,6 ClaudeAgentEffort,
3 CodexReasoningEffort,7 CodexReasoningEffort,
4 DEFAULT_MODEL_BY_PROVIDER,
5 type EnvironmentId,8 type EnvironmentId,
6 ModelSelection,9 ModelSelection,
7 ProjectId,10 ProjectId,
@@ -341,6 +344,7 @@ interface ComposerDraftStoreState { · 333 unmodified lines
341 provider: ProviderKind,344 provider: ProviderKind,
342 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,345 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
343 options?: {346 options?: {
347 model?: string | null | undefined;
344 persistSticky?: boolean;348 persistSticky?: boolean;
345 },349 },
346 ) => void;350 ) => void;
@@ -527,7 +531,9 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { · 180 unmodified lines
527}531}
528 532
529function normalizeProviderKind(value: unknown): ProviderKind | null {533function normalizeProviderKind(value: unknown): ProviderKind | null {
530 return value === "codex" || value === "claudeAgent" || value === "opencode" ? value : null;534 return value === "codex" || value === "claudeAgent" || value === "cursor" || value === "opencode"
535 ? value
536 : null;
531}537}
532 538
533function normalizeProviderModelOptions(539function normalizeProviderModelOptions(
@@ -544,15 +550,22 @@ function normalizeProviderModelOptions( · 10 unmodified lines
544 candidate?.claudeAgent && typeof candidate.claudeAgent === "object"550 candidate?.claudeAgent && typeof candidate.claudeAgent === "object"
545 ? (candidate.claudeAgent as Record<string, unknown>)551 ? (candidate.claudeAgent as Record<string, unknown>)
546 : null;552 : null;
553 const cursorCandidate =
554 candidate?.cursor && typeof candidate.cursor === "object"
555 ? (candidate.cursor as Record<string, unknown>)
556 : null;
547 const openCodeCandidate =557 const openCodeCandidate =
548 candidate?.opencode && typeof candidate.opencode === "object"558 candidate?.opencode && typeof candidate.opencode === "object"
549 ? (candidate.opencode as Record<string, unknown>)559 ? (candidate.opencode as Record<string, unknown>)
550 : null;560 : null;
551 561
552 const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort)562 const isCodexReasoningEffort = Schema.is(CodexReasoningEffort);
563 const isClaudeAgentEffort = Schema.is(ClaudeAgentEffort);
564
565 const codexReasoningEffort = isCodexReasoningEffort(codexCandidate?.reasoningEffort)
553 ? codexCandidate.reasoningEffort566 ? codexCandidate.reasoningEffort
554 : provider === "codex"567 : provider === "codex"
555 ? Schema.is(CodexReasoningEffort)(legacy?.effort)568 ? isCodexReasoningEffort(legacy?.effort)
556 ? legacy.effort569 ? legacy.effort
557 : undefined570 : undefined
558 : undefined;571 : undefined;
@@ -579,7 +592,7 @@ function normalizeProviderModelOptions( · 20 unmodified lines
579 : claudeCandidate?.thinking === false592 : claudeCandidate?.thinking === false
580 ? false593 ? false
581 : undefined;594 : undefined;
582 const claudeEffort = Schema.is(ClaudeAgentEffort)(claudeCandidate?.effort)595 const claudeEffort = isClaudeAgentEffort(claudeCandidate?.effort)
583 ? claudeCandidate.effort596 ? claudeCandidate.effort
584 : undefined;597 : undefined;
585 const claudeFastMode =598 const claudeFastMode =
@@ -604,6 +617,43 @@ function normalizeProviderModelOptions( · 18 unmodified lines
604 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),617 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),
605 }618 }
606 : undefined;619 : undefined;
620
621 const cursorReasoningRaw = cursorCandidate?.reasoning;
622 const cursorReasoning: CursorReasoningOption | undefined =
623 typeof cursorReasoningRaw === "string" &&
624 (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw)
625 ? (cursorReasoningRaw as CursorReasoningOption)
626 : undefined;
627 const cursorFastMode =
628 cursorCandidate?.fastMode === true
629 ? true
630 : cursorCandidate?.fastMode === false
631 ? false
632 : undefined;
633 const cursorThinking =
634 cursorCandidate?.thinking === true
635 ? true
636 : cursorCandidate?.thinking === false
637 ? false
638 : undefined;
639 const cursorContextWindow =
640 typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0
641 ? cursorCandidate.contextWindow
642 : undefined;
643
644 const cursor: CursorModelOptions | undefined =
645 cursorCandidate !== null
646 ? (() => {
647 const nextCursor = {
648 ...(cursorReasoning ? { reasoning: cursorReasoning } : {}),
649 ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}),
650 ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}),
651 ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}),
652 } satisfies CursorModelOptions;
653 return Object.keys(nextCursor).length > 0 ? nextCursor : undefined;
654 })()
655 : undefined;
656
607 const openCodeVariant =657 const openCodeVariant =
608 typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0658 typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0
609 ? openCodeCandidate.variant659 ? openCodeCandidate.variant
@@ -620,12 +670,13 @@ function normalizeProviderModelOptions( · 10 unmodified lines
620 }670 }
621 : undefined;671 : undefined;
622 672
623 if (!codex && !claude && !opencode) {673 if (!codex && !claude && cursor === undefined && !opencode) {
624 return null;674 return null;
625 }675 }
626 return {676 return {
627 ...(codex ? { codex } : {}),677 ...(codex ? { codex } : {}),
628 ...(claude ? { claudeAgent: claude } : {}),678 ...(claude ? { claudeAgent: claude } : {}),
679 ...(cursor !== undefined ? { cursor } : {}),
629 ...(opencode ? { opencode } : {}),680 ...(opencode ? { opencode } : {}),
630 };681 };
631}682}
@@ -715,7 +766,7 @@ function legacyToModelSelectionByProvider( · 83 unmodified lines
715 const result: Partial<Record<ProviderKind, ModelSelection>> = {};766 const result: Partial<Record<ProviderKind, ModelSelection>> = {};
716 // Add entries from the options bag (for non-active providers)767 // Add entries from the options bag (for non-active providers)
717 if (modelOptions) {768 if (modelOptions) {
718 for (const provider of ["codex", "claudeAgent", "opencode"] as const) {769 for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
719 const options = modelOptions[provider];770 const options = modelOptions[provider];
720 if (options && Object.keys(options).length > 0) {771 if (options && Object.keys(options).length > 0) {
721 result[provider] = createModelSelection(772 result[provider] = createModelSelection(
@@ -2261,7 +2312,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 1539 unmodified lines
2261 }2312 }
2262 const base = existing ?? createEmptyThreadDraft();2313 const base = existing ?? createEmptyThreadDraft();
2263 const nextMap = { ...base.modelSelectionByProvider };2314 const nextMap = { ...base.modelSelectionByProvider };
2264 for (const provider of ["codex", "claudeAgent", "opencode"] as const) {2315 for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
2265 // Only touch providers explicitly present in the input2316 // Only touch providers explicitly present in the input
2266 if (!normalizedOpts || !(provider in normalizedOpts)) continue;2317 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
2267 const opts = normalizedOpts[provider];2318 const opts = normalizedOpts[provider];
@@ -2303,6 +2354,9 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 35 unmodified lines
2303 if (normalizedProvider === null) {2354 if (normalizedProvider === null) {
2304 return;2355 return;
2305 }2356 }
2357 const fallbackModel =
2358 normalizeModelSlug(options?.model, normalizedProvider) ??
2359 DEFAULT_MODEL_BY_PROVIDER[normalizedProvider];
2306 // Normalize just this provider's options2360 // Normalize just this provider's options
2307 const normalizedOpts = normalizeProviderModelOptions(2361 const normalizedOpts = normalizeProviderModelOptions(
2308 { [normalizedProvider]: nextProviderOptions },2362 { [normalizedProvider]: nextProviderOptions },
@@ -2320,7 +2374,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 11 unmodified lines
2320 if (providerOpts) {2374 if (providerOpts) {
2321 nextMap[normalizedProvider] = createModelSelection(2375 nextMap[normalizedProvider] = createModelSelection(
2322 normalizedProvider,2376 normalizedProvider,
2323 currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],2377 currentForProvider?.model ?? fallbackModel,
2324 providerOpts,2378 providerOpts,
2325 );2379 );
2326 } else if (currentForProvider?.options) {2380 } else if (currentForProvider?.options) {
@@ -2336,10 +2390,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 9 unmodified lines
2336 const stickyBase =2390 const stickyBase =
2337 nextStickyMap[normalizedProvider] ??2391 nextStickyMap[normalizedProvider] ??
2338 base.modelSelectionByProvider[normalizedProvider] ??2392 base.modelSelectionByProvider[normalizedProvider] ??
2339 createModelSelection(2393 createModelSelection(normalizedProvider, fallbackModel);
2340 normalizedProvider,
2341 DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2342 );
2343 if (providerOpts) {2394 if (providerOpts) {
2344 nextStickyMap[normalizedProvider] = createModelSelection(2395 nextStickyMap[normalizedProvider] = createModelSelection(
2345 normalizedProvider,2396 normalizedProvider,
apps/web/src/modelSelection.ts+13−0
@@ -49,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf · 48 unmodified lines
49 placeholder: "your-claude-model-slug",49 placeholder: "your-claude-model-slug",
50 example: "claude-sonnet-5-0",50 example: "claude-sonnet-5-0",
51 },51 },
52 cursor: {
53 provider: "cursor",
54 title: "Cursor",
55 description: "Save additional Cursor model slugs for the picker and `/model` command.",
56 placeholder: "your-cursor-model-slug",
57 example: "claude-sonnet-4-6",
58 },
52 opencode: {59 opencode: {
53 provider: "opencode",60 provider: "opencode",
54 title: "OpenCode",61 title: "OpenCode",
@@ -176,6 +183,12 @@ export function getCustomModelOptionsByProvider( · 121 unmodified lines
176 "claudeAgent",183 "claudeAgent",
177 selectedProvider === "claudeAgent" ? selectedModel : undefined,184 selectedProvider === "claudeAgent" ? selectedModel : undefined,
178 ),185 ),
186 cursor: getAppModelOptions(
187 settings,
188 providers,
189 "cursor",
190 selectedProvider === "cursor" ? selectedModel : undefined,
191 ),
179 opencode: getAppModelOptions(192 opencode: getAppModelOptions(
180 settings,193 settings,
181 providers,194 providers,
apps/web/src/providerModels.ts+38−2
@@ -1,11 +1,17 @@
1import {1import {
2 DEFAULT_MODEL_BY_PROVIDER,2 DEFAULT_MODEL_BY_PROVIDER,
3 type CursorModelOptions,
3 type ModelCapabilities,4 type ModelCapabilities,
4 type ProviderKind,5 type ProviderKind,
5 type ServerProvider,6 type ServerProvider,
6 type ServerProviderModel,7 type ServerProviderModel,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import { normalizeModelSlug } from "@t3tools/shared/model";9import {
10 hasEffortLevel,
11 normalizeModelSlug,
12 resolveContextWindow,
13 trimOrNull,
14} from "@t3tools/shared/model";
9 15
10const EMPTY_CAPABILITIES: ModelCapabilities = {16const EMPTY_CAPABILITIES: ModelCapabilities = {
11 reasoningEffortLevels: [],17 reasoningEffortLevels: [],
@@ -33,7 +39,10 @@ export function isProviderEnabled( · 21 unmodified lines
33 providers: ReadonlyArray<ServerProvider>,39 providers: ReadonlyArray<ServerProvider>,
34 provider: ProviderKind,40 provider: ProviderKind,
35): boolean {41): boolean {
36 return getProviderSnapshot(providers, provider)?.enabled ?? true;42 if (providers.length === 0) {
43 return true;
44 }
45 return getProviderSnapshot(providers, provider)?.enabled ?? false;
37}46}
38 47
39export function resolveSelectableProvider(48export function resolveSelectableProvider(
@@ -67,3 +76,30 @@ export function getDefaultServerModel( · 27 unmodified lines
67 DEFAULT_MODEL_BY_PROVIDER[provider]76 DEFAULT_MODEL_BY_PROVIDER[provider]
68 );77 );
69}78}
79
80export function normalizeCursorModelOptionsWithCapabilities(
81 caps: ModelCapabilities,
82 modelOptions: CursorModelOptions | null | undefined,
83): CursorModelOptions | undefined {
84 const reasoning = trimOrNull(modelOptions?.reasoning);
85 const reasoningValue =
86 reasoning && hasEffortLevel(caps, reasoning)
87 ? (reasoning as CursorModelOptions["reasoning"])
88 : undefined;
89 const fastMode =
90 caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean"
91 ? modelOptions.fastMode
92 : undefined;
93 const thinking =
94 caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean"
95 ? modelOptions.thinking
96 : undefined;
97 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
98 const nextOptions: CursorModelOptions = {
99 ...(reasoningValue ? { reasoning: reasoningValue } : {}),
100 ...(fastMode !== undefined ? { fastMode } : {}),
101 ...(thinking !== undefined ? { thinking } : {}),
102 ...(contextWindow ? { contextWindow } : {}),
103 };
104 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
105}
apps/web/src/session-logic.test.ts+193−29
@@ -921,6 +921,199 @@ describe("deriveWorkLogEntries", () => { · 920 unmodified lines
921 ]);921 ]);
922 });922 });
923 923
924 it("drops duplicated tool detail when it only repeats the title", () => {
925 const activities: OrchestrationThreadActivity[] = [
926 makeActivity({
927 id: "read-file-generic",
928 kind: "tool.completed",
929 summary: "Read File",
930 payload: {
931 itemType: "dynamic_tool_call",
932 title: "Read File",
933 detail: "Read File",
934 },
935 }),
936 ];
937
938 const [entry] = deriveWorkLogEntries(activities, undefined);
939 expect(entry?.toolTitle).toBe("Read File");
940 expect(entry?.detail).toBeUndefined();
941 });
942
943 it("uses grep raw output summaries instead of repeating the generic tool label", () => {
944 const activities: OrchestrationThreadActivity[] = [
945 makeActivity({
946 id: "grep-update",
947 createdAt: "2026-02-23T00:00:01.000Z",
948 kind: "tool.updated",
949 summary: "grep",
950 payload: {
951 itemType: "web_search",
952 title: "grep",
953 detail: "grep",
954 data: {
955 toolCallId: "tool-grep-1",
956 kind: "search",
957 rawInput: {},
958 },
959 },
960 }),
961 makeActivity({
962 id: "grep-complete",
963 createdAt: "2026-02-23T00:00:02.000Z",
964 kind: "tool.completed",
965 summary: "grep",
966 payload: {
967 itemType: "web_search",
968 title: "grep",
969 detail: "grep",
970 data: {
971 toolCallId: "tool-grep-1",
972 kind: "search",
973 rawOutput: {
974 totalFiles: 19,
975 truncated: false,
976 },
977 },
978 },
979 }),
980 ];
981
982 const entries = deriveWorkLogEntries(activities, undefined);
983 expect(entries).toHaveLength(1);
984 expect(entries[0]).toMatchObject({
985 id: "grep-complete",
986 toolTitle: "grep",
987 detail: "19 files",
988 itemType: "web_search",
989 });
990 });
991
992 it("uses completed read-file output previews and still collapses the same tool call", () => {
993 const activities: OrchestrationThreadActivity[] = [
994 makeActivity({
995 id: "read-update",
996 createdAt: "2026-02-23T00:00:01.000Z",
997 kind: "tool.updated",
998 summary: "Read File",
999 payload: {
1000 itemType: "dynamic_tool_call",
1001 title: "Read File",
1002 detail: "Read File",
1003 data: {
1004 toolCallId: "tool-read-1",
1005 kind: "read",
1006 rawInput: {},
1007 },
1008 },
1009 }),
1010 makeActivity({
1011 id: "read-complete",
1012 createdAt: "2026-02-23T00:00:02.000Z",
1013 kind: "tool.completed",
1014 summary: "Read File",
1015 payload: {
1016 itemType: "dynamic_tool_call",
1017 title: "Read File",
1018 detail: "Read File",
1019 data: {
1020 toolCallId: "tool-read-1",
1021 kind: "read",
1022 rawOutput: {
1023 content:
1024 'import * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\n',
1025 },
1026 },
1027 },
1028 }),
1029 ];
1030
1031 const entries = deriveWorkLogEntries(activities, undefined);
1032 expect(entries).toHaveLength(1);
1033 expect(entries[0]).toMatchObject({
1034 id: "read-complete",
1035 toolTitle: "Read File",
1036 detail: 'import * as Effect from "effect/Effect"',
1037 itemType: "dynamic_tool_call",
1038 });
1039 });
1040
1041 it("does not use command stdout as the detail when Cursor omits the command input", () => {
1042 const activities: OrchestrationThreadActivity[] = [
1043 makeActivity({
1044 id: "cursor-command-complete",
1045 createdAt: "2026-04-16T22:40:42.221Z",
1046 kind: "tool.completed",
1047 summary: "Ran command",
1048 payload: {
1049 itemType: "command_execution",
1050 title: "Ran command",
1051 data: {
1052 toolCallId: "toolu_vrtx_01WypXgRM8PPygBtrVAZwzy5",
1053 kind: "execute",
1054 rawInput: {},
1055 rawOutput: {
1056 exitCode: 0,
1057 stdout: "total 960\napps\npackages\n",
1058 stderr: "",
1059 },
1060 },
1061 },
1062 }),
1063 ];
1064
1065 const [entry] = deriveWorkLogEntries(activities, undefined);
1066 expect(entry).toMatchObject({
1067 id: "cursor-command-complete",
1068 label: "Ran command",
1069 itemType: "command_execution",
1070 toolTitle: "Ran command",
1071 });
1072 expect(entry?.detail).toBeUndefined();
1073 expect(entry?.command).toBeUndefined();
1074 });
1075
1076 it("collapses legacy completed tool rows that are missing tool metadata", () => {
1077 const activities: OrchestrationThreadActivity[] = [
1078 makeActivity({
1079 id: "legacy-read-update",
1080 createdAt: "2026-02-23T00:00:01.000Z",
1081 kind: "tool.updated",
1082 summary: "Read File",
1083 payload: {
1084 itemType: "dynamic_tool_call",
1085 title: "Read File",
1086 detail: "Read File",
1087 data: {
1088 toolCallId: "tool-read-legacy",
1089 kind: "read",
1090 rawInput: {},
1091 },
1092 },
1093 }),
1094 makeActivity({
1095 id: "legacy-read-complete",
1096 createdAt: "2026-02-23T00:00:02.000Z",
1097 kind: "tool.completed",
1098 summary: "Read File",
1099 payload: {
1100 itemType: "dynamic_tool_call",
1101 title: "Read File",
1102 detail: "Read File",
1103 },
1104 }),
1105 ];
1106
1107 const entries = deriveWorkLogEntries(activities, undefined);
1108 expect(entries).toHaveLength(1);
1109 expect(entries[0]).toMatchObject({
1110 id: "legacy-read-complete",
1111 toolTitle: "Read File",
1112 itemType: "dynamic_tool_call",
1113 });
1114 expect(entries[0]?.detail).toBeUndefined();
1115 });
1116
924 it("collapses repeated lifecycle updates for the same tool call into one entry", () => {1117 it("collapses repeated lifecycle updates for the same tool call into one entry", () => {
925 const activities: OrchestrationThreadActivity[] = [1118 const activities: OrchestrationThreadActivity[] = [
926 makeActivity({1119 makeActivity({
@@ -1324,32 +1517,3 @@ describe("deriveActiveWorkStartedAt", () => { · 397 unmodified lines
1324 ).toBe("2026-02-27T21:11:00.000Z");1517 ).toBe("2026-02-27T21:11:00.000Z");
1325 });1518 });
1326});1519});
1327
1328describe("PROVIDER_OPTIONS", () => {
1329 it("advertises available providers while keeping Cursor as a placeholder", () => {
1330 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent");
1331 const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode");
1332 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");
1333 expect(PROVIDER_OPTIONS).toEqual([
1334 { value: "codex", label: "Codex", available: true },
1335 { value: "claudeAgent", label: "Claude", available: true },
1336 { value: "opencode", label: "OpenCode", available: true },
1337 { value: "cursor", label: "Cursor", available: false },
1338 ]);
1339 expect(claude).toEqual({
1340 value: "claudeAgent",
1341 label: "Claude",
1342 available: true,
1343 });
1344 expect(opencode).toEqual({
1345 value: "opencode",
1346 label: "OpenCode",
1347 available: true,
1348 });
1349 expect(cursor).toEqual({
1350 value: "cursor",
1351 label: "Cursor",
1352 available: false,
1353 });
1354 });
1355});
apps/web/src/session-logic.ts+149−19
@@ -1,3 +1,5 @@
1import * as Option from "effect/Option";
2import * as Arr from "effect/Array";
1import {3import {
2 ApprovalRequestId,4 ApprovalRequestId,
3 isToolLifecycleItemType,5 isToolLifecycleItemType,
@@ -20,7 +22,7 @@ import type { · 16 unmodified lines
20 TurnDiffSummary,22 TurnDiffSummary,
21} from "./types";23} from "./types";
22 24
23export type ProviderPickerKind = ProviderKind | "cursor";25export type ProviderPickerKind = ProviderKind;
24 26
25export const PROVIDER_OPTIONS: Array<{27export const PROVIDER_OPTIONS: Array<{
26 value: ProviderPickerKind;28 value: ProviderPickerKind;
@@ -30,7 +32,7 @@ export const PROVIDER_OPTIONS: Array<{ · 3 unmodified lines
30 { value: "codex", label: "Codex", available: true },32 { value: "codex", label: "Codex", available: true },
31 { value: "claudeAgent", label: "Claude", available: true },33 { value: "claudeAgent", label: "Claude", available: true },
32 { value: "opencode", label: "OpenCode", available: true },34 { value: "opencode", label: "OpenCode", available: true },
33 { value: "cursor", label: "Cursor", available: false },35 { value: "cursor", label: "Cursor", available: true },
34];36];
35 37
36export interface WorkLogEntry {38export interface WorkLogEntry {
@@ -50,6 +52,7 @@ export interface WorkLogEntry { · 13 unmodified lines
50interface DerivedWorkLogEntry extends WorkLogEntry {52interface DerivedWorkLogEntry extends WorkLogEntry {
51 activityKind: OrchestrationThreadActivity["kind"];53 activityKind: OrchestrationThreadActivity["kind"];
52 collapseKey?: string;54 collapseKey?: string;
55 toolCallId?: string;
53}56}
54 57
55export interface PendingApproval {58export interface PendingApproval {
@@ -352,12 +355,12 @@ export function deriveActivePlanState( · 296 unmodified lines
352 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");355 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
353 // Prefer plan from the current turn; fall back to the most recent plan from any turn356 // Prefer plan from the current turn; fall back to the most recent plan from any turn
354 // so that TodoWrite tasks persist across follow-up messages.357 // so that TodoWrite tasks persist across follow-up messages.
355 const latest =358 const latest = Option.firstSomeOf([
356 (latestTurnId359 ...(latestTurnId
357 ? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1)360 ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)
358 : undefined) ??361 : Option.none()),
359 allPlanActivities.at(-1) ??362 Arr.last(allPlanActivities),
360 null;363 ]).pipe(Option.getOrNull);
361 if (!latest) {364 if (!latest) {
362 return null;365 return null;
363 }366 }
@@ -515,6 +518,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 151 unmodified lines
515 ? payload.detail518 ? payload.detail
516 : null;519 : null;
517 const taskLabel = taskSummary || taskDetailAsLabel;520 const taskLabel = taskSummary || taskDetailAsLabel;
521 const detail = isTaskActivity
522 ? !taskDetailAsLabel &&
523 payload &&
524 typeof payload.detail === "string" &&
525 payload.detail.length > 0
526 ? stripTrailingExitCode(payload.detail).output
527 : null
528 : extractToolDetail(payload, title ?? activity.summary);
529 const toolCallId = isTaskActivity ? null : extractToolCallId(payload);
518 const entry: DerivedWorkLogEntry = {530 const entry: DerivedWorkLogEntry = {
519 id: activity.id,531 id: activity.id,
520 createdAt: activity.createdAt,532 createdAt: activity.createdAt,
@@ -529,16 +541,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 8 unmodified lines
529 };541 };
530 const itemType = extractWorkLogItemType(payload);542 const itemType = extractWorkLogItemType(payload);
531 const requestKind = extractWorkLogRequestKind(payload);543 const requestKind = extractWorkLogRequestKind(payload);
532 if (544 if (detail) {
533 !taskDetailAsLabel &&545 entry.detail = detail;
534 payload &&
535 typeof payload.detail === "string" &&
536 payload.detail.length > 0
537 ) {
538 const detail = stripTrailingExitCode(payload.detail).output;
539 if (detail) {
540 entry.detail = detail;
541 }
542 }546 }
543 if (commandPreview.command) {547 if (commandPreview.command) {
544 entry.command = commandPreview.command;548 entry.command = commandPreview.command;
@@ -558,6 +562,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 13 unmodified lines
558 if (requestKind) {562 if (requestKind) {
559 entry.requestKind = requestKind;563 entry.requestKind = requestKind;
560 }564 }
565 if (toolCallId) {
566 entry.toolCallId = toolCallId;
567 }
561 const collapseKey = deriveToolLifecycleCollapseKey(entry);568 const collapseKey = deriveToolLifecycleCollapseKey(entry);
562 if (collapseKey) {569 if (collapseKey) {
563 entry.collapseKey = collapseKey;570 entry.collapseKey = collapseKey;
@@ -593,7 +600,16 @@ function shouldCollapseToolLifecycleEntries( · 29 unmodified lines
593 if (previous.activityKind === "tool.completed") {600 if (previous.activityKind === "tool.completed") {
594 return false;601 return false;
595 }602 }
596 return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey;603 if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
604 return true;
605 }
606 return (
607 previous.toolCallId !== undefined &&
608 next.toolCallId === undefined &&
609 previous.itemType === next.itemType &&
610 normalizeCompactToolLabel(previous.toolTitle ?? previous.label) ===
611 normalizeCompactToolLabel(next.toolTitle ?? next.label)
612 );
597}613}
598 614
599function mergeDerivedWorkLogEntries(615function mergeDerivedWorkLogEntries(
@@ -608,6 +624,7 @@ function mergeDerivedWorkLogEntries( · 8 unmodified lines
608 const itemType = next.itemType ?? previous.itemType;624 const itemType = next.itemType ?? previous.itemType;
609 const requestKind = next.requestKind ?? previous.requestKind;625 const requestKind = next.requestKind ?? previous.requestKind;
610 const collapseKey = next.collapseKey ?? previous.collapseKey;626 const collapseKey = next.collapseKey ?? previous.collapseKey;
627 const toolCallId = next.toolCallId ?? previous.toolCallId;
611 return {628 return {
612 ...previous,629 ...previous,
613 ...next,630 ...next,
@@ -619,6 +636,7 @@ function mergeDerivedWorkLogEntries( · 5 unmodified lines
619 ...(itemType ? { itemType } : {}),636 ...(itemType ? { itemType } : {}),
620 ...(requestKind ? { requestKind } : {}),637 ...(requestKind ? { requestKind } : {}),
621 ...(collapseKey ? { collapseKey } : {}),638 ...(collapseKey ? { collapseKey } : {}),
639 ...(toolCallId ? { toolCallId } : {}),
622 };640 };
623}641}
624 642
@@ -637,6 +655,9 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un · 12 unmodified lines
637 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {655 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
638 return undefined;656 return undefined;
639 }657 }
658 if (entry.toolCallId) {
659 return `tool:${entry.toolCallId}`;
660 }
640 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);661 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
641 const detail = entry.detail?.trim() ?? "";662 const detail = entry.detail?.trim() ?? "";
642 const itemType = entry.itemType ?? "";663 const itemType = entry.itemType ?? "";
@@ -674,6 +695,10 @@ function asTrimmedString(value: unknown): string | null { · 31 unmodified lines
674 return trimmed.length > 0 ? trimmed : null;695 return trimmed.length > 0 ? trimmed : null;
675}696}
676 697
698function asNumber(value: unknown): number | null {
699 return typeof value === "number" && Number.isFinite(value) ? value : null;
700}
701
677function trimMatchingOuterQuotes(value: string): string {702function trimMatchingOuterQuotes(value: string): string {
678 const trimmed = value.trim();703 const trimmed = value.trim();
679 if (704 if (
@@ -857,6 +882,111 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul · 177 unmodified lines
857 return asTrimmedString(payload?.title);882 return asTrimmedString(payload?.title);
858}883}
859 884
885function extractToolCallId(payload: Record<string, unknown> | null): string | null {
886 const data = asRecord(payload?.data);
887 return asTrimmedString(data?.toolCallId);
888}
889
890function normalizeInlinePreview(value: string): string {
891 return value.replace(/\s+/g, " ").trim();
892}
893
894function truncateInlinePreview(value: string, maxLength = 84): string {
895 if (value.length <= maxLength) {
896 return value;
897 }
898 return `${value.slice(0, maxLength - 1).trimEnd()}…`;
899}
900
901function normalizePreviewForComparison(value: string | null | undefined): string | null {
902 const normalized = asTrimmedString(value);
903 if (!normalized) {
904 return null;
905 }
906 return normalizeCompactToolLabel(normalizeInlinePreview(normalized)).toLowerCase();
907}
908
909function summarizeToolTextOutput(value: string): string | null {
910 const lines = value
911 .split(/\r?\n/u)
912 .map((line) => normalizeInlinePreview(line))
913 .filter((line) => line.length > 0);
914 const firstLine = lines.find((line) => line !== "```");
915 if (firstLine) {
916 return truncateInlinePreview(firstLine);
917 }
918 if (lines.length > 1) {
919 return `${lines.length.toLocaleString()} lines`;
920 }
921 return null;
922}
923
924function summarizeToolRawOutput(payload: Record<string, unknown> | null): string | null {
925 const data = asRecord(payload?.data);
926 const rawOutput = asRecord(data?.rawOutput);
927 if (!rawOutput) {
928 return null;
929 }
930
931 const totalFiles = asNumber(rawOutput.totalFiles);
932 if (totalFiles !== null) {
933 const suffix = rawOutput.truncated === true ? "+" : "";
934 return `${totalFiles.toLocaleString()} file${totalFiles === 1 ? "" : "s"}${suffix}`;
935 }
936
937 const content = asTrimmedString(rawOutput.content);
938 if (content) {
939 return summarizeToolTextOutput(content);
940 }
941
942 const stdout = asTrimmedString(rawOutput.stdout);
943 if (stdout) {
944 return summarizeToolTextOutput(stdout);
945 }
946
947 return null;
948}
949
950function isCommandToolDetail(payload: Record<string, unknown> | null, heading: string): boolean {
951 const data = asRecord(payload?.data);
952 const kind = asTrimmedString(data?.kind)?.toLowerCase();
953 const title = asTrimmedString(payload?.title ?? heading)?.toLowerCase();
954 return (
955 extractWorkLogItemType(payload) === "command_execution" ||
956 kind === "execute" ||
957 title === "terminal" ||
958 title === "ran command"
959 );
960}
961
962function extractToolDetail(
963 payload: Record<string, unknown> | null,
964 heading: string,
965): string | null {
966 const rawDetail = asTrimmedString(payload?.detail);
967 const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null;
968 const normalizedHeading = normalizePreviewForComparison(heading);
969 const normalizedDetail = normalizePreviewForComparison(detail);
970
971 if (detail && normalizedHeading !== normalizedDetail) {
972 return detail;
973 }
974
975 if (isCommandToolDetail(payload, heading)) {
976 return null;
977 }
978
979 const rawOutputSummary = summarizeToolRawOutput(payload);
980 if (rawOutputSummary) {
981 const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary);
982 if (normalizedRawOutputSummary !== normalizedHeading) {
983 return rawOutputSummary;
984 }
985 }
986
987 return null;
988}
989
860function stripTrailingExitCode(value: string): {990function stripTrailingExitCode(value: string): {
861 output: string | null;991 output: string | null;
862 exitCode?: number | undefined;992 exitCode?: number | undefined;
apps/web/src/store.ts+4−4
@@ -15,12 +15,12 @@ import type { · 14 unmodified lines
15 OrchestrationThreadShell,15 OrchestrationThreadShell,
16 OrchestrationThreadActivity,16 OrchestrationThreadActivity,
17 ProjectId,17 ProjectId,
18 ProviderKind,
19 ScopedProjectRef,18 ScopedProjectRef,
20 ScopedThreadRef,19 ScopedThreadRef,
21 ThreadId,
22 TurnId,
23} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { ProviderKind } from "@t3tools/contracts";
22import type { ThreadId, TurnId } from "@t3tools/contracts";
23import { Schema } from "effect";
24import { resolveModelSlugForProvider } from "@t3tools/shared/model";24import { resolveModelSlugForProvider } from "@t3tools/shared/model";
25import { create } from "zustand";25import { create } from "zustand";
26import {26import {
@@ -1001,7 +1001,7 @@ function toLegacySessionStatus( · 974 unmodified lines
1001}1001}
1002 1002
1003function toLegacyProvider(providerName: string | null): ProviderKind {1003function toLegacyProvider(providerName: string | null): ProviderKind {
1004 if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {1004 if (Schema.is(ProviderKind)(providerName)) {
1005 return providerName;1005 return providerName;
1006 }1006 }
1007 return "codex";1007 return "codex";
bun.lock+22−0
@@ -24,6 +24,7 @@ · 23 unmodified lines
24 "@t3tools/contracts": "workspace:*",24 "@t3tools/contracts": "workspace:*",
25 "@t3tools/shared": "workspace:*",25 "@t3tools/shared": "workspace:*",
26 "@types/node": "catalog:",26 "@types/node": "catalog:",
27 "effect-acp": "workspace:*",
27 "tsdown": "catalog:",28 "tsdown": "catalog:",
28 "typescript": "catalog:",29 "typescript": "catalog:",
29 "vitest": "catalog:",30 "vitest": "catalog:",
@@ -65,6 +66,7 @@ · 35 unmodified lines
65 "@t3tools/web": "workspace:*",66 "@t3tools/web": "workspace:*",
66 "@types/bun": "catalog:",67 "@types/bun": "catalog:",
67 "@types/node": "catalog:",68 "@types/node": "catalog:",
69 "effect-acp": "workspace:*",
68 "tsdown": "catalog:",70 "tsdown": "catalog:",
69 "typescript": "catalog:",71 "typescript": "catalog:",
70 "vitest": "catalog:",72 "vitest": "catalog:",
@@ -149,6 +151,21 @@ · 78 unmodified lines
149 "vitest": "catalog:",151 "vitest": "catalog:",
150 },152 },
151 },153 },
154 "packages/effect-acp": {
155 "name": "effect-acp",
156 "dependencies": {
157 "effect": "catalog:",
158 },
159 "devDependencies": {
160 "@effect/language-service": "catalog:",
161 "@effect/openapi-generator": "catalog:",
162 "@effect/platform-node": "catalog:",
163 "@effect/vitest": "catalog:",
164 "tsdown": "catalog:",
165 "typescript": "catalog:",
166 "vitest": "catalog:",
167 },
168 },
152 "packages/shared": {169 "packages/shared": {
153 "name": "@t3tools/shared",170 "name": "@t3tools/shared",
154 "version": "0.0.0-alpha.1",171 "version": "0.0.0-alpha.1",
@@ -198,6 +215,7 @@ · 43 unmodified lines
198 "catalog": {215 "catalog": {
199 "@effect/atom-react": "4.0.0-beta.45",216 "@effect/atom-react": "4.0.0-beta.45",
200 "@effect/language-service": "0.84.2",217 "@effect/language-service": "0.84.2",
218 "@effect/openapi-generator": "4.0.0-beta.45",
201 "@effect/platform-bun": "4.0.0-beta.45",219 "@effect/platform-bun": "4.0.0-beta.45",
202 "@effect/platform-node": "4.0.0-beta.45",220 "@effect/platform-node": "4.0.0-beta.45",
203 "@effect/platform-node-shared": "4.0.0-beta.45",221 "@effect/platform-node-shared": "4.0.0-beta.45",
@@ -297,6 +315,8 @@ · 93 unmodified lines
297 315
298 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],316 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
299 317
318 "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.45", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.45", "effect": "^4.0.0-beta.45" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-zT1UPzapV6mZebMuYhoDPQj/FKNn4R+oDgz1hEcQwazal4+lVDyEYGhRl0PgZroy6CP78fAOWNgW7TriGqSlOQ=="],
319
300 "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45" }, "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-dZsbkJ+o4JfKE1OIAlMq8YWhmEfXxtTYennYADK/M/XlOtkFLsc2jkZppDvMznVni6JpFxWc7wd5rUf8jBVP5g=="],320 "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45" }, "peerDependencies": { "effect": "^4.0.0-beta.45" } }, "sha512-dZsbkJ+o4JfKE1OIAlMq8YWhmEfXxtTYennYADK/M/XlOtkFLsc2jkZppDvMznVni6JpFxWc7wd5rUf8jBVP5g=="],
301 321
302 "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.45", "ioredis": "^5.7.0" } }, "sha512-P07NMG4eoy62iLX9szak0hkr7hrwgq5+XMkm7URVug/3zqfZVxEG2kxn9JzVK1lF5WbaVrEKwjt3IkEJxzSPtg=="],322 "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.45", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.45", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.45", "ioredis": "^5.7.0" } }, "sha512-P07NMG4eoy62iLX9szak0hkr7hrwgq5+XMkm7URVug/3zqfZVxEG2kxn9JzVK1lF5WbaVrEKwjt3IkEJxzSPtg=="],
@@ -1061,6 +1081,8 @@ · 758 unmodified lines
1061 1081
1062 "effect": ["effect@4.0.0-beta.45", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-vvNrUWqnzBIW1hRMa+zw0CLRW6HLgdu7hQ6K7PT/rS+UY/73Ma11O+Oi9oc9zwL8KcN37M47UDseAdlF0bGNWw=="],1082 "effect": ["effect@4.0.0-beta.45", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-vvNrUWqnzBIW1hRMa+zw0CLRW6HLgdu7hQ6K7PT/rS+UY/73Ma11O+Oi9oc9zwL8KcN37M47UDseAdlF0bGNWw=="],
1063 1083
1084 "effect-acp": ["effect-acp@workspace:packages/effect-acp"],
1085
1064 "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="],1086 "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="],
1065 1087
1066 "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],1088 "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 "catalog": {10 "catalog": {
11 "effect": "4.0.0-beta.45",11 "effect": "4.0.0-beta.45",
12 "@effect/atom-react": "4.0.0-beta.45",12 "@effect/atom-react": "4.0.0-beta.45",
13 "@effect/openapi-generator": "4.0.0-beta.45",
13 "@effect/platform-bun": "4.0.0-beta.45",14 "@effect/platform-bun": "4.0.0-beta.45",
14 "@effect/platform-node": "4.0.0-beta.45",15 "@effect/platform-node": "4.0.0-beta.45",
15 "@effect/platform-node-shared": "4.0.0-beta.45",16 "@effect/platform-node-shared": "4.0.0-beta.45",
packages/contracts/src/model.ts+36−4
@@ -2,18 +2,28 @@ import { Schema } from "effect"; · 1 unmodified line
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3import type { ProviderKind } from "./orchestration.ts";3import type { ProviderKind } from "./orchestration.ts";
4 4
5export const CodexReasoningEffort = Schema.Literals(["xhigh", "high", "medium", "low"]);5export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
6export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS);
6export type CodexReasoningEffort = typeof CodexReasoningEffort.Type;7export type CodexReasoningEffort = typeof CodexReasoningEffort.Type;
7export const ClaudeAgentEffort = Schema.Literals([8export const CLAUDE_AGENT_EFFORT_OPTIONS = [
8 "low",9 "low",
9 "medium",10 "medium",
10 "high",11 "high",
11 "xhigh",12 "xhigh",
12 "max",13 "max",
13 "ultrathink",14 "ultrathink",
14]);15] as const;
16export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS);
15export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type;17export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type;
16export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeAgentEffort;18export type ClaudeCodeEffort = ClaudeAgentEffort;
19export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const;
20export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS);
21export type CursorReasoningOption = typeof CursorReasoningOption.Type;
22
23export type ProviderReasoningEffort =
24 | CodexReasoningEffort
25 | ClaudeAgentEffort
26 | CursorReasoningOption;
17 27
18export const CodexModelOptions = Schema.Struct({28export const CodexModelOptions = Schema.Struct({
19 reasoningEffort: Schema.optional(CodexReasoningEffort),29 reasoningEffort: Schema.optional(CodexReasoningEffort),
@@ -29,6 +39,13 @@ export const ClaudeModelOptions = Schema.Struct({ · 9 unmodified lines
29});39});
30export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;40export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
31 41
42export const CursorModelOptions = Schema.Struct({
43 reasoning: Schema.optional(CursorReasoningOption),
44 fastMode: Schema.optional(Schema.Boolean),
45 thinking: Schema.optional(Schema.Boolean),
46 contextWindow: Schema.optional(Schema.String),
47});
48export type CursorModelOptions = typeof CursorModelOptions.Type;
32export const OpenCodeModelOptions = Schema.Struct({49export const OpenCodeModelOptions = Schema.Struct({
33 variant: Schema.optional(TrimmedNonEmptyString),50 variant: Schema.optional(TrimmedNonEmptyString),
34 agent: Schema.optional(TrimmedNonEmptyString),51 agent: Schema.optional(TrimmedNonEmptyString),
@@ -38,6 +55,7 @@ export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; · 3 unmodified lines
38export const ProviderModelOptions = Schema.Struct({55export const ProviderModelOptions = Schema.Struct({
39 codex: Schema.optional(CodexModelOptions),56 codex: Schema.optional(CodexModelOptions),
40 claudeAgent: Schema.optional(ClaudeModelOptions),57 claudeAgent: Schema.optional(ClaudeModelOptions),
58 cursor: Schema.optional(CursorModelOptions),
41 opencode: Schema.optional(OpenCodeModelOptions),59 opencode: Schema.optional(OpenCodeModelOptions),
42});60});
43export type ProviderModelOptions = typeof ProviderModelOptions.Type;61export type ProviderModelOptions = typeof ProviderModelOptions.Type;
@@ -70,6 +88,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; · 26 unmodified lines
70export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {88export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
71 codex: "gpt-5.4",89 codex: "gpt-5.4",
72 claudeAgent: "claude-sonnet-4-6",90 claudeAgent: "claude-sonnet-4-6",
91 cursor: "auto",
73 opencode: "openai/gpt-5",92 opencode: "openai/gpt-5",
74};93};
75 94
@@ -79,6 +98,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; · 3 unmodified lines
79export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {98export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
80 codex: "gpt-5.4-mini",99 codex: "gpt-5.4-mini",
81 claudeAgent: "claude-haiku-4-5",100 claudeAgent: "claude-haiku-4-5",
101 cursor: "composer-2",
82 opencode: "openai/gpt-5",102 opencode: "openai/gpt-5",
83};103};
84 104
@@ -107,6 +127,17 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 22 unmodified lines
107 "claude-haiku-4.5": "claude-haiku-4-5",127 "claude-haiku-4.5": "claude-haiku-4-5",
108 "claude-haiku-4-5-20251001": "claude-haiku-4-5",128 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
109 },129 },
130 cursor: {
131 composer: "composer-2",
132 "composer-1.5": "composer-1.5",
133 "composer-1": "composer-1.5",
134 "opus-4.6-thinking": "claude-opus-4-6",
135 "opus-4.6": "claude-opus-4-6",
136 "sonnet-4.6-thinking": "claude-sonnet-4-6",
137 "sonnet-4.6": "claude-sonnet-4-6",
138 "opus-4.5-thinking": "claude-opus-4-5",
139 "opus-4.5": "claude-opus-4-5",
140 },
110 opencode: {},141 opencode: {},
111};142};
112 143
@@ -115,5 +146,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 2 unmodified lines
115export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {146export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {
116 codex: "Codex",147 codex: "Codex",
117 claudeAgent: "Claude",148 claudeAgent: "Claude",
149 cursor: "Cursor",
118 opencode: "OpenCode",150 opencode: "OpenCode",
119};151};
packages/contracts/src/orchestration.ts+14−2
@@ -1,5 +1,10 @@
1import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";1import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";
2import { ClaudeModelOptions, CodexModelOptions, OpenCodeModelOptions } from "./model.ts";2import {
3 ClaudeModelOptions,
4 CodexModelOptions,
5 CursorModelOptions,
6 OpenCodeModelOptions,
7} from "./model.ts";
3import { RepositoryIdentity } from "./environment.ts";8import { RepositoryIdentity } from "./environment.ts";
4import {9import {
5 ApprovalRequestId,10 ApprovalRequestId,
@@ -25,7 +30,7 @@ export const ORCHESTRATION_WS_METHODS = { · 19 unmodified lines
25 subscribeThread: "orchestration.subscribeThread",30 subscribeThread: "orchestration.subscribeThread",
26} as const;31} as const;
27 32
28export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]);33export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]);
29export type ProviderKind = typeof ProviderKind.Type;34export type ProviderKind = typeof ProviderKind.Type;
30export const ProviderApprovalPolicy = Schema.Literals([35export const ProviderApprovalPolicy = Schema.Literals([
31 "untrusted",36 "untrusted",
@@ -57,6 +62,12 @@ export const ClaudeModelSelection = Schema.Struct({ · 25 unmodified lines
57});62});
58export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;63export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
59 64
65export const CursorModelSelection = Schema.Struct({
66 provider: Schema.Literal("cursor"),
67 model: TrimmedNonEmptyString,
68 options: Schema.optionalKey(CursorModelOptions),
69});
70export type CursorModelSelection = typeof CursorModelSelection.Type;
60export const OpenCodeModelSelection = Schema.Struct({71export const OpenCodeModelSelection = Schema.Struct({
61 provider: Schema.Literal("opencode"),72 provider: Schema.Literal("opencode"),
62 model: TrimmedNonEmptyString,73 model: TrimmedNonEmptyString,
@@ -67,6 +78,7 @@ export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; · 4 unmodified lines
67export const ModelSelection = Schema.Union([78export const ModelSelection = Schema.Union([
68 CodexModelSelection,79 CodexModelSelection,
69 ClaudeModelSelection,80 ClaudeModelSelection,
81 CursorModelSelection,
70 OpenCodeModelSelection,82 OpenCodeModelSelection,
71]);83]);
72export type ModelSelection = typeof ModelSelection.Type;84export type ModelSelection = typeof ModelSelection.Type;
packages/contracts/src/provider.test.ts+20−0
@@ -68,6 +68,26 @@ describe("ProviderSessionStartInput", () => { · 67 unmodified lines
68 expect(parsed.modelSelection.options?.fastMode).toBe(true);68 expect(parsed.modelSelection.options?.fastMode).toBe(true);
69 expect(parsed.runtimeMode).toBe("full-access");69 expect(parsed.runtimeMode).toBe("full-access");
70 });70 });
71
72 it("accepts cursor provider", () => {
73 const parsed = decodeProviderSessionStartInput({
74 threadId: "thread-1",
75 provider: "cursor",
76 cwd: "/tmp/workspace",
77 runtimeMode: "full-access",
78 modelSelection: {
79 provider: "cursor",
80 model: "composer-2",
81 options: { fastMode: true },
82 },
83 });
84 expect(parsed.provider).toBe("cursor");
85 expect(parsed.modelSelection?.provider).toBe("cursor");
86 expect(parsed.modelSelection?.model).toBe("composer-2");
87 if (parsed.modelSelection?.provider === "cursor") {
88 expect(parsed.modelSelection.options?.fastMode).toBe(true);
89 }
90 });
71});91});
72 92
73describe("ProviderSendTurnInput", () => {93describe("ProviderSendTurnInput", () => {
packages/contracts/src/providerRuntime.ts+10−8
@@ -17,14 +17,16 @@ import { ProviderKind } from "./orchestration.ts"; · 16 unmodified lines
17const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;17const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
18const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);18const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);
19 19
20const RuntimeEventRawSource = Schema.Literals([20const RuntimeEventRawSource = Schema.Union([
21 "codex.app-server.notification",21 Schema.Literal("codex.app-server.notification"),
22 "codex.app-server.request",22 Schema.Literal("codex.app-server.request"),
23 "codex.eventmsg",23 Schema.Literal("codex.eventmsg"),
24 "claude.sdk.message",24 Schema.Literal("claude.sdk.message"),
25 "claude.sdk.permission",25 Schema.Literal("claude.sdk.permission"),
26 "codex.sdk.thread-event",26 Schema.Literal("codex.sdk.thread-event"),
27 "opencode.sdk.event",27 Schema.Literal("opencode.sdk.event"),
28 Schema.Literal("acp.jsonrpc"),
29 Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]),
28]);30]);
29export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;31export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
30 32
packages/contracts/src/settings.ts+29−0
@@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; · 4 unmodified lines
5import {5import {
6 ClaudeModelOptions,6 ClaudeModelOptions,
7 CodexModelOptions,7 CodexModelOptions,
8 CursorModelOptions,
8 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,9 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
9 OpenCodeModelOptions,10 OpenCodeModelOptions,
10} from "./model.ts";11} from "./model.ts";
@@ -90,6 +91,13 @@ export const ClaudeSettings = Schema.Struct({ · 79 unmodified lines
90});91});
91export type ClaudeSettings = typeof ClaudeSettings.Type;92export type ClaudeSettings = typeof ClaudeSettings.Type;
92 93
94export const CursorSettings = Schema.Struct({
95 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
96 binaryPath: makeBinaryPathSetting("agent"),
97 apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
98 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
99});
100export type CursorSettings = typeof CursorSettings.Type;
93export const OpenCodeSettings = Schema.Struct({101export const OpenCodeSettings = Schema.Struct({
94 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),102 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
95 binaryPath: makeBinaryPathSetting("opencode"),103 binaryPath: makeBinaryPathSetting("opencode"),
@@ -124,6 +132,7 @@ export const ServerSettings = Schema.Struct({ · 28 unmodified lines
124 providers: Schema.Struct({132 providers: Schema.Struct({
125 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),133 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
126 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),134 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
135 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
127 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),136 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
128 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),137 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
129 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),138 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
@@ -167,6 +176,13 @@ const ClaudeModelOptionsPatch = Schema.Struct({ · 37 unmodified lines
167 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),176 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),
168});177});
169 178
179const CursorModelOptionsPatch = Schema.Struct({
180 reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning),
181 fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode),
182 thinking: Schema.optionalKey(CursorModelOptions.fields.thinking),
183 contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow),
184});
185
170const OpenCodeModelOptionsPatch = Schema.Struct({186const OpenCodeModelOptionsPatch = Schema.Struct({
171 variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant),187 variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant),
172 agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent),188 agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent),
@@ -183,6 +199,11 @@ const ModelSelectionPatch = Schema.Union([ · 10 unmodified lines
183 model: Schema.optionalKey(TrimmedNonEmptyString),199 model: Schema.optionalKey(TrimmedNonEmptyString),
184 options: Schema.optionalKey(ClaudeModelOptionsPatch),200 options: Schema.optionalKey(ClaudeModelOptionsPatch),
185 }),201 }),
202 Schema.Struct({
203 provider: Schema.optionalKey(Schema.Literal("cursor")),
204 model: Schema.optionalKey(TrimmedNonEmptyString),
205 options: Schema.optionalKey(CursorModelOptionsPatch),
206 }),
186 Schema.Struct({207 Schema.Struct({
187 provider: Schema.optionalKey(Schema.Literal("opencode")),208 provider: Schema.optionalKey(Schema.Literal("opencode")),
188 model: Schema.optionalKey(TrimmedNonEmptyString),209 model: Schema.optionalKey(TrimmedNonEmptyString),
@@ -204,6 +225,13 @@ const ClaudeSettingsPatch = Schema.Struct({ · 15 unmodified lines
204 launchArgs: Schema.optionalKey(Schema.String),225 launchArgs: Schema.optionalKey(Schema.String),
205});226});
206 227
228const CursorSettingsPatch = Schema.Struct({
229 enabled: Schema.optionalKey(Schema.Boolean),
230 binaryPath: Schema.optionalKey(Schema.String),
231 apiEndpoint: Schema.optionalKey(Schema.String),
232 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
233});
234
207const OpenCodeSettingsPatch = Schema.Struct({235const OpenCodeSettingsPatch = Schema.Struct({
208 enabled: Schema.optionalKey(Schema.Boolean),236 enabled: Schema.optionalKey(Schema.Boolean),
209 binaryPath: Schema.optionalKey(Schema.String),237 binaryPath: Schema.optionalKey(Schema.String),
@@ -227,6 +255,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 17 unmodified lines
227 Schema.Struct({255 Schema.Struct({
228 codex: Schema.optionalKey(CodexSettingsPatch),256 codex: Schema.optionalKey(CodexSettingsPatch),
229 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),257 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
258 cursor: Schema.optionalKey(CursorSettingsPatch),
230 opencode: Schema.optionalKey(OpenCodeSettingsPatch),259 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
231 }),260 }),
232 ),261 ),
packages/effect-acp/package.json+55−0
@@ -0,0 +1,55 @@
1{
2 "name": "effect-acp",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./client": {
7 "types": "./src/client.ts",
8 "import": "./src/client.ts"
9 },
10 "./agent": {
11 "types": "./src/agent.ts",
12 "import": "./src/agent.ts"
13 },
14 "./schema": {
15 "types": "./src/schema.ts",
16 "import": "./src/schema.ts"
17 },
18 "./rpc": {
19 "types": "./src/rpc.ts",
20 "import": "./src/rpc.ts"
21 },
22 "./protocol": {
23 "types": "./src/protocol.ts",
24 "import": "./src/protocol.ts"
25 },
26 "./terminal": {
27 "types": "./src/terminal.ts",
28 "import": "./src/terminal.ts"
29 },
30 "./errors": {
31 "types": "./src/errors.ts",
32 "import": "./src/errors.ts"
33 }
34 },
35 "scripts": {
36 "dev": "tsdown src/client.ts src/agent.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --watch --clean",
37 "build": "tsdown src/client.ts src/agent.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --clean",
38 "prepare": "effect-language-service patch",
39 "typecheck": "tsc --noEmit",
40 "test": "vitest run",
41 "generate": "bun run scripts/generate.ts"
42 },
43 "dependencies": {
44 "effect": "catalog:"
45 },
46 "devDependencies": {
47 "@effect/language-service": "catalog:",
48 "@effect/openapi-generator": "catalog:",
49 "@effect/platform-node": "catalog:",
50 "@effect/vitest": "catalog:",
51 "tsdown": "catalog:",
52 "typescript": "catalog:",
53 "vitest": "catalog:"
54 }
55}
packages/effect-acp/scripts/generate.ts+289−0
@@ -0,0 +1,289 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";
6import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
7import { Command, Flag } from "effect/unstable/cli";
8import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";
9import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10
11const CURRENT_SCHEMA_RELEASE = "v0.11.3";
12
13interface GenerateCommandError {
14 readonly _tag: "GenerateCommandError";
15 readonly message: string;
16}
17
18interface GeneratedPaths {
19 readonly generatedDir: string;
20 readonly upstreamSchemaPath: string;
21 readonly upstreamMetaPath: string;
22 readonly schemaOutputPath: string;
23 readonly metaOutputPath: string;
24}
25
26const MetaJsonSchema = Schema.Struct({
27 agentMethods: Schema.Record(Schema.String, Schema.String),
28 clientMethods: Schema.Record(Schema.String, Schema.String),
29 version: Schema.Union([Schema.Number, Schema.String]),
30});
31
32const UpstreamJsonSchemaSchema = Schema.Struct({
33 $defs: Schema.Record(Schema.String, Schema.Json),
34});
35
36const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () {
37 const path = yield* Path.Path;
38 const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated");
39 return {
40 generatedDir,
41 upstreamSchemaPath: path.join(generatedDir, "upstream-schema.json"),
42 upstreamMetaPath: path.join(generatedDir, "upstream-meta.json"),
43 schemaOutputPath: path.join(generatedDir, "schema.gen.ts"),
44 metaOutputPath: path.join(generatedDir, "meta.gen.ts"),
45 } satisfies GeneratedPaths;
46});
47
48const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const { generatedDir } = yield* getGeneratedPaths();
51
52 yield* fs.makeDirectory(generatedDir, { recursive: true });
53});
54
55const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) {
56 const fs = yield* FileSystem.FileSystem;
57 const path = yield* Path.Path;
58
59 yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
60
61 const text = yield* HttpClient.get(url).pipe(
62 Effect.flatMap(HttpClientResponse.filterStatusOk),
63 Effect.flatMap((response) => response.text),
64 );
65
66 yield* fs.writeFileString(outputPath, text);
67});
68
69const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) {
70 const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths();
71 const fs = yield* FileSystem.FileSystem;
72 const baseUrl = `https://github.com/agentclientprotocol/agent-client-protocol/releases/download/${tag}`;
73
74 yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath);
75 yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath);
76
77 yield* Effect.addFinalizer(() =>
78 Effect.all([fs.remove(upstreamSchemaPath), fs.remove(upstreamMetaPath)]).pipe(
79 Effect.ignoreCause({ log: true }),
80 ),
81 );
82});
83
84const readJsonFile = Effect.fn("readJsonFile")(function* <
85 S extends Schema.Top & { readonly DecodingServices: never },
86>(schema: S, filePath: string) {
87 const fs = yield* FileSystem.FileSystem;
88 const raw = yield* fs.readFileString(filePath);
89 return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(raw);
90});
91
92const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* (
93 schemaOutput: string,
94 metaOutput: string,
95) {
96 const fs = yield* FileSystem.FileSystem;
97 const { metaOutputPath, schemaOutputPath } = yield* getGeneratedPaths();
98
99 yield* fs.writeFileString(schemaOutputPath, schemaOutput);
100 yield* fs.writeFileString(metaOutputPath, metaOutput);
101});
102
103function collectSchemaEntries(
104 chunk: string,
105): ReadonlyArray<{ readonly name: string; readonly code: string }> {
106 const lines = chunk
107 .split("\n")
108 .map((line) => line.trim())
109 .filter((line) => line.length > 0 && !line.startsWith("//"));
110 const entries: Array<{ name: string; code: string }> = [];
111
112 for (let index = 0; index < lines.length; index += 1) {
113 const typeLine = lines[index];
114 if (!typeLine?.startsWith("export type ")) {
115 continue;
116 }
117
118 const constLine = lines[index + 1];
119 if (!constLine?.startsWith("export const ")) {
120 throw new Error(`Malformed generator output near: ${typeLine}`);
121 }
122
123 const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine);
124 if (!match?.[1]) {
125 throw new Error(`Could not extract schema name from: ${typeLine}`);
126 }
127
128 entries.push({
129 name: match[1],
130 code: `${typeLine}\n${constLine}`,
131 });
132 index += 1;
133 }
134
135 return entries;
136}
137
138function normalizeNullableTypes(value: typeof Schema.Json.Type): typeof Schema.Json.Type {
139 if (Array.isArray(value)) {
140 return value.map(normalizeNullableTypes);
141 }
142 if (value === null || typeof value !== "object") {
143 return value;
144 }
145
146 const normalizedEntries = Object.entries(value).map(([key, child]) => [
147 key,
148 normalizeNullableTypes(child),
149 ]);
150 const normalizedObject = Object.fromEntries(normalizedEntries) as Record<
151 string,
152 typeof Schema.Json.Type
153 >;
154 const typeValue = normalizedObject.type;
155
156 if (!Array.isArray(typeValue)) {
157 return normalizedObject;
158 }
159
160 const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string");
161 if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) {
162 return normalizedObject;
163 }
164
165 const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null");
166 if (nonNullTypes.length !== 1) {
167 return normalizedObject;
168 }
169 const nonNullType = nonNullTypes[0]!;
170
171 const nextObject: Record<string, typeof Schema.Json.Type> = {};
172 for (const [key, child] of Object.entries(normalizedObject)) {
173 if (key !== "type") {
174 nextObject[key] = child;
175 }
176 }
177
178 return {
179 anyOf: [
180 {
181 ...nextObject,
182 type: nonNullType,
183 },
184 { type: "null" },
185 ],
186 };
187}
188
189const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: boolean) {
190 const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths();
191
192 yield* ensureGeneratedDir();
193
194 if (!skipDownload) {
195 yield* Effect.log(`Downloading ACP schema assets for ${CURRENT_SCHEMA_RELEASE}`);
196 yield* downloadSchemas(CURRENT_SCHEMA_RELEASE);
197 }
198
199 const upstreamSchema = yield* readJsonFile(UpstreamJsonSchemaSchema, upstreamSchemaPath);
200 const upstreamMeta = yield* readJsonFile(MetaJsonSchema, upstreamMetaPath);
201 const normalizedDefinitions = Object.fromEntries(
202 Object.entries(upstreamSchema.$defs).map(([name, schema]) => [
203 name,
204 normalizeNullableTypes(schema),
205 ]),
206 );
207
208 const sortedEntries = Object.entries(normalizedDefinitions).toSorted(([left], [right]) =>
209 left.localeCompare(right),
210 );
211 const generatedEntries = new Map<string, string>();
212 const generator = makeJsonSchemaGenerator();
213
214 for (const [name, schema] of sortedEntries) {
215 generator.addSchema(name, schema as never);
216 }
217
218 const output = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim();
219 if (output.length > 0) {
220 for (const entry of collectSchemaEntries(output)) {
221 if (!generatedEntries.has(entry.name)) {
222 generatedEntries.set(entry.name, entry.code);
223 }
224 }
225 }
226
227 const prelude = [
228 `// This file is generated by the effect-acp package. Do not edit manually.`,
229 `// Current ACP schema release: ${CURRENT_SCHEMA_RELEASE}`,
230 "",
231 ];
232
233 const schemaOutput = [
234 ...prelude,
235 'import * as Schema from "effect/Schema";',
236 "",
237 [...generatedEntries.values()].join("\n\n"),
238 "",
239 ].join("\n");
240
241 const metaOutput = [
242 ...prelude,
243 `export const AGENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.agentMethods))(upstreamMeta.agentMethods)} as const;`,
244 "",
245 `export const CLIENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.clientMethods))(upstreamMeta.clientMethods)} as const;`,
246 "",
247 `export const PROTOCOL_VERSION = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.version))(upstreamMeta.version)} as const;`,
248 "",
249 ].join("\n");
250
251 yield* writeGeneratedFiles(schemaOutput, metaOutput);
252 yield* Effect.log(
253 `Generated ${generatedEntries.size} ACP schemas from ${CURRENT_SCHEMA_RELEASE}`,
254 );
255
256 const { generatedDir } = yield* getGeneratedPaths();
257 yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe(
258 Effect.flatMap((spawner) => spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))),
259 Effect.flatMap((child) => child.exitCode),
260 Effect.tap((code) =>
261 code === 0
262 ? Effect.void
263 : Effect.fail<GenerateCommandError>({
264 _tag: "GenerateCommandError",
265 message: `oxfmt failed with exit code ${code}`,
266 }),
267 ),
268 );
269});
270
271const generateCommand = Command.make(
272 "generate",
273 {
274 skipDownload: Flag.boolean("skip-download").pipe(Flag.withDefault(false)),
275 },
276 ({ skipDownload }) => generateSchemas(skipDownload),
277).pipe(Command.withDescription("Generate Effect ACP schemas from the pinned ACP release assets."));
278
279const runtimeLayer = Layer.mergeAll(
280 Logger.layer([Logger.consolePretty()]),
281 NodeServices.layer,
282 FetchHttpClient.layer,
283);
284
285Command.run(generateCommand, { version: "0.0.0" }).pipe(
286 Effect.scoped,
287 Effect.provide(runtimeLayer),
288 NodeRuntime.runMain,
289);
packages/effect-acp/src/_generated/meta.gen.ts+35−0
@@ -0,0 +1,35 @@
1// This file is generated by the effect-acp package. Do not edit manually.
2// Current ACP schema release: v0.11.3
3
4export const AGENT_METHODS = {
5 authenticate: "authenticate",
6 initialize: "initialize",
7 logout: "logout",
8 session_cancel: "session/cancel",
9 session_close: "session/close",
10 session_fork: "session/fork",
11 session_list: "session/list",
12 session_load: "session/load",
13 session_new: "session/new",
14 session_prompt: "session/prompt",
15 session_resume: "session/resume",
16 session_set_config_option: "session/set_config_option",
17 session_set_mode: "session/set_mode",
18 session_set_model: "session/set_model",
19} as const;
20
21export const CLIENT_METHODS = {
22 fs_read_text_file: "fs/read_text_file",
23 fs_write_text_file: "fs/write_text_file",
24 session_elicitation: "session/elicitation",
25 session_elicitation_complete: "session/elicitation/complete",
26 session_request_permission: "session/request_permission",
27 session_update: "session/update",
28 terminal_create: "terminal/create",
29 terminal_kill: "terminal/kill",
30 terminal_output: "terminal/output",
31 terminal_release: "terminal/release",
32 terminal_wait_for_exit: "terminal/wait_for_exit",
33} as const;
34
35export const PROTOCOL_VERSION = 1 as const;
packages/effect-acp/src/_generated/schema.gen.ts+10375−0
@@ -0,0 +1,10375 @@
1// This file is generated by the effect-acp package. Do not edit manually.
2// Current ACP schema release: v0.11.3
3
4import * as Schema from "effect/Schema";
5
6export type AuthEnvVar = {
7 readonly _meta?: { readonly [x: string]: unknown } | null;
8 readonly label?: string | null;
9 readonly name: string;
10 readonly optional?: boolean;
11 readonly secret?: boolean;
12};
13export const AuthEnvVar = Schema.Struct({
14 _meta: Schema.optionalKey(
15 Schema.Union([
16 Schema.Record(Schema.String, Schema.Unknown).annotate({
17 description:
18 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
19 }),
20 Schema.Null,
21 ]),
22 ),
23 label: Schema.optionalKey(
24 Schema.Union([
25 Schema.String.annotate({
26 description: "Human-readable label for this variable, displayed in client UI.",
27 }),
28 Schema.Null,
29 ]),
30 ),
31 name: Schema.String.annotate({
32 description: 'The environment variable name (e.g. `"OPENAI_API_KEY"`).',
33 }),
34 optional: Schema.optionalKey(
35 Schema.Boolean.annotate({
36 description: "Whether this variable is optional.\n\nDefaults to `false`.",
37 default: false,
38 }),
39 ),
40 secret: Schema.optionalKey(
41 Schema.Boolean.annotate({
42 description:
43 "Whether this value is a secret (e.g. API key, token).\nClients should use a password-style input for secret vars.\n\nDefaults to `true`.",
44 default: true,
45 }),
46 ),
47}).annotate({
48 description:
49 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nDescribes a single environment variable for an [`AuthMethodEnvVar`] authentication method.",
50});
51
52export type AvailableCommandInput = {
53 readonly _meta?: { readonly [x: string]: unknown } | null;
54 readonly hint: string;
55};
56export const AvailableCommandInput = Schema.Union([
57 Schema.Struct({
58 _meta: Schema.optionalKey(
59 Schema.Union([
60 Schema.Record(Schema.String, Schema.Unknown).annotate({
61 description:
62 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
63 }),
64 Schema.Null,
65 ]),
66 ),
67 hint: Schema.String.annotate({
68 description: "A hint to display when the input hasn't been provided yet",
69 }),
70 }).annotate({
71 title: "unstructured",
72 description: "All text that was typed after the command name is provided as input.",
73 }),
74]).annotate({ description: "The input specification for a command." });
75
76export type Cost = { readonly amount: number; readonly currency: string };
77export const Cost = Schema.Struct({
78 amount: Schema.Number.annotate({
79 description: "Total cumulative cost for session.",
80 format: "double",
81 }).check(Schema.isFinite()),
82 currency: Schema.String.annotate({ description: 'ISO 4217 currency code (e.g., "USD", "EUR").' }),
83}).annotate({
84 description:
85 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCost information for a session.",
86});
87
88export type ElicitationContentValue = string | number | number | boolean | ReadonlyArray<string>;
89export const ElicitationContentValue = Schema.Union([
90 Schema.String.annotate({ title: "String" }),
91 Schema.Number.annotate({ title: "Integer", format: "int64" }).check(Schema.isInt()),
92 Schema.Number.annotate({ title: "Number", format: "double" }).check(Schema.isFinite()),
93 Schema.Boolean.annotate({ title: "Boolean" }),
94 Schema.Array(Schema.String).annotate({ title: "StringArray" }),
95]);
96
97export type ElicitationFormCapabilities = {
98 readonly _meta?: { readonly [x: string]: unknown } | null;
99};
100export const ElicitationFormCapabilities = Schema.Struct({
101 _meta: Schema.optionalKey(
102 Schema.Union([
103 Schema.Record(Schema.String, Schema.Unknown).annotate({
104 description:
105 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
106 }),
107 Schema.Null,
108 ]),
109 ),
110}).annotate({
111 description:
112 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation capabilities.",
113});
114
115export type ElicitationUrlCapabilities = {
116 readonly _meta?: { readonly [x: string]: unknown } | null;
117};
118export const ElicitationUrlCapabilities = Schema.Struct({
119 _meta: Schema.optionalKey(
120 Schema.Union([
121 Schema.Record(Schema.String, Schema.Unknown).annotate({
122 description:
123 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
124 }),
125 Schema.Null,
126 ]),
127 ),
128}).annotate({
129 description:
130 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation capabilities.",
131});
132
133export type EmbeddedResourceResource =
134 | {
135 readonly _meta?: { readonly [x: string]: unknown } | null;
136 readonly mimeType?: string | null;
137 readonly text: string;
138 readonly uri: string;
139 }
140 | {
141 readonly _meta?: { readonly [x: string]: unknown } | null;
142 readonly blob: string;
143 readonly mimeType?: string | null;
144 readonly uri: string;
145 };
146export const EmbeddedResourceResource = Schema.Union([
147 Schema.Struct({
148 _meta: Schema.optionalKey(
149 Schema.Union([
150 Schema.Record(Schema.String, Schema.Unknown).annotate({
151 description:
152 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
153 }),
154 Schema.Null,
155 ]),
156 ),
157 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
158 text: Schema.String,
159 uri: Schema.String,
160 }).annotate({ title: "TextResourceContents", description: "Text-based resource contents." }),
161 Schema.Struct({
162 _meta: Schema.optionalKey(
163 Schema.Union([
164 Schema.Record(Schema.String, Schema.Unknown).annotate({
165 description:
166 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
167 }),
168 Schema.Null,
169 ]),
170 ),
171 blob: Schema.String,
172 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
173 uri: Schema.String,
174 }).annotate({ title: "BlobResourceContents", description: "Binary resource contents." }),
175]).annotate({ description: "Resource content that can be embedded in a message." });
176
177export type EnumOption = { readonly const: string; readonly title: string };
178export const EnumOption = Schema.Struct({
179 const: Schema.String.annotate({ description: "The constant value for this option." }),
180 title: Schema.String.annotate({ description: "Human-readable title for this option." }),
181}).annotate({ description: "A titled enum option with a const value and human-readable title." });
182
183export type EnvVariable = {
184 readonly _meta?: { readonly [x: string]: unknown } | null;
185 readonly name: string;
186 readonly value: string;
187};
188export const EnvVariable = Schema.Struct({
189 _meta: Schema.optionalKey(
190 Schema.Union([
191 Schema.Record(Schema.String, Schema.Unknown).annotate({
192 description:
193 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
194 }),
195 Schema.Null,
196 ]),
197 ),
198 name: Schema.String.annotate({ description: "The name of the environment variable." }),
199 value: Schema.String.annotate({ description: "The value to set for the environment variable." }),
200}).annotate({ description: "An environment variable to set when launching an MCP server." });
201
202export type Error = {
203 readonly code:
204 | -32700
205 | -32600
206 | -32601
207 | -32602
208 | -32603
209 | -32800
210 | -32000
211 | -32002
212 | -32042
213 | number;
214 readonly data?: unknown;
215 readonly message: string;
216};
217export const Error = Schema.Struct({
218 code: Schema.Union([
219 Schema.Literal(-32700).annotate({
220 title: "Parse error",
221 description:
222 "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.",
223 format: "int32",
224 }),
225 Schema.Literal(-32600).annotate({
226 title: "Invalid request",
227 description: "**Invalid request**: The JSON sent is not a valid Request object.",
228 format: "int32",
229 }),
230 Schema.Literal(-32601).annotate({
231 title: "Method not found",
232 description: "**Method not found**: The method does not exist or is not available.",
233 format: "int32",
234 }),
235 Schema.Literal(-32602).annotate({
236 title: "Invalid params",
237 description: "**Invalid params**: Invalid method parameter(s).",
238 format: "int32",
239 }),
240 Schema.Literal(-32603).annotate({
241 title: "Internal error",
242 description:
243 "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.",
244 format: "int32",
245 }),
246 Schema.Literal(-32800).annotate({
247 title: "Request cancelled",
248 description:
249 "**Request cancelled**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nExecution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.",
250 format: "int32",
251 }),
252 Schema.Literal(-32000).annotate({
253 title: "Authentication required",
254 description:
255 "**Authentication required**: Authentication is required before this operation can be performed.",
256 format: "int32",
257 }),
258 Schema.Literal(-32002).annotate({
259 title: "Resource not found",
260 description: "**Resource not found**: A given resource, such as a file, was not found.",
261 format: "int32",
262 }),
263 Schema.Literal(-32042).annotate({
264 title: "URL elicitation required",
265 description:
266 "**URL elicitation required**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe agent requires user input via a URL-based elicitation before it can proceed.",
267 format: "int32",
268 }),
269 Schema.Number.annotate({
270 title: "Other",
271 description: "Other undefined error code.",
272 format: "int32",
273 }).check(Schema.isInt()),
274 ]).annotate({
275 description:
276 "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors.",
277 }),
278 data: Schema.optionalKey(
279 Schema.Unknown.annotate({
280 description:
281 "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details.",
282 }),
283 ),
284 message: Schema.String.annotate({
285 description:
286 "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.",
287 }),
288}).annotate({
289 description:
290 "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)",
291});
292
293export type HttpHeader = {
294 readonly _meta?: { readonly [x: string]: unknown } | null;
295 readonly name: string;
296 readonly value: string;
297};
298export const HttpHeader = Schema.Struct({
299 _meta: Schema.optionalKey(
300 Schema.Union([
301 Schema.Record(Schema.String, Schema.Unknown).annotate({
302 description:
303 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
304 }),
305 Schema.Null,
306 ]),
307 ),
308 name: Schema.String.annotate({ description: "The name of the HTTP header." }),
309 value: Schema.String.annotate({ description: "The value to set for the HTTP header." }),
310}).annotate({ description: "An HTTP header to set when making requests to the MCP server." });
311
312export type Implementation = {
313 readonly _meta?: { readonly [x: string]: unknown } | null;
314 readonly name: string;
315 readonly title?: string | null;
316 readonly version: string;
317};
318export const Implementation = Schema.Struct({
319 _meta: Schema.optionalKey(
320 Schema.Union([
321 Schema.Record(Schema.String, Schema.Unknown).annotate({
322 description:
323 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
324 }),
325 Schema.Null,
326 ]),
327 ),
328 name: Schema.String.annotate({
329 description:
330 "Intended for programmatic or logical use, but can be used as a display\nname fallback if title isn’t present.",
331 }),
332 title: Schema.optionalKey(
333 Schema.Union([
334 Schema.String.annotate({
335 description:
336 "Intended for UI and end-user contexts — optimized to be human-readable\nand easily understood.\n\nIf not provided, the name should be used for display.",
337 }),
338 Schema.Null,
339 ]),
340 ),
341 version: Schema.String.annotate({
342 description:
343 'Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. "1.0.0").',
344 }),
345}).annotate({
346 description:
347 "Metadata about the implementation of the client or agent.\nDescribes the name and version of an MCP implementation, with an optional\ntitle for UI representation.",
348});
349
350export type LogoutCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
351export const LogoutCapabilities = Schema.Struct({
352 _meta: Schema.optionalKey(
353 Schema.Union([
354 Schema.Record(Schema.String, Schema.Unknown).annotate({
355 description:
356 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
357 }),
358 Schema.Null,
359 ]),
360 ),
361}).annotate({
362 description:
363 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nLogout capabilities supported by the agent.\n\nBy supplying `{}` it means that the agent supports the logout method.",
364});
365
366export type ModelInfo = {
367 readonly _meta?: { readonly [x: string]: unknown } | null;
368 readonly description?: string | null;
369 readonly modelId: string;
370 readonly name: string;
371};
372export const ModelInfo = Schema.Struct({
373 _meta: Schema.optionalKey(
374 Schema.Union([
375 Schema.Record(Schema.String, Schema.Unknown).annotate({
376 description:
377 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
378 }),
379 Schema.Null,
380 ]),
381 ),
382 description: Schema.optionalKey(
383 Schema.Union([
384 Schema.String.annotate({ description: "Optional description of the model." }),
385 Schema.Null,
386 ]),
387 ),
388 modelId: Schema.String.annotate({
389 description:
390 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.",
391 }),
392 name: Schema.String.annotate({ description: "Human-readable name of the model." }),
393}).annotate({
394 description:
395 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInformation about a selectable model.",
396});
397
398export type PermissionOption = {
399 readonly _meta?: { readonly [x: string]: unknown } | null;
400 readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
401 readonly name: string;
402 readonly optionId: string;
403};
404export const PermissionOption = Schema.Struct({
405 _meta: Schema.optionalKey(
406 Schema.Union([
407 Schema.Record(Schema.String, Schema.Unknown).annotate({
408 description:
409 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
410 }),
411 Schema.Null,
412 ]),
413 ),
414 kind: Schema.Literals(["allow_once", "allow_always", "reject_once", "reject_always"]).annotate({
415 description:
416 "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.",
417 }),
418 name: Schema.String.annotate({ description: "Human-readable label to display to the user." }),
419 optionId: Schema.String.annotate({ description: "Unique identifier for a permission option." }),
420}).annotate({ description: "An option presented to the user when requesting permission." });
421
422export type PlanEntry = {
423 readonly _meta?: { readonly [x: string]: unknown } | null;
424 readonly content: string;
425 readonly priority: "high" | "medium" | "low";
426 readonly status: "pending" | "in_progress" | "completed";
427};
428export const PlanEntry = Schema.Struct({
429 _meta: Schema.optionalKey(
430 Schema.Union([
431 Schema.Record(Schema.String, Schema.Unknown).annotate({
432 description:
433 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
434 }),
435 Schema.Null,
436 ]),
437 ),
438 content: Schema.String.annotate({
439 description: "Human-readable description of what this task aims to accomplish.",
440 }),
441 priority: Schema.Literals(["high", "medium", "low"]).annotate({
442 description:
443 "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)",
444 }),
445 status: Schema.Literals(["pending", "in_progress", "completed"]).annotate({
446 description:
447 "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)",
448 }),
449}).annotate({
450 description:
451 "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)",
452});
453
454export type RequestId = null | number | string;
455export const RequestId = Schema.Union([
456 Schema.Null.annotate({ title: "Null" }),
457 Schema.Number.annotate({ title: "Number", format: "int64" }).check(Schema.isInt()),
458 Schema.String.annotate({ title: "Str" }),
459]).annotate({
460 description:
461 "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.",
462});
463
464export type Role = "assistant" | "user";
465export const Role = Schema.Literals(["assistant", "user"]).annotate({
466 description: "The sender or recipient of messages and data in a conversation.",
467});
468
469export type SessionCloseCapabilities = {
470 readonly _meta?: { readonly [x: string]: unknown } | null;
471};
472export const SessionCloseCapabilities = Schema.Struct({
473 _meta: Schema.optionalKey(
474 Schema.Union([
475 Schema.Record(Schema.String, Schema.Unknown).annotate({
476 description:
477 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
478 }),
479 Schema.Null,
480 ]),
481 ),
482}).annotate({
483 description:
484 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/close` method.\n\nBy supplying `{}` it means that the agent supports closing of sessions.",
485});
486
487export type SessionConfigOptionCategory = "mode" | "model" | "thought_level" | string;
488export const SessionConfigOptionCategory = Schema.Union([
489 Schema.Literal("mode").annotate({ description: "Session mode selector." }),
490 Schema.Literal("model").annotate({ description: "Model selector." }),
491 Schema.Literal("thought_level").annotate({ description: "Thought/reasoning level selector." }),
492 Schema.String.annotate({ title: "other", description: "Unknown / uncategorized selector." }),
493]).annotate({
494 description:
495 "Semantic category for a session configuration option.\n\nThis is intended to help Clients distinguish broadly common selectors (e.g. model selector vs\nsession mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons,\nplacement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown\ncategories gracefully.\n\nCategory names beginning with `_` are free for custom use, like other ACP extension methods.\nCategory names that do not begin with `_` are reserved for the ACP spec.",
496});
497
498export type SessionConfigSelectOption = {
499 readonly _meta?: { readonly [x: string]: unknown } | null;
500 readonly description?: string | null;
501 readonly name: string;
502 readonly value: string;
503};
504export const SessionConfigSelectOption = Schema.Struct({
505 _meta: Schema.optionalKey(
506 Schema.Union([
507 Schema.Record(Schema.String, Schema.Unknown).annotate({
508 description:
509 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
510 }),
511 Schema.Null,
512 ]),
513 ),
514 description: Schema.optionalKey(
515 Schema.Union([
516 Schema.String.annotate({ description: "Optional description for this option value." }),
517 Schema.Null,
518 ]),
519 ),
520 name: Schema.String.annotate({ description: "Human-readable label for this option value." }),
521 value: Schema.String.annotate({
522 description: "Unique identifier for a session configuration option value.",
523 }),
524}).annotate({ description: "A possible value for a session configuration option." });
525
526export type SessionForkCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
527export const SessionForkCapabilities = Schema.Struct({
528 _meta: Schema.optionalKey(
529 Schema.Union([
530 Schema.Record(Schema.String, Schema.Unknown).annotate({
531 description:
532 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
533 }),
534 Schema.Null,
535 ]),
536 ),
537}).annotate({
538 description:
539 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/fork` method.\n\nBy supplying `{}` it means that the agent supports forking of sessions.",
540});
541
542export type SessionInfo = {
543 readonly _meta?: { readonly [x: string]: unknown } | null;
544 readonly cwd: string;
545 readonly sessionId: string;
546 readonly title?: string | null;
547 readonly updatedAt?: string | null;
548};
549export const SessionInfo = Schema.Struct({
550 _meta: Schema.optionalKey(
551 Schema.Union([
552 Schema.Record(Schema.String, Schema.Unknown).annotate({
553 description:
554 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
555 }),
556 Schema.Null,
557 ]),
558 ),
559 cwd: Schema.String.annotate({
560 description: "The working directory for this session. Must be an absolute path.",
561 }),
562 sessionId: Schema.String.annotate({
563 description:
564 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
565 }),
566 title: Schema.optionalKey(
567 Schema.Union([
568 Schema.String.annotate({ description: "Human-readable title for the session" }),
569 Schema.Null,
570 ]),
571 ),
572 updatedAt: Schema.optionalKey(
573 Schema.Union([
574 Schema.String.annotate({ description: "ISO 8601 timestamp of last activity" }),
575 Schema.Null,
576 ]),
577 ),
578}).annotate({ description: "Information about a session returned by session/list" });
579
580export type SessionListCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
581export const SessionListCapabilities = Schema.Struct({
582 _meta: Schema.optionalKey(
583 Schema.Union([
584 Schema.Record(Schema.String, Schema.Unknown).annotate({
585 description:
586 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
587 }),
588 Schema.Null,
589 ]),
590 ),
591}).annotate({
592 description:
593 "Capabilities for the `session/list` method.\n\nBy supplying `{}` it means that the agent supports listing of sessions.",
594});
595
596export type SessionModeId = string;
597export const SessionModeId = Schema.String.annotate({
598 description: "Unique identifier for a Session Mode.",
599});
600
601export type SessionResumeCapabilities = {
602 readonly _meta?: { readonly [x: string]: unknown } | null;
603};
604export const SessionResumeCapabilities = Schema.Struct({
605 _meta: Schema.optionalKey(
606 Schema.Union([
607 Schema.Record(Schema.String, Schema.Unknown).annotate({
608 description:
609 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
610 }),
611 Schema.Null,
612 ]),
613 ),
614}).annotate({
615 description:
616 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/resume` method.\n\nBy supplying `{}` it means that the agent supports resuming of sessions.",
617});
618
619export type StringFormat = "email" | "uri" | "date" | "date-time";
620export const StringFormat = Schema.Literals(["email", "uri", "date", "date-time"]).annotate({
621 description: "String format types for string properties in elicitation schemas.",
622});
623
624export type TerminalExitStatus = {
625 readonly _meta?: { readonly [x: string]: unknown } | null;
626 readonly exitCode?: number | null;
627 readonly signal?: string | null;
628};
629export const TerminalExitStatus = Schema.Struct({
630 _meta: Schema.optionalKey(
631 Schema.Union([
632 Schema.Record(Schema.String, Schema.Unknown).annotate({
633 description:
634 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
635 }),
636 Schema.Null,
637 ]),
638 ),
639 exitCode: Schema.optionalKey(
640 Schema.Union([
641 Schema.Number.annotate({
642 description: "The process exit code (may be null if terminated by signal).",
643 format: "uint32",
644 })
645 .check(Schema.isInt())
646 .check(Schema.isGreaterThanOrEqualTo(0)),
647 Schema.Null,
648 ]),
649 ),
650 signal: Schema.optionalKey(
651 Schema.Union([
652 Schema.String.annotate({
653 description: "The signal that terminated the process (may be null if exited normally).",
654 }),
655 Schema.Null,
656 ]),
657 ),
658}).annotate({ description: "Exit status of a terminal command." });
659
660export type ToolCallLocation = {
661 readonly _meta?: { readonly [x: string]: unknown } | null;
662 readonly line?: number | null;
663 readonly path: string;
664};
665export const ToolCallLocation = Schema.Struct({
666 _meta: Schema.optionalKey(
667 Schema.Union([
668 Schema.Record(Schema.String, Schema.Unknown).annotate({
669 description:
670 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
671 }),
672 Schema.Null,
673 ]),
674 ),
675 line: Schema.optionalKey(
676 Schema.Union([
677 Schema.Number.annotate({
678 description: "Optional line number within the file.",
679 format: "uint32",
680 })
681 .check(Schema.isInt())
682 .check(Schema.isGreaterThanOrEqualTo(0)),
683 Schema.Null,
684 ]),
685 ),
686 path: Schema.String.annotate({ description: "The file path being accessed or modified." }),
687}).annotate({
688 description:
689 'A file location being accessed or modified by a tool.\n\nEnables clients to implement "follow-along" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)',
690});
691
692export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
693export const ToolCallStatus = Schema.Literals([
694 "pending",
695 "in_progress",
696 "completed",
697 "failed",
698]).annotate({
699 description:
700 "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)",
701});
702
703export type ToolKind =
704 | "read"
705 | "edit"
706 | "delete"
707 | "move"
708 | "search"
709 | "execute"
710 | "think"
711 | "fetch"
712 | "switch_mode"
713 | "other";
714export const ToolKind = Schema.Literals([
715 "read",
716 "edit",
717 "delete",
718 "move",
719 "search",
720 "execute",
721 "think",
722 "fetch",
723 "switch_mode",
724 "other",
725]).annotate({
726 description:
727 "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)",
728});
729
730export type Usage = {
731 readonly cachedReadTokens?: number | null;
732 readonly cachedWriteTokens?: number | null;
733 readonly inputTokens: number;
734 readonly outputTokens: number;
735 readonly thoughtTokens?: number | null;
736 readonly totalTokens: number;
737};
738export const Usage = Schema.Struct({
739 cachedReadTokens: Schema.optionalKey(
740 Schema.Union([
741 Schema.Number.annotate({ description: "Total cache read tokens.", format: "uint64" })
742 .check(Schema.isInt())
743 .check(Schema.isGreaterThanOrEqualTo(0)),
744 Schema.Null,
745 ]),
746 ),
747 cachedWriteTokens: Schema.optionalKey(
748 Schema.Union([
749 Schema.Number.annotate({ description: "Total cache write tokens.", format: "uint64" })
750 .check(Schema.isInt())
751 .check(Schema.isGreaterThanOrEqualTo(0)),
752 Schema.Null,
753 ]),
754 ),
755 inputTokens: Schema.Number.annotate({
756 description: "Total input tokens across all turns.",
757 format: "uint64",
758 })
759 .check(Schema.isInt())
760 .check(Schema.isGreaterThanOrEqualTo(0)),
761 outputTokens: Schema.Number.annotate({
762 description: "Total output tokens across all turns.",
763 format: "uint64",
764 })
765 .check(Schema.isInt())
766 .check(Schema.isGreaterThanOrEqualTo(0)),
767 thoughtTokens: Schema.optionalKey(
768 Schema.Union([
769 Schema.Number.annotate({ description: "Total thought/reasoning tokens", format: "uint64" })
770 .check(Schema.isInt())
771 .check(Schema.isGreaterThanOrEqualTo(0)),
772 Schema.Null,
773 ]),
774 ),
775 totalTokens: Schema.Number.annotate({
776 description: "Sum of all token types across session.",
777 format: "uint64",
778 })
779 .check(Schema.isInt())
780 .check(Schema.isGreaterThanOrEqualTo(0)),
781}).annotate({
782 description:
783 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nToken usage information for a prompt turn.",
784});
785
786export type AuthMethod =
787 | {
788 readonly type: "env_var";
789 readonly _meta?: { readonly [x: string]: unknown } | null;
790 readonly description?: string | null;
791 readonly id: string;
792 readonly link?: string | null;
793 readonly name: string;
794 readonly vars: ReadonlyArray<AuthEnvVar>;
795 }
796 | {
797 readonly type: "terminal";
798 readonly _meta?: { readonly [x: string]: unknown } | null;
799 readonly args?: ReadonlyArray<string>;
800 readonly description?: string | null;
801 readonly env?: { readonly [x: string]: string };
802 readonly id: string;
803 readonly name: string;
804 }
805 | {
806 readonly _meta?: { readonly [x: string]: unknown } | null;
807 readonly description?: string | null;
808 readonly id: string;
809 readonly name: string;
810 };
811export const AuthMethod = Schema.Union([
812 Schema.Struct({
813 type: Schema.Literal("env_var"),
814 _meta: Schema.optionalKey(
815 Schema.Union([
816 Schema.Record(Schema.String, Schema.Unknown).annotate({
817 description:
818 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
819 }),
820 Schema.Null,
821 ]),
822 ),
823 description: Schema.optionalKey(
824 Schema.Union([
825 Schema.String.annotate({
826 description:
827 "Optional description providing more details about this authentication method.",
828 }),
829 Schema.Null,
830 ]),
831 ),
832 id: Schema.String.annotate({
833 description: "Unique identifier for this authentication method.",
834 }),
835 link: Schema.optionalKey(
836 Schema.Union([
837 Schema.String.annotate({
838 description: "Optional link to a page where the user can obtain their credentials.",
839 }),
840 Schema.Null,
841 ]),
842 ),
843 name: Schema.String.annotate({
844 description: "Human-readable name of the authentication method.",
845 }),
846 vars: Schema.Array(AuthEnvVar).annotate({
847 description: "The environment variables the client should set.",
848 }),
849 }).annotate({
850 description:
851 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nEnvironment variable authentication method.\n\nThe user provides credentials that the client passes to the agent as environment variables.",
852 }),
853 Schema.Struct({
854 type: Schema.Literal("terminal"),
855 _meta: Schema.optionalKey(
856 Schema.Union([
857 Schema.Record(Schema.String, Schema.Unknown).annotate({
858 description:
859 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
860 }),
861 Schema.Null,
862 ]),
863 ),
864 args: Schema.optionalKey(
865 Schema.Array(Schema.String).annotate({
866 description:
867 "Additional arguments to pass when running the agent binary for terminal auth.",
868 }),
869 ),
870 description: Schema.optionalKey(
871 Schema.Union([
872 Schema.String.annotate({
873 description:
874 "Optional description providing more details about this authentication method.",
875 }),
876 Schema.Null,
877 ]),
878 ),
879 env: Schema.optionalKey(
880 Schema.Record(Schema.String, Schema.String).annotate({
881 description:
882 "Additional environment variables to set when running the agent binary for terminal auth.",
883 }),
884 ),
885 id: Schema.String.annotate({
886 description: "Unique identifier for this authentication method.",
887 }),
888 name: Schema.String.annotate({
889 description: "Human-readable name of the authentication method.",
890 }),
891 }).annotate({
892 description:
893 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nTerminal-based authentication method.\n\nThe client runs an interactive terminal for the user to authenticate via a TUI.",
894 }),
895 Schema.Struct({
896 _meta: Schema.optionalKey(
897 Schema.Union([
898 Schema.Record(Schema.String, Schema.Unknown).annotate({
899 description:
900 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
901 }),
902 Schema.Null,
903 ]),
904 ),
905 description: Schema.optionalKey(
906 Schema.Union([
907 Schema.String.annotate({
908 description:
909 "Optional description providing more details about this authentication method.",
910 }),
911 Schema.Null,
912 ]),
913 ),
914 id: Schema.String.annotate({
915 description: "Unique identifier for this authentication method.",
916 }),
917 name: Schema.String.annotate({
918 description: "Human-readable name of the authentication method.",
919 }),
920 }).annotate({
921 title: "agent",
922 description:
923 "Agent handles authentication itself.\n\nThis is the default authentication method type.",
924 }),
925]).annotate({
926 description:
927 "Describes an available authentication method.\n\nThe `type` field acts as the discriminator in the serialized JSON form.\nWhen no `type` is present, the method is treated as `agent`.",
928});
929
930export type AvailableCommand = {
931 readonly _meta?: { readonly [x: string]: unknown } | null;
932 readonly description: string;
933 readonly input?: AvailableCommandInput | null;
934 readonly name: string;
935};
936export const AvailableCommand = Schema.Struct({
937 _meta: Schema.optionalKey(
938 Schema.Union([
939 Schema.Record(Schema.String, Schema.Unknown).annotate({
940 description:
941 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
942 }),
943 Schema.Null,
944 ]),
945 ),
946 description: Schema.String.annotate({
947 description: "Human-readable description of what the command does.",
948 }),
949 input: Schema.optionalKey(
950 Schema.Union([AvailableCommandInput, Schema.Null]).annotate({
951 description: "Input for the command if required",
952 }),
953 ),
954 name: Schema.String.annotate({
955 description: "Command name (e.g., `create_plan`, `research_codebase`).",
956 }),
957}).annotate({ description: "Information about a command." });
958
959export type ElicitationCapabilities = {
960 readonly _meta?: { readonly [x: string]: unknown } | null;
961 readonly form?: ElicitationFormCapabilities | null;
962 readonly url?: ElicitationUrlCapabilities | null;
963};
964export const ElicitationCapabilities = Schema.Struct({
965 _meta: Schema.optionalKey(
966 Schema.Union([
967 Schema.Record(Schema.String, Schema.Unknown).annotate({
968 description:
969 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
970 }),
971 Schema.Null,
972 ]),
973 ),
974 form: Schema.optionalKey(
975 Schema.Union([ElicitationFormCapabilities, Schema.Null]).annotate({
976 description: "Whether the client supports form-based elicitation.",
977 }),
978 ),
979 url: Schema.optionalKey(
980 Schema.Union([ElicitationUrlCapabilities, Schema.Null]).annotate({
981 description: "Whether the client supports URL-based elicitation.",
982 }),
983 ),
984}).annotate({
985 description:
986 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.",
987});
988
989export type McpServer =
990 | {
991 readonly type: "http";
992 readonly _meta?: { readonly [x: string]: unknown } | null;
993 readonly headers: ReadonlyArray<HttpHeader>;
994 readonly name: string;
995 readonly url: string;
996 }
997 | {
998 readonly type: "sse";
999 readonly _meta?: { readonly [x: string]: unknown } | null;
1000 readonly headers: ReadonlyArray<HttpHeader>;
1001 readonly name: string;
1002 readonly url: string;
1003 }
1004 | {
1005 readonly _meta?: { readonly [x: string]: unknown } | null;
1006 readonly args: ReadonlyArray<string>;
1007 readonly command: string;
1008 readonly env: ReadonlyArray<EnvVariable>;
1009 readonly name: string;
1010 };
1011export const McpServer = Schema.Union([
1012 Schema.Struct({
1013 type: Schema.Literal("http"),
1014 _meta: Schema.optionalKey(
1015 Schema.Union([
1016 Schema.Record(Schema.String, Schema.Unknown).annotate({
1017 description:
1018 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1019 }),
1020 Schema.Null,
1021 ]),
1022 ),
1023 headers: Schema.Array(HttpHeader).annotate({
1024 description: "HTTP headers to set when making requests to the MCP server.",
1025 }),
1026 name: Schema.String.annotate({
1027 description: "Human-readable name identifying this MCP server.",
1028 }),
1029 url: Schema.String.annotate({ description: "URL to the MCP server." }),
1030 }).annotate({ description: "HTTP transport configuration for MCP." }),
1031 Schema.Struct({
1032 type: Schema.Literal("sse"),
1033 _meta: Schema.optionalKey(
1034 Schema.Union([
1035 Schema.Record(Schema.String, Schema.Unknown).annotate({
1036 description:
1037 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1038 }),
1039 Schema.Null,
1040 ]),
1041 ),
1042 headers: Schema.Array(HttpHeader).annotate({
1043 description: "HTTP headers to set when making requests to the MCP server.",
1044 }),
1045 name: Schema.String.annotate({
1046 description: "Human-readable name identifying this MCP server.",
1047 }),
1048 url: Schema.String.annotate({ description: "URL to the MCP server." }),
1049 }).annotate({ description: "SSE transport configuration for MCP." }),
1050 Schema.Struct({
1051 _meta: Schema.optionalKey(
1052 Schema.Union([
1053 Schema.Record(Schema.String, Schema.Unknown).annotate({
1054 description:
1055 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1056 }),
1057 Schema.Null,
1058 ]),
1059 ),
1060 args: Schema.Array(Schema.String).annotate({
1061 description: "Command-line arguments to pass to the MCP server.",
1062 }),
1063 command: Schema.String.annotate({ description: "Path to the MCP server executable." }),
1064 env: Schema.Array(EnvVariable).annotate({
1065 description: "Environment variables to set when launching the MCP server.",
1066 }),
1067 name: Schema.String.annotate({
1068 description: "Human-readable name identifying this MCP server.",
1069 }),
1070 }).annotate({ title: "stdio", description: "Stdio transport configuration for MCP." }),
1071]).annotate({
1072 description:
1073 "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)",
1074});
1075
1076export type SessionModelState = {
1077 readonly _meta?: { readonly [x: string]: unknown } | null;
1078 readonly availableModels: ReadonlyArray<ModelInfo>;
1079 readonly currentModelId: string;
1080};
1081export const SessionModelState = Schema.Struct({
1082 _meta: Schema.optionalKey(
1083 Schema.Union([
1084 Schema.Record(Schema.String, Schema.Unknown).annotate({
1085 description:
1086 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1087 }),
1088 Schema.Null,
1089 ]),
1090 ),
1091 availableModels: Schema.Array(ModelInfo).annotate({
1092 description: "The set of models that the Agent can use",
1093 }),
1094 currentModelId: Schema.String.annotate({
1095 description:
1096 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.",
1097 }),
1098}).annotate({
1099 description:
1100 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe set of models and the one currently active.",
1101});
1102
1103export type Annotations = {
1104 readonly _meta?: { readonly [x: string]: unknown } | null;
1105 readonly audience?: ReadonlyArray<Role> | null;
1106 readonly lastModified?: string | null;
1107 readonly priority?: number | null;
1108};
1109export const Annotations = Schema.Struct({
1110 _meta: Schema.optionalKey(
1111 Schema.Union([
1112 Schema.Record(Schema.String, Schema.Unknown).annotate({
1113 description:
1114 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1115 }),
1116 Schema.Null,
1117 ]),
1118 ),
1119 audience: Schema.optionalKey(Schema.Union([Schema.Array(Role), Schema.Null])),
1120 lastModified: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1121 priority: Schema.optionalKey(
1122 Schema.Union([
1123 Schema.Number.annotate({ format: "double" }).check(Schema.isFinite()),
1124 Schema.Null,
1125 ]),
1126 ),
1127}).annotate({
1128 description:
1129 "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed",
1130});
1131
1132export type SessionConfigSelectGroup = {
1133 readonly _meta?: { readonly [x: string]: unknown } | null;
1134 readonly group: string;
1135 readonly name: string;
1136 readonly options: ReadonlyArray<SessionConfigSelectOption>;
1137};
1138export const SessionConfigSelectGroup = Schema.Struct({
1139 _meta: Schema.optionalKey(
1140 Schema.Union([
1141 Schema.Record(Schema.String, Schema.Unknown).annotate({
1142 description:
1143 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1144 }),
1145 Schema.Null,
1146 ]),
1147 ),
1148 group: Schema.String.annotate({
1149 description: "Unique identifier for a session configuration option value group.",
1150 }),
1151 name: Schema.String.annotate({ description: "Human-readable label for this group." }),
1152 options: Schema.Array(SessionConfigSelectOption).annotate({
1153 description: "The set of option values in this group.",
1154 }),
1155}).annotate({ description: "A group of possible values for a session configuration option." });
1156
1157export type SessionMode = {
1158 readonly _meta?: { readonly [x: string]: unknown } | null;
1159 readonly description?: string | null;
1160 readonly id: SessionModeId;
1161 readonly name: string;
1162};
1163export const SessionMode = Schema.Struct({
1164 _meta: Schema.optionalKey(
1165 Schema.Union([
1166 Schema.Record(Schema.String, Schema.Unknown).annotate({
1167 description:
1168 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1169 }),
1170 Schema.Null,
1171 ]),
1172 ),
1173 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1174 id: SessionModeId,
1175 name: Schema.String,
1176}).annotate({
1177 description:
1178 "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
1179});
1180
1181export type ElicitationPropertySchema =
1182 | {
1183 readonly type: "string";
1184 readonly default?: string | null;
1185 readonly description?: string | null;
1186 readonly enum?: ReadonlyArray<string> | null;
1187 readonly format?: StringFormat | null;
1188 readonly maxLength?: number | null;
1189 readonly minLength?: number | null;
1190 readonly oneOf?: ReadonlyArray<EnumOption> | null;
1191 readonly pattern?: string | null;
1192 readonly title?: string | null;
1193 }
1194 | {
1195 readonly type: "number";
1196 readonly default?: number | null;
1197 readonly description?: string | null;
1198 readonly maximum?: number | null;
1199 readonly minimum?: number | null;
1200 readonly title?: string | null;
1201 }
1202 | {
1203 readonly type: "integer";
1204 readonly default?: number | null;
1205 readonly description?: string | null;
1206 readonly maximum?: number | null;
1207 readonly minimum?: number | null;
1208 readonly title?: string | null;
1209 }
1210 | {
1211 readonly type: "boolean";
1212 readonly default?: boolean | null;
1213 readonly description?: string | null;
1214 readonly title?: string | null;
1215 }
1216 | {
1217 readonly type: "array";
1218 readonly default?: ReadonlyArray<string> | null;
1219 readonly description?: string | null;
1220 readonly items:
1221 | { readonly enum: ReadonlyArray<string>; readonly type: "string" }
1222 | { readonly anyOf: ReadonlyArray<EnumOption> };
1223 readonly maxItems?: number | null;
1224 readonly minItems?: number | null;
1225 readonly title?: string | null;
1226 };
1227export const ElicitationPropertySchema = Schema.Union(
1228 [
1229 Schema.Struct({
1230 type: Schema.Literal("string"),
1231 default: Schema.optionalKey(
1232 Schema.Union([Schema.String.annotate({ description: "Default value." }), Schema.Null]),
1233 ),
1234 description: Schema.optionalKey(
1235 Schema.Union([
1236 Schema.String.annotate({ description: "Human-readable description." }),
1237 Schema.Null,
1238 ]),
1239 ),
1240 enum: Schema.optionalKey(
1241 Schema.Union([
1242 Schema.Array(Schema.String).annotate({
1243 description: "Enum values for untitled single-select enums.",
1244 }),
1245 Schema.Null,
1246 ]),
1247 ),
1248 format: Schema.optionalKey(
1249 Schema.Union([StringFormat, Schema.Null]).annotate({ description: "String format." }),
1250 ),
1251 maxLength: Schema.optionalKey(
1252 Schema.Union([
1253 Schema.Number.annotate({ description: "Maximum string length.", format: "uint32" })
1254 .check(Schema.isInt())
1255 .check(Schema.isGreaterThanOrEqualTo(0)),
1256 Schema.Null,
1257 ]),
1258 ),
1259 minLength: Schema.optionalKey(
1260 Schema.Union([
1261 Schema.Number.annotate({ description: "Minimum string length.", format: "uint32" })
1262 .check(Schema.isInt())
1263 .check(Schema.isGreaterThanOrEqualTo(0)),
1264 Schema.Null,
1265 ]),
1266 ),
1267 oneOf: Schema.optionalKey(
1268 Schema.Union([
1269 Schema.Array(EnumOption).annotate({
1270 description: "Titled enum options for titled single-select enums.",
1271 }),
1272 Schema.Null,
1273 ]),
1274 ),
1275 pattern: Schema.optionalKey(
1276 Schema.Union([
1277 Schema.String.annotate({ description: "Pattern the string must match." }),
1278 Schema.Null,
1279 ]),
1280 ),
1281 title: Schema.optionalKey(
1282 Schema.Union([
1283 Schema.String.annotate({ description: "Optional title for the property." }),
1284 Schema.Null,
1285 ]),
1286 ),
1287 }).annotate({
1288 description:
1289 'Schema for string properties in an elicitation form.\n\nWhen `enum` or `oneOf` is set, this represents a single-select enum\nwith `"type": "string"`.',
1290 }),
1291 Schema.Struct({
1292 type: Schema.Literal("number"),
1293 default: Schema.optionalKey(
1294 Schema.Union([
1295 Schema.Number.annotate({ description: "Default value.", format: "double" }).check(
1296 Schema.isFinite(),
1297 ),
1298 Schema.Null,
1299 ]),
1300 ),
1301 description: Schema.optionalKey(
1302 Schema.Union([
1303 Schema.String.annotate({ description: "Human-readable description." }),
1304 Schema.Null,
1305 ]),
1306 ),
1307 maximum: Schema.optionalKey(
1308 Schema.Union([
1309 Schema.Number.annotate({
1310 description: "Maximum value (inclusive).",
1311 format: "double",
1312 }).check(Schema.isFinite()),
1313 Schema.Null,
1314 ]),
1315 ),
1316 minimum: Schema.optionalKey(
1317 Schema.Union([
1318 Schema.Number.annotate({
1319 description: "Minimum value (inclusive).",
1320 format: "double",
1321 }).check(Schema.isFinite()),
1322 Schema.Null,
1323 ]),
1324 ),
1325 title: Schema.optionalKey(
1326 Schema.Union([
1327 Schema.String.annotate({ description: "Optional title for the property." }),
1328 Schema.Null,
1329 ]),
1330 ),
1331 }).annotate({
1332 description: "Schema for number (floating-point) properties in an elicitation form.",
1333 }),
1334 Schema.Struct({
1335 type: Schema.Literal("integer"),
1336 default: Schema.optionalKey(
1337 Schema.Union([
1338 Schema.Number.annotate({ description: "Default value.", format: "int64" }).check(
1339 Schema.isInt(),
1340 ),
1341 Schema.Null,
1342 ]),
1343 ),
1344 description: Schema.optionalKey(
1345 Schema.Union([
1346 Schema.String.annotate({ description: "Human-readable description." }),
1347 Schema.Null,
1348 ]),
1349 ),
1350 maximum: Schema.optionalKey(
1351 Schema.Union([
1352 Schema.Number.annotate({
1353 description: "Maximum value (inclusive).",
1354 format: "int64",
1355 }).check(Schema.isInt()),
1356 Schema.Null,
1357 ]),
1358 ),
1359 minimum: Schema.optionalKey(
1360 Schema.Union([
1361 Schema.Number.annotate({
1362 description: "Minimum value (inclusive).",
1363 format: "int64",
1364 }).check(Schema.isInt()),
1365 Schema.Null,
1366 ]),
1367 ),
1368 title: Schema.optionalKey(
1369 Schema.Union([
1370 Schema.String.annotate({ description: "Optional title for the property." }),
1371 Schema.Null,
1372 ]),
1373 ),
1374 }).annotate({ description: "Schema for integer properties in an elicitation form." }),
1375 Schema.Struct({
1376 type: Schema.Literal("boolean"),
1377 default: Schema.optionalKey(
1378 Schema.Union([Schema.Boolean.annotate({ description: "Default value." }), Schema.Null]),
1379 ),
1380 description: Schema.optionalKey(
1381 Schema.Union([
1382 Schema.String.annotate({ description: "Human-readable description." }),
1383 Schema.Null,
1384 ]),
1385 ),
1386 title: Schema.optionalKey(
1387 Schema.Union([
1388 Schema.String.annotate({ description: "Optional title for the property." }),
1389 Schema.Null,
1390 ]),
1391 ),
1392 }).annotate({ description: "Schema for boolean properties in an elicitation form." }),
1393 Schema.Struct({
1394 type: Schema.Literal("array"),
1395 default: Schema.optionalKey(
1396 Schema.Union([
1397 Schema.Array(Schema.String).annotate({ description: "Default selected values." }),
1398 Schema.Null,
1399 ]),
1400 ),
1401 description: Schema.optionalKey(
1402 Schema.Union([
1403 Schema.String.annotate({ description: "Human-readable description." }),
1404 Schema.Null,
1405 ]),
1406 ),
1407 items: Schema.Union([
1408 Schema.Struct({
1409 enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }),
1410 type: Schema.Literal("string").annotate({
1411 description: "Items definition for untitled multi-select enum properties.",
1412 }),
1413 }).annotate({
1414 title: "Untitled",
1415 description: "Items definition for untitled multi-select enum properties.",
1416 }),
1417 Schema.Struct({
1418 anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }),
1419 }).annotate({
1420 title: "Titled",
1421 description: "Items definition for titled multi-select enum properties.",
1422 }),
1423 ]).annotate({ description: "Items for a multi-select (array) property schema." }),
1424 maxItems: Schema.optionalKey(
1425 Schema.Union([
1426 Schema.Number.annotate({
1427 description: "Maximum number of items to select.",
1428 format: "uint64",
1429 })
1430 .check(Schema.isInt())
1431 .check(Schema.isGreaterThanOrEqualTo(0)),
1432 Schema.Null,
1433 ]),
1434 ),
1435 minItems: Schema.optionalKey(
1436 Schema.Union([
1437 Schema.Number.annotate({
1438 description: "Minimum number of items to select.",
1439 format: "uint64",
1440 })
1441 .check(Schema.isInt())
1442 .check(Schema.isGreaterThanOrEqualTo(0)),
1443 Schema.Null,
1444 ]),
1445 ),
1446 title: Schema.optionalKey(
1447 Schema.Union([
1448 Schema.String.annotate({ description: "Optional title for the property." }),
1449 Schema.Null,
1450 ]),
1451 ),
1452 }).annotate({
1453 description: "Schema for multi-select (array) properties in an elicitation form.",
1454 }),
1455 ],
1456 { mode: "oneOf" },
1457).annotate({
1458 description:
1459 'Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `"type"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.',
1460});
1461
1462export type ContentBlock =
1463 | {
1464 readonly type: "text";
1465 readonly _meta?: { readonly [x: string]: unknown } | null;
1466 readonly annotations?: Annotations | null;
1467 readonly text: string;
1468 }
1469 | {
1470 readonly type: "image";
1471 readonly _meta?: { readonly [x: string]: unknown } | null;
1472 readonly annotations?: Annotations | null;
1473 readonly data: string;
1474 readonly mimeType: string;
1475 readonly uri?: string | null;
1476 }
1477 | {
1478 readonly type: "audio";
1479 readonly _meta?: { readonly [x: string]: unknown } | null;
1480 readonly annotations?: Annotations | null;
1481 readonly data: string;
1482 readonly mimeType: string;
1483 }
1484 | {
1485 readonly type: "resource_link";
1486 readonly _meta?: { readonly [x: string]: unknown } | null;
1487 readonly annotations?: Annotations | null;
1488 readonly description?: string | null;
1489 readonly mimeType?: string | null;
1490 readonly name: string;
1491 readonly size?: number | null;
1492 readonly title?: string | null;
1493 readonly uri: string;
1494 }
1495 | {
1496 readonly type: "resource";
1497 readonly _meta?: { readonly [x: string]: unknown } | null;
1498 readonly annotations?: Annotations | null;
1499 readonly resource: EmbeddedResourceResource;
1500 };
1501export const ContentBlock = Schema.Union(
1502 [
1503 Schema.Struct({
1504 type: Schema.Literal("text"),
1505 _meta: Schema.optionalKey(
1506 Schema.Union([
1507 Schema.Record(Schema.String, Schema.Unknown).annotate({
1508 description:
1509 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1510 }),
1511 Schema.Null,
1512 ]),
1513 ),
1514 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1515 text: Schema.String,
1516 }).annotate({ description: "Text provided to or from an LLM." }),
1517 Schema.Struct({
1518 type: Schema.Literal("image"),
1519 _meta: Schema.optionalKey(
1520 Schema.Union([
1521 Schema.Record(Schema.String, Schema.Unknown).annotate({
1522 description:
1523 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1524 }),
1525 Schema.Null,
1526 ]),
1527 ),
1528 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1529 data: Schema.String,
1530 mimeType: Schema.String,
1531 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1532 }).annotate({ description: "An image provided to or from an LLM." }),
1533 Schema.Struct({
1534 type: Schema.Literal("audio"),
1535 _meta: Schema.optionalKey(
1536 Schema.Union([
1537 Schema.Record(Schema.String, Schema.Unknown).annotate({
1538 description:
1539 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1540 }),
1541 Schema.Null,
1542 ]),
1543 ),
1544 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1545 data: Schema.String,
1546 mimeType: Schema.String,
1547 }).annotate({ description: "Audio provided to or from an LLM." }),
1548 Schema.Struct({
1549 type: Schema.Literal("resource_link"),
1550 _meta: Schema.optionalKey(
1551 Schema.Union([
1552 Schema.Record(Schema.String, Schema.Unknown).annotate({
1553 description:
1554 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1555 }),
1556 Schema.Null,
1557 ]),
1558 ),
1559 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1560 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1561 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1562 name: Schema.String,
1563 size: Schema.optionalKey(
1564 Schema.Union([
1565 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
1566 Schema.Null,
1567 ]),
1568 ),
1569 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1570 uri: Schema.String,
1571 }).annotate({
1572 description:
1573 "A resource that the server is capable of reading, included in a prompt or tool call result.",
1574 }),
1575 Schema.Struct({
1576 type: Schema.Literal("resource"),
1577 _meta: Schema.optionalKey(
1578 Schema.Union([
1579 Schema.Record(Schema.String, Schema.Unknown).annotate({
1580 description:
1581 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1582 }),
1583 Schema.Null,
1584 ]),
1585 ),
1586 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1587 resource: EmbeddedResourceResource,
1588 }).annotate({
1589 description: "The contents of a resource, embedded into a prompt or tool call result.",
1590 }),
1591 ],
1592 { mode: "oneOf" },
1593).annotate({
1594 description:
1595 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
1596});
1597
1598export type ToolCallContent =
1599 | {
1600 readonly type: "content";
1601 readonly _meta?: { readonly [x: string]: unknown } | null;
1602 readonly content:
1603 | {
1604 readonly type: "text";
1605 readonly _meta?: { readonly [x: string]: unknown } | null;
1606 readonly annotations?: Annotations | null;
1607 readonly text: string;
1608 }
1609 | {
1610 readonly type: "image";
1611 readonly _meta?: { readonly [x: string]: unknown } | null;
1612 readonly annotations?: Annotations | null;
1613 readonly data: string;
1614 readonly mimeType: string;
1615 readonly uri?: string | null;
1616 }
1617 | {
1618 readonly type: "audio";
1619 readonly _meta?: { readonly [x: string]: unknown } | null;
1620 readonly annotations?: Annotations | null;
1621 readonly data: string;
1622 readonly mimeType: string;
1623 }
1624 | {
1625 readonly type: "resource_link";
1626 readonly _meta?: { readonly [x: string]: unknown } | null;
1627 readonly annotations?: Annotations | null;
1628 readonly description?: string | null;
1629 readonly mimeType?: string | null;
1630 readonly name: string;
1631 readonly size?: number | null;
1632 readonly title?: string | null;
1633 readonly uri: string;
1634 }
1635 | {
1636 readonly type: "resource";
1637 readonly _meta?: { readonly [x: string]: unknown } | null;
1638 readonly annotations?: Annotations | null;
1639 readonly resource: EmbeddedResourceResource;
1640 };
1641 }
1642 | {
1643 readonly type: "diff";
1644 readonly _meta?: { readonly [x: string]: unknown } | null;
1645 readonly newText: string;
1646 readonly oldText?: string | null;
1647 readonly path: string;
1648 }
1649 | {
1650 readonly type: "terminal";
1651 readonly _meta?: { readonly [x: string]: unknown } | null;
1652 readonly terminalId: string;
1653 };
1654export const ToolCallContent = Schema.Union(
1655 [
1656 Schema.Struct({
1657 type: Schema.Literal("content"),
1658 _meta: Schema.optionalKey(
1659 Schema.Union([
1660 Schema.Record(Schema.String, Schema.Unknown).annotate({
1661 description:
1662 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1663 }),
1664 Schema.Null,
1665 ]),
1666 ),
1667 content: Schema.Union(
1668 [
1669 Schema.Struct({
1670 type: Schema.Literal("text"),
1671 _meta: Schema.optionalKey(
1672 Schema.Union([
1673 Schema.Record(Schema.String, Schema.Unknown).annotate({
1674 description:
1675 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1676 }),
1677 Schema.Null,
1678 ]),
1679 ),
1680 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1681 text: Schema.String,
1682 }).annotate({ description: "Text provided to or from an LLM." }),
1683 Schema.Struct({
1684 type: Schema.Literal("image"),
1685 _meta: Schema.optionalKey(
1686 Schema.Union([
1687 Schema.Record(Schema.String, Schema.Unknown).annotate({
1688 description:
1689 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1690 }),
1691 Schema.Null,
1692 ]),
1693 ),
1694 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1695 data: Schema.String,
1696 mimeType: Schema.String,
1697 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1698 }).annotate({ description: "An image provided to or from an LLM." }),
1699 Schema.Struct({
1700 type: Schema.Literal("audio"),
1701 _meta: Schema.optionalKey(
1702 Schema.Union([
1703 Schema.Record(Schema.String, Schema.Unknown).annotate({
1704 description:
1705 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1706 }),
1707 Schema.Null,
1708 ]),
1709 ),
1710 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1711 data: Schema.String,
1712 mimeType: Schema.String,
1713 }).annotate({ description: "Audio provided to or from an LLM." }),
1714 Schema.Struct({
1715 type: Schema.Literal("resource_link"),
1716 _meta: Schema.optionalKey(
1717 Schema.Union([
1718 Schema.Record(Schema.String, Schema.Unknown).annotate({
1719 description:
1720 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1721 }),
1722 Schema.Null,
1723 ]),
1724 ),
1725 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1726 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1727 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1728 name: Schema.String,
1729 size: Schema.optionalKey(
1730 Schema.Union([
1731 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
1732 Schema.Null,
1733 ]),
1734 ),
1735 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1736 uri: Schema.String,
1737 }).annotate({
1738 description:
1739 "A resource that the server is capable of reading, included in a prompt or tool call result.",
1740 }),
1741 Schema.Struct({
1742 type: Schema.Literal("resource"),
1743 _meta: Schema.optionalKey(
1744 Schema.Union([
1745 Schema.Record(Schema.String, Schema.Unknown).annotate({
1746 description:
1747 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1748 }),
1749 Schema.Null,
1750 ]),
1751 ),
1752 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1753 resource: EmbeddedResourceResource,
1754 }).annotate({
1755 description: "The contents of a resource, embedded into a prompt or tool call result.",
1756 }),
1757 ],
1758 { mode: "oneOf" },
1759 ).annotate({
1760 description:
1761 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
1762 }),
1763 }).annotate({ description: "Standard content block (text, images, resources)." }),
1764 Schema.Struct({
1765 type: Schema.Literal("diff"),
1766 _meta: Schema.optionalKey(
1767 Schema.Union([
1768 Schema.Record(Schema.String, Schema.Unknown).annotate({
1769 description:
1770 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1771 }),
1772 Schema.Null,
1773 ]),
1774 ),
1775 newText: Schema.String.annotate({ description: "The new content after modification." }),
1776 oldText: Schema.optionalKey(
1777 Schema.Union([
1778 Schema.String.annotate({ description: "The original content (None for new files)." }),
1779 Schema.Null,
1780 ]),
1781 ),
1782 path: Schema.String.annotate({ description: "The file path being modified." }),
1783 }).annotate({
1784 description:
1785 "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)",
1786 }),
1787 Schema.Struct({
1788 type: Schema.Literal("terminal"),
1789 _meta: Schema.optionalKey(
1790 Schema.Union([
1791 Schema.Record(Schema.String, Schema.Unknown).annotate({
1792 description:
1793 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1794 }),
1795 Schema.Null,
1796 ]),
1797 ),
1798 terminalId: Schema.String,
1799 }).annotate({
1800 description:
1801 "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)",
1802 }),
1803 ],
1804 { mode: "oneOf" },
1805).annotate({
1806 description:
1807 "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)",
1808});
1809
1810export type SessionConfigOption =
1811 | {
1812 readonly type: "select";
1813 readonly currentValue: string;
1814 readonly options:
1815 | ReadonlyArray<SessionConfigSelectOption>
1816 | ReadonlyArray<SessionConfigSelectGroup>;
1817 readonly _meta?: { readonly [x: string]: unknown } | null;
1818 readonly category?: SessionConfigOptionCategory | null;
1819 readonly description?: string | null;
1820 readonly id: string;
1821 readonly name: string;
1822 }
1823 | {
1824 readonly type: "boolean";
1825 readonly currentValue: boolean;
1826 readonly _meta?: { readonly [x: string]: unknown } | null;
1827 readonly category?: SessionConfigOptionCategory | null;
1828 readonly description?: string | null;
1829 readonly id: string;
1830 readonly name: string;
1831 };
1832export const SessionConfigOption = Schema.Union([
1833 Schema.Struct({
1834 type: Schema.Literal("select"),
1835 currentValue: Schema.String.annotate({
1836 description: "Unique identifier for a session configuration option value.",
1837 }),
1838 options: Schema.Union([
1839 Schema.Array(SessionConfigSelectOption).annotate({
1840 title: "Ungrouped",
1841 description: "A flat list of options with no grouping.",
1842 }),
1843 Schema.Array(SessionConfigSelectGroup).annotate({
1844 title: "Grouped",
1845 description: "A list of options grouped under headers.",
1846 }),
1847 ]).annotate({ description: "Possible values for a session configuration option." }),
1848 _meta: Schema.optionalKey(
1849 Schema.Union([
1850 Schema.Record(Schema.String, Schema.Unknown).annotate({
1851 description:
1852 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1853 }),
1854 Schema.Null,
1855 ]),
1856 ),
1857 category: Schema.optionalKey(
1858 Schema.Union([SessionConfigOptionCategory, Schema.Null]).annotate({
1859 description: "Optional semantic category for this option (UX only).",
1860 }),
1861 ),
1862 description: Schema.optionalKey(
1863 Schema.Union([
1864 Schema.String.annotate({
1865 description: "Optional description for the Client to display to the user.",
1866 }),
1867 Schema.Null,
1868 ]),
1869 ),
1870 id: Schema.String.annotate({
1871 description: "Unique identifier for a session configuration option.",
1872 }),
1873 name: Schema.String.annotate({ description: "Human-readable label for the option." }),
1874 }).annotate({ description: "A session configuration option selector and its current state." }),
1875 Schema.Struct({
1876 type: Schema.Literal("boolean"),
1877 currentValue: Schema.Boolean.annotate({
1878 description: "The current value of the boolean option.",
1879 }),
1880 _meta: Schema.optionalKey(
1881 Schema.Union([
1882 Schema.Record(Schema.String, Schema.Unknown).annotate({
1883 description:
1884 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1885 }),
1886 Schema.Null,
1887 ]),
1888 ),
1889 category: Schema.optionalKey(
1890 Schema.Union([SessionConfigOptionCategory, Schema.Null]).annotate({
1891 description: "Optional semantic category for this option (UX only).",
1892 }),
1893 ),
1894 description: Schema.optionalKey(
1895 Schema.Union([
1896 Schema.String.annotate({
1897 description: "Optional description for the Client to display to the user.",
1898 }),
1899 Schema.Null,
1900 ]),
1901 ),
1902 id: Schema.String.annotate({
1903 description: "Unique identifier for a session configuration option.",
1904 }),
1905 name: Schema.String.annotate({ description: "Human-readable label for the option." }),
1906 }).annotate({ description: "A session configuration option selector and its current state." }),
1907]);
1908
1909export type SessionModeState = {
1910 readonly _meta?: { readonly [x: string]: unknown } | null;
1911 readonly availableModes: ReadonlyArray<SessionMode>;
1912 readonly currentModeId: string;
1913};
1914export const SessionModeState = Schema.Struct({
1915 _meta: Schema.optionalKey(
1916 Schema.Union([
1917 Schema.Record(Schema.String, Schema.Unknown).annotate({
1918 description:
1919 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1920 }),
1921 Schema.Null,
1922 ]),
1923 ),
1924 availableModes: Schema.Array(SessionMode).annotate({
1925 description: "The set of modes that the Agent can operate in",
1926 }),
1927 currentModeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }),
1928}).annotate({ description: "The set of modes and the one currently active." });
1929
1930export type AgentAuthCapabilities = {
1931 readonly _meta?: { readonly [x: string]: unknown } | null;
1932 readonly logout?: LogoutCapabilities | null;
1933};
1934export const AgentAuthCapabilities = Schema.Struct({
1935 _meta: Schema.optionalKey(
1936 Schema.Union([
1937 Schema.Record(Schema.String, Schema.Unknown).annotate({
1938 description:
1939 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1940 }),
1941 Schema.Null,
1942 ]),
1943 ),
1944 logout: Schema.optionalKey(
1945 Schema.Union([LogoutCapabilities, Schema.Null]).annotate({
1946 description:
1947 "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.",
1948 }),
1949 ),
1950}).annotate({
1951 description:
1952 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.",
1953});
1954
1955export type AgentCapabilities = {
1956 readonly _meta?: { readonly [x: string]: unknown } | null;
1957 readonly auth?: {
1958 readonly _meta?: { readonly [x: string]: unknown } | null;
1959 readonly logout?: LogoutCapabilities | null;
1960 };
1961 readonly loadSession?: boolean;
1962 readonly mcpCapabilities?: {
1963 readonly _meta?: { readonly [x: string]: unknown } | null;
1964 readonly http?: boolean;
1965 readonly sse?: boolean;
1966 };
1967 readonly promptCapabilities?: {
1968 readonly _meta?: { readonly [x: string]: unknown } | null;
1969 readonly audio?: boolean;
1970 readonly embeddedContext?: boolean;
1971 readonly image?: boolean;
1972 };
1973 readonly sessionCapabilities?: {
1974 readonly _meta?: { readonly [x: string]: unknown } | null;
1975 readonly close?: SessionCloseCapabilities | null;
1976 readonly fork?: SessionForkCapabilities | null;
1977 readonly list?: SessionListCapabilities | null;
1978 readonly resume?: SessionResumeCapabilities | null;
1979 };
1980};
1981export const AgentCapabilities = Schema.Struct({
1982 _meta: Schema.optionalKey(
1983 Schema.Union([
1984 Schema.Record(Schema.String, Schema.Unknown).annotate({
1985 description:
1986 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1987 }),
1988 Schema.Null,
1989 ]),
1990 ),
1991 auth: Schema.optionalKey(
1992 Schema.Struct({
1993 _meta: Schema.optionalKey(
1994 Schema.Union([
1995 Schema.Record(Schema.String, Schema.Unknown).annotate({
1996 description:
1997 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1998 }),
1999 Schema.Null,
2000 ]),
2001 ),
2002 logout: Schema.optionalKey(
2003 Schema.Union([LogoutCapabilities, Schema.Null]).annotate({
2004 description:
2005 "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.",
2006 }),
2007 ),
2008 }).annotate({
2009 description:
2010 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.",
2011 default: {},
2012 }),
2013 ),
2014 loadSession: Schema.optionalKey(
2015 Schema.Boolean.annotate({
2016 description: "Whether the agent supports `session/load`.",
2017 default: false,
2018 }),
2019 ),
2020 mcpCapabilities: Schema.optionalKey(
2021 Schema.Struct({
2022 _meta: Schema.optionalKey(
2023 Schema.Union([
2024 Schema.Record(Schema.String, Schema.Unknown).annotate({
2025 description:
2026 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2027 }),
2028 Schema.Null,
2029 ]),
2030 ),
2031 http: Schema.optionalKey(
2032 Schema.Boolean.annotate({
2033 description: "Agent supports [`McpServer::Http`].",
2034 default: false,
2035 }),
2036 ),
2037 sse: Schema.optionalKey(
2038 Schema.Boolean.annotate({
2039 description: "Agent supports [`McpServer::Sse`].",
2040 default: false,
2041 }),
2042 ),
2043 }).annotate({
2044 description: "MCP capabilities supported by the agent",
2045 default: { http: false, sse: false },
2046 }),
2047 ),
2048 promptCapabilities: Schema.optionalKey(
2049 Schema.Struct({
2050 _meta: Schema.optionalKey(
2051 Schema.Union([
2052 Schema.Record(Schema.String, Schema.Unknown).annotate({
2053 description:
2054 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2055 }),
2056 Schema.Null,
2057 ]),
2058 ),
2059 audio: Schema.optionalKey(
2060 Schema.Boolean.annotate({
2061 description: "Agent supports [`ContentBlock::Audio`].",
2062 default: false,
2063 }),
2064 ),
2065 embeddedContext: Schema.optionalKey(
2066 Schema.Boolean.annotate({
2067 description:
2068 "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.",
2069 default: false,
2070 }),
2071 ),
2072 image: Schema.optionalKey(
2073 Schema.Boolean.annotate({
2074 description: "Agent supports [`ContentBlock::Image`].",
2075 default: false,
2076 }),
2077 ),
2078 }).annotate({
2079 description:
2080 "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)",
2081 default: { audio: false, embeddedContext: false, image: false },
2082 }),
2083 ),
2084 sessionCapabilities: Schema.optionalKey(
2085 Schema.Struct({
2086 _meta: Schema.optionalKey(
2087 Schema.Union([
2088 Schema.Record(Schema.String, Schema.Unknown).annotate({
2089 description:
2090 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2091 }),
2092 Schema.Null,
2093 ]),
2094 ),
2095 close: Schema.optionalKey(
2096 Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({
2097 description:
2098 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.",
2099 }),
2100 ),
2101 fork: Schema.optionalKey(
2102 Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({
2103 description:
2104 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.",
2105 }),
2106 ),
2107 list: Schema.optionalKey(
2108 Schema.Union([SessionListCapabilities, Schema.Null]).annotate({
2109 description: "Whether the agent supports `session/list`.",
2110 }),
2111 ),
2112 resume: Schema.optionalKey(
2113 Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({
2114 description:
2115 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.",
2116 }),
2117 ),
2118 }).annotate({
2119 default: {},
2120 description:
2121 "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)",
2122 }),
2123 ),
2124}).annotate({
2125 description:
2126 "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)",
2127});
2128
2129export type AgentNotification = {
2130 readonly method: string;
2131 readonly params?:
2132 | {
2133 readonly _meta?: { readonly [x: string]: unknown } | null;
2134 readonly sessionId: string;
2135 readonly update:
2136 | {
2137 readonly sessionUpdate: "user_message_chunk";
2138 readonly _meta?: { readonly [x: string]: unknown } | null;
2139 readonly content:
2140 | {
2141 readonly type: "text";
2142 readonly _meta?: { readonly [x: string]: unknown } | null;
2143 readonly annotations?: Annotations | null;
2144 readonly text: string;
2145 }
2146 | {
2147 readonly type: "image";
2148 readonly _meta?: { readonly [x: string]: unknown } | null;
2149 readonly annotations?: Annotations | null;
2150 readonly data: string;
2151 readonly mimeType: string;
2152 readonly uri?: string | null;
2153 }
2154 | {
2155 readonly type: "audio";
2156 readonly _meta?: { readonly [x: string]: unknown } | null;
2157 readonly annotations?: Annotations | null;
2158 readonly data: string;
2159 readonly mimeType: string;
2160 }
2161 | {
2162 readonly type: "resource_link";
2163 readonly _meta?: { readonly [x: string]: unknown } | null;
2164 readonly annotations?: Annotations | null;
2165 readonly description?: string | null;
2166 readonly mimeType?: string | null;
2167 readonly name: string;
2168 readonly size?: number | null;
2169 readonly title?: string | null;
2170 readonly uri: string;
2171 }
2172 | {
2173 readonly type: "resource";
2174 readonly _meta?: { readonly [x: string]: unknown } | null;
2175 readonly annotations?: Annotations | null;
2176 readonly resource: EmbeddedResourceResource;
2177 };
2178 readonly messageId?: string | null;
2179 }
2180 | {
2181 readonly sessionUpdate: "agent_message_chunk";
2182 readonly _meta?: { readonly [x: string]: unknown } | null;
2183 readonly content:
2184 | {
2185 readonly type: "text";
2186 readonly _meta?: { readonly [x: string]: unknown } | null;
2187 readonly annotations?: Annotations | null;
2188 readonly text: string;
2189 }
2190 | {
2191 readonly type: "image";
2192 readonly _meta?: { readonly [x: string]: unknown } | null;
2193 readonly annotations?: Annotations | null;
2194 readonly data: string;
2195 readonly mimeType: string;
2196 readonly uri?: string | null;
2197 }
2198 | {
2199 readonly type: "audio";
2200 readonly _meta?: { readonly [x: string]: unknown } | null;
2201 readonly annotations?: Annotations | null;
2202 readonly data: string;
2203 readonly mimeType: string;
2204 }
2205 | {
2206 readonly type: "resource_link";
2207 readonly _meta?: { readonly [x: string]: unknown } | null;
2208 readonly annotations?: Annotations | null;
2209 readonly description?: string | null;
2210 readonly mimeType?: string | null;
2211 readonly name: string;
2212 readonly size?: number | null;
2213 readonly title?: string | null;
2214 readonly uri: string;
2215 }
2216 | {
2217 readonly type: "resource";
2218 readonly _meta?: { readonly [x: string]: unknown } | null;
2219 readonly annotations?: Annotations | null;
2220 readonly resource: EmbeddedResourceResource;
2221 };
2222 readonly messageId?: string | null;
2223 }
2224 | {
2225 readonly sessionUpdate: "agent_thought_chunk";
2226 readonly _meta?: { readonly [x: string]: unknown } | null;
2227 readonly content:
2228 | {
2229 readonly type: "text";
2230 readonly _meta?: { readonly [x: string]: unknown } | null;
2231 readonly annotations?: Annotations | null;
2232 readonly text: string;
2233 }
2234 | {
2235 readonly type: "image";
2236 readonly _meta?: { readonly [x: string]: unknown } | null;
2237 readonly annotations?: Annotations | null;
2238 readonly data: string;
2239 readonly mimeType: string;
2240 readonly uri?: string | null;
2241 }
2242 | {
2243 readonly type: "audio";
2244 readonly _meta?: { readonly [x: string]: unknown } | null;
2245 readonly annotations?: Annotations | null;
2246 readonly data: string;
2247 readonly mimeType: string;
2248 }
2249 | {
2250 readonly type: "resource_link";
2251 readonly _meta?: { readonly [x: string]: unknown } | null;
2252 readonly annotations?: Annotations | null;
2253 readonly description?: string | null;
2254 readonly mimeType?: string | null;
2255 readonly name: string;
2256 readonly size?: number | null;
2257 readonly title?: string | null;
2258 readonly uri: string;
2259 }
2260 | {
2261 readonly type: "resource";
2262 readonly _meta?: { readonly [x: string]: unknown } | null;
2263 readonly annotations?: Annotations | null;
2264 readonly resource: EmbeddedResourceResource;
2265 };
2266 readonly messageId?: string | null;
2267 }
2268 | {
2269 readonly sessionUpdate: "tool_call";
2270 readonly _meta?: { readonly [x: string]: unknown } | null;
2271 readonly content?: ReadonlyArray<ToolCallContent>;
2272 readonly kind?:
2273 | "read"
2274 | "edit"
2275 | "delete"
2276 | "move"
2277 | "search"
2278 | "execute"
2279 | "think"
2280 | "fetch"
2281 | "switch_mode"
2282 | "other";
2283 readonly locations?: ReadonlyArray<ToolCallLocation>;
2284 readonly rawInput?: unknown;
2285 readonly rawOutput?: unknown;
2286 readonly status?: "pending" | "in_progress" | "completed" | "failed";
2287 readonly title: string;
2288 readonly toolCallId: string;
2289 }
2290 | {
2291 readonly sessionUpdate: "tool_call_update";
2292 readonly _meta?: { readonly [x: string]: unknown } | null;
2293 readonly content?: ReadonlyArray<ToolCallContent> | null;
2294 readonly kind?: ToolKind | null;
2295 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
2296 readonly rawInput?: unknown;
2297 readonly rawOutput?: unknown;
2298 readonly status?: ToolCallStatus | null;
2299 readonly title?: string | null;
2300 readonly toolCallId: string;
2301 }
2302 | {
2303 readonly sessionUpdate: "plan";
2304 readonly _meta?: { readonly [x: string]: unknown } | null;
2305 readonly entries: ReadonlyArray<PlanEntry>;
2306 }
2307 | {
2308 readonly sessionUpdate: "available_commands_update";
2309 readonly _meta?: { readonly [x: string]: unknown } | null;
2310 readonly availableCommands: ReadonlyArray<AvailableCommand>;
2311 }
2312 | {
2313 readonly sessionUpdate: "current_mode_update";
2314 readonly _meta?: { readonly [x: string]: unknown } | null;
2315 readonly currentModeId: string;
2316 }
2317 | {
2318 readonly sessionUpdate: "config_option_update";
2319 readonly _meta?: { readonly [x: string]: unknown } | null;
2320 readonly configOptions: ReadonlyArray<SessionConfigOption>;
2321 }
2322 | {
2323 readonly sessionUpdate: "session_info_update";
2324 readonly _meta?: { readonly [x: string]: unknown } | null;
2325 readonly title?: string | null;
2326 readonly updatedAt?: string | null;
2327 }
2328 | {
2329 readonly sessionUpdate: "usage_update";
2330 readonly _meta?: { readonly [x: string]: unknown } | null;
2331 readonly cost?: Cost | null;
2332 readonly size: number;
2333 readonly used: number;
2334 };
2335 }
2336 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly elicitationId: string }
2337 | unknown
2338 | null;
2339};
2340export const AgentNotification = Schema.Struct({
2341 method: Schema.String,
2342 params: Schema.optionalKey(
2343 Schema.Union([
2344 Schema.Union([
2345 Schema.Struct({
2346 _meta: Schema.optionalKey(
2347 Schema.Union([
2348 Schema.Record(Schema.String, Schema.Unknown).annotate({
2349 description:
2350 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2351 }),
2352 Schema.Null,
2353 ]),
2354 ),
2355 sessionId: Schema.String.annotate({
2356 description:
2357 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
2358 }),
2359 update: Schema.Union(
2360 [
2361 Schema.Struct({
2362 sessionUpdate: Schema.Literal("user_message_chunk"),
2363 _meta: Schema.optionalKey(
2364 Schema.Union([
2365 Schema.Record(Schema.String, Schema.Unknown).annotate({
2366 description:
2367 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2368 }),
2369 Schema.Null,
2370 ]),
2371 ),
2372 content: Schema.Union(
2373 [
2374 Schema.Struct({
2375 type: Schema.Literal("text"),
2376 _meta: Schema.optionalKey(
2377 Schema.Union([
2378 Schema.Record(Schema.String, Schema.Unknown).annotate({
2379 description:
2380 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2381 }),
2382 Schema.Null,
2383 ]),
2384 ),
2385 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2386 text: Schema.String,
2387 }).annotate({ description: "Text provided to or from an LLM." }),
2388 Schema.Struct({
2389 type: Schema.Literal("image"),
2390 _meta: Schema.optionalKey(
2391 Schema.Union([
2392 Schema.Record(Schema.String, Schema.Unknown).annotate({
2393 description:
2394 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2395 }),
2396 Schema.Null,
2397 ]),
2398 ),
2399 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2400 data: Schema.String,
2401 mimeType: Schema.String,
2402 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2403 }).annotate({ description: "An image provided to or from an LLM." }),
2404 Schema.Struct({
2405 type: Schema.Literal("audio"),
2406 _meta: Schema.optionalKey(
2407 Schema.Union([
2408 Schema.Record(Schema.String, Schema.Unknown).annotate({
2409 description:
2410 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2411 }),
2412 Schema.Null,
2413 ]),
2414 ),
2415 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2416 data: Schema.String,
2417 mimeType: Schema.String,
2418 }).annotate({ description: "Audio provided to or from an LLM." }),
2419 Schema.Struct({
2420 type: Schema.Literal("resource_link"),
2421 _meta: Schema.optionalKey(
2422 Schema.Union([
2423 Schema.Record(Schema.String, Schema.Unknown).annotate({
2424 description:
2425 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2426 }),
2427 Schema.Null,
2428 ]),
2429 ),
2430 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2431 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2432 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2433 name: Schema.String,
2434 size: Schema.optionalKey(
2435 Schema.Union([
2436 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
2437 Schema.Null,
2438 ]),
2439 ),
2440 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2441 uri: Schema.String,
2442 }).annotate({
2443 description:
2444 "A resource that the server is capable of reading, included in a prompt or tool call result.",
2445 }),
2446 Schema.Struct({
2447 type: Schema.Literal("resource"),
2448 _meta: Schema.optionalKey(
2449 Schema.Union([
2450 Schema.Record(Schema.String, Schema.Unknown).annotate({
2451 description:
2452 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2453 }),
2454 Schema.Null,
2455 ]),
2456 ),
2457 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2458 resource: EmbeddedResourceResource,
2459 }).annotate({
2460 description:
2461 "The contents of a resource, embedded into a prompt or tool call result.",
2462 }),
2463 ],
2464 { mode: "oneOf" },
2465 ).annotate({
2466 description:
2467 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
2468 }),
2469 messageId: Schema.optionalKey(
2470 Schema.Union([
2471 Schema.String.annotate({
2472 description:
2473 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.",
2474 }),
2475 Schema.Null,
2476 ]),
2477 ),
2478 }).annotate({ description: "A streamed item of content" }),
2479 Schema.Struct({
2480 sessionUpdate: Schema.Literal("agent_message_chunk"),
2481 _meta: Schema.optionalKey(
2482 Schema.Union([
2483 Schema.Record(Schema.String, Schema.Unknown).annotate({
2484 description:
2485 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2486 }),
2487 Schema.Null,
2488 ]),
2489 ),
2490 content: Schema.Union(
2491 [
2492 Schema.Struct({
2493 type: Schema.Literal("text"),
2494 _meta: Schema.optionalKey(
2495 Schema.Union([
2496 Schema.Record(Schema.String, Schema.Unknown).annotate({
2497 description:
2498 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2499 }),
2500 Schema.Null,
2501 ]),
2502 ),
2503 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2504 text: Schema.String,
2505 }).annotate({ description: "Text provided to or from an LLM." }),
2506 Schema.Struct({
2507 type: Schema.Literal("image"),
2508 _meta: Schema.optionalKey(
2509 Schema.Union([
2510 Schema.Record(Schema.String, Schema.Unknown).annotate({
2511 description:
2512 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2513 }),
2514 Schema.Null,
2515 ]),
2516 ),
2517 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2518 data: Schema.String,
2519 mimeType: Schema.String,
2520 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2521 }).annotate({ description: "An image provided to or from an LLM." }),
2522 Schema.Struct({
2523 type: Schema.Literal("audio"),
2524 _meta: Schema.optionalKey(
2525 Schema.Union([
2526 Schema.Record(Schema.String, Schema.Unknown).annotate({
2527 description:
2528 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2529 }),
2530 Schema.Null,
2531 ]),
2532 ),
2533 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2534 data: Schema.String,
2535 mimeType: Schema.String,
2536 }).annotate({ description: "Audio provided to or from an LLM." }),
2537 Schema.Struct({
2538 type: Schema.Literal("resource_link"),
2539 _meta: Schema.optionalKey(
2540 Schema.Union([
2541 Schema.Record(Schema.String, Schema.Unknown).annotate({
2542 description:
2543 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2544 }),
2545 Schema.Null,
2546 ]),
2547 ),
2548 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2549 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2550 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2551 name: Schema.String,
2552 size: Schema.optionalKey(
2553 Schema.Union([
2554 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
2555 Schema.Null,
2556 ]),
2557 ),
2558 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2559 uri: Schema.String,
2560 }).annotate({
2561 description:
2562 "A resource that the server is capable of reading, included in a prompt or tool call result.",
2563 }),
2564 Schema.Struct({
2565 type: Schema.Literal("resource"),
2566 _meta: Schema.optionalKey(
2567 Schema.Union([
2568 Schema.Record(Schema.String, Schema.Unknown).annotate({
2569 description:
2570 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2571 }),
2572 Schema.Null,
2573 ]),
2574 ),
2575 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2576 resource: EmbeddedResourceResource,
2577 }).annotate({
2578 description:
2579 "The contents of a resource, embedded into a prompt or tool call result.",
2580 }),
2581 ],
2582 { mode: "oneOf" },
2583 ).annotate({
2584 description:
2585 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
2586 }),
2587 messageId: Schema.optionalKey(
2588 Schema.Union([
2589 Schema.String.annotate({
2590 description:
2591 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.",
2592 }),
2593 Schema.Null,
2594 ]),
2595 ),
2596 }).annotate({ description: "A streamed item of content" }),
2597 Schema.Struct({
2598 sessionUpdate: Schema.Literal("agent_thought_chunk"),
2599 _meta: Schema.optionalKey(
2600 Schema.Union([
2601 Schema.Record(Schema.String, Schema.Unknown).annotate({
2602 description:
2603 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2604 }),
2605 Schema.Null,
2606 ]),
2607 ),
2608 content: Schema.Union(
2609 [
2610 Schema.Struct({
2611 type: Schema.Literal("text"),
2612 _meta: Schema.optionalKey(
2613 Schema.Union([
2614 Schema.Record(Schema.String, Schema.Unknown).annotate({
2615 description:
2616 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2617 }),
2618 Schema.Null,
2619 ]),
2620 ),
2621 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2622 text: Schema.String,
2623 }).annotate({ description: "Text provided to or from an LLM." }),
2624 Schema.Struct({
2625 type: Schema.Literal("image"),
2626 _meta: Schema.optionalKey(
2627 Schema.Union([
2628 Schema.Record(Schema.String, Schema.Unknown).annotate({
2629 description:
2630 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2631 }),
2632 Schema.Null,
2633 ]),
2634 ),
2635 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2636 data: Schema.String,
2637 mimeType: Schema.String,
2638 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2639 }).annotate({ description: "An image provided to or from an LLM." }),
2640 Schema.Struct({
2641 type: Schema.Literal("audio"),
2642 _meta: Schema.optionalKey(
2643 Schema.Union([
2644 Schema.Record(Schema.String, Schema.Unknown).annotate({
2645 description:
2646 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2647 }),
2648 Schema.Null,
2649 ]),
2650 ),
2651 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2652 data: Schema.String,
2653 mimeType: Schema.String,
2654 }).annotate({ description: "Audio provided to or from an LLM." }),
2655 Schema.Struct({
2656 type: Schema.Literal("resource_link"),
2657 _meta: Schema.optionalKey(
2658 Schema.Union([
2659 Schema.Record(Schema.String, Schema.Unknown).annotate({
2660 description:
2661 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2662 }),
2663 Schema.Null,
2664 ]),
2665 ),
2666 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2667 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2668 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2669 name: Schema.String,
2670 size: Schema.optionalKey(
2671 Schema.Union([
2672 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
2673 Schema.Null,
2674 ]),
2675 ),
2676 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2677 uri: Schema.String,
2678 }).annotate({
2679 description:
2680 "A resource that the server is capable of reading, included in a prompt or tool call result.",
2681 }),
2682 Schema.Struct({
2683 type: Schema.Literal("resource"),
2684 _meta: Schema.optionalKey(
2685 Schema.Union([
2686 Schema.Record(Schema.String, Schema.Unknown).annotate({
2687 description:
2688 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2689 }),
2690 Schema.Null,
2691 ]),
2692 ),
2693 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2694 resource: EmbeddedResourceResource,
2695 }).annotate({
2696 description:
2697 "The contents of a resource, embedded into a prompt or tool call result.",
2698 }),
2699 ],
2700 { mode: "oneOf" },
2701 ).annotate({
2702 description:
2703 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
2704 }),
2705 messageId: Schema.optionalKey(
2706 Schema.Union([
2707 Schema.String.annotate({
2708 description:
2709 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.",
2710 }),
2711 Schema.Null,
2712 ]),
2713 ),
2714 }).annotate({ description: "A streamed item of content" }),
2715 Schema.Struct({
2716 sessionUpdate: Schema.Literal("tool_call"),
2717 _meta: Schema.optionalKey(
2718 Schema.Union([
2719 Schema.Record(Schema.String, Schema.Unknown).annotate({
2720 description:
2721 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2722 }),
2723 Schema.Null,
2724 ]),
2725 ),
2726 content: Schema.optionalKey(
2727 Schema.Array(ToolCallContent).annotate({
2728 description: "Content produced by the tool call.",
2729 }),
2730 ),
2731 kind: Schema.optionalKey(
2732 Schema.Literals([
2733 "read",
2734 "edit",
2735 "delete",
2736 "move",
2737 "search",
2738 "execute",
2739 "think",
2740 "fetch",
2741 "switch_mode",
2742 "other",
2743 ]).annotate({
2744 description:
2745 "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)",
2746 }),
2747 ),
2748 locations: Schema.optionalKey(
2749 Schema.Array(ToolCallLocation).annotate({
2750 description:
2751 'File locations affected by this tool call.\nEnables "follow-along" features in clients.',
2752 }),
2753 ),
2754 rawInput: Schema.optionalKey(
2755 Schema.Unknown.annotate({
2756 description: "Raw input parameters sent to the tool.",
2757 }),
2758 ),
2759 rawOutput: Schema.optionalKey(
2760 Schema.Unknown.annotate({ description: "Raw output returned by the tool." }),
2761 ),
2762 status: Schema.optionalKey(
2763 Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({
2764 description:
2765 "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)",
2766 }),
2767 ),
2768 title: Schema.String.annotate({
2769 description: "Human-readable title describing what the tool is doing.",
2770 }),
2771 toolCallId: Schema.String.annotate({
2772 description: "Unique identifier for a tool call within a session.",
2773 }),
2774 }).annotate({
2775 description:
2776 "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)",
2777 }),
2778 Schema.Struct({
2779 sessionUpdate: Schema.Literal("tool_call_update"),
2780 _meta: Schema.optionalKey(
2781 Schema.Union([
2782 Schema.Record(Schema.String, Schema.Unknown).annotate({
2783 description:
2784 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2785 }),
2786 Schema.Null,
2787 ]),
2788 ),
2789 content: Schema.optionalKey(
2790 Schema.Union([
2791 Schema.Array(ToolCallContent).annotate({
2792 description: "Replace the content collection.",
2793 }),
2794 Schema.Null,
2795 ]),
2796 ),
2797 kind: Schema.optionalKey(
2798 Schema.Union([ToolKind, Schema.Null]).annotate({
2799 description: "Update the tool kind.",
2800 }),
2801 ),
2802 locations: Schema.optionalKey(
2803 Schema.Union([
2804 Schema.Array(ToolCallLocation).annotate({
2805 description: "Replace the locations collection.",
2806 }),
2807 Schema.Null,
2808 ]),
2809 ),
2810 rawInput: Schema.optionalKey(
2811 Schema.Unknown.annotate({ description: "Update the raw input." }),
2812 ),
2813 rawOutput: Schema.optionalKey(
2814 Schema.Unknown.annotate({ description: "Update the raw output." }),
2815 ),
2816 status: Schema.optionalKey(
2817 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
2818 description: "Update the execution status.",
2819 }),
2820 ),
2821 title: Schema.optionalKey(
2822 Schema.Union([
2823 Schema.String.annotate({ description: "Update the human-readable title." }),
2824 Schema.Null,
2825 ]),
2826 ),
2827 toolCallId: Schema.String.annotate({
2828 description: "Unique identifier for a tool call within a session.",
2829 }),
2830 }).annotate({
2831 description:
2832 "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)",
2833 }),
2834 Schema.Struct({
2835 sessionUpdate: Schema.Literal("plan"),
2836 _meta: Schema.optionalKey(
2837 Schema.Union([
2838 Schema.Record(Schema.String, Schema.Unknown).annotate({
2839 description:
2840 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2841 }),
2842 Schema.Null,
2843 ]),
2844 ),
2845 entries: Schema.Array(PlanEntry).annotate({
2846 description:
2847 "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.",
2848 }),
2849 }).annotate({
2850 description:
2851 "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)",
2852 }),
2853 Schema.Struct({
2854 sessionUpdate: Schema.Literal("available_commands_update"),
2855 _meta: Schema.optionalKey(
2856 Schema.Union([
2857 Schema.Record(Schema.String, Schema.Unknown).annotate({
2858 description:
2859 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2860 }),
2861 Schema.Null,
2862 ]),
2863 ),
2864 availableCommands: Schema.Array(AvailableCommand).annotate({
2865 description: "Commands the agent can execute",
2866 }),
2867 }).annotate({ description: "Available commands are ready or have changed" }),
2868 Schema.Struct({
2869 sessionUpdate: Schema.Literal("current_mode_update"),
2870 _meta: Schema.optionalKey(
2871 Schema.Union([
2872 Schema.Record(Schema.String, Schema.Unknown).annotate({
2873 description:
2874 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2875 }),
2876 Schema.Null,
2877 ]),
2878 ),
2879 currentModeId: Schema.String.annotate({
2880 description: "Unique identifier for a Session Mode.",
2881 }),
2882 }).annotate({
2883 description:
2884 "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
2885 }),
2886 Schema.Struct({
2887 sessionUpdate: Schema.Literal("config_option_update"),
2888 _meta: Schema.optionalKey(
2889 Schema.Union([
2890 Schema.Record(Schema.String, Schema.Unknown).annotate({
2891 description:
2892 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2893 }),
2894 Schema.Null,
2895 ]),
2896 ),
2897 configOptions: Schema.Array(SessionConfigOption).annotate({
2898 description: "The full set of configuration options and their current values.",
2899 }),
2900 }).annotate({ description: "Session configuration options have been updated." }),
2901 Schema.Struct({
2902 sessionUpdate: Schema.Literal("session_info_update"),
2903 _meta: Schema.optionalKey(
2904 Schema.Union([
2905 Schema.Record(Schema.String, Schema.Unknown).annotate({
2906 description:
2907 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2908 }),
2909 Schema.Null,
2910 ]),
2911 ),
2912 title: Schema.optionalKey(
2913 Schema.Union([
2914 Schema.String.annotate({
2915 description: "Human-readable title for the session. Set to null to clear.",
2916 }),
2917 Schema.Null,
2918 ]),
2919 ),
2920 updatedAt: Schema.optionalKey(
2921 Schema.Union([
2922 Schema.String.annotate({
2923 description: "ISO 8601 timestamp of last activity. Set to null to clear.",
2924 }),
2925 Schema.Null,
2926 ]),
2927 ),
2928 }).annotate({
2929 description:
2930 "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.",
2931 }),
2932 Schema.Struct({
2933 sessionUpdate: Schema.Literal("usage_update"),
2934 _meta: Schema.optionalKey(
2935 Schema.Union([
2936 Schema.Record(Schema.String, Schema.Unknown).annotate({
2937 description:
2938 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2939 }),
2940 Schema.Null,
2941 ]),
2942 ),
2943 cost: Schema.optionalKey(
2944 Schema.Union([Cost, Schema.Null]).annotate({
2945 description: "Cumulative session cost (optional).",
2946 }),
2947 ),
2948 size: Schema.Number.annotate({
2949 description: "Total context window size in tokens.",
2950 format: "uint64",
2951 })
2952 .check(Schema.isInt())
2953 .check(Schema.isGreaterThanOrEqualTo(0)),
2954 used: Schema.Number.annotate({
2955 description: "Tokens currently in context.",
2956 format: "uint64",
2957 })
2958 .check(Schema.isInt())
2959 .check(Schema.isGreaterThanOrEqualTo(0)),
2960 }).annotate({
2961 description:
2962 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nContext window and cost update for a session.",
2963 }),
2964 ],
2965 { mode: "oneOf" },
2966 ).annotate({
2967 description:
2968 "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)",
2969 }),
2970 }).annotate({
2971 title: "SessionNotification",
2972 description:
2973 "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)",
2974 }),
2975 Schema.Struct({
2976 _meta: Schema.optionalKey(
2977 Schema.Union([
2978 Schema.Record(Schema.String, Schema.Unknown).annotate({
2979 description:
2980 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2981 }),
2982 Schema.Null,
2983 ]),
2984 ),
2985 elicitationId: Schema.String.annotate({
2986 description:
2987 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.",
2988 }),
2989 }).annotate({
2990 title: "ElicitationCompleteNotification",
2991 description:
2992 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification sent by the agent when a URL-based elicitation is complete.",
2993 }),
2994 Schema.Unknown.annotate({
2995 title: "ExtNotification",
2996 description:
2997 "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2998 }),
2999 ]).annotate({
3000 description:
3001 "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response.",
3002 }),
3003 Schema.Null,
3004 ]),
3005 ),
3006});
3007
3008export type AgentRequest = {
3009 readonly id: RequestId;
3010 readonly method: string;
3011 readonly params?:
3012 | {
3013 readonly _meta?: { readonly [x: string]: unknown } | null;
3014 readonly content: string;
3015 readonly path: string;
3016 readonly sessionId: string;
3017 }
3018 | {
3019 readonly _meta?: { readonly [x: string]: unknown } | null;
3020 readonly limit?: number | null;
3021 readonly line?: number | null;
3022 readonly path: string;
3023 readonly sessionId: string;
3024 }
3025 | {
3026 readonly _meta?: { readonly [x: string]: unknown } | null;
3027 readonly options: ReadonlyArray<PermissionOption>;
3028 readonly sessionId: string;
3029 readonly toolCall: {
3030 readonly _meta?: { readonly [x: string]: unknown } | null;
3031 readonly content?: ReadonlyArray<ToolCallContent> | null;
3032 readonly kind?: ToolKind | null;
3033 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
3034 readonly rawInput?: unknown;
3035 readonly rawOutput?: unknown;
3036 readonly status?: ToolCallStatus | null;
3037 readonly title?: string | null;
3038 readonly toolCallId: string;
3039 };
3040 }
3041 | {
3042 readonly _meta?: { readonly [x: string]: unknown } | null;
3043 readonly args?: ReadonlyArray<string>;
3044 readonly command: string;
3045 readonly cwd?: string | null;
3046 readonly env?: ReadonlyArray<EnvVariable>;
3047 readonly outputByteLimit?: number | null;
3048 readonly sessionId: string;
3049 }
3050 | {
3051 readonly _meta?: { readonly [x: string]: unknown } | null;
3052 readonly sessionId: string;
3053 readonly terminalId: string;
3054 }
3055 | {
3056 readonly _meta?: { readonly [x: string]: unknown } | null;
3057 readonly sessionId: string;
3058 readonly terminalId: string;
3059 }
3060 | {
3061 readonly _meta?: { readonly [x: string]: unknown } | null;
3062 readonly sessionId: string;
3063 readonly terminalId: string;
3064 }
3065 | {
3066 readonly _meta?: { readonly [x: string]: unknown } | null;
3067 readonly sessionId: string;
3068 readonly terminalId: string;
3069 }
3070 | {
3071 readonly mode: "form";
3072 readonly requestedSchema: {
3073 readonly description?: string | null;
3074 readonly properties?: { readonly [x: string]: ElicitationPropertySchema };
3075 readonly required?: ReadonlyArray<string> | null;
3076 readonly title?: string | null;
3077 readonly type?: "object";
3078 };
3079 readonly _meta?: { readonly [x: string]: unknown } | null;
3080 readonly message: string;
3081 readonly sessionId: string;
3082 }
3083 | {
3084 readonly mode: "url";
3085 readonly elicitationId: string;
3086 readonly url: string;
3087 readonly _meta?: { readonly [x: string]: unknown } | null;
3088 readonly message: string;
3089 readonly sessionId: string;
3090 }
3091 | unknown
3092 | null;
3093};
3094export const AgentRequest = Schema.Struct({
3095 id: RequestId,
3096 method: Schema.String,
3097 params: Schema.optionalKey(
3098 Schema.Union([
3099 Schema.Union([
3100 Schema.Struct({
3101 _meta: Schema.optionalKey(
3102 Schema.Union([
3103 Schema.Record(Schema.String, Schema.Unknown).annotate({
3104 description:
3105 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3106 }),
3107 Schema.Null,
3108 ]),
3109 ),
3110 content: Schema.String.annotate({
3111 description: "The text content to write to the file.",
3112 }),
3113 path: Schema.String.annotate({ description: "Absolute path to the file to write." }),
3114 sessionId: Schema.String.annotate({
3115 description:
3116 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3117 }),
3118 }).annotate({
3119 title: "WriteTextFileRequest",
3120 description:
3121 "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.",
3122 }),
3123 Schema.Struct({
3124 _meta: Schema.optionalKey(
3125 Schema.Union([
3126 Schema.Record(Schema.String, Schema.Unknown).annotate({
3127 description:
3128 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3129 }),
3130 Schema.Null,
3131 ]),
3132 ),
3133 limit: Schema.optionalKey(
3134 Schema.Union([
3135 Schema.Number.annotate({
3136 description: "Maximum number of lines to read.",
3137 format: "uint32",
3138 })
3139 .check(Schema.isInt())
3140 .check(Schema.isGreaterThanOrEqualTo(0)),
3141 Schema.Null,
3142 ]),
3143 ),
3144 line: Schema.optionalKey(
3145 Schema.Union([
3146 Schema.Number.annotate({
3147 description: "Line number to start reading from (1-based).",
3148 format: "uint32",
3149 })
3150 .check(Schema.isInt())
3151 .check(Schema.isGreaterThanOrEqualTo(0)),
3152 Schema.Null,
3153 ]),
3154 ),
3155 path: Schema.String.annotate({ description: "Absolute path to the file to read." }),
3156 sessionId: Schema.String.annotate({
3157 description:
3158 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3159 }),
3160 }).annotate({
3161 title: "ReadTextFileRequest",
3162 description:
3163 "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.",
3164 }),
3165 Schema.Struct({
3166 _meta: Schema.optionalKey(
3167 Schema.Union([
3168 Schema.Record(Schema.String, Schema.Unknown).annotate({
3169 description:
3170 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3171 }),
3172 Schema.Null,
3173 ]),
3174 ),
3175 options: Schema.Array(PermissionOption).annotate({
3176 description: "Available permission options for the user to choose from.",
3177 }),
3178 sessionId: Schema.String.annotate({
3179 description:
3180 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3181 }),
3182 toolCall: Schema.Struct({
3183 _meta: Schema.optionalKey(
3184 Schema.Union([
3185 Schema.Record(Schema.String, Schema.Unknown).annotate({
3186 description:
3187 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3188 }),
3189 Schema.Null,
3190 ]),
3191 ),
3192 content: Schema.optionalKey(
3193 Schema.Union([
3194 Schema.Array(ToolCallContent).annotate({
3195 description: "Replace the content collection.",
3196 }),
3197 Schema.Null,
3198 ]),
3199 ),
3200 kind: Schema.optionalKey(
3201 Schema.Union([ToolKind, Schema.Null]).annotate({
3202 description: "Update the tool kind.",
3203 }),
3204 ),
3205 locations: Schema.optionalKey(
3206 Schema.Union([
3207 Schema.Array(ToolCallLocation).annotate({
3208 description: "Replace the locations collection.",
3209 }),
3210 Schema.Null,
3211 ]),
3212 ),
3213 rawInput: Schema.optionalKey(
3214 Schema.Unknown.annotate({ description: "Update the raw input." }),
3215 ),
3216 rawOutput: Schema.optionalKey(
3217 Schema.Unknown.annotate({ description: "Update the raw output." }),
3218 ),
3219 status: Schema.optionalKey(
3220 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
3221 description: "Update the execution status.",
3222 }),
3223 ),
3224 title: Schema.optionalKey(
3225 Schema.Union([
3226 Schema.String.annotate({ description: "Update the human-readable title." }),
3227 Schema.Null,
3228 ]),
3229 ),
3230 toolCallId: Schema.String.annotate({
3231 description: "Unique identifier for a tool call within a session.",
3232 }),
3233 }).annotate({
3234 description:
3235 "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)",
3236 }),
3237 }).annotate({
3238 title: "RequestPermissionRequest",
3239 description:
3240 "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)",
3241 }),
3242 Schema.Struct({
3243 _meta: Schema.optionalKey(
3244 Schema.Union([
3245 Schema.Record(Schema.String, Schema.Unknown).annotate({
3246 description:
3247 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3248 }),
3249 Schema.Null,
3250 ]),
3251 ),
3252 args: Schema.optionalKey(
3253 Schema.Array(Schema.String).annotate({ description: "Array of command arguments." }),
3254 ),
3255 command: Schema.String.annotate({ description: "The command to execute." }),
3256 cwd: Schema.optionalKey(
3257 Schema.Union([
3258 Schema.String.annotate({
3259 description: "Working directory for the command (absolute path).",
3260 }),
3261 Schema.Null,
3262 ]),
3263 ),
3264 env: Schema.optionalKey(
3265 Schema.Array(EnvVariable).annotate({
3266 description: "Environment variables for the command.",
3267 }),
3268 ),
3269 outputByteLimit: Schema.optionalKey(
3270 Schema.Union([
3271 Schema.Number.annotate({
3272 description:
3273 "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.",
3274 format: "uint64",
3275 })
3276 .check(Schema.isInt())
3277 .check(Schema.isGreaterThanOrEqualTo(0)),
3278 Schema.Null,
3279 ]),
3280 ),
3281 sessionId: Schema.String.annotate({
3282 description:
3283 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3284 }),
3285 }).annotate({
3286 title: "CreateTerminalRequest",
3287 description: "Request to create a new terminal and execute a command.",
3288 }),
3289 Schema.Struct({
3290 _meta: Schema.optionalKey(
3291 Schema.Union([
3292 Schema.Record(Schema.String, Schema.Unknown).annotate({
3293 description:
3294 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3295 }),
3296 Schema.Null,
3297 ]),
3298 ),
3299 sessionId: Schema.String.annotate({
3300 description:
3301 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3302 }),
3303 terminalId: Schema.String.annotate({
3304 description: "The ID of the terminal to get output from.",
3305 }),
3306 }).annotate({
3307 title: "TerminalOutputRequest",
3308 description: "Request to get the current output and status of a terminal.",
3309 }),
3310 Schema.Struct({
3311 _meta: Schema.optionalKey(
3312 Schema.Union([
3313 Schema.Record(Schema.String, Schema.Unknown).annotate({
3314 description:
3315 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3316 }),
3317 Schema.Null,
3318 ]),
3319 ),
3320 sessionId: Schema.String.annotate({
3321 description:
3322 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3323 }),
3324 terminalId: Schema.String.annotate({ description: "The ID of the terminal to release." }),
3325 }).annotate({
3326 title: "ReleaseTerminalRequest",
3327 description: "Request to release a terminal and free its resources.",
3328 }),
3329 Schema.Struct({
3330 _meta: Schema.optionalKey(
3331 Schema.Union([
3332 Schema.Record(Schema.String, Schema.Unknown).annotate({
3333 description:
3334 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3335 }),
3336 Schema.Null,
3337 ]),
3338 ),
3339 sessionId: Schema.String.annotate({
3340 description:
3341 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3342 }),
3343 terminalId: Schema.String.annotate({
3344 description: "The ID of the terminal to wait for.",
3345 }),
3346 }).annotate({
3347 title: "WaitForTerminalExitRequest",
3348 description: "Request to wait for a terminal command to exit.",
3349 }),
3350 Schema.Struct({
3351 _meta: Schema.optionalKey(
3352 Schema.Union([
3353 Schema.Record(Schema.String, Schema.Unknown).annotate({
3354 description:
3355 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3356 }),
3357 Schema.Null,
3358 ]),
3359 ),
3360 sessionId: Schema.String.annotate({
3361 description:
3362 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3363 }),
3364 terminalId: Schema.String.annotate({ description: "The ID of the terminal to kill." }),
3365 }).annotate({
3366 title: "KillTerminalRequest",
3367 description: "Request to kill a terminal without releasing it.",
3368 }),
3369 Schema.Union([
3370 Schema.Struct({
3371 mode: Schema.Literal("form"),
3372 requestedSchema: Schema.Struct({
3373 description: Schema.optionalKey(
3374 Schema.Union([
3375 Schema.String.annotate({
3376 description: "Optional description of what this schema represents.",
3377 }),
3378 Schema.Null,
3379 ]),
3380 ),
3381 properties: Schema.optionalKey(
3382 Schema.Record(Schema.String, ElicitationPropertySchema).annotate({
3383 description: "Property definitions (must be primitive types).",
3384 default: {},
3385 }),
3386 ),
3387 required: Schema.optionalKey(
3388 Schema.Union([
3389 Schema.Array(Schema.String).annotate({
3390 description: "List of required property names.",
3391 }),
3392 Schema.Null,
3393 ]),
3394 ),
3395 title: Schema.optionalKey(
3396 Schema.Union([
3397 Schema.String.annotate({ description: "Optional title for the schema." }),
3398 Schema.Null,
3399 ]),
3400 ),
3401 type: Schema.optionalKey(
3402 Schema.Literal("object").annotate({
3403 description: "Type discriminator for elicitation schemas.",
3404 default: "object",
3405 }),
3406 ),
3407 }).annotate({
3408 description:
3409 "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.",
3410 }),
3411 _meta: Schema.optionalKey(
3412 Schema.Union([
3413 Schema.Record(Schema.String, Schema.Unknown).annotate({
3414 description:
3415 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3416 }),
3417 Schema.Null,
3418 ]),
3419 ),
3420 message: Schema.String.annotate({
3421 description: "A human-readable message describing what input is needed.",
3422 }),
3423 sessionId: Schema.String.annotate({
3424 description:
3425 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3426 }),
3427 }).annotate({
3428 description:
3429 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.",
3430 }),
3431 Schema.Struct({
3432 mode: Schema.Literal("url"),
3433 elicitationId: Schema.String.annotate({
3434 description:
3435 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.",
3436 }),
3437 url: Schema.String.annotate({
3438 description: "The URL to direct the user to.",
3439 format: "uri",
3440 }),
3441 _meta: Schema.optionalKey(
3442 Schema.Union([
3443 Schema.Record(Schema.String, Schema.Unknown).annotate({
3444 description:
3445 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3446 }),
3447 Schema.Null,
3448 ]),
3449 ),
3450 message: Schema.String.annotate({
3451 description: "A human-readable message describing what input is needed.",
3452 }),
3453 sessionId: Schema.String.annotate({
3454 description:
3455 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3456 }),
3457 }).annotate({
3458 description:
3459 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.",
3460 }),
3461 ]).annotate({
3462 title: "ElicitationRequest",
3463 description:
3464 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequests structured user input via a form or URL.",
3465 }),
3466 Schema.Unknown.annotate({
3467 title: "ExtMethodRequest",
3468 description:
3469 "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3470 }),
3471 ]).annotate({
3472 description:
3473 "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Client`] trait.\n\nThis enum encompasses all method calls from agent to client.",
3474 }),
3475 Schema.Null,
3476 ]),
3477 ),
3478});
3479
3480export type AgentResponse =
3481 | {
3482 readonly id: RequestId;
3483 readonly result:
3484 | {
3485 readonly _meta?: { readonly [x: string]: unknown } | null;
3486 readonly agentCapabilities?: {
3487 readonly _meta?: { readonly [x: string]: unknown } | null;
3488 readonly auth?: {
3489 readonly _meta?: { readonly [x: string]: unknown } | null;
3490 readonly logout?: LogoutCapabilities | null;
3491 };
3492 readonly loadSession?: boolean;
3493 readonly mcpCapabilities?: {
3494 readonly _meta?: { readonly [x: string]: unknown } | null;
3495 readonly http?: boolean;
3496 readonly sse?: boolean;
3497 };
3498 readonly promptCapabilities?: {
3499 readonly _meta?: { readonly [x: string]: unknown } | null;
3500 readonly audio?: boolean;
3501 readonly embeddedContext?: boolean;
3502 readonly image?: boolean;
3503 };
3504 readonly sessionCapabilities?: {
3505 readonly _meta?: { readonly [x: string]: unknown } | null;
3506 readonly close?: SessionCloseCapabilities | null;
3507 readonly fork?: SessionForkCapabilities | null;
3508 readonly list?: SessionListCapabilities | null;
3509 readonly resume?: SessionResumeCapabilities | null;
3510 };
3511 };
3512 readonly agentInfo?: Implementation | null;
3513 readonly authMethods?: ReadonlyArray<AuthMethod>;
3514 readonly protocolVersion: number;
3515 }
3516 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3517 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3518 | {
3519 readonly _meta?: { readonly [x: string]: unknown } | null;
3520 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3521 readonly models?: SessionModelState | null;
3522 readonly modes?: SessionModeState | null;
3523 readonly sessionId: string;
3524 }
3525 | {
3526 readonly _meta?: { readonly [x: string]: unknown } | null;
3527 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3528 readonly models?: SessionModelState | null;
3529 readonly modes?: SessionModeState | null;
3530 }
3531 | {
3532 readonly _meta?: { readonly [x: string]: unknown } | null;
3533 readonly nextCursor?: string | null;
3534 readonly sessions: ReadonlyArray<SessionInfo>;
3535 }
3536 | {
3537 readonly _meta?: { readonly [x: string]: unknown } | null;
3538 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3539 readonly models?: SessionModelState | null;
3540 readonly modes?: SessionModeState | null;
3541 readonly sessionId: string;
3542 }
3543 | {
3544 readonly _meta?: { readonly [x: string]: unknown } | null;
3545 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3546 readonly models?: SessionModelState | null;
3547 readonly modes?: SessionModeState | null;
3548 }
3549 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3550 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3551 | {
3552 readonly _meta?: { readonly [x: string]: unknown } | null;
3553 readonly configOptions: ReadonlyArray<SessionConfigOption>;
3554 }
3555 | {
3556 readonly _meta?: { readonly [x: string]: unknown } | null;
3557 readonly stopReason:
3558 | "end_turn"
3559 | "max_tokens"
3560 | "max_turn_requests"
3561 | "refusal"
3562 | "cancelled";
3563 readonly usage?: Usage | null;
3564 readonly userMessageId?: string | null;
3565 }
3566 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3567 | unknown;
3568 }
3569 | { readonly error: Error; readonly id: RequestId };
3570export const AgentResponse = Schema.Union([
3571 Schema.Struct({
3572 id: RequestId,
3573 result: Schema.Union([
3574 Schema.Struct({
3575 _meta: Schema.optionalKey(
3576 Schema.Union([
3577 Schema.Record(Schema.String, Schema.Unknown).annotate({
3578 description:
3579 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3580 }),
3581 Schema.Null,
3582 ]),
3583 ),
3584 agentCapabilities: Schema.optionalKey(
3585 Schema.Struct({
3586 _meta: Schema.optionalKey(
3587 Schema.Union([
3588 Schema.Record(Schema.String, Schema.Unknown).annotate({
3589 description:
3590 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3591 }),
3592 Schema.Null,
3593 ]),
3594 ),
3595 auth: Schema.optionalKey(
3596 Schema.Struct({
3597 _meta: Schema.optionalKey(
3598 Schema.Union([
3599 Schema.Record(Schema.String, Schema.Unknown).annotate({
3600 description:
3601 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3602 }),
3603 Schema.Null,
3604 ]),
3605 ),
3606 logout: Schema.optionalKey(
3607 Schema.Union([LogoutCapabilities, Schema.Null]).annotate({
3608 description:
3609 "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.",
3610 }),
3611 ),
3612 }).annotate({
3613 description:
3614 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.",
3615 default: {},
3616 }),
3617 ),
3618 loadSession: Schema.optionalKey(
3619 Schema.Boolean.annotate({
3620 description: "Whether the agent supports `session/load`.",
3621 default: false,
3622 }),
3623 ),
3624 mcpCapabilities: Schema.optionalKey(
3625 Schema.Struct({
3626 _meta: Schema.optionalKey(
3627 Schema.Union([
3628 Schema.Record(Schema.String, Schema.Unknown).annotate({
3629 description:
3630 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3631 }),
3632 Schema.Null,
3633 ]),
3634 ),
3635 http: Schema.optionalKey(
3636 Schema.Boolean.annotate({
3637 description: "Agent supports [`McpServer::Http`].",
3638 default: false,
3639 }),
3640 ),
3641 sse: Schema.optionalKey(
3642 Schema.Boolean.annotate({
3643 description: "Agent supports [`McpServer::Sse`].",
3644 default: false,
3645 }),
3646 ),
3647 }).annotate({
3648 description: "MCP capabilities supported by the agent",
3649 default: { http: false, sse: false },
3650 }),
3651 ),
3652 promptCapabilities: Schema.optionalKey(
3653 Schema.Struct({
3654 _meta: Schema.optionalKey(
3655 Schema.Union([
3656 Schema.Record(Schema.String, Schema.Unknown).annotate({
3657 description:
3658 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3659 }),
3660 Schema.Null,
3661 ]),
3662 ),
3663 audio: Schema.optionalKey(
3664 Schema.Boolean.annotate({
3665 description: "Agent supports [`ContentBlock::Audio`].",
3666 default: false,
3667 }),
3668 ),
3669 embeddedContext: Schema.optionalKey(
3670 Schema.Boolean.annotate({
3671 description:
3672 "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.",
3673 default: false,
3674 }),
3675 ),
3676 image: Schema.optionalKey(
3677 Schema.Boolean.annotate({
3678 description: "Agent supports [`ContentBlock::Image`].",
3679 default: false,
3680 }),
3681 ),
3682 }).annotate({
3683 description:
3684 "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)",
3685 default: { audio: false, embeddedContext: false, image: false },
3686 }),
3687 ),
3688 sessionCapabilities: Schema.optionalKey(
3689 Schema.Struct({
3690 _meta: Schema.optionalKey(
3691 Schema.Union([
3692 Schema.Record(Schema.String, Schema.Unknown).annotate({
3693 description:
3694 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3695 }),
3696 Schema.Null,
3697 ]),
3698 ),
3699 close: Schema.optionalKey(
3700 Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({
3701 description:
3702 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.",
3703 }),
3704 ),
3705 fork: Schema.optionalKey(
3706 Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({
3707 description:
3708 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.",
3709 }),
3710 ),
3711 list: Schema.optionalKey(
3712 Schema.Union([SessionListCapabilities, Schema.Null]).annotate({
3713 description: "Whether the agent supports `session/list`.",
3714 }),
3715 ),
3716 resume: Schema.optionalKey(
3717 Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({
3718 description:
3719 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.",
3720 }),
3721 ),
3722 }).annotate({
3723 default: {},
3724 description:
3725 "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)",
3726 }),
3727 ),
3728 }).annotate({
3729 description:
3730 "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)",
3731 default: {
3732 auth: {},
3733 loadSession: false,
3734 mcpCapabilities: { http: false, sse: false },
3735 promptCapabilities: { audio: false, embeddedContext: false, image: false },
3736 sessionCapabilities: {},
3737 },
3738 }),
3739 ),
3740 agentInfo: Schema.optionalKey(
3741 Schema.Union([Implementation, Schema.Null]).annotate({
3742 description:
3743 "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required.",
3744 }),
3745 ),
3746 authMethods: Schema.optionalKey(
3747 Schema.Array(AuthMethod).annotate({
3748 description: "Authentication methods supported by the agent.",
3749 default: [],
3750 }),
3751 ),
3752 protocolVersion: Schema.Number.annotate({
3753 description:
3754 "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.",
3755 format: "uint16",
3756 })
3757 .check(Schema.isInt())
3758 .check(Schema.isGreaterThanOrEqualTo(0))
3759 .check(Schema.isLessThanOrEqualTo(65535)),
3760 }).annotate({
3761 title: "InitializeResponse",
3762 description:
3763 "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)",
3764 }),
3765 Schema.Struct({
3766 _meta: Schema.optionalKey(
3767 Schema.Union([
3768 Schema.Record(Schema.String, Schema.Unknown).annotate({
3769 description:
3770 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3771 }),
3772 Schema.Null,
3773 ]),
3774 ),
3775 }).annotate({
3776 title: "AuthenticateResponse",
3777 description: "Response to the `authenticate` method.",
3778 }),
3779 Schema.Struct({
3780 _meta: Schema.optionalKey(
3781 Schema.Union([
3782 Schema.Record(Schema.String, Schema.Unknown).annotate({
3783 description:
3784 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3785 }),
3786 Schema.Null,
3787 ]),
3788 ),
3789 }).annotate({
3790 title: "LogoutResponse",
3791 description:
3792 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to the `logout` method.",
3793 }),
3794 Schema.Struct({
3795 _meta: Schema.optionalKey(
3796 Schema.Union([
3797 Schema.Record(Schema.String, Schema.Unknown).annotate({
3798 description:
3799 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3800 }),
3801 Schema.Null,
3802 ]),
3803 ),
3804 configOptions: Schema.optionalKey(
3805 Schema.Union([
3806 Schema.Array(SessionConfigOption).annotate({
3807 description: "Initial session configuration options if supported by the Agent.",
3808 }),
3809 Schema.Null,
3810 ]),
3811 ),
3812 models: Schema.optionalKey(
3813 Schema.Union([SessionModelState, Schema.Null]).annotate({
3814 description:
3815 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3816 }),
3817 ),
3818 modes: Schema.optionalKey(
3819 Schema.Union([SessionModeState, Schema.Null]).annotate({
3820 description:
3821 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3822 }),
3823 ),
3824 sessionId: Schema.String.annotate({
3825 description:
3826 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3827 }),
3828 }).annotate({
3829 title: "NewSessionResponse",
3830 description:
3831 "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)",
3832 }),
3833 Schema.Struct({
3834 _meta: Schema.optionalKey(
3835 Schema.Union([
3836 Schema.Record(Schema.String, Schema.Unknown).annotate({
3837 description:
3838 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3839 }),
3840 Schema.Null,
3841 ]),
3842 ),
3843 configOptions: Schema.optionalKey(
3844 Schema.Union([
3845 Schema.Array(SessionConfigOption).annotate({
3846 description: "Initial session configuration options if supported by the Agent.",
3847 }),
3848 Schema.Null,
3849 ]),
3850 ),
3851 models: Schema.optionalKey(
3852 Schema.Union([SessionModelState, Schema.Null]).annotate({
3853 description:
3854 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3855 }),
3856 ),
3857 modes: Schema.optionalKey(
3858 Schema.Union([SessionModeState, Schema.Null]).annotate({
3859 description:
3860 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3861 }),
3862 ),
3863 }).annotate({
3864 title: "LoadSessionResponse",
3865 description: "Response from loading an existing session.",
3866 }),
3867 Schema.Struct({
3868 _meta: Schema.optionalKey(
3869 Schema.Union([
3870 Schema.Record(Schema.String, Schema.Unknown).annotate({
3871 description:
3872 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3873 }),
3874 Schema.Null,
3875 ]),
3876 ),
3877 nextCursor: Schema.optionalKey(
3878 Schema.Union([
3879 Schema.String.annotate({
3880 description:
3881 "Opaque cursor token. If present, pass this in the next request's cursor parameter\nto fetch the next page. If absent, there are no more results.",
3882 }),
3883 Schema.Null,
3884 ]),
3885 ),
3886 sessions: Schema.Array(SessionInfo).annotate({
3887 description: "Array of session information objects",
3888 }),
3889 }).annotate({
3890 title: "ListSessionsResponse",
3891 description: "Response from listing sessions.",
3892 }),
3893 Schema.Struct({
3894 _meta: Schema.optionalKey(
3895 Schema.Union([
3896 Schema.Record(Schema.String, Schema.Unknown).annotate({
3897 description:
3898 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3899 }),
3900 Schema.Null,
3901 ]),
3902 ),
3903 configOptions: Schema.optionalKey(
3904 Schema.Union([
3905 Schema.Array(SessionConfigOption).annotate({
3906 description: "Initial session configuration options if supported by the Agent.",
3907 }),
3908 Schema.Null,
3909 ]),
3910 ),
3911 models: Schema.optionalKey(
3912 Schema.Union([SessionModelState, Schema.Null]).annotate({
3913 description:
3914 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3915 }),
3916 ),
3917 modes: Schema.optionalKey(
3918 Schema.Union([SessionModeState, Schema.Null]).annotate({
3919 description:
3920 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3921 }),
3922 ),
3923 sessionId: Schema.String.annotate({
3924 description:
3925 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3926 }),
3927 }).annotate({
3928 title: "ForkSessionResponse",
3929 description:
3930 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from forking an existing session.",
3931 }),
3932 Schema.Struct({
3933 _meta: Schema.optionalKey(
3934 Schema.Union([
3935 Schema.Record(Schema.String, Schema.Unknown).annotate({
3936 description:
3937 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3938 }),
3939 Schema.Null,
3940 ]),
3941 ),
3942 configOptions: Schema.optionalKey(
3943 Schema.Union([
3944 Schema.Array(SessionConfigOption).annotate({
3945 description: "Initial session configuration options if supported by the Agent.",
3946 }),
3947 Schema.Null,
3948 ]),
3949 ),
3950 models: Schema.optionalKey(
3951 Schema.Union([SessionModelState, Schema.Null]).annotate({
3952 description:
3953 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3954 }),
3955 ),
3956 modes: Schema.optionalKey(
3957 Schema.Union([SessionModeState, Schema.Null]).annotate({
3958 description:
3959 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3960 }),
3961 ),
3962 }).annotate({
3963 title: "ResumeSessionResponse",
3964 description:
3965 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from resuming an existing session.",
3966 }),
3967 Schema.Struct({
3968 _meta: Schema.optionalKey(
3969 Schema.Union([
3970 Schema.Record(Schema.String, Schema.Unknown).annotate({
3971 description:
3972 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3973 }),
3974 Schema.Null,
3975 ]),
3976 ),
3977 }).annotate({
3978 title: "CloseSessionResponse",
3979 description:
3980 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from closing a session.",
3981 }),
3982 Schema.Struct({
3983 _meta: Schema.optionalKey(
3984 Schema.Union([
3985 Schema.Record(Schema.String, Schema.Unknown).annotate({
3986 description:
3987 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3988 }),
3989 Schema.Null,
3990 ]),
3991 ),
3992 }).annotate({
3993 title: "SetSessionModeResponse",
3994 description: "Response to `session/set_mode` method.",
3995 }),
3996 Schema.Struct({
3997 _meta: Schema.optionalKey(
3998 Schema.Union([
3999 Schema.Record(Schema.String, Schema.Unknown).annotate({
4000 description:
4001 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4002 }),
4003 Schema.Null,
4004 ]),
4005 ),
4006 configOptions: Schema.Array(SessionConfigOption).annotate({
4007 description: "The full set of configuration options and their current values.",
4008 }),
4009 }).annotate({
4010 title: "SetSessionConfigOptionResponse",
4011 description: "Response to `session/set_config_option` method.",
4012 }),
4013 Schema.Struct({
4014 _meta: Schema.optionalKey(
4015 Schema.Union([
4016 Schema.Record(Schema.String, Schema.Unknown).annotate({
4017 description:
4018 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4019 }),
4020 Schema.Null,
4021 ]),
4022 ),
4023 stopReason: Schema.Literals([
4024 "end_turn",
4025 "max_tokens",
4026 "max_turn_requests",
4027 "refusal",
4028 "cancelled",
4029 ]).annotate({
4030 description:
4031 "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)",
4032 }),
4033 usage: Schema.optionalKey(
4034 Schema.Union([Usage, Schema.Null]).annotate({
4035 description:
4036 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nToken usage for this turn (optional).",
4037 }),
4038 ),
4039 userMessageId: Schema.optionalKey(
4040 Schema.Union([
4041 Schema.String.annotate({
4042 description:
4043 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe acknowledged user message ID.\n\nIf the client provided a `messageId` in the [`PromptRequest`], the agent echoes it here\nto confirm it was recorded. If the client did not provide one, the agent MAY assign one\nand return it here. Absence of this field indicates the agent did not record a message ID.",
4044 }),
4045 Schema.Null,
4046 ]),
4047 ),
4048 }).annotate({
4049 title: "PromptResponse",
4050 description:
4051 "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)",
4052 }),
4053 Schema.Struct({
4054 _meta: Schema.optionalKey(
4055 Schema.Union([
4056 Schema.Record(Schema.String, Schema.Unknown).annotate({
4057 description:
4058 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4059 }),
4060 Schema.Null,
4061 ]),
4062 ),
4063 }).annotate({
4064 title: "SetSessionModelResponse",
4065 description:
4066 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to `session/set_model` method.",
4067 }),
4068 Schema.Unknown.annotate({
4069 title: "ExtMethodResponse",
4070 description:
4071 "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4072 }),
4073 ]).annotate({
4074 description:
4075 "All possible responses that an agent can send to a client.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `ClientRequest` variants.",
4076 }),
4077 }).annotate({ title: "Result" }),
4078 Schema.Struct({ error: Error, id: RequestId }).annotate({ title: "Error" }),
4079]);
4080
4081export type AudioContent = {
4082 readonly _meta?: { readonly [x: string]: unknown } | null;
4083 readonly annotations?: Annotations | null;
4084 readonly data: string;
4085 readonly mimeType: string;
4086};
4087export const AudioContent = Schema.Struct({
4088 _meta: Schema.optionalKey(
4089 Schema.Union([
4090 Schema.Record(Schema.String, Schema.Unknown).annotate({
4091 description:
4092 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4093 }),
4094 Schema.Null,
4095 ]),
4096 ),
4097 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
4098 data: Schema.String,
4099 mimeType: Schema.String,
4100}).annotate({ description: "Audio provided to or from an LLM." });
4101
4102export type AuthCapabilities = {
4103 readonly _meta?: { readonly [x: string]: unknown } | null;
4104 readonly terminal?: boolean;
4105};
4106export const AuthCapabilities = Schema.Struct({
4107 _meta: Schema.optionalKey(
4108 Schema.Union([
4109 Schema.Record(Schema.String, Schema.Unknown).annotate({
4110 description:
4111 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4112 }),
4113 Schema.Null,
4114 ]),
4115 ),
4116 terminal: Schema.optionalKey(
4117 Schema.Boolean.annotate({
4118 description:
4119 "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.",
4120 default: false,
4121 }),
4122 ),
4123}).annotate({
4124 description:
4125 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.",
4126});
4127
4128export type AuthenticateRequest = {
4129 readonly _meta?: { readonly [x: string]: unknown } | null;
4130 readonly methodId: string;
4131};
4132export const AuthenticateRequest = Schema.Struct({
4133 _meta: Schema.optionalKey(
4134 Schema.Union([
4135 Schema.Record(Schema.String, Schema.Unknown).annotate({
4136 description:
4137 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4138 }),
4139 Schema.Null,
4140 ]),
4141 ),
4142 methodId: Schema.String.annotate({
4143 description:
4144 "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.",
4145 }),
4146}).annotate({
4147 description:
4148 "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.",
4149});
4150
4151export type AuthenticateResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
4152export const AuthenticateResponse = Schema.Struct({
4153 _meta: Schema.optionalKey(
4154 Schema.Union([
4155 Schema.Record(Schema.String, Schema.Unknown).annotate({
4156 description:
4157 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4158 }),
4159 Schema.Null,
4160 ]),
4161 ),
4162}).annotate({ description: "Response to the `authenticate` method." });
4163
4164export type AuthMethodAgent = {
4165 readonly _meta?: { readonly [x: string]: unknown } | null;
4166 readonly description?: string | null;
4167 readonly id: string;
4168 readonly name: string;
4169};
4170export const AuthMethodAgent = Schema.Struct({
4171 _meta: Schema.optionalKey(
4172 Schema.Union([
4173 Schema.Record(Schema.String, Schema.Unknown).annotate({
4174 description:
4175 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4176 }),
4177 Schema.Null,
4178 ]),
4179 ),
4180 description: Schema.optionalKey(
4181 Schema.Union([
4182 Schema.String.annotate({
4183 description:
4184 "Optional description providing more details about this authentication method.",
4185 }),
4186 Schema.Null,
4187 ]),
4188 ),
4189 id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }),
4190 name: Schema.String.annotate({
4191 description: "Human-readable name of the authentication method.",
4192 }),
4193}).annotate({
4194 description:
4195 "Agent handles authentication itself.\n\nThis is the default authentication method type.",
4196});
4197
4198export type AuthMethodEnvVar = {
4199 readonly _meta?: { readonly [x: string]: unknown } | null;
4200 readonly description?: string | null;
4201 readonly id: string;
4202 readonly link?: string | null;
4203 readonly name: string;
4204 readonly vars: ReadonlyArray<AuthEnvVar>;
4205};
4206export const AuthMethodEnvVar = Schema.Struct({
4207 _meta: Schema.optionalKey(
4208 Schema.Union([
4209 Schema.Record(Schema.String, Schema.Unknown).annotate({
4210 description:
4211 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4212 }),
4213 Schema.Null,
4214 ]),
4215 ),
4216 description: Schema.optionalKey(
4217 Schema.Union([
4218 Schema.String.annotate({
4219 description:
4220 "Optional description providing more details about this authentication method.",
4221 }),
4222 Schema.Null,
4223 ]),
4224 ),
4225 id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }),
4226 link: Schema.optionalKey(
4227 Schema.Union([
4228 Schema.String.annotate({
4229 description: "Optional link to a page where the user can obtain their credentials.",
4230 }),
4231 Schema.Null,
4232 ]),
4233 ),
4234 name: Schema.String.annotate({
4235 description: "Human-readable name of the authentication method.",
4236 }),
4237 vars: Schema.Array(AuthEnvVar).annotate({
4238 description: "The environment variables the client should set.",
4239 }),
4240}).annotate({
4241 description:
4242 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nEnvironment variable authentication method.\n\nThe user provides credentials that the client passes to the agent as environment variables.",
4243});
4244
4245export type AuthMethodTerminal = {
4246 readonly _meta?: { readonly [x: string]: unknown } | null;
4247 readonly args?: ReadonlyArray<string>;
4248 readonly description?: string | null;
4249 readonly env?: { readonly [x: string]: string };
4250 readonly id: string;
4251 readonly name: string;
4252};
4253export const AuthMethodTerminal = Schema.Struct({
4254 _meta: Schema.optionalKey(
4255 Schema.Union([
4256 Schema.Record(Schema.String, Schema.Unknown).annotate({
4257 description:
4258 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4259 }),
4260 Schema.Null,
4261 ]),
4262 ),
4263 args: Schema.optionalKey(
4264 Schema.Array(Schema.String).annotate({
4265 description: "Additional arguments to pass when running the agent binary for terminal auth.",
4266 }),
4267 ),
4268 description: Schema.optionalKey(
4269 Schema.Union([
4270 Schema.String.annotate({
4271 description:
4272 "Optional description providing more details about this authentication method.",
4273 }),
4274 Schema.Null,
4275 ]),
4276 ),
4277 env: Schema.optionalKey(
4278 Schema.Record(Schema.String, Schema.String).annotate({
4279 description:
4280 "Additional environment variables to set when running the agent binary for terminal auth.",
4281 }),
4282 ),
4283 id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }),
4284 name: Schema.String.annotate({
4285 description: "Human-readable name of the authentication method.",
4286 }),
4287}).annotate({
4288 description:
4289 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nTerminal-based authentication method.\n\nThe client runs an interactive terminal for the user to authenticate via a TUI.",
4290});
4291
4292export type AvailableCommandsUpdate = {
4293 readonly _meta?: { readonly [x: string]: unknown } | null;
4294 readonly availableCommands: ReadonlyArray<AvailableCommand>;
4295};
4296export const AvailableCommandsUpdate = Schema.Struct({
4297 _meta: Schema.optionalKey(
4298 Schema.Union([
4299 Schema.Record(Schema.String, Schema.Unknown).annotate({
4300 description:
4301 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4302 }),
4303 Schema.Null,
4304 ]),
4305 ),
4306 availableCommands: Schema.Array(AvailableCommand).annotate({
4307 description: "Commands the agent can execute",
4308 }),
4309}).annotate({ description: "Available commands are ready or have changed" });
4310
4311export type BlobResourceContents = {
4312 readonly _meta?: { readonly [x: string]: unknown } | null;
4313 readonly blob: string;
4314 readonly mimeType?: string | null;
4315 readonly uri: string;
4316};
4317export const BlobResourceContents = Schema.Struct({
4318 _meta: Schema.optionalKey(
4319 Schema.Union([
4320 Schema.Record(Schema.String, Schema.Unknown).annotate({
4321 description:
4322 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4323 }),
4324 Schema.Null,
4325 ]),
4326 ),
4327 blob: Schema.String,
4328 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
4329 uri: Schema.String,
4330}).annotate({ description: "Binary resource contents." });
4331
4332export type BooleanPropertySchema = {
4333 readonly default?: boolean | null;
4334 readonly description?: string | null;
4335 readonly title?: string | null;
4336};
4337export const BooleanPropertySchema = Schema.Struct({
4338 default: Schema.optionalKey(
4339 Schema.Union([Schema.Boolean.annotate({ description: "Default value." }), Schema.Null]),
4340 ),
4341 description: Schema.optionalKey(
4342 Schema.Union([
4343 Schema.String.annotate({ description: "Human-readable description." }),
4344 Schema.Null,
4345 ]),
4346 ),
4347 title: Schema.optionalKey(
4348 Schema.Union([
4349 Schema.String.annotate({ description: "Optional title for the property." }),
4350 Schema.Null,
4351 ]),
4352 ),
4353}).annotate({ description: "Schema for boolean properties in an elicitation form." });
4354
4355export type CancelNotification = {
4356 readonly _meta?: { readonly [x: string]: unknown } | null;
4357 readonly sessionId: string;
4358};
4359export const CancelNotification = Schema.Struct({
4360 _meta: Schema.optionalKey(
4361 Schema.Union([
4362 Schema.Record(Schema.String, Schema.Unknown).annotate({
4363 description:
4364 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4365 }),
4366 Schema.Null,
4367 ]),
4368 ),
4369 sessionId: Schema.String.annotate({
4370 description:
4371 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4372 }),
4373}).annotate({
4374 description:
4375 "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)",
4376});
4377
4378export type CancelRequestNotification = {
4379 readonly _meta?: { readonly [x: string]: unknown } | null;
4380 readonly requestId: null | number | string;
4381};
4382export const CancelRequestNotification = Schema.Struct({
4383 _meta: Schema.optionalKey(
4384 Schema.Union([
4385 Schema.Record(Schema.String, Schema.Unknown).annotate({
4386 description:
4387 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4388 }),
4389 Schema.Null,
4390 ]),
4391 ),
4392 requestId: Schema.Union([
4393 Schema.Null.annotate({ title: "Null" }),
4394 Schema.Number.annotate({ title: "Number", format: "int64" }).check(Schema.isInt()),
4395 Schema.String.annotate({ title: "Str" }),
4396 ]).annotate({
4397 description:
4398 "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.",
4399 }),
4400}).annotate({
4401 description:
4402 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification to cancel an ongoing request.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)",
4403});
4404
4405export type ClientCapabilities = {
4406 readonly _meta?: { readonly [x: string]: unknown } | null;
4407 readonly auth?: {
4408 readonly _meta?: { readonly [x: string]: unknown } | null;
4409 readonly terminal?: boolean;
4410 };
4411 readonly elicitation?: ElicitationCapabilities | null;
4412 readonly fs?: {
4413 readonly _meta?: { readonly [x: string]: unknown } | null;
4414 readonly readTextFile?: boolean;
4415 readonly writeTextFile?: boolean;
4416 };
4417 readonly terminal?: boolean;
4418};
4419export const ClientCapabilities = Schema.Struct({
4420 _meta: Schema.optionalKey(
4421 Schema.Union([
4422 Schema.Record(Schema.String, Schema.Unknown).annotate({
4423 description:
4424 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4425 }),
4426 Schema.Null,
4427 ]),
4428 ),
4429 auth: Schema.optionalKey(
4430 Schema.Struct({
4431 _meta: Schema.optionalKey(
4432 Schema.Union([
4433 Schema.Record(Schema.String, Schema.Unknown).annotate({
4434 description:
4435 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4436 }),
4437 Schema.Null,
4438 ]),
4439 ),
4440 terminal: Schema.optionalKey(
4441 Schema.Boolean.annotate({
4442 description:
4443 "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.",
4444 default: false,
4445 }),
4446 ),
4447 }).annotate({
4448 description:
4449 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.",
4450 default: { terminal: false },
4451 }),
4452 ),
4453 elicitation: Schema.optionalKey(
4454 Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({
4455 description:
4456 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.",
4457 }),
4458 ),
4459 fs: Schema.optionalKey(
4460 Schema.Struct({
4461 _meta: Schema.optionalKey(
4462 Schema.Union([
4463 Schema.Record(Schema.String, Schema.Unknown).annotate({
4464 description:
4465 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4466 }),
4467 Schema.Null,
4468 ]),
4469 ),
4470 readTextFile: Schema.optionalKey(
4471 Schema.Boolean.annotate({
4472 description: "Whether the Client supports `fs/read_text_file` requests.",
4473 default: false,
4474 }),
4475 ),
4476 writeTextFile: Schema.optionalKey(
4477 Schema.Boolean.annotate({
4478 description: "Whether the Client supports `fs/write_text_file` requests.",
4479 default: false,
4480 }),
4481 ),
4482 }).annotate({
4483 description:
4484 "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)",
4485 default: { readTextFile: false, writeTextFile: false },
4486 }),
4487 ),
4488 terminal: Schema.optionalKey(
4489 Schema.Boolean.annotate({
4490 description: "Whether the Client support all `terminal/*` methods.",
4491 default: false,
4492 }),
4493 ),
4494}).annotate({
4495 description:
4496 "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)",
4497});
4498
4499export type ClientNotification = {
4500 readonly method: string;
4501 readonly params?:
4502 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly sessionId: string }
4503 | unknown
4504 | null;
4505};
4506export const ClientNotification = Schema.Struct({
4507 method: Schema.String,
4508 params: Schema.optionalKey(
4509 Schema.Union([
4510 Schema.Union([
4511 Schema.Struct({
4512 _meta: Schema.optionalKey(
4513 Schema.Union([
4514 Schema.Record(Schema.String, Schema.Unknown).annotate({
4515 description:
4516 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4517 }),
4518 Schema.Null,
4519 ]),
4520 ),
4521 sessionId: Schema.String.annotate({
4522 description:
4523 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4524 }),
4525 }).annotate({
4526 title: "CancelNotification",
4527 description:
4528 "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)",
4529 }),
4530 Schema.Unknown.annotate({
4531 title: "ExtNotification",
4532 description:
4533 "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4534 }),
4535 ]).annotate({
4536 description:
4537 "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response.",
4538 }),
4539 Schema.Null,
4540 ]),
4541 ),
4542});
4543
4544export type ClientRequest = {
4545 readonly id: RequestId;
4546 readonly method: string;
4547 readonly params?:
4548 | {
4549 readonly _meta?: { readonly [x: string]: unknown } | null;
4550 readonly clientCapabilities?: {
4551 readonly _meta?: { readonly [x: string]: unknown } | null;
4552 readonly auth?: {
4553 readonly _meta?: { readonly [x: string]: unknown } | null;
4554 readonly terminal?: boolean;
4555 };
4556 readonly elicitation?: ElicitationCapabilities | null;
4557 readonly fs?: {
4558 readonly _meta?: { readonly [x: string]: unknown } | null;
4559 readonly readTextFile?: boolean;
4560 readonly writeTextFile?: boolean;
4561 };
4562 readonly terminal?: boolean;
4563 };
4564 readonly clientInfo?: Implementation | null;
4565 readonly protocolVersion: number;
4566 }
4567 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly methodId: string }
4568 | { readonly _meta?: { readonly [x: string]: unknown } | null }
4569 | {
4570 readonly _meta?: { readonly [x: string]: unknown } | null;
4571 readonly cwd: string;
4572 readonly mcpServers: ReadonlyArray<McpServer>;
4573 }
4574 | {
4575 readonly _meta?: { readonly [x: string]: unknown } | null;
4576 readonly cwd: string;
4577 readonly mcpServers: ReadonlyArray<McpServer>;
4578 readonly sessionId: string;
4579 }
4580 | {
4581 readonly _meta?: { readonly [x: string]: unknown } | null;
4582 readonly cursor?: string | null;
4583 readonly cwd?: string | null;
4584 }
4585 | {
4586 readonly _meta?: { readonly [x: string]: unknown } | null;
4587 readonly cwd: string;
4588 readonly mcpServers?: ReadonlyArray<McpServer>;
4589 readonly sessionId: string;
4590 }
4591 | {
4592 readonly _meta?: { readonly [x: string]: unknown } | null;
4593 readonly cwd: string;
4594 readonly mcpServers?: ReadonlyArray<McpServer>;
4595 readonly sessionId: string;
4596 }
4597 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly sessionId: string }
4598 | {
4599 readonly _meta?: { readonly [x: string]: unknown } | null;
4600 readonly modeId: string;
4601 readonly sessionId: string;
4602 }
4603 | {
4604 readonly type: "boolean";
4605 readonly value: boolean;
4606 readonly _meta?: { readonly [x: string]: unknown } | null;
4607 readonly configId: string;
4608 readonly sessionId: string;
4609 }
4610 | {
4611 readonly value: string;
4612 readonly _meta?: { readonly [x: string]: unknown } | null;
4613 readonly configId: string;
4614 readonly sessionId: string;
4615 }
4616 | {
4617 readonly _meta?: { readonly [x: string]: unknown } | null;
4618 readonly messageId?: string | null;
4619 readonly prompt: ReadonlyArray<ContentBlock>;
4620 readonly sessionId: string;
4621 }
4622 | {
4623 readonly _meta?: { readonly [x: string]: unknown } | null;
4624 readonly modelId: string;
4625 readonly sessionId: string;
4626 }
4627 | unknown
4628 | null;
4629};
4630export const ClientRequest = Schema.Struct({
4631 id: RequestId,
4632 method: Schema.String,
4633 params: Schema.optionalKey(
4634 Schema.Union([
4635 Schema.Union([
4636 Schema.Struct({
4637 _meta: Schema.optionalKey(
4638 Schema.Union([
4639 Schema.Record(Schema.String, Schema.Unknown).annotate({
4640 description:
4641 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4642 }),
4643 Schema.Null,
4644 ]),
4645 ),
4646 clientCapabilities: Schema.optionalKey(
4647 Schema.Struct({
4648 _meta: Schema.optionalKey(
4649 Schema.Union([
4650 Schema.Record(Schema.String, Schema.Unknown).annotate({
4651 description:
4652 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4653 }),
4654 Schema.Null,
4655 ]),
4656 ),
4657 auth: Schema.optionalKey(
4658 Schema.Struct({
4659 _meta: Schema.optionalKey(
4660 Schema.Union([
4661 Schema.Record(Schema.String, Schema.Unknown).annotate({
4662 description:
4663 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4664 }),
4665 Schema.Null,
4666 ]),
4667 ),
4668 terminal: Schema.optionalKey(
4669 Schema.Boolean.annotate({
4670 description:
4671 "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.",
4672 default: false,
4673 }),
4674 ),
4675 }).annotate({
4676 description:
4677 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.",
4678 default: { terminal: false },
4679 }),
4680 ),
4681 elicitation: Schema.optionalKey(
4682 Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({
4683 description:
4684 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.",
4685 }),
4686 ),
4687 fs: Schema.optionalKey(
4688 Schema.Struct({
4689 _meta: Schema.optionalKey(
4690 Schema.Union([
4691 Schema.Record(Schema.String, Schema.Unknown).annotate({
4692 description:
4693 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4694 }),
4695 Schema.Null,
4696 ]),
4697 ),
4698 readTextFile: Schema.optionalKey(
4699 Schema.Boolean.annotate({
4700 description: "Whether the Client supports `fs/read_text_file` requests.",
4701 default: false,
4702 }),
4703 ),
4704 writeTextFile: Schema.optionalKey(
4705 Schema.Boolean.annotate({
4706 description: "Whether the Client supports `fs/write_text_file` requests.",
4707 default: false,
4708 }),
4709 ),
4710 }).annotate({
4711 description:
4712 "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)",
4713 default: { readTextFile: false, writeTextFile: false },
4714 }),
4715 ),
4716 terminal: Schema.optionalKey(
4717 Schema.Boolean.annotate({
4718 description: "Whether the Client support all `terminal/*` methods.",
4719 default: false,
4720 }),
4721 ),
4722 }).annotate({
4723 description:
4724 "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)",
4725 default: {
4726 auth: { terminal: false },
4727 fs: { readTextFile: false, writeTextFile: false },
4728 terminal: false,
4729 },
4730 }),
4731 ),
4732 clientInfo: Schema.optionalKey(
4733 Schema.Union([Implementation, Schema.Null]).annotate({
4734 description:
4735 "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required.",
4736 }),
4737 ),
4738 protocolVersion: Schema.Number.annotate({
4739 description:
4740 "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.",
4741 format: "uint16",
4742 })
4743 .check(Schema.isInt())
4744 .check(Schema.isGreaterThanOrEqualTo(0))
4745 .check(Schema.isLessThanOrEqualTo(65535)),
4746 }).annotate({
4747 title: "InitializeRequest",
4748 description:
4749 "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)",
4750 }),
4751 Schema.Struct({
4752 _meta: Schema.optionalKey(
4753 Schema.Union([
4754 Schema.Record(Schema.String, Schema.Unknown).annotate({
4755 description:
4756 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4757 }),
4758 Schema.Null,
4759 ]),
4760 ),
4761 methodId: Schema.String.annotate({
4762 description:
4763 "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.",
4764 }),
4765 }).annotate({
4766 title: "AuthenticateRequest",
4767 description:
4768 "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.",
4769 }),
4770 Schema.Struct({
4771 _meta: Schema.optionalKey(
4772 Schema.Union([
4773 Schema.Record(Schema.String, Schema.Unknown).annotate({
4774 description:
4775 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4776 }),
4777 Schema.Null,
4778 ]),
4779 ),
4780 }).annotate({
4781 title: "LogoutRequest",
4782 description:
4783 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for the logout method.\n\nTerminates the current authenticated session.",
4784 }),
4785 Schema.Struct({
4786 _meta: Schema.optionalKey(
4787 Schema.Union([
4788 Schema.Record(Schema.String, Schema.Unknown).annotate({
4789 description:
4790 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4791 }),
4792 Schema.Null,
4793 ]),
4794 ),
4795 cwd: Schema.String.annotate({
4796 description: "The working directory for this session. Must be an absolute path.",
4797 }),
4798 mcpServers: Schema.Array(McpServer).annotate({
4799 description:
4800 "List of MCP (Model Context Protocol) servers the agent should connect to.",
4801 }),
4802 }).annotate({
4803 title: "NewSessionRequest",
4804 description:
4805 "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)",
4806 }),
4807 Schema.Struct({
4808 _meta: Schema.optionalKey(
4809 Schema.Union([
4810 Schema.Record(Schema.String, Schema.Unknown).annotate({
4811 description:
4812 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4813 }),
4814 Schema.Null,
4815 ]),
4816 ),
4817 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
4818 mcpServers: Schema.Array(McpServer).annotate({
4819 description: "List of MCP servers to connect to for this session.",
4820 }),
4821 sessionId: Schema.String.annotate({
4822 description:
4823 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4824 }),
4825 }).annotate({
4826 title: "LoadSessionRequest",
4827 description:
4828 "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)",
4829 }),
4830 Schema.Struct({
4831 _meta: Schema.optionalKey(
4832 Schema.Union([
4833 Schema.Record(Schema.String, Schema.Unknown).annotate({
4834 description:
4835 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4836 }),
4837 Schema.Null,
4838 ]),
4839 ),
4840 cursor: Schema.optionalKey(
4841 Schema.Union([
4842 Schema.String.annotate({
4843 description:
4844 "Opaque cursor token from a previous response's nextCursor field for cursor-based pagination",
4845 }),
4846 Schema.Null,
4847 ]),
4848 ),
4849 cwd: Schema.optionalKey(
4850 Schema.Union([
4851 Schema.String.annotate({
4852 description: "Filter sessions by working directory. Must be an absolute path.",
4853 }),
4854 Schema.Null,
4855 ]),
4856 ),
4857 }).annotate({
4858 title: "ListSessionsRequest",
4859 description:
4860 "Request parameters for listing existing sessions.\n\nOnly available if the Agent supports the `sessionCapabilities.list` capability.",
4861 }),
4862 Schema.Struct({
4863 _meta: Schema.optionalKey(
4864 Schema.Union([
4865 Schema.Record(Schema.String, Schema.Unknown).annotate({
4866 description:
4867 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4868 }),
4869 Schema.Null,
4870 ]),
4871 ),
4872 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
4873 mcpServers: Schema.optionalKey(
4874 Schema.Array(McpServer).annotate({
4875 description: "List of MCP servers to connect to for this session.",
4876 }),
4877 ),
4878 sessionId: Schema.String.annotate({
4879 description:
4880 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4881 }),
4882 }).annotate({
4883 title: "ForkSessionRequest",
4884 description:
4885 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for forking an existing session.\n\nCreates a new session based on the context of an existing one, allowing\noperations like generating summaries without affecting the original session's history.\n\nOnly available if the Agent supports the `session.fork` capability.",
4886 }),
4887 Schema.Struct({
4888 _meta: Schema.optionalKey(
4889 Schema.Union([
4890 Schema.Record(Schema.String, Schema.Unknown).annotate({
4891 description:
4892 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4893 }),
4894 Schema.Null,
4895 ]),
4896 ),
4897 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
4898 mcpServers: Schema.optionalKey(
4899 Schema.Array(McpServer).annotate({
4900 description: "List of MCP servers to connect to for this session.",
4901 }),
4902 ),
4903 sessionId: Schema.String.annotate({
4904 description:
4905 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4906 }),
4907 }).annotate({
4908 title: "ResumeSessionRequest",
4909 description:
4910 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for resuming an existing session.\n\nResumes an existing session without returning previous messages (unlike `session/load`).\nThis is useful for agents that can resume sessions but don't implement full session loading.\n\nOnly available if the Agent supports the `session.resume` capability.",
4911 }),
4912 Schema.Struct({
4913 _meta: Schema.optionalKey(
4914 Schema.Union([
4915 Schema.Record(Schema.String, Schema.Unknown).annotate({
4916 description:
4917 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4918 }),
4919 Schema.Null,
4920 ]),
4921 ),
4922 sessionId: Schema.String.annotate({
4923 description:
4924 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4925 }),
4926 }).annotate({
4927 title: "CloseSessionRequest",
4928 description:
4929 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for closing an active session.\n\nIf supported, the agent **must** cancel any ongoing work related to the session\n(treat it as if `session/cancel` was called) and then free up any resources\nassociated with the session.\n\nOnly available if the Agent supports the `session.close` capability.",
4930 }),
4931 Schema.Struct({
4932 _meta: Schema.optionalKey(
4933 Schema.Union([
4934 Schema.Record(Schema.String, Schema.Unknown).annotate({
4935 description:
4936 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4937 }),
4938 Schema.Null,
4939 ]),
4940 ),
4941 modeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }),
4942 sessionId: Schema.String.annotate({
4943 description:
4944 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4945 }),
4946 }).annotate({
4947 title: "SetSessionModeRequest",
4948 description: "Request parameters for setting a session mode.",
4949 }),
4950 Schema.Union([
4951 Schema.Struct({
4952 type: Schema.Literal("boolean"),
4953 value: Schema.Boolean.annotate({ description: "The boolean value." }),
4954 _meta: Schema.optionalKey(
4955 Schema.Union([
4956 Schema.Record(Schema.String, Schema.Unknown).annotate({
4957 description:
4958 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4959 }),
4960 Schema.Null,
4961 ]),
4962 ),
4963 configId: Schema.String.annotate({
4964 description: "Unique identifier for a session configuration option.",
4965 }),
4966 sessionId: Schema.String.annotate({
4967 description:
4968 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4969 }),
4970 }).annotate({
4971 description: "Request parameters for setting a session configuration option.",
4972 }),
4973 Schema.Struct({
4974 value: Schema.String.annotate({
4975 description: "Unique identifier for a session configuration option value.",
4976 }),
4977 _meta: Schema.optionalKey(
4978 Schema.Union([
4979 Schema.Record(Schema.String, Schema.Unknown).annotate({
4980 description:
4981 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4982 }),
4983 Schema.Null,
4984 ]),
4985 ),
4986 configId: Schema.String.annotate({
4987 description: "Unique identifier for a session configuration option.",
4988 }),
4989 sessionId: Schema.String.annotate({
4990 description:
4991 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4992 }),
4993 }).annotate({
4994 title: "value_id",
4995 description: "Request parameters for setting a session configuration option.",
4996 }),
4997 ]).annotate({
4998 title: "SetSessionConfigOptionRequest",
4999 description: "Sets the current value for a session configuration option.",

5376 more lines aren’t shown here.

packages/effect-acp/src/_internal/shared.ts+111−0
@@ -0,0 +1,111 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as SchemaIssue from "effect/SchemaIssue";
4import { RpcClientError } from "effect/unstable/rpc";
5
6import * as AcpSchema from "../_generated/schema.gen.ts";
7import * as AcpError from "../errors.ts";
8
9const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
10
11export const callRpc = <A>(
12 effect: Effect.Effect<A, RpcClientError.RpcClientError | AcpSchema.Error>,
13): Effect.Effect<A, AcpError.AcpError> =>
14 effect.pipe(
15 Effect.catchTag("RpcClientError", (error) =>
16 Effect.fail(
17 new AcpError.AcpTransportError({
18 detail: error.message,
19 cause: error,
20 }),
21 ),
22 ),
23 Effect.catchIf(Schema.is(AcpSchema.Error), (error) =>
24 Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)),
25 ),
26 );
27
28export const runHandler = Effect.fnUntraced(function* <A, B>(
29 handler: ((payload: A) => Effect.Effect<B, AcpError.AcpError>) | undefined,
30 payload: A,
31 method: string,
32) {
33 if (!handler) {
34 return yield* Effect.fail(AcpError.AcpRequestError.methodNotFound(method).toProtocolError());
35 }
36 return yield* handler(payload).pipe(
37 Effect.mapError((error) =>
38 Schema.is(AcpError.AcpRequestError)(error)
39 ? error.toProtocolError()
40 : AcpError.AcpRequestError.internalError(error.message).toProtocolError(),
41 ),
42 );
43});
44
45export function decodeExtRequestRegistration<A, I>(
46 method: string,
47 payload: Schema.Codec<A, I>,
48 handler: (payload: A) => Effect.Effect<unknown, AcpError.AcpError>,
49) {
50 return (params: unknown): Effect.Effect<unknown, AcpError.AcpError> =>
51 Schema.decodeUnknownEffect(payload)(params).pipe(
52 Effect.mapError((error) =>
53 AcpError.AcpRequestError.invalidParams(
54 `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`,
55 { issue: error.issue },
56 ),
57 ),
58 Effect.flatMap((decoded) => handler(decoded)),
59 );
60}
61
62export function decodeExtNotificationRegistration<A, I>(
63 method: string,
64 payload: Schema.Codec<A, I>,
65 handler: (payload: A) => Effect.Effect<void, AcpError.AcpError>,
66) {
67 return (params: unknown): Effect.Effect<void, AcpError.AcpError> =>
68 Schema.decodeUnknownEffect(payload)(params).pipe(
69 Effect.mapError(
70 (error) =>
71 new AcpError.AcpProtocolParseError({
72 detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`,
73 cause: error,
74 }),
75 ),
76 Effect.flatMap((decoded) => handler(decoded)),
77 );
78}
79
80const encoder = new TextEncoder();
81
82const JsonRpcId = Schema.Union([Schema.Number, Schema.String]);
83const JsonRpcHeaders = Schema.Array(Schema.Unknown);
84
85export const jsonRpcRequest = <A, I>(method: string, params: Schema.Codec<A, I>) =>
86 Schema.Struct({
87 jsonrpc: Schema.Literal("2.0"),
88 id: JsonRpcId,
89 method: Schema.Literal(method),
90 params,
91 headers: JsonRpcHeaders,
92 });
93
94export const jsonRpcNotification = <A, I>(method: string, params: Schema.Codec<A, I>) =>
95 Schema.Struct({
96 jsonrpc: Schema.Literal("2.0"),
97 method: Schema.Literal(method),
98 params,
99 });
100
101export const jsonRpcResponse = <A, I>(result: Schema.Codec<A, I>) =>
102 Schema.Struct({
103 jsonrpc: Schema.Literal("2.0"),
104 id: JsonRpcId,
105 result,
106 });
107
108export const encodeJsonl = <A, I>(schema: Schema.Codec<A, I>, value: A) =>
109 Effect.map(Schema.encodeEffect(Schema.fromJsonString(schema))(value), (encoded) =>
110 encoder.encode(`${encoded}\n`),
111 );
packages/effect-acp/src/_internal/stdio.ts+54−0
@@ -0,0 +1,54 @@
1import * as Queue from "effect/Queue";
2import * as Stream from "effect/Stream";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Sink from "effect/Sink";
6import * as Stdio from "effect/Stdio";
7import { ChildProcessSpawner } from "effect/unstable/process";
8
9import * as AcpError from "../errors.ts";
10
11const encoder = new TextEncoder();
12const decoder = new TextDecoder();
13
14export const makeChildStdio = (handle: ChildProcessSpawner.ChildProcessHandle) =>
15 Stdio.make({
16 args: Effect.succeed([]),
17 stdin: handle.stdout,
18 stdout: () =>
19 Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) =>
20 typeof chunk === "string" ? encoder.encode(chunk) : chunk,
21 ),
22 stderr: () => Sink.drain,
23 });
24
25export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () {
26 const input = yield* Queue.unbounded<Uint8Array, Cause.Done<void>>();
27 const output = yield* Queue.unbounded<string>();
28
29 return {
30 stdio: Stdio.make({
31 args: Effect.succeed([]),
32 stdin: Stream.fromQueue(input),
33 stdout: () =>
34 Sink.forEach((chunk: string | Uint8Array) =>
35 Queue.offer(output, typeof chunk === "string" ? chunk : decoder.decode(chunk)),
36 ),
37 stderr: () => Sink.drain,
38 }),
39 input,
40 output,
41 };
42});
43
44export const makeTerminationError = (
45 handle: ChildProcessSpawner.ChildProcessHandle,
46): Effect.Effect<AcpError.AcpError> =>
47 Effect.match(handle.exitCode, {
48 onFailure: (cause) =>
49 new AcpError.AcpTransportError({
50 detail: "Failed to determine ACP process exit status",
51 cause,
52 }),
53 onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }),
54 });
packages/effect-acp/src/agent.test.ts+255−0
@@ -0,0 +1,255 @@
1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Queue from "effect/Queue";
7import * as Ref from "effect/Ref";
8import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";
10
11import { assert, it } from "@effect/vitest";
12
13import * as AcpAgent from "./agent.ts";
14import * as AcpSchema from "./_generated/schema.gen.ts";
15import {
16 encodeJsonl,
17 jsonRpcNotification,
18 jsonRpcRequest,
19 jsonRpcResponse,
20} from "./_internal/shared.ts";
21import { makeInMemoryStdio } from "./_internal/stdio.ts";
22
23const RequestPermissionRequest = jsonRpcRequest(
24 "session/request_permission",
25 AcpSchema.RequestPermissionRequest,
26);
27const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest);
28const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
29const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
30const SessionCancelNotification = jsonRpcNotification(
31 "session/cancel",
32 AcpSchema.CancelNotification,
33);
34const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number }));
35const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
36const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
37
38it.effect("effect-acp agent handles core agent requests and outbound client requests", () =>
39 Effect.gen(function* () {
40 const { stdio, input, output } = yield* makeInMemoryStdio();
41 const cancelNotifications = yield* Ref.make<Array<string>>([]);
42 const extNotifications = yield* Ref.make<Array<number>>([]);
43 const cancelReceived = yield* Deferred.make<void>();
44 const extReceived = yield* Deferred.make<void>();
45 const scope = yield* Scope.make();
46 const context = yield* Layer.buildWithScope(AcpAgent.layer(stdio), scope);
47
48 yield* Effect.gen(function* () {
49 const agent = yield* AcpAgent.AcpAgent;
50
51 yield* agent.handleInitialize(() =>
52 Effect.succeed({
53 protocolVersion: 1,
54 agentCapabilities: {},
55 agentInfo: {
56 name: "mock-agent",
57 version: "0.0.0",
58 },
59 }),
60 );
61 yield* agent.handleCancel((notification) =>
62 Ref.update(cancelNotifications, (current) => [...current, notification.sessionId]).pipe(
63 Effect.andThen(Deferred.succeed(cancelReceived, undefined)),
64 ),
65 );
66 yield* agent.handleExtNotification(
67 "x/ping",
68 Schema.Struct({ count: Schema.Number }),
69 (payload) =>
70 Ref.update(extNotifications, (current) => [...current, payload.count]).pipe(
71 Effect.andThen(Deferred.succeed(extReceived, undefined)),
72 ),
73 );
74
75 const permissionFiber = yield* agent.client
76 .requestPermission({
77 sessionId: "session-1",
78 toolCall: {
79 toolCallId: "tool-1",
80 title: "Allow mock action",
81 },
82 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
83 })
84 .pipe(Effect.forkScoped);
85
86 const permissionRequest = yield* Schema.decodeEffect(
87 Schema.fromJsonString(RequestPermissionRequest),
88 )(yield* Queue.take(output));
89 assert.equal(permissionRequest.jsonrpc, "2.0");
90 assert.equal(permissionRequest.method, "session/request_permission");
91 assert.deepEqual(permissionRequest.params, {
92 sessionId: "session-1",
93 toolCall: {

162 more lines aren’t shown here.

packages/effect-acp/src/agent.ts+519−0

This diff is large: its 520 lines aren’t shown here.

packages/effect-acp/src/client.test.ts+450−0

This diff is large: its 451 lines aren’t shown here.

packages/effect-acp/src/client.ts+569−0

This diff is large: its 570 lines aren’t shown here.

packages/effect-acp/src/errors.ts+139−0

This diff is large: its 140 lines aren’t shown here.

packages/effect-acp/src/protocol.test.ts+448−0

This diff is large: its 449 lines aren’t shown here.

packages/effect-acp/src/protocol.ts+536−0

This diff is large: its 537 lines aren’t shown here.

packages/effect-acp/src/rpc.ts+158−0

This diff is large: its 159 lines aren’t shown here.

packages/effect-acp/src/schema.ts+2−0

This diff is large: its 3 lines aren’t shown here.

packages/effect-acp/src/terminal.ts+45−0

This diff is large: its 46 lines aren’t shown here.

packages/effect-acp/test/examples/cursor-acp-client.example.ts+81−0

This diff is large: its 82 lines aren’t shown here.

packages/effect-acp/test/fixtures/acp-mock-peer.ts+136−0

This diff is large: its 137 lines aren’t shown here.

packages/effect-acp/tsconfig.json+18−0

This diff is large: its 19 lines aren’t shown here.

packages/shared/package.json+4−0

This diff is large: its 11 lines aren’t shown here.

packages/shared/src/model.test.ts+6−41

This diff is large: its 84 lines aren’t shown here.

packages/shared/src/model.ts+32−46

This diff is large: its 148 lines aren’t shown here.

packages/shared/src/serverSettings.ts+16−7

This diff is large: its 36 lines aren’t shown here.

packages/shared/src/toolActivity.test.ts+57−0

This diff is large: its 58 lines aren’t shown here.

packages/shared/src/toolActivity.ts+257−0

This diff is large: its 258 lines aren’t shown here.

scripts/release-smoke.ts+1−0

This diff is large: its 8 lines aren’t shown here.