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
2323 "@t3tools/contracts": "workspace:*",
2424 "@t3tools/shared": "workspace:*",
2525 "@types/node": "catalog:",
26+ "effect-acp": "workspace:*",
2627 "tsdown": "catalog:",
2728 "typescript": "catalog:",
2829 "vitest": "catalog:"
apps/desktop/tsdown.config.ts+1−1
@@ -12,7 +12,7 @@ export default defineConfig([ · 11 unmodified lines
1212 ...shared,
1313 entry: ["src/main.ts"],
1414 clean: true,
15− noExternal: (id) => id.startsWith("@t3tools/"),
15+ noExternal: (id) => id.startsWith("@t3tools/") || id.startsWith("effect-acp"),
1616 },
1717 {
1818 ...shared,
apps/server/package.json+1−0
@@ -43,6 +43,7 @@ · 42 unmodified lines
4343 "@t3tools/web": "workspace:*",
4444 "@types/bun": "catalog:",
4545 "@types/node": "catalog:",
46+ "effect-acp": "workspace:*",
4647 "tsdown": "catalog:",
4748 "typescript": "catalog:",
4849 "vitest": "catalog:"
apps/server/scripts/acp-mock-agent.ts+590−0
@@ -0,0 +1,590 @@
1+#!/usr/bin/env bun
2+import { appendFileSync } from "node:fs";
3+
4+import * as Effect from "effect/Effect";
5+
6+import * as NodeServices from "@effect/platform-node/NodeServices";
7+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
8+
9+import * as EffectAcpAgent from "effect-acp/agent";
10+import * as AcpError from "effect-acp/errors";
11+import type * as AcpSchema from "effect-acp/schema";
12+
13+const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
14+const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
15+const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
16+const emitInterleavedAssistantToolCalls =
17+ process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
18+const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
19+const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
20+const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
21+const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
22+const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
23+const sessionId = "mock-session-1";
24+
25+let currentModeId = "ask";
26+let currentModelId = "default";
27+let parameterizedModelPicker = false;
28+let currentReasoning = "medium";
29+let currentContext = "272k";
30+let currentFast = false;
31+const cancelledSessions = new Set<string>();
32+
33+function logExit(reason: string): void {
34+ if (!exitLogPath) {
35+ return;
36+ }
37+ appendFileSync(exitLogPath, `${reason}\n`, "utf8");
38+}
39+
40+process.once("SIGTERM", () => {
41+ logExit("SIGTERM");
42+ process.exit(0);
43+});
44+
45+process.once("SIGINT", () => {
46+ logExit("SIGINT");
47+ process.exit(0);
48+});
49+
50+process.once("exit", (code) => {
51+ logExit(`exit:${code}`);
52+});
53+
54+function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
55+ if (parameterizedModelPicker) {
56+ const baseOptions: Array<AcpSchema.SessionConfigOption> = [
57+ {
58+ id: "mode",
59+ name: "Mode",
60+ category: "mode",
61+ type: "select",
62+ currentValue: currentModeId,
63+ options: availableModes.map((mode) => ({
64+ value: mode.id,
65+ name: mode.name,
66+ ...(mode.description ? { description: mode.description } : {}),
67+ })),
68+ },
69+ {
70+ id: "model",
71+ name: "Model",
72+ category: "model",
73+ type: "select",
74+ currentValue: currentModelId,
75+ options: [
76+ { value: "default", name: "Auto" },
77+ { value: "composer-2", name: "Composer 2" },
78+ { value: "gpt-5.4", name: "GPT-5.4" },
79+ { value: "claude-opus-4-6", name: "Opus 4.6" },
80+ ],
81+ },
82+ ];
83+
84+ switch (currentModelId) {
85+ case "gpt-5.4":
86+ return [
87+ ...baseOptions,
88+ {
89+ id: "reasoning",
90+ name: "Reasoning",
91+ category: "thought_level",
92+ type: "select",
93+ currentValue: currentReasoning,
94+ options: [
95+ { value: "none", name: "None" },
96+ { value: "low", name: "Low" },
97+ { value: "medium", name: "Medium" },
98+ { value: "high", name: "High" },
99+ { value: "extra-high", name: "Extra High" },
100+ ],
101+ },
102+ {
103+ id: "context",
104+ name: "Context",
105+ category: "model_config",
106+ type: "select",
107+ currentValue: currentContext,
108+ options: [
109+ { value: "272k", name: "272K" },
110+ { value: "1m", name: "1M" },
111+ ],
112+ },
113+ {
114+ id: "fast",
115+ name: "Fast",
116+ category: "model_config",
117+ type: "select",
118+ currentValue: String(currentFast),
119+ options: [
120+ { value: "false", name: "Off" },
121+ { value: "true", name: "Fast" },
122+ ],
123+ },
124+ ];
125+ case "composer-2":
126+ return [
127+ ...baseOptions,
128+ {
129+ id: "fast",
130+ name: "Fast",
131+ category: "model_config",
132+ type: "select",
133+ currentValue: String(currentFast),
134+ options: [
135+ { value: "false", name: "Off" },
136+ { value: "true", name: "Fast" },
137+ ],
138+ },
139+ ];
140+ case "claude-opus-4-6":
141+ return [
142+ ...baseOptions,
143+ {
144+ id: "reasoning",
145+ name: "Reasoning",
146+ category: "thought_level",
147+ type: "select",
148+ currentValue: currentReasoning,
149+ options: [
150+ { value: "low", name: "Low" },
151+ { value: "medium", name: "Medium" },
152+ { value: "high", name: "High" },
153+ ],
154+ },
155+ {
156+ id: "thinking",
157+ name: "Thinking",
158+ category: "model_config",
159+ type: "boolean",
160+ currentValue: true,
161+ },
162+ ];
163+ default:
164+ return baseOptions;
165+ }
166+ }
167+
168+ return [
169+ {
170+ id: "model",
171+ name: "Model",
172+ category: "model",
173+ type: "select" as const,
174+ currentValue: currentModelId,
175+ options: [
176+ { value: "default", name: "Auto" },
177+ { value: "composer-2", name: "Composer 2" },
178+ { value: "composer-2[fast=true]", name: "Composer 2 Fast" },
179+ { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" },
180+ ],
181+ },
182+ ];
183+}
184+
185+const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [
186+ {
187+ id: "ask",
188+ name: "Ask",
189+ description: "Request permission before making any changes",
190+ },
191+ {
192+ id: "architect",
193+ name: "Architect",
194+ description: "Design and plan software systems without implementation",
195+ },
196+ {
197+ id: "code",
198+ name: "Code",
199+ description: "Write and modify code with full tool access",
200+ },
201+];
202+
203+function modeState(): AcpSchema.SessionModeState {
204+ return {
205+ currentModeId,
206+ availableModes,
207+ };
208+}
209+
210+const program = Effect.gen(function* () {
211+ const agent = yield* EffectAcpAgent.AcpAgent;
212+
213+ yield* agent.handleInitialize((request) =>
214+ Effect.sync(() => {
215+ parameterizedModelPicker =
216+ request.clientCapabilities?._meta?.parameterizedModelPicker === true;
217+ return {
218+ protocolVersion: 1,
219+ agentCapabilities: { loadSession: true },
220+ };
221+ }),
222+ );
223+
224+ yield* agent.handleAuthenticate(() => Effect.succeed({}));
225+
226+ yield* agent.handleCreateSession(() =>
227+ Effect.succeed({
228+ sessionId,
229+ modes: modeState(),
230+ configOptions: configOptions(),
231+ }),
232+ );
233+
234+ yield* agent.handleLoadSession((request) =>
235+ agent.client
236+ .sessionUpdate({
237+ sessionId: String(request.sessionId ?? sessionId),
238+ update: {
239+ sessionUpdate: "user_message_chunk",
240+ content: { type: "text", text: "replay" },
241+ },
242+ })
243+ .pipe(
244+ Effect.as({
245+ modes: modeState(),
246+ configOptions: configOptions(),
247+ }),
248+ ),
249+ );
250+
251+ yield* agent.handleSetSessionConfigOption((request) =>
252+ Effect.gen(function* () {
253+ if (exitOnSetConfigOption) {
254+ return yield* Effect.sync(() => {
255+ process.exit(7);
256+ });
257+ }
258+ if (failSetConfigOption) {
259+ return yield* AcpError.AcpRequestError.invalidParams(
260+ "Mock invalid params for session/set_config_option",
261+ {
262+ method: "session/set_config_option",
263+ params: request,
264+ },
265+ );
266+ }
267+ if (request.configId === "mode" && typeof request.value === "string") {
268+ currentModeId = request.value;
269+ }
270+ if (request.configId === "model" && typeof request.value === "string") {
271+ currentModelId = request.value;
272+ }
273+ if (request.configId === "reasoning" && typeof request.value === "string") {
274+ currentReasoning = request.value;
275+ }
276+ if (request.configId === "context" && typeof request.value === "string") {
277+ currentContext = request.value;
278+ }
279+ if (request.configId === "fast") {
280+ currentFast = request.value === true || request.value === "true";
281+ }
282+ return {
283+ configOptions: configOptions(),
284+ };
285+ }),
286+ );
287+
288+ yield* agent.handleCancel(({ sessionId }) =>
289+ Effect.sync(() => {
290+ cancelledSessions.add(String(sessionId ?? "mock-session-1"));
291+ }),
292+ );
293+
294+ yield* agent.handlePrompt((request) =>
295+ Effect.gen(function* () {
296+ const requestedSessionId = String(request.sessionId ?? sessionId);
297+
298+ if (emitInterleavedAssistantToolCalls) {
299+ const toolCallId = "tool-call-1";
300+
301+ yield* agent.client.sessionUpdate({
302+ sessionId: requestedSessionId,
303+ update: {
304+ sessionUpdate: "agent_message_chunk",
305+ content: { type: "text", text: "before tool" },
306+ },
307+ });
308+
309+ yield* agent.client.sessionUpdate({
310+ sessionId: requestedSessionId,
311+ update: {
312+ sessionUpdate: "tool_call",
313+ toolCallId,
314+ title: "Terminal",
315+ kind: "execute",
316+ status: "pending",
317+ rawInput: {
318+ command: ["echo", "hello"],
319+ },
320+ },
321+ });
322+
323+ yield* agent.client.sessionUpdate({
324+ sessionId: requestedSessionId,
325+ update: {
326+ sessionUpdate: "tool_call_update",
327+ toolCallId,
328+ status: "completed",
329+ rawOutput: {
330+ exitCode: 0,
331+ stdout: "hello",
332+ stderr: "",
333+ },
334+ },
335+ });
336+
337+ yield* agent.client.sessionUpdate({
338+ sessionId: requestedSessionId,
339+ update: {
340+ sessionUpdate: "agent_message_chunk",
341+ content: { type: "text", text: "after tool" },
342+ },
343+ });
344+
345+ return { stopReason: "end_turn" };
346+ }
347+
348+ if (emitToolCalls) {
349+ const toolCallId = "tool-call-1";
350+
351+ yield* agent.client.sessionUpdate({
352+ sessionId: requestedSessionId,
353+ update: {
354+ sessionUpdate: "tool_call",
355+ toolCallId,
356+ title: "Terminal",
357+ kind: "execute",
358+ status: "pending",
359+ rawInput: {
360+ command: ["cat", "server/package.json"],
361+ },
362+ },
363+ });
364+
365+ yield* agent.client.sessionUpdate({
366+ sessionId: requestedSessionId,
367+ update: {
368+ sessionUpdate: "tool_call_update",
369+ toolCallId,
370+ status: "in_progress",
371+ },
372+ });
373+
374+ const permission = yield* agent.client.requestPermission({
375+ sessionId: requestedSessionId,
376+ toolCall: {
377+ toolCallId,
378+ title: "`cat server/package.json`",
379+ kind: "execute",
380+ status: "pending",
381+ content: [
382+ {
383+ type: "content",
384+ content: {
385+ type: "text",
386+ text: "Not in allowlist: cat server/package.json",
387+ },
388+ },
389+ ],
390+ },
391+ options: [
392+ { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
393+ { optionId: "allow-always", name: "Allow always", kind: "allow_always" },
394+ { optionId: "reject-once", name: "Reject", kind: "reject_once" },
395+ ],
396+ });
397+
398+ const cancelled =
399+ cancelledSessions.delete(requestedSessionId) ||
400+ permission.outcome.outcome === "cancelled";
401+
402+ yield* agent.client.sessionUpdate({
403+ sessionId: requestedSessionId,
404+ update: {
405+ sessionUpdate: "tool_call_update",
406+ toolCallId,
407+ title: "Terminal",
408+ kind: "execute",
409+ status: "completed",
410+ rawOutput: {
411+ exitCode: 0,
412+ stdout: '{ "name": "t3" }',
413+ stderr: "",
414+ },
415+ },
416+ });
417+
418+ yield* agent.client.sessionUpdate({
419+ sessionId: requestedSessionId,
420+ update: {
421+ sessionUpdate: "agent_message_chunk",
422+ content: { type: "text", text: "hello from mock" },
423+ },
424+ });
425+
426+ return { stopReason: cancelled ? "cancelled" : "end_turn" };
427+ }
428+
429+ if (emitGenericToolPlaceholders) {
430+ const toolCallId = "tool-call-generic-1";
431+
432+ yield* agent.client.sessionUpdate({
433+ sessionId: requestedSessionId,
434+ update: {
435+ sessionUpdate: "tool_call",
436+ toolCallId,
437+ title: "Read File",
438+ kind: "read",
439+ status: "pending",
440+ rawInput: {},
441+ },
442+ });
443+
444+ yield* agent.client.sessionUpdate({
445+ sessionId: requestedSessionId,
446+ update: {
447+ sessionUpdate: "tool_call_update",
448+ toolCallId,
449+ status: "in_progress",
450+ },
451+ });
452+
453+ yield* agent.client.sessionUpdate({
454+ sessionId: requestedSessionId,
455+ update: {
456+ sessionUpdate: "tool_call_update",
457+ toolCallId,
458+ status: "completed",
459+ rawOutput: {
460+ content: "package.json\n",
461+ },
462+ },
463+ });
464+
465+ return { stopReason: "end_turn" };
466+ }
467+
468+ if (emitAskQuestion) {
469+ yield* agent.client.extRequest("cursor/ask_question", {
470+ toolCallId: "ask-question-tool-call-1",
471+ title: "Question",
472+ questions: [
473+ {
474+ id: "scope",
475+ prompt: "Which scope?",
476+ options: [
477+ { id: "workspace", label: "Workspace" },
478+ { id: "session", label: "Session" },
479+ ],
480+ },
481+ ],
482+ });
483+
484+ return { stopReason: "end_turn" };
485+ }
486+
487+ yield* agent.client.sessionUpdate({
488+ sessionId: requestedSessionId,
489+ update: {
490+ sessionUpdate: "plan",
491+ entries: [
492+ {
493+ content: "Inspect mock ACP state",
494+ priority: "high",
495+ status: "completed",
496+ },
497+ {
498+ content: "Implement the requested change",
499+ priority: "high",
500+ status: "in_progress",
501+ },
502+ ],
503+ },
504+ });
505+
506+ yield* agent.client.sessionUpdate({
507+ sessionId: requestedSessionId,
508+ update: {
509+ sessionUpdate: "agent_message_chunk",
510+ content: { type: "text", text: promptResponseText ?? "hello from mock" },
511+ },
512+ });
513+
514+ return { stopReason: "end_turn" };
515+ }),
516+ );
517+
518+ yield* agent.handleUnknownExtRequest((method, params) => {
519+ if (method !== "session/mode/set") {
520+ return Effect.fail(AcpError.AcpRequestError.methodNotFound(method));
521+ }
522+
523+ const nextModeId =
524+ typeof params === "object" &&
525+ params !== null &&
526+ "modeId" in params &&
527+ typeof params.modeId === "string"
528+ ? params.modeId
529+ : typeof params === "object" &&
530+ params !== null &&
531+ "mode" in params &&
532+ typeof params.mode === "string"
533+ ? params.mode
534+ : undefined;
535+ const requestedSessionId =
536+ typeof params === "object" &&
537+ params !== null &&
538+ "sessionId" in params &&
539+ typeof params.sessionId === "string"
540+ ? params.sessionId
541+ : sessionId;
542+
543+ if (typeof nextModeId === "string" && nextModeId.trim()) {
544+ currentModeId = nextModeId.trim();
545+ return agent.client
546+ .sessionUpdate({
547+ sessionId: requestedSessionId,
548+ update: {
549+ sessionUpdate: "current_mode_update",
550+ currentModeId,
551+ },
552+ })
553+ .pipe(Effect.as({}));
554+ }
555+
556+ return Effect.succeed({});
557+ });
558+
559+ return yield* Effect.never;
560+}).pipe(
561+ Effect.provide(
562+ EffectAcpAgent.layerStdio(
563+ requestLogPath
564+ ? {
565+ logIncoming: true,
566+ logger: (event) => {
567+ if (event.direction !== "incoming" || event.stage !== "raw") {
568+ return Effect.void;
569+ }
570+ if (typeof event.payload !== "string") {
571+ return Effect.void;
572+ }
573+ const payload = event.payload;
574+ return Effect.sync(() => {
575+ appendFileSync(
576+ requestLogPath,
577+ payload.endsWith("\n") ? payload : `${payload}\n`,
578+ "utf8",
579+ );
580+ });
581+ },
582+ }
583+ : {},
584+ ),
585+ ),
586+ Effect.scoped,
587+ Effect.provide(NodeServices.layer),
588+);
589+
590+NodeRuntime.runMain(program);
apps/server/scripts/cursor-acp-model-mismatch-probe.ts+435−0
@@ -0,0 +1,435 @@
1+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2+import process from "node:process";
3+import readline from "node:readline";
4+
5+type JsonPrimitive = null | boolean | number | string;
6+type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
7+
8+type JsonRpcId = number | string;
9+
10+type JsonRpcMessage = {
11+ jsonrpc?: string;
12+ id?: JsonRpcId;
13+ method?: string;
14+ params?: JsonValue;
15+ result?: JsonValue;
16+ error?: JsonValue;
17+ headers?: JsonValue;
18+};
19+
20+type SelectLeafOption = {
21+ value: string;
22+ label?: string;
23+ name?: string;
24+};
25+
26+type SelectGroupOption = {
27+ label?: string;
28+ name?: string;
29+ options: SelectLeafOption[];
30+};
31+
32+type SessionConfigOption = {
33+ id: string;
34+ name?: string;
35+ category?: string;
36+ type?: string;
37+ options?: Array<SelectLeafOption | SelectGroupOption>;
38+};
39+
40+type SessionNewResult = {
41+ sessionId: string;
42+ configOptions?: SessionConfigOption[];
43+};
44+
45+type SetConfigResult = {
46+ configOptions?: SessionConfigOption[];
47+};
48+
49+type PendingRequest = {
50+ method: string;
51+ resolve: (value: JsonValue | undefined) => void;
52+ reject: (error: Error) => void;
53+};
54+
55+const targetCwd = process.argv[2] ?? process.cwd();
56+const targetModel = process.argv[3] ?? "gpt-5.4";
57+const promptText = process.argv[4] ?? "helo";
58+const targetReasoning = process.env.CURSOR_REASONING ?? "";
59+const targetContext = process.env.CURSOR_CONTEXT ?? "";
60+const targetFast = process.env.CURSOR_FAST ?? "";
61+const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent";
62+const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000");
63+const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000");
64+
65+function logSection(title: string, value: unknown) {
66+ process.stdout.write(`\n=== ${title} ===\n`);
67+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
68+}
69+
70+function fail(message: string): never {
71+ throw new Error(message);
72+}
73+
74+function asString(value: JsonValue | undefined): string | null {
75+ return typeof value === "string" ? value : null;
76+}
77+
78+function flattenSelectValues(option: SessionConfigOption | undefined): string[] {
79+ if (!option || option.type !== "select" || !Array.isArray(option.options)) {
80+ return [];
81+ }
82+
83+ const values: string[] = [];
84+ for (const entry of option.options) {
85+ if (!entry || typeof entry !== "object") {
86+ continue;
87+ }
88+ if ("value" in entry && typeof entry.value === "string") {
89+ values.push(entry.value);
90+ continue;
91+ }
92+ if ("options" in entry && Array.isArray(entry.options)) {
93+ for (const nested of entry.options) {
94+ if (nested && typeof nested === "object" && typeof nested.value === "string") {
95+ values.push(nested.value);
96+ }
97+ }
98+ }
99+ }
100+ return values;
101+}
102+
103+function findConfigOption(
104+ configOptions: SessionConfigOption[],
105+ predicate: (option: SessionConfigOption) => boolean,
106+): SessionConfigOption | undefined {
107+ return configOptions.find(predicate);
108+}
109+
110+function matchesKeyword(option: SessionConfigOption, keyword: string): boolean {
111+ const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase();
112+ return haystack.includes(keyword.toLowerCase());
113+}
114+
115+function sleep(ms: number) {
116+ return new Promise<void>((resolve) => {
117+ setTimeout(resolve, ms);
118+ });
119+}
120+
121+class JsonRpcChild {
122+ readonly child: ChildProcessWithoutNullStreams;
123+ readonly pending = new Map<JsonRpcId, PendingRequest>();
124+ nextId = 1;
125+ closed = false;
126+
127+ constructor(bin: string, args: string[], cwd: string) {
128+ this.child = spawn(bin, args, {
129+ cwd,
130+ shell: process.platform === "win32",
131+ stdio: ["pipe", "pipe", "pipe"],
132+ env: process.env,
133+ });
134+
135+ this.child.on("exit", (code, signal) => {
136+ this.closed = true;
137+ const detail = `ACP process exited (code=${String(code)}, signal=${String(signal)})`;
138+ for (const pending of this.pending.values()) {
139+ pending.reject(new Error(`${detail} while waiting for ${pending.method}`));
140+ }
141+ this.pending.clear();
142+ });
143+
144+ this.child.on("error", (error) => {
145+ this.closed = true;
146+ for (const pending of this.pending.values()) {
147+ pending.reject(error);
148+ }
149+ this.pending.clear();
150+ });
151+
152+ const stdout = readline.createInterface({ input: this.child.stdout });
153+ stdout.on("line", (line) => {
154+ void this.handleStdoutLine(line);
155+ });
156+
157+ const stderr = readline.createInterface({ input: this.child.stderr });
158+ stderr.on("line", (line) => {
159+ process.stdout.write(`[stderr] ${line}\n`);
160+ });
161+ }
162+
163+ write(message: JsonRpcMessage) {
164+ if (this.closed) {
165+ fail("ACP process is already closed.");
166+ }
167+ const payload = JSON.stringify({
168+ jsonrpc: "2.0",
169+ headers: [],
170+ ...message,
171+ });
172+ process.stdout.write(`>>> ${payload}\n`);
173+ this.child.stdin.write(`${payload}\n`);
174+ }
175+
176+ async request(method: string, params: JsonValue, timeoutMs = requestTimeoutMs) {
177+ const id = this.nextId++;
178+
179+ const responsePromise = new Promise<JsonValue | undefined>((resolve, reject) => {
180+ const timeout = setTimeout(() => {
181+ this.pending.delete(id);
182+ reject(new Error(`Timed out waiting for ${method} response after ${timeoutMs}ms.`));
183+ }, timeoutMs);
184+
185+ this.pending.set(id, {
186+ method,
187+ resolve: (value) => {
188+ clearTimeout(timeout);
189+ resolve(value);
190+ },
191+ reject: (error) => {
192+ clearTimeout(timeout);
193+ reject(error);
194+ },
195+ });
196+ });
197+
198+ this.write({
199+ id,
200+ method,
201+ params,
202+ });
203+
204+ return responsePromise;
205+ }
206+
207+ notify(method: string, params: JsonValue) {
208+ this.write({
209+ method,
210+ params,
211+ });
212+ }
213+
214+ respond(id: JsonRpcId, result: JsonValue) {
215+ this.write({
216+ id,
217+ result,
218+ });
219+ }
220+
221+ respondError(id: JsonRpcId, code: number, message: string) {
222+ this.write({
223+ id,
224+ error: {
225+ code,
226+ message,
227+ },
228+ });
229+ }
230+
231+ async handleStdoutLine(line: string) {
232+ if (line.trim().length === 0) {
233+ return;
234+ }
235+
236+ process.stdout.write(`<<< ${line}\n`);
237+
238+ let message: JsonRpcMessage;
239+ try {
240+ message = JSON.parse(line) as JsonRpcMessage;
241+ } catch (error) {
242+ process.stdout.write(`[parse-error] ${(error as Error).message}\n`);
243+ return;
244+ }
245+
246+ if (typeof message.id !== "undefined" && !message.method) {
247+ const pending = this.pending.get(message.id);
248+ if (!pending) {
249+ return;
250+ }
251+ this.pending.delete(message.id);
252+ if (typeof message.error !== "undefined") {
253+ pending.reject(
254+ new Error(`RPC ${pending.method} failed: ${JSON.stringify(message.error, null, 2)}`),
255+ );
256+ return;
257+ }
258+ pending.resolve(message.result);
259+ return;
260+ }
261+
262+ if (message.method === "session/request_permission" && typeof message.id !== "undefined") {
263+ this.respond(message.id, {
264+ outcome: {
265+ outcome: "selected",
266+ optionId: "allow",
267+ },
268+ });
269+ return;
270+ }
271+
272+ if (typeof message.id !== "undefined" && message.id !== "") {
273+ this.respondError(
274+ message.id,
275+ -32601,
276+ `Unhandled server request: ${message.method ?? "unknown"}`,
277+ );
278+ }
279+ }
280+
281+ async close() {
282+ if (this.closed) {
283+ return;
284+ }
285+ this.child.kill("SIGTERM");
286+ await sleep(250);
287+ if (!this.closed) {
288+ this.child.kill("SIGKILL");
289+ }
290+ }
291+}
292+
293+async function setSelectOptionIfAdvertised(
294+ rpc: JsonRpcChild,
295+ sessionId: string,
296+ configOptions: SessionConfigOption[],
297+ predicate: (option: SessionConfigOption) => boolean,
298+ value: string,
299+ label: string,
300+) {
301+ if (value.length === 0) {
302+ return configOptions;
303+ }
304+
305+ const option = findConfigOption(configOptions, predicate);
306+ const values = flattenSelectValues(option);
307+ if (!option || !values.includes(value)) {
308+ logSection(`SKIP_${label}`, {
309+ requestedValue: value,
310+ availableValues: values,
311+ });
312+ return configOptions;
313+ }
314+
315+ const response = (await rpc.request("session/set_config_option", {
316+ sessionId,
317+ configId: option.id,
318+ value,
319+ })) as SetConfigResult | null | undefined;
320+
321+ logSection(`SET_${label}_RESPONSE`, response);
322+ return response?.configOptions ?? configOptions;
323+}
324+
325+async function main() {
326+ const rpc = new JsonRpcChild(agentBin, ["acp"], targetCwd);
327+
328+ try {
329+ const initializeResponse = await rpc.request("initialize", {
330+ protocolVersion: 1,
331+ clientCapabilities: {
332+ fs: { readTextFile: false, writeTextFile: false },
333+ terminal: false,
334+ _meta: {
335+ parameterizedModelPicker: true,
336+ },
337+ },
338+ clientInfo: {
339+ name: "cursor-acp-model-mismatch-probe",
340+ version: "0.0.0",
341+ },
342+ });
343+ logSection("INITIALIZE_RESPONSE", initializeResponse);
344+
345+ const authenticateResponse = await rpc.request("authenticate", {
346+ methodId: "cursor_login",
347+ });
348+ logSection("AUTHENTICATE_RESPONSE", authenticateResponse);
349+
350+ const sessionResponse = (await rpc.request("session/new", {
351+ cwd: targetCwd,
352+ mcpServers: [],
353+ })) as SessionNewResult;
354+ logSection("SESSION_NEW_RESPONSE", sessionResponse);
355+
356+ const sessionId = asString(sessionResponse.sessionId);
357+ if (!sessionId) {
358+ fail("session/new did not return a sessionId.");
359+ }
360+
361+ let configOptions = sessionResponse.configOptions ?? [];
362+ const modelConfig = findConfigOption(configOptions, (option) => option.category === "model");
363+ const advertisedModels = flattenSelectValues(modelConfig);
364+ logSection("ADVERTISED_MODEL_VALUES", advertisedModels);
365+
366+ if (!modelConfig || modelConfig.type !== "select") {
367+ fail("Cursor ACP did not expose a select-type model config option.");
368+ }
369+
370+ if (!advertisedModels.includes(targetModel)) {
371+ fail(
372+ `Cursor ACP did not advertise model ${JSON.stringify(targetModel)}. Advertised values: ${advertisedModels.join(", ")}`,
373+ );
374+ }
375+
376+ const setModelResponse = (await rpc.request("session/set_config_option", {
377+ sessionId,
378+ configId: modelConfig.id,
379+ value: targetModel,
380+ })) as SetConfigResult | null | undefined;
381+ logSection("SET_MODEL_RESPONSE", setModelResponse);
382+
383+ configOptions = setModelResponse?.configOptions ?? configOptions;
384+
385+ configOptions = await setSelectOptionIfAdvertised(
386+ rpc,
387+ sessionId,
388+ configOptions,
389+ (option) => option.category === "thought_level",
390+ targetReasoning,
391+ "REASONING",
392+ );
393+
394+ configOptions = await setSelectOptionIfAdvertised(
395+ rpc,
396+ sessionId,
397+ configOptions,
398+ (option) => option.category === "model_config" && matchesKeyword(option, "context"),
399+ targetContext,
400+ "CONTEXT",
401+ );
402+
403+ configOptions = await setSelectOptionIfAdvertised(
404+ rpc,
405+ sessionId,
406+ configOptions,
407+ (option) => option.category === "model_config" && matchesKeyword(option, "fast"),
408+ targetFast,
409+ "FAST",
410+ );
411+
412+ const promptResponse = await rpc.request("session/prompt", {
413+ sessionId,
414+ prompt: [
415+ {
416+ type: "text",
417+ text: promptText,
418+ },
419+ ],
420+ });
421+ logSection("PROMPT_RESPONSE", promptResponse);
422+
423+ await sleep(promptWaitMs);
424+ rpc.notify("session/cancel", { sessionId });
425+ } finally {
426+ await rpc.close();
427+ }
428+}
429+
430+void main().catch((error: unknown) => {
431+ process.stderr.write(
432+ `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
433+ );
434+ process.exitCode = 1;
435+});
apps/server/src/checkpointing/Layers/CheckpointStore.ts+3−0
@@ -19,6 +19,8 @@ import { GitCore } from "../../git/Services/GitCore.ts"; · 18 unmodified lines
1919 import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
2020 import { CheckpointRef } from "@t3tools/contracts";
2121
22+const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
23+
2224 const makeCheckpointStore = Effect.gen(function* () {
2325 const fs = yield* FileSystem.FileSystem;
2426 const path = yield* Path.Path;
@@ -245,6 +247,7 @@ const makeCheckpointStore = Effect.gen(function* () { · 220 unmodified lines
245247 operation,
246248 cwd: input.cwd,
247249 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
250+ maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
248251 });
249252
250253 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
1111 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
1313 import { ClaudeModelSelection } from "@t3tools/contracts";
14−import { resolveApiModelId } from "@t3tools/shared/model";
1514 import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1615
1716 import { TextGenerationError } from "@t3tools/contracts";
@@ -30,6 +29,7 @@ import { · 12 unmodified lines
3029 toJsonSchemaObject,
3130 } from "../Utils.ts";
3231 import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
32+import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts";
3333 import { ServerSettingsService } from "../../serverSettings.ts";
3434 import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
3535
@@ -110,7 +110,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { · 74 unmodified lines
110110 "--json-schema",
111111 jsonSchemaStr,
112112 "--model",
113− resolveApiModelId(modelSelection),
113+ resolveClaudeApiModelId(modelSelection),
114114 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
115115 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
116116 "--dangerously-skip-permissions",
apps/server/src/git/Layers/CursorTextGeneration.test.ts+298−0
@@ -0,0 +1,298 @@
1+import * as path from "node:path";
2+import * as os from "node:os";
3+import { fileURLToPath } from "node:url";
4+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5+
6+import * as NodeServices from "@effect/platform-node/NodeServices";
7+import { it } from "@effect/vitest";
8+import { Effect, Layer } from "effect";
9+import { expect } from "vitest";
10+
11+import { ServerSettingsError } from "@t3tools/contracts";
12+
13+import { ServerConfig } from "../../config.ts";
14+import { TextGeneration } from "../Services/TextGeneration.ts";
15+import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
16+import { ServerSettingsService } from "../../serverSettings.ts";
17+
18+const __dirname = path.dirname(fileURLToPath(import.meta.url));
19+const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
20+
21+function shellSingleQuote(value: string): string {
22+ return `'${value.replaceAll("'", `'"'"'`)}'`;
23+}
24+
25+const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe(
26+ Layer.provideMerge(ServerSettingsService.layerTest()),
27+ Layer.provideMerge(
28+ ServerConfig.layerTest(process.cwd(), {
29+ prefix: "t3code-cursor-text-generation-test-",
30+ }),
31+ ),
32+ Layer.provideMerge(NodeServices.layer),
33+);
34+
35+function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
36+ const binDir = path.join(dir, "bin");
37+ const agentPath = path.join(binDir, "agent");
38+ mkdirSync(binDir, { recursive: true });
39+ writeFileSync(
40+ agentPath,
41+ [
42+ "#!/bin/sh",
43+ ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
44+ 'if [ "$1" != "acp" ]; then',
45+ ' printf "%s\\n" "unexpected args: $*" >&2',
46+ " exit 11",
47+ "fi",
48+ `exec bun ${JSON.stringify(mockAgentPath)}`,
49+ "",
50+ ].join("\n"),
51+ "utf8",
52+ );
53+ chmodSync(agentPath, 0o755);
54+ return agentPath;
55+}
56+
57+function withFakeAcpAgent<A, E, R>(
58+ env: Record<string, string>,
59+ effect: Effect.Effect<A, E, R>,
60+): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> {
61+ return Effect.gen(function* () {
62+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
63+ const agentPath = makeAcpAgentWrapper(tempDir, env);
64+ const serverSettings = yield* ServerSettingsService;
65+ const previousSettings = yield* serverSettings.getSettings;
66+
67+ yield* serverSettings.updateSettings({
68+ providers: {
69+ cursor: {
70+ binaryPath: agentPath,
71+ },
72+ },
73+ });
74+
75+ return yield* effect.pipe(
76+ Effect.ensuring(
77+ serverSettings
78+ .updateSettings({
79+ providers: {
80+ cursor: {
81+ binaryPath: previousSettings.providers.cursor.binaryPath,
82+ },
83+ },
84+ })
85+ .pipe(
86+ Effect.catch(() => Effect.void),
87+ Effect.ensuring(
88+ Effect.sync(() => {
89+ rmSync(tempDir, { recursive: true, force: true });
90+ }),
91+ ),
92+ Effect.asVoid,
93+ ),
94+ ),
95+ );
96+ });
97+}
98+
99+function waitForFileContent(path: string): Effect.Effect<string> {
100+ return Effect.promise(async () => {
101+ const deadline = Date.now() + 5_000;
102+ for (;;) {
103+ try {
104+ return readFileSync(path, "utf8");
105+ } catch (error) {
106+ if (Date.now() >= deadline) {
107+ throw error instanceof Error ? error : new Error(String(error));
108+ }
109+ }
110+ await new Promise((resolve) => setTimeout(resolve, 25));
111+ }
112+ });
113+}
114+
115+it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
116+ it.effect("uses ACP model config options instead of raw CLI model ids", () => {
117+ const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-"));
118+ const requestLogPath = path.join(requestLogDir, "requests.ndjson");
119+
120+ return withFakeAcpAgent(
121+ {
122+ T3_ACP_REQUEST_LOG_PATH: requestLogPath,
123+ T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
124+ subject: "Add generated commit message",
125+ body: "- verify cursor acp model config path",
126+ }),
127+ },
128+ Effect.gen(function* () {
129+ const textGeneration = yield* TextGeneration;
130+
131+ const generated = yield* textGeneration.generateCommitMessage({
132+ cwd: process.cwd(),
133+ branch: "feature/cursor-text-generation",
134+ stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
135+ stagedPatch:
136+ "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
137+ modelSelection: {
138+ provider: "cursor",
139+ model: "gpt-5.4",
140+ options: {
141+ reasoning: "xhigh",
142+ fastMode: true,
143+ contextWindow: "1m",
144+ },
145+ },
146+ });
147+
148+ expect(generated.subject).toBe("Add generated commit message");
149+ expect(generated.body).toBe("- verify cursor acp model config path");
150+
151+ const requests = readFileSync(requestLogPath, "utf8")
152+ .trim()
153+ .split("\n")
154+ .filter((line) => line.length > 0)
155+ .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
156+
157+ expect(
158+ requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
159+ ).toMatchObject({
160+ _meta: {
161+ parameterizedModelPicker: true,
162+ },
163+ });
164+ expect(
165+ requests.some(
166+ (request) =>
167+ request.method === "session/set_config_option" &&
168+ request.params?.configId === "model" &&
169+ request.params?.value === "gpt-5.4",
170+ ),
171+ ).toBe(true);
172+ expect(
173+ requests.some(
174+ (request) =>
175+ request.method === "session/set_config_option" &&
176+ request.params?.configId === "reasoning" &&
177+ request.params?.value === "extra-high",
178+ ),
179+ ).toBe(true);
180+ expect(
181+ requests.some(
182+ (request) =>
183+ request.method === "session/set_config_option" &&
184+ request.params?.configId === "context" &&
185+ request.params?.value === "1m",
186+ ),
187+ ).toBe(true);
188+ expect(
189+ requests.some(
190+ (request) =>
191+ request.method === "session/set_config_option" &&
192+ request.params?.configId === "fast" &&
193+ request.params?.value === "true",
194+ ),
195+ ).toBe(true);
196+ expect(
197+ requests.find((request) => request.method === "session/prompt")?.params?.prompt,
198+ ).toEqual(
199+ expect.arrayContaining([
200+ expect.objectContaining({
201+ type: "text",
202+ text: expect.stringContaining("Staged patch:"),
203+ }),
204+ ]),
205+ );
206+
207+ rmSync(requestLogDir, { recursive: true, force: true });
208+ }),
209+ );
210+ });
211+
212+ it.effect("accepts json objects with extra assistant text around them", () =>
213+ withFakeAcpAgent(
214+ {
215+ T3_ACP_PROMPT_RESPONSE_TEXT:
216+ 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
217+ },
218+ Effect.gen(function* () {
219+ const textGeneration = yield* TextGeneration;
220+
221+ const generated = yield* textGeneration.generateCommitMessage({
222+ cwd: process.cwd(),
223+ branch: "feature/cursor-noisy-json",
224+ stagedSummary: "M README.md",
225+ stagedPatch: "diff --git a/README.md b/README.md",
226+ modelSelection: {
227+ provider: "cursor",
228+ model: "composer-2",
229+ },
230+ });
231+
232+ expect(generated.subject).toBe("Update README dummy comment with attribution and date");
233+ expect(generated.body).toBe("");
234+ }),
235+ ),
236+ );
237+
238+ it.effect("generates thread titles through Cursor ACP text generation", () =>
239+ withFakeAcpAgent(
240+ {
241+ T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
242+ title: '"Trim reconnect spinner status after resume."',
243+ }),
244+ },
245+ Effect.gen(function* () {
246+ const textGeneration = yield* TextGeneration;
247+
248+ const generated = yield* textGeneration.generateThreadTitle({
249+ cwd: process.cwd(),
250+ message: "Fix the reconnect spinner after a resumed session.",
251+ modelSelection: {
252+ provider: "cursor",
253+ model: "composer-2",
254+ },
255+ });
256+
257+ expect(generated.title).toBe("Trim reconnect spinner status after resume.");
258+ }),
259+ ),
260+ );
261+
262+ it.effect("closes the ACP child process after text generation completes", () => {
263+ const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-"));
264+ const exitLogPath = path.join(exitLogDir, "exit.log");
265+
266+ return withFakeAcpAgent(
267+ {
268+ T3_ACP_EXIT_LOG_PATH: exitLogPath,
269+ T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
270+ subject: "Close runtime after generation",
271+ body: "",
272+ }),
273+ },
274+ Effect.gen(function* () {
275+ const textGeneration = yield* TextGeneration;
276+
277+ const generated = yield* textGeneration.generateCommitMessage({
278+ cwd: process.cwd(),
279+ branch: "feature/cursor-runtime-close",
280+ stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
281+ stagedPatch:
282+ "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
283+ modelSelection: {
284+ provider: "cursor",
285+ model: "composer-2",
286+ },
287+ });
288+
289+ expect(generated.subject).toBe("Close runtime after generation");
290+
291+ const exitLog = yield* waitForFileContent(exitLogPath);
292+ expect(exitLog).toContain("exit:0");
293+
294+ rmSync(exitLogDir, { recursive: true, force: true });
295+ }),
296+ );
297+ });
298+});
apps/server/src/git/Layers/CursorTextGeneration.ts+352−0
@@ -0,0 +1,352 @@
1+import { Effect, Layer, Option, Ref, Schema } from "effect";
2+import { ChildProcessSpawner } from "effect/unstable/process";
3+
4+import { CursorModelSelection } from "@t3tools/contracts";
5+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6+
7+import { TextGenerationError } from "@t3tools/contracts";
8+import {
9+ type ThreadTitleGenerationResult,
10+ type TextGenerationShape,
11+ TextGeneration,
12+} from "../Services/TextGeneration.ts";
13+import {
14+ buildBranchNamePrompt,
15+ buildCommitMessagePrompt,
16+ buildPrContentPrompt,
17+ buildThreadTitlePrompt,
18+} from "../Prompts.ts";
19+import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts";
20+import {
21+ applyCursorAcpModelSelection,
22+ makeCursorAcpRuntime,
23+} from "../../provider/acp/CursorAcpSupport.ts";
24+import { ServerSettingsService } from "../../serverSettings.ts";
25+
26+const CURSOR_TIMEOUT_MS = 180_000;
27+
28+function 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+
76+function 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+
92+function 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+
101+const 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+
352+export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration);
apps/server/src/git/Layers/RoutingTextGeneration.ts+22−1
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 } from "../Services/TextGeneration.ts";
1919 import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
2020 import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21+import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
2122 import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
2223
2324 // ---------------------------------------------------------------------------
@@ -32,6 +33,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 8 unmodified lines
3233 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
3334 ) {}
3435
36+class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()(
37+ "t3/git/Layers/RoutingTextGeneration/CursorTextGen",
38+) {}
39+
3540 class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
3641 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
3742 ) {}
@@ -43,10 +48,17 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha · 5 unmodified lines
4348 const makeRoutingTextGeneration = Effect.gen(function* () {
4449 const codex = yield* CodexTextGen;
4550 const claude = yield* ClaudeTextGen;
51+ const cursor = yield* CursorTextGen;
4652 const openCode = yield* OpenCodeTextGen;
4753
4854 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;
5062
5163 return {
5264 generateCommitMessage: (input) =>
@@ -73,6 +85,14 @@ const InternalClaudeLayer = Layer.effect( · 20 unmodified lines
7385 }),
7486 ).pipe(Layer.provide(ClaudeTextGenerationLive));
7587
88+const InternalCursorLayer = Layer.effect(
89+ CursorTextGen,
90+ Effect.gen(function* () {
91+ const svc = yield* TextGeneration;
92+ return svc;
93+ }),
94+).pipe(Layer.provide(CursorTextGenerationLive));
95+
7696 const InternalOpenCodeLayer = Layer.effect(
7797 OpenCodeTextGen,
7898 Effect.gen(function* () {
@@ -87,5 +107,6 @@ export const RoutingTextGenerationLive = Layer.effect( · 8 unmodified lines
87107 ).pipe(
88108 Layer.provide(InternalCodexLayer),
89109 Layer.provide(InternalClaudeLayer),
110+ Layer.provide(InternalCursorLayer),
90111 Layer.provide(InternalOpenCodeLayer),
91112 );
apps/server/src/git/Services/TextGeneration.ts+1−1
@@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; · 12 unmodified lines
1313 import type { TextGenerationError } from "@t3tools/contracts";
1414
1515 /** Providers that support git text generation (commit messages, PR content, branch names). */
16−export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";
16+export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
1717
1818 export interface CommitMessageGenerationInput {
1919 cwd: string;
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1−1
@@ -266,7 +266,7 @@ describe("ProviderCommandReactor", () => { · 265 unmodified lines
266266 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
267267 Layer.provideMerge(NodeServices.layer),
268268 );
269− const runtime = ManagedRuntime.make(layer);
269+ runtime = ManagedRuntime.make(layer);
270270
271271 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
272272 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
8989 : false;
9090 }
9191
92+function 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+
9299 function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
93− const error = Cause.squash(cause);
94− if (Schema.is(ProviderAdapterRequestError)(error)) {
100+ const error = findProviderAdapterRequestError(cause);
101+ if (error) {
95102 const detail = error.detail.toLowerCase();
96103 return (
97104 detail.includes("unknown pending approval request") ||
@@ -106,8 +113,8 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService · 8 unmodified lines
106113 }
107114
108115 function isUnknownPendingUserInputRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
109− const error = Cause.squash(cause);
110− if (Schema.is(ProviderAdapterRequestError)(error)) {
116+ const error = findProviderAdapterRequestError(cause);
117+ if (error) {
111118 return error.detail.toLowerCase().includes("unknown pending user-input request");
112119 }
113120 return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request");
@@ -198,6 +205,17 @@ const make = Effect.gen(function* () { · 84 unmodified lines
198205 createdAt: input.createdAt,
199206 });
200207
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+
201219 const setThreadSession = (input: {
202220 readonly threadId: ThreadId;
203221 readonly session: OrchestrationSession;
@@ -211,7 +229,30 @@ const make = Effect.gen(function* () { · 7 unmodified lines
211229 createdAt: input.createdAt,
212230 });
213231
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) {
215256 const readModel = yield* orchestrationEngine.getReadModel();
216257 return readModel.threads.find((entry) => entry.id === threadId);
217258 });
@@ -300,7 +341,7 @@ const make = Effect.gen(function* () { · 82 unmodified lines
300341 const modelChanged =
301342 requestedModelSelection !== undefined &&
302343 requestedModelSelection.model !== activeSession?.model;
303− const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
344+ const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported";
304345 const previousModelSelection = threadModelSelections.get(threadId);
305346 const shouldRestartForModelSelectionChange =
306347 currentProvider === "claudeAgent" &&
@@ -350,7 +391,7 @@ const make = Effect.gen(function* () { · 43 unmodified lines
350391 return startedSession.threadId;
351392 });
352393
353− const sendTurnForThread = Effect.fn("sendTurnForThread")(function* (input: {
394+ const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: {
354395 readonly threadId: ThreadId;
355396 readonly messageText: string;
356397 readonly attachments?: ReadonlyArray<ChatAttachment>;
@@ -360,7 +401,9 @@ const make = Effect.gen(function* () { · 3 unmodified lines
360401 }) {
361402 const thread = yield* resolveThread(input.threadId);
362403 if (!thread) {
363− return;
404+ return yield* Effect.die(
405+ new Error(`Thread '${input.threadId}' was not found in read model.`),
406+ );
364407 }
365408 yield* ensureSessionForThread(
366409 input.threadId,
@@ -384,7 +427,7 @@ const make = Effect.gen(function* () { · 17 unmodified lines
384427 const requestedModelSelection =
385428 input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection;
386429 const modelForTurn =
387− sessionModelSwitch === "unsupported"
430+ sessionModelSwitch === "unsupported" && input.modelSelection === undefined
388431 ? activeSession?.model !== undefined
389432 ? {
390433 ...requestedModelSelection,
@@ -393,13 +436,13 @@ const make = Effect.gen(function* () { · 2 unmodified lines
393436 : requestedModelSelection
394437 : input.modelSelection;
395438
396− yield* providerService.sendTurn({
439+ return {
397440 threadId: input.threadId,
398441 ...(normalizedInput ? { input: normalizedInput } : {}),
399442 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
400443 ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
401444 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
402− });
445+ };
403446 });
404447
405448 const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fn(
@@ -558,7 +601,43 @@ const make = Effect.gen(function* () { · 152 unmodified lines
558601 }
559602 }
560603
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({
562641 threadId: event.payload.threadId,
563642 messageText: message.text,
564643 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
@@ -568,17 +647,17 @@ const make = Effect.gen(function* () { · 3 unmodified lines
568647 interactionMode: event.payload.interactionMode,
569648 createdAt: event.payload.createdAt,
570649 }).pipe(
571− Effect.catchCause((cause) =>
572− appendProviderFailureActivity({
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− ),
650+ Effect.map(Option.some),
651+ Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))),
581652 );
653+
654+ if (Option.isNone(sendTurnRequest)) {
655+ return;
656+ }
657+
658+ yield* providerService
659+ .sendTurn(sendTurnRequest.value)
660+ .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
582661 });
583662
584663 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
723723 expect(message?.streaming).toBe(false);
724724 });
725725
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+
726866 it("projects completed plan items into first-class proposed plans", async () => {
727867 const harness = await createHarness();
728868 const now = new Date().toISOString();
@@ -1392,6 +1532,432 @@ describe("ProviderRuntimeIngestion", () => { · 663 unmodified lines
13921532 expect(message?.streaming).toBe(false);
13931533 });
13941534
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+
13951961 it("streams assistant deltas when thread.turn.start requests streaming mode", async () => {
13961962 const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } });
13971963 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
3232 const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
3333 CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
3434
35+interface AssistantSegmentState {
36+ baseKey: string;
37+ nextSegmentIndex: number;
38+ activeMessageId: MessageId | null;
39+}
40+
3541 const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
3642 const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);
3743 const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
@@ -83,6 +89,10 @@ function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string · 45 unmodified lines
8389 return trimmed;
8490 }
8591
92+function hasRenderableAssistantText(text: string | undefined): boolean {
93+ return (text?.trim().length ?? 0) > 0;
94+}
95+
8696 function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string {
8797 return `plan:${threadId}:turn:${turnId}`;
8898 }
@@ -98,6 +108,15 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId · 9 unmodified lines
98108 return `plan:${threadId}:event:${event.eventId}`;
99109 }
100110
111+function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string {
112+ return String(event.itemId ?? event.turnId ?? event.eventId);
113+}
114+
115+function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId {
116+ return MessageId.make(
117+ segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`,
118+ );
119+}
101120 function buildContextWindowActivityPayload(
102121 event: ProviderRuntimeEvent,
103122 ): ThreadTokenUsageSnapshot | undefined {
@@ -501,7 +520,7 @@ function runtimeEventToActivities( · 397 unmodified lines
501520 return [];
502521 }
503522
504−const make = Effect.fn("make")(function* () {
523+const make = Effect.gen(function* () {
505524 const orchestrationEngine = yield* OrchestrationEngineService;
506525 const providerService = yield* ProviderService;
507526 const projectionTurnRepository = yield* ProjectionTurnRepository;
@@ -519,6 +538,15 @@ const make = Effect.fn("make")(function* () { · 11 unmodified lines
519538 lookup: () => Effect.succeed(""),
520539 });
521540
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+
522550 const bufferedProposedPlanById = yield* Cache.make<string, { text: string; createdAt: string }>({
523551 capacity: BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY,
524552 timeToLive: BUFFERED_PROPOSED_PLAN_BY_ID_TTL,
@@ -586,10 +614,86 @@ const make = Effect.fn("make")(function* () { · 61 unmodified lines
586614 const clearAssistantMessageIdsForTurn = (threadId: ThreadId, turnId: TurnId) =>
587615 Cache.invalidate(turnMessageIdsByTurnKey, providerTurnKey(threadId, turnId));
588616
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+
589693 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>
590694 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
591− Effect.flatMap(
592− Effect.fn("appendBufferedAssistantText")(function* (existingText) {
695+ Effect.flatMap((existingText) =>
696+ Effect.gen(function* () {
593697 const nextText = Option.match(existingText, {
594698 onNone: () => delta,
595699 onSome: (text) => `${text}${delta}`,
@@ -645,48 +749,154 @@ const make = Effect.fn("make")(function* () { · 49 unmodified lines
645749 const clearAssistantMessageState = (messageId: MessageId) =>
646750 clearBufferedAssistantText(messageId);
647751
648− const finalizeAssistantMessage = Effect.fn("finalizeAssistantMessage")(function* (input: {
752+ const flushBufferedAssistantMessage = (input: {
649753 event: ProviderRuntimeEvent;
650754 threadId: ThreadId;
651755 messageId: MessageId;
652756 turnId?: TurnId;
653757 createdAt: string;
654758 commandTag: string;
655− finalDeltaCommandTag: string;
656− fallbackText?: string;
657− }) {
658− const bufferedText = yield* takeBufferedAssistantText(input.messageId);
659− const text =
660− bufferedText.length > 0
661− ? bufferedText
662− : (input.fallbackText?.trim().length ?? 0) > 0
663− ? input.fallbackText!
664− : "";
665−
666− if (text.length > 0) {
759+ }) =>
760+ Effect.gen(function* () {
761+ const bufferedText = yield* takeBufferedAssistantText(input.messageId);
762+ if (!hasRenderableAssistantText(bufferedText)) {
763+ return false;
764+ }
765+
667766 yield* orchestrationEngine.dispatch({
668767 type: "thread.message.assistant.delta",
669− commandId: providerCommandId(input.event, input.finalDeltaCommandTag),
768+ commandId: providerCommandId(input.event, input.commandTag),
670769 threadId: input.threadId,
671770 messageId: input.messageId,
672− delta: text,
771+ delta: bufferedText,
673772 ...(input.turnId ? { turnId: input.turnId } : {}),
674773 createdAt: input.createdAt,
675774 });
676− }
775+ return true;
776+ });
677777
678− yield* orchestrationEngine.dispatch({
679− type: "thread.message.assistant.complete",
680− commandId: providerCommandId(input.event, input.commandTag),
681− threadId: input.threadId,
682− messageId: input.messageId,
683− ...(input.turnId ? { turnId: input.turnId } : {}),
684− createdAt: input.createdAt,
778+ const flushBufferedAssistantMessagesForTurn = (input: {
779+ event: ProviderRuntimeEvent;
780+ threadId: ThreadId;
781+ turnId: TurnId;
782+ createdAt: string;
783+ commandTag: string;
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;
685809 });
686− yield* clearAssistantMessageState(input.messageId);
687− });
688810
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: {
690900 event: ProviderRuntimeEvent;
691901 threadId: ThreadId;
692902 threadProposedPlans: ReadonlyArray<{
@@ -700,31 +910,32 @@ const make = Effect.fn("make")(function* () { · 7 unmodified lines
700910 planMarkdown: string | undefined;
701911 createdAt: string;
702912 updatedAt: string;
703− }) {
704− const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown);
705− if (!planMarkdown) {
706− return;
707− }
913+ }) =>
914+ Effect.gen(function* () {
915+ const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown);
916+ if (!planMarkdown) {
917+ return;
918+ }
708919
709− const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId);
710− yield* orchestrationEngine.dispatch({
711− type: "thread.proposed-plan.upsert",
712− commandId: providerCommandId(input.event, "proposed-plan-upsert"),
713− threadId: input.threadId,
714− proposedPlan: {
715− id: input.planId,
716− turnId: input.turnId ?? null,
717− planMarkdown,
718− implementedAt: existingPlan?.implementedAt ?? null,
719− implementationThreadId: existingPlan?.implementationThreadId ?? null,
720− createdAt: existingPlan?.createdAt ?? input.createdAt,
721− updatedAt: input.updatedAt,
722− },
723− createdAt: input.updatedAt,
920+ const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId);
921+ yield* orchestrationEngine.dispatch({
922+ type: "thread.proposed-plan.upsert",
923+ commandId: providerCommandId(input.event, "proposed-plan-upsert"),
924+ threadId: input.threadId,
925+ proposedPlan: {
926+ id: input.planId,
927+ turnId: input.turnId ?? null,
928+ planMarkdown,
929+ implementedAt: existingPlan?.implementedAt ?? null,
930+ implementationThreadId: existingPlan?.implementationThreadId ?? null,
931+ createdAt: existingPlan?.createdAt ?? input.createdAt,
932+ updatedAt: input.updatedAt,
933+ },
934+ createdAt: input.updatedAt,
935+ });
724936 });
725− });
726937
727− const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: {
938+ const finalizeBufferedProposedPlan = (input: {
728939 event: ProviderRuntimeEvent;
729940 threadId: ThreadId;
730941 threadProposedPlans: ReadonlyArray<{
@@ -737,65 +948,75 @@ const make = Effect.fn("make")(function* () { · 6 unmodified lines
737948 turnId?: TurnId;
738949 fallbackMarkdown?: string;
739950 updatedAt: string;
740− }) {
741− const bufferedPlan = yield* takeBufferedProposedPlan(input.planId);
742− const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text);
743− const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown);
744− const planMarkdown = bufferedMarkdown ?? fallbackMarkdown;
745− if (!planMarkdown) {
746− return;
747− }
951+ }) =>
952+ Effect.gen(function* () {
953+ const bufferedPlan = yield* takeBufferedProposedPlan(input.planId);
954+ const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text);
955+ const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown);
956+ const planMarkdown = bufferedMarkdown ?? fallbackMarkdown;
957+ if (!planMarkdown) {
958+ return;
959+ }
748960
749− yield* upsertProposedPlan({
750− event: input.event,
751− threadId: input.threadId,
752− threadProposedPlans: input.threadProposedPlans,
753− planId: input.planId,
754− ...(input.turnId ? { turnId: input.turnId } : {}),
755− planMarkdown,
756− createdAt:
757− bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0
758− ? bufferedPlan.createdAt
759− : input.updatedAt,
760− updatedAt: input.updatedAt,
961+ yield* upsertProposedPlan({
962+ event: input.event,
963+ threadId: input.threadId,
964+ threadProposedPlans: input.threadProposedPlans,
965+ planId: input.planId,
966+ ...(input.turnId ? { turnId: input.turnId } : {}),
967+ planMarkdown,
968+ createdAt:
969+ bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0
970+ ? bufferedPlan.createdAt
971+ : input.updatedAt,
972+ updatedAt: input.updatedAt,
973+ });
974+ yield* clearBufferedProposedPlan(input.planId);
761975 });
762− yield* clearBufferedProposedPlan(input.planId);
763− });
764976
765− const clearTurnStateForSession = Effect.fn("clearTurnStateForSession")(function* (
766− threadId: ThreadId,
767− ) {
768− const prefix = `${threadId}:`;
769− const proposedPlanPrefix = `plan:${threadId}:`;
770− const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));
771− const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById));
772− yield* Effect.forEach(
773− turnKeys,
774− Effect.fn(function* (key) {
775− if (!key.startsWith(prefix)) {
776− return;
777− }
977+ const clearTurnStateForSession = (threadId: ThreadId) =>
978+ Effect.gen(function* () {
979+ const prefix = `${threadId}:`;
980+ const proposedPlanPrefix = `plan:${threadId}:`;
981+ const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));
982+ const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey));
983+ const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById));
984+ yield* Effect.forEach(
985+ turnKeys,
986+ (key) =>
987+ Effect.gen(function* () {
988+ if (!key.startsWith(prefix)) {
989+ return;
990+ }
778991
779− const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key);
780− if (Option.isSome(messageIds)) {
781− yield* Effect.forEach(messageIds.value, clearAssistantMessageState, {
782− concurrency: 1,
783− }).pipe(Effect.asVoid);
784− }
992+ const messageIds = yield* Cache.getOption(turnMessageIdsByTurnKey, key);
993+ if (Option.isSome(messageIds)) {
994+ yield* Effect.forEach(messageIds.value, clearAssistantMessageState, {
995+ concurrency: 1,
996+ }).pipe(Effect.asVoid);
997+ }
785998
786− yield* Cache.invalidate(turnMessageIdsByTurnKey, key);
787− }),
788− { concurrency: 1 },
789− ).pipe(Effect.asVoid);
790− yield* Effect.forEach(
791− proposedPlanKeys,
792− (key) =>
793− key.startsWith(proposedPlanPrefix)
794− ? Cache.invalidate(bufferedProposedPlanById, key)
795− : Effect.void,
796− { concurrency: 1 },
797− ).pipe(Effect.asVoid);
798− });
999+ yield* Cache.invalidate(turnMessageIdsByTurnKey, key);
1000+ }),
1001+ { concurrency: 1 },
1002+ ).pipe(Effect.asVoid);
1003+ yield* Effect.forEach(
1004+ assistantSegmentKeys,
1005+ (key) =>
1006+ key.startsWith(prefix)
1007+ ? Cache.invalidate(assistantSegmentStateByTurnKey, key)
1008+ : Effect.void,
1009+ { concurrency: 1 },
1010+ ).pipe(Effect.asVoid);
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+ });
7991020
8001021 const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn(
8011022 "getSourceProposedPlanReferenceForPendingTurnStart",
@@ -873,353 +1094,432 @@ const make = Effect.fn("make")(function* () { · 71 unmodified lines
8731094 },
8741095 );
8751096
876− const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* (
877− event: ProviderRuntimeEvent,
878− ) {
879− const readModel = yield* orchestrationEngine.getReadModel();
880− const thread = readModel.threads.find((entry) => entry.id === event.threadId);
881− if (!thread) return;
1097+ const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
1098+ Effect.gen(function* () {
1099+ const readModel = yield* orchestrationEngine.getReadModel();
1100+ const thread = readModel.threads.find((entry) => entry.id === event.threadId);
1101+ if (!thread) return;
8821102
883− const now = event.createdAt;
884− const eventTurnId = toTurnId(event.turnId);
885− const activeTurnId = thread.session?.activeTurnId ?? null;
1103+ const now = event.createdAt;
1104+ const eventTurnId = toTurnId(event.turnId);
1105+ const activeTurnId = thread.session?.activeTurnId ?? null;
8861106
887− const conflictsWithActiveTurn =
888− activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
889− const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;
1107+ const conflictsWithActiveTurn =
1108+ activeTurnId !== null && eventTurnId !== undefined && !sameId(activeTurnId, eventTurnId);
1109+ const missingTurnForActiveTurn = activeTurnId !== null && eventTurnId === undefined;
8901110
891− const shouldApplyThreadLifecycle = (() => {
892− 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.
1111+ const shouldApplyThreadLifecycle = (() => {
1112+ if (!STRICT_PROVIDER_LIFECYCLE_GUARD) {
9121113 return true;
913− default:
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 = (() => {
1114+ }
9371115 switch (event.type) {
938− case "session.state.changed":
939− return orchestrationSessionStatusFromRuntimeState(event.payload.state);
940− case "turn.started":
941− return "running";
9421116 case "session.exited":
943− return "stopped";
944− case "turn.completed":
945− return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready";
1117+ return true;
9461118 case "session.started":
9471119 case "thread.started":
948− // Provider thread/session start notifications can arrive during an
949− // active turn; preserve turn-running state in that case.
950− return activeTurnId !== null ? "running" : "ready";
1120+ return true;
1121+ case "turn.started":
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;
9511135 }
9521136 })();
953− const lastError =
954− event.type === "session.state.changed" && event.payload.state === "error"
955− ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error")
956− : event.type === "turn.completed" &&
957− normalizeRuntimeTurnState(event.payload.state) === "failed"
958− ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed")
959− : status === "ready"
1137+ const acceptedTurnStartedSourcePlan =
1138+ event.type === "turn.started" && shouldApplyThreadLifecycle
1139+ ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId)
1140+ : null;
1141+
1142+ if (
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"
9601154 ? null
961− : (thread.session?.lastError ?? null);
962−
963− if (shouldApplyThreadLifecycle) {
964− if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) {
965− yield* markSourceProposedPlanImplemented(
966− acceptedTurnStartedSourcePlan.sourceThreadId,
967− acceptedTurnStartedSourcePlan.sourcePlanId,
968− thread.id,
969− now,
970− ).pipe(
971− Effect.catchCause((cause) =>
972− Effect.logWarning("provider runtime ingestion failed to mark source proposed plan", {
973− eventId: event.eventId,
974− eventType: event.type,
975− cause: Cause.pretty(cause),
976− }),
977− ),
978− );
979− }
1155+ : activeTurnId;
1156+ const status = (() => {
1157+ switch (event.type) {
1158+ case "session.state.changed":
1159+ return orchestrationSessionStatusFromRuntimeState(event.payload.state);
1160+ case "turn.started":
1161+ return "running";
1162+ case "session.exited":
1163+ return "stopped";
1164+ case "turn.completed":
1165+ return normalizeRuntimeTurnState(event.payload.state) === "failed"
1166+ ? "error"
1167+ : "ready";
1168+ case "session.started":
1169+ case "thread.started":
1170+ // Provider thread/session start notifications can arrive during an
1171+ // active turn; preserve turn-running state in that case.
1172+ return activeTurnId !== null ? "running" : "ready";
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+ }
9801205
981− yield* orchestrationEngine.dispatch({
982− type: "thread.session.set",
983− commandId: providerCommandId(event, "thread-session-set"),
984− threadId: thread.id,
985− session: {
1206+ yield* orchestrationEngine.dispatch({
1207+ type: "thread.session.set",
1208+ commandId: providerCommandId(event, "thread-session-set"),
9861209 threadId: thread.id,
987− status,
988− providerName: event.provider,
989− runtimeMode: thread.session?.runtimeMode ?? "full-access",
990− activeTurnId: nextActiveTurnId,
991− lastError,
992− updatedAt: now,
993− },
994− createdAt: now,
995− });
1210+ session: {
1211+ threadId: thread.id,
1212+ status,
1213+ providerName: event.provider,
1214+ runtimeMode: thread.session?.runtimeMode ?? "full-access",
1215+ activeTurnId: nextActiveTurnId,
1216+ lastError,
1217+ updatedAt: now,
1218+ },
1219+ createdAt: now,
1220+ });
1221+ }
9961222 }
997− }
9981223
999− const assistantDelta =
1000− event.type === "content.delta" && event.payload.streamKind === "assistant_text"
1001− ? event.payload.delta
1002− : undefined;
1003− const proposedPlanDelta =
1004− event.type === "turn.proposed.delta" ? event.payload.delta : undefined;
1224+ const assistantDelta =
1225+ event.type === "content.delta" && event.payload.streamKind === "assistant_text"
1226+ ? event.payload.delta
1227+ : undefined;
1228+ const proposedPlanDelta =
1229+ event.type === "turn.proposed.delta" ? event.payload.delta : undefined;
10051230
1006− if (assistantDelta && assistantDelta.length > 0) {
1007− const assistantMessageId = MessageId.make(
1008− `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1009− );
1010− const turnId = toTurnId(event.turnId);
1011− if (turnId) {
1012− yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
1013− }
1231+ if (assistantDelta && assistantDelta.length > 0) {
1232+ const turnId = toTurnId(event.turnId);
1233+ const assistantMessageId = yield* getOrCreateAssistantMessageId({
1234+ threadId: thread.id,
1235+ event,
1236+ ...(turnId ? { turnId } : {}),
1237+ });
1238+ if (turnId) {
1239+ yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
1240+ }
10141241
1015− const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1016− serverSettingsService.getSettings,
1017− (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),
1018− );
1019− if (assistantDeliveryMode === "buffered") {
1020− const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
1021− if (spillChunk.length > 0) {
1242+ const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1243+ serverSettingsService.getSettings,
1244+ (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),
1245+ );
1246+ if (assistantDeliveryMode === "buffered") {
1247+ const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
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 {
10221260 yield* orchestrationEngine.dispatch({
10231261 type: "thread.message.assistant.delta",
1024− commandId: providerCommandId(event, "assistant-delta-buffer-spill"),
1262+ commandId: providerCommandId(event, "assistant-delta"),
10251263 threadId: thread.id,
10261264 messageId: assistantMessageId,
1027− delta: spillChunk,
1265+ delta: assistantDelta,
10281266 ...(turnId ? { turnId } : {}),
10291267 createdAt: now,
10301268 });
10311269 }
1032− } else {
1033− yield* orchestrationEngine.dispatch({
1034− type: "thread.message.assistant.delta",
1035− commandId: providerCommandId(event, "assistant-delta"),
1270+ }
1271+
1272+ const pauseForUserTurnId =
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,
10361296 threadId: thread.id,
1037− messageId: assistantMessageId,
1038− delta: assistantDelta,
1039− ...(turnId ? { turnId } : {}),
1297+ turnId: pauseForUserTurnId,
10401298 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,
10411312 });
10421313 }
1043− }
10441314
1045− if (proposedPlanDelta && proposedPlanDelta.length > 0) {
1046− const planId = proposedPlanIdFromEvent(event, thread.id);
1047− yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now);
1048− }
1315+ if (proposedPlanDelta && proposedPlanDelta.length > 0) {
1316+ const planId = proposedPlanIdFromEvent(event, thread.id);
1317+ yield* appendBufferedProposedPlan(planId, proposedPlanDelta, now);
1318+ }
10491319
1050− const assistantCompletion =
1051− event.type === "item.completed" && event.payload.itemType === "assistant_message"
1052− ? {
1053− messageId: MessageId.make(`assistant:${event.itemId ?? event.turnId ?? event.eventId}`),
1054− fallbackText: event.payload.detail,
1055− }
1056− : undefined;
1057− const proposedPlanCompletion =
1058− event.type === "turn.proposed.completed"
1059− ? {
1060− planId: proposedPlanIdFromEvent(event, thread.id),
1061− turnId: toTurnId(event.turnId),
1062− planMarkdown: event.payload.planMarkdown,
1320+ const assistantCompletion =
1321+ event.type === "item.completed" && event.payload.itemType === "assistant_message"
1322+ ? {
1323+ messageId: MessageId.make(
1324+ `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1325+ ),
1326+ fallbackText: event.payload.detail,
1327+ }
1328+ : undefined;
1329+ const proposedPlanCompletion =
1330+ event.type === "turn.proposed.completed"
1331+ ? {
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);
10631366 }
1064− : undefined;
10651367
1066− if (assistantCompletion) {
1067− const assistantMessageId = assistantCompletion.messageId;
1068− const turnId = toTurnId(event.turnId);
1069− const existingAssistantMessage = thread.messages.find(
1070− (entry) => entry.id === assistantMessageId,
1071− );
1072− const shouldApplyFallbackCompletionText =
1073− !existingAssistantMessage || existingAssistantMessage.text.length === 0;
1074− if (turnId) {
1075− yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId);
1076− }
1368+ yield* finalizeAssistantMessage({
1369+ event,
1370+ threadId: thread.id,
1371+ messageId: assistantMessageId,
1372+ ...(turnId ? { turnId } : {}),
1373+ createdAt: now,
1374+ commandTag: "assistant-complete",
1375+ finalDeltaCommandTag: "assistant-delta-finalize",
1376+ hasProjectedMessage: existingAssistantMessage !== undefined,
1377+ ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText
1378+ ? { fallbackText: assistantCompletion.fallbackText }
1379+ : {}),
1380+ });
10771381
1078− yield* finalizeAssistantMessage({
1079− event,
1080− threadId: thread.id,
1081− messageId: assistantMessageId,
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− });
1382+ if (turnId) {
1383+ yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId);
1384+ }
1385+ }
10901386
1091− if (turnId) {
1092− yield* forgetAssistantMessageId(thread.id, turnId, assistantMessageId);
1387+ if (turnId) {
1388+ yield* clearAssistantSegmentStateForTurn(thread.id, turnId);
1389+ }
10931390 }
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);
11271391
1392+ if (proposedPlanCompletion) {
11281393 yield* finalizeBufferedProposedPlan({
11291394 event,
11301395 threadId: thread.id,
11311396 threadProposedPlans: thread.proposedPlans,
1132− planId: proposedPlanIdForTurn(thread.id, turnId),
1133− turnId,
1397+ planId: proposedPlanCompletion.planId,
1398+ ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}),
1399+ fallbackMarkdown: proposedPlanCompletion.planMarkdown,
11341400 updatedAt: now,
11351401 });
11361402 }
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;
11451403
1146− const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
1147− ? true
1148− : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId);
1404+ if (event.type === "turn.completed") {
1405+ const turnId = toTurnId(event.turnId);
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);
11491427
1150− if (shouldApplyRuntimeError) {
1151− yield* orchestrationEngine.dispatch({
1152− type: "thread.session.set",
1153− commandId: providerCommandId(event, "runtime-error-session-set"),
1154− threadId: thread.id,
1155− session: {
1428+ yield* finalizeBufferedProposedPlan({
1429+ event,
11561430 threadId: thread.id,
1157− status: "error",
1158− providerName: event.provider,
1159− runtimeMode: thread.session?.runtimeMode ?? "full-access",
1160− activeTurnId: eventTurnId ?? null,
1161− lastError: runtimeErrorMessage,
1431+ threadProposedPlans: thread.proposedPlans,
1432+ planId: proposedPlanIdForTurn(thread.id, turnId),
1433+ turnId,
11621434 updatedAt: now,
1163− },
1164− createdAt: now,
1165− });
1435+ });
1436+ }
11661437 }
1167− }
11681438
1169− if (event.type === "thread.metadata.updated" && event.payload.name) {
1170− yield* orchestrationEngine.dispatch({
1171− type: "thread.meta.update",
1172− commandId: providerCommandId(event, "thread-meta-update"),
1173− threadId: thread.id,
1174− title: event.payload.name,
1175− });
1176− }
1439+ if (event.type === "session.exited") {
1440+ yield* clearTurnStateForSession(thread.id);
1441+ }
11771442
1178− if (event.type === "turn.diff.updated") {
1179− const turnId = toTurnId(event.turnId);
1180− if (turnId && (yield* isGitRepoForThread(thread.id))) {
1181− // Skip if a checkpoint already exists for this turn. A real
1182− // (non-placeholder) capture from CheckpointReactor should not
1183− // be clobbered, and dispatching a duplicate placeholder for the
1184− // same turnId would produce an unstable checkpointTurnCount.
1185− if (thread.checkpoints.some((c) => c.turnId === turnId)) {
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− );
1443+ if (event.type === "runtime.error") {
1444+ const runtimeErrorMessage = event.payload.message;
1445+
1446+ const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
1447+ ? true
1448+ : activeTurnId === null || eventTurnId === undefined || sameId(activeTurnId, eventTurnId);
1449+
1450+ if (shouldApplyRuntimeError) {
11951451 yield* orchestrationEngine.dispatch({
1196− type: "thread.turn.diff.complete",
1197− commandId: providerCommandId(event, "thread-turn-diff-complete"),
1452+ type: "thread.session.set",
1453+ commandId: providerCommandId(event, "runtime-error-session-set"),
11981454 threadId: thread.id,
1199− turnId,
1200− completedAt: now,
1201− checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`),
1202− status: "missing",
1203− files: [],
1204− assistantMessageId,
1205− checkpointTurnCount: maxTurnCount + 1,
1455+ session: {
1456+ threadId: thread.id,
1457+ status: "error",
1458+ providerName: event.provider,
1459+ runtimeMode: thread.session?.runtimeMode ?? "full-access",
1460+ activeTurnId: eventTurnId ?? null,
1461+ lastError: runtimeErrorMessage,
1462+ updatedAt: now,
1463+ },
12061464 createdAt: now,
12071465 });
12081466 }
12091467 }
1210− }
12111468
1212− const activities = runtimeEventToActivities(event);
1213− yield* Effect.forEach(activities, (activity) =>
1214− orchestrationEngine.dispatch({
1215− type: "thread.activity.append",
1216− commandId: providerCommandId(event, "thread-activity-append"),
1217− threadId: thread.id,
1218− activity,
1219− createdAt: activity.createdAt,
1220− }),
1221− ).pipe(Effect.asVoid);
1222− });
1469+ if (event.type === "thread.metadata.updated" && event.payload.name) {
1470+ yield* orchestrationEngine.dispatch({
1471+ type: "thread.meta.update",
1472+ commandId: providerCommandId(event, "thread-meta-update"),
1473+ threadId: thread.id,
1474+ title: event.payload.name,
1475+ });
1476+ }
1477+
1478+ if (event.type === "turn.diff.updated") {
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+ });
12231523
12241524 const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void;
12251525
@@ -1243,21 +1543,22 @@ const make = Effect.fn("make")(function* () { · 17 unmodified lines
12431543
12441544 const worker = yield* makeDrainableWorker(processInputSafely);
12451545
1246− const start: ProviderRuntimeIngestionShape["start"] = Effect.fn("start")(function* () {
1247− yield* Effect.forkScoped(
1248− Stream.runForEach(providerService.streamEvents, (event) =>
1249− worker.enqueue({ source: "runtime", event }),
1250− ),
1251− );
1252− yield* Effect.forkScoped(
1253− Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
1254− if (event.type !== "thread.turn-start-requested") {
1255− return Effect.void;
1256− }
1257− return worker.enqueue({ source: "domain", event });
1258− }),
1259− );
1260− });
1546+ const start: ProviderRuntimeIngestionShape["start"] = () =>
1547+ Effect.gen(function* () {
1548+ yield* Effect.forkScoped(
1549+ Stream.runForEach(providerService.streamEvents, (event) =>
1550+ worker.enqueue({ source: "runtime", event }),
1551+ ),
1552+ );
1553+ yield* Effect.forkScoped(
1554+ Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
1555+ if (event.type !== "thread.turn-start-requested") {
1556+ return Effect.void;
1557+ }
1558+ return worker.enqueue({ source: "domain", event });
1559+ }),
1560+ );
1561+ });
12611562
12621563 return {
12631564 start,
@@ -1267,5 +1568,5 @@ const make = Effect.fn("make")(function* () { · 3 unmodified lines
12671568
12681569 export const ProviderRuntimeIngestionLive = Layer.effect(
12691570 ProviderRuntimeIngestionService,
1270− make(),
1571+ make,
12711572 ).pipe(Layer.provide(ProjectionTurnRepositoryLive));
apps/server/src/provider/Layers/ClaudeAdapter.ts+11−10
@@ -42,12 +42,7 @@ import { · 41 unmodified lines
4242 type UserInputQuestion,
4343 ClaudeAgentEffort,
4444 } from "@t3tools/contracts";
45−import {
46− applyClaudePromptEffortPrefix,
47− resolveApiModelId,
48− resolveEffort,
49− trimOrNull,
50−} from "@t3tools/shared/model";
45+import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model";
5146 import {
5247 Cause,
5348 DateTime,
@@ -66,7 +61,7 @@ import { · 12 unmodified lines
6661 import { resolveAttachmentPath } from "../../attachmentStore.ts";
6762 import { ServerConfig } from "../../config.ts";
6863 import { ServerSettingsService } from "../../serverSettings.ts";
69−import { getClaudeModelCapabilities } from "./ClaudeProvider.ts";
64+import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts";
7065 import {
7166 ProviderAdapterProcessError,
7267 ProviderAdapterRequestError,
@@ -2829,7 +2824,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 2756 unmodified lines
28292824 const modelSelection =
28302825 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
28312826 const caps = getClaudeModelCapabilities(modelSelection?.model);
2832− const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined;
2827+ const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined;
28332828 const effort = (resolveEffort(caps, modelSelection?.options?.effort) ??
28342829 null) as ClaudeAgentEffort | null;
28352830 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
@@ -2853,7 +2848,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 17 unmodified lines
28532848 ...(apiModelId ? { model: apiModelId } : {}),
28542849 pathToClaudeCodeExecutable: claudeBinaryPath,
28552850 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+ : {}),
28572858 ...(permissionMode ? { permissionMode } : {}),
28582859 ...(permissionMode === "bypassPermissions"
28592860 ? { allowDangerouslySkipPermissions: true }
@@ -3006,7 +3007,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 146 unmodified lines
30063007 }
30073008
30083009 if (modelSelection?.model) {
3009− const apiModelId = resolveApiModelId(modelSelection);
3010+ const apiModelId = resolveClaudeApiModelId(modelSelection);
30103011 if (context.currentApiModelId !== apiModelId) {
30113012 yield* Effect.tryPromise({
30123013 try: () => context.query.setModel(apiModelId),
apps/server/src/provider/Layers/ClaudeProvider.ts+9−0
@@ -1,5 +1,6 @@
11 import type {
22 ClaudeSettings,
3+ ClaudeModelSelection,
34 ModelCapabilities,
45 ServerProvider,
56 ServerProviderModel,
@@ -163,6 +164,14 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo · 157 unmodified lines
163164 );
164165 }
165166
167+export 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+}
166175 export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
167176 readonly status: Exclude<ServerProviderState, "disabled">;
168177 readonly auth: Pick<ServerProviderAuth, "status">;
apps/server/src/provider/Layers/CursorAdapter.test.ts+1174−0
@@ -0,0 +1,1174 @@
1+import * as path from "node:path";
2+import * as os from "node:os";
3+import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4+import { fileURLToPath } from "node:url";
5+
6+import * as NodeServices from "@effect/platform-node/NodeServices";
7+import { assert, it } from "@effect/vitest";
8+import { Deferred, Effect, Fiber, Layer, Stream } from "effect";
9+
10+import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts";
11+
12+import { ServerConfig } from "../../config.ts";
13+import { ServerSettingsService } from "../../serverSettings.ts";
14+import { CursorAdapter } from "../Services/CursorAdapter.ts";
15+import { makeCursorAdapterLive } from "./CursorAdapter.ts";
16+
17+const __dirname = path.dirname(fileURLToPath(import.meta.url));
18+const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
19+const bunExe = "bun";
20+
21+async 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))}` : ""}
33+exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
34+`;
35+ await writeFile(wrapperPath, script, "utf8");
36+ await chmod(wrapperPath, 0o755);
37+ return wrapperPath;
38+}
39+
40+async 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
51+printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)}
52+printf '\n' >> ${JSON.stringify(argvLogPath)}
53+export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)}
54+${envExports}
55+exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
56+`;
57+ await writeFile(wrapperPath, script, "utf8");
58+ await chmod(wrapperPath, 0o755);
59+ return wrapperPath;
60+}
61+
62+async 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+
71+async 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+
80+async 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+
93+const 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+
105+cursorAdapterTestLayer("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+ */
6+import * as nodePath from "node:path";
7+
8+import {
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";
22+import {
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";
38+import { ChildProcessSpawner } from "effect/unstable/process";
39+import type * as EffectAcpSchema from "effect-acp/schema";
40+
41+import { resolveAttachmentPath } from "../../attachmentStore.ts";
42+import { ServerConfig } from "../../config.ts";
43+import { ServerSettingsService } from "../../serverSettings.ts";
44+import {
45+ ProviderAdapterProcessError,
46+ ProviderAdapterRequestError,
47+ ProviderAdapterSessionNotFoundError,
48+ ProviderAdapterValidationError,
49+} from "../Errors.ts";
50+import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
51+import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts";
52+import {
53+ makeAcpAssistantItemEvent,
54+ makeAcpContentDeltaEvent,
55+ makeAcpPlanUpdatedEvent,
56+ makeAcpRequestOpenedEvent,
57+ makeAcpRequestResolvedEvent,
58+ makeAcpToolCallEvent,
59+} from "../acp/AcpCoreRuntimeEvents.ts";
60+import {
61+ type AcpSessionMode,
62+ type AcpSessionModeState,
63+ parsePermissionRequest,
64+} from "../acp/AcpRuntimeModel.ts";
65+import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts";
66+import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts";
67+import {
68+ CursorAskQuestionRequest,
69+ CursorCreatePlanRequest,
70+ CursorUpdateTodosRequest,
71+ extractAskQuestions,
72+ extractPlanMarkdown,
73+ extractTodosAsPlan,
74+} from "../acp/CursorAcpExtension.ts";
75+import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts";
76+import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
77+import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
78+
79+const PROVIDER = "cursor" as const;
80+const CURSOR_RESUME_VERSION = 1 as const;
81+const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
82+const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
83+const ACP_APPROVAL_MODE_ALIASES = ["ask"];
84+
85+export interface CursorAdapterLiveOptions {
86+ readonly nativeEventLogPath?: string;
87+ readonly nativeEventLogger?: EventNdjsonLogger;
88+}
89+
90+interface PendingApproval {
91+ readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
92+ readonly kind: string | "unknown";
93+}
94+
95+interface PendingUserInput {
96+ readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
97+}
98+
99+interface 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+
113+function 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+
126+function 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+
139+function isRecord(value: unknown): value is Record<string, unknown> {
140+ return typeof value === "object" && value !== null && !Array.isArray(value);
141+}
142+
143+function 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+
150+function 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+
159+function 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+
183+function isPlanMode(mode: AcpSessionMode): boolean {
184+ return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined;
185+}
186+
187+function 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+
218+function 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+
267+function 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+
283+function 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+
1053+export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter());
1054+
1055+export 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 @@
1+import * as path from "node:path";
2+import * as os from "node:os";
3+import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4+import { fileURLToPath } from "node:url";
5+
6+import * as NodeServices from "@effect/platform-node/NodeServices";
7+import { Effect } from "effect";
8+import { describe, expect, it } from "vitest";
9+import type * as EffectAcpSchema from "effect-acp/schema";
10+import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";
11+
12+import {
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+
27+const __dirname = path.dirname(fileURLToPath(import.meta.url));
28+const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
29+
30+async 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}
38+exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@"
39+`;
40+ await writeFile(wrapperPath, script, "utf8");
41+ await chmod(wrapperPath, 0o755);
42+ return wrapperPath;
43+}
44+
45+async 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+
58+const 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+
105+const 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+
135+const 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+
193+const 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+
235+const baseCursorSettings: CursorSettings = {
236+ enabled: true,
237+ binaryPath: "agent",
238+ apiEndpoint: "",
239+ customModels: [],
240+};
241+
242+const emptyCapabilities = {
243+ reasoningEffortLevels: [],
244+ supportsFastMode: false,
245+ supportsThinkingToggle: false,
246+ contextWindowOptions: [],
247+ promptInjectedEffortLevels: [],
248+} as const;
249+
250+describe("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+
260+describe("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+
310+describe("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+
361+describe("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+
440+describe("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+
482+describe("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+
525+describe("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+
591+describe("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+
633+describe("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+
645+describe("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 @@
1+import * as nodeFs from "node:fs";
2+import * as nodeOs from "node:os";
3+import * as nodePath from "node:path";
4+
5+import type {
6+ CursorModelOptions,
7+ CursorSettings,
8+ ModelCapabilities,
9+ ServerProvider,
10+ ServerProviderAuth,
11+ ServerProviderModel,
12+ ServerProviderState,
13+ ServerSettingsError,
14+} from "@t3tools/contracts";
15+import type * as EffectAcpSchema from "effect-acp/schema";
16+import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect";
17+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18+
19+import {
20+ buildServerProvider,
21+ collectStreamAsString,
22+ isCommandMissingCause,
23+ providerModelsFromSettings,
24+ type CommandResult,
25+} from "../providerSnapshot.ts";
26+import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
27+import { CursorProvider } from "../Services/CursorProvider.ts";
28+import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
29+import { ServerSettingsService } from "../../serverSettings.ts";
30+
31+const PROVIDER = "cursor" as const;
32+const EMPTY_CAPABILITIES: ModelCapabilities = {
33+ reasoningEffortLevels: [],
34+ supportsFastMode: false,
35+ supportsThinkingToggle: false,
36+ contextWindowOptions: [],
37+ promptInjectedEffortLevels: [],
38+};
39+
40+const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
41+const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";
42+const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4;
43+const CURSOR_REFRESH_INTERVAL = "1 hour";
44+const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;
45+export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {
46+ _meta: {
47+ parameterizedModelPicker: true,
48+ },
49+} satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
50+
51+function 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+
86+interface CursorSessionSelectOption {
87+ readonly value: string;
88+ readonly name: string;
89+}
90+
91+interface CursorAcpDiscoveredModel {
92+ readonly slug: string;
93+ readonly name: string;
94+ readonly capabilities: ModelCapabilities;
95+}
96+
97+function 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+
116+function 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+
133+function findCursorModelConfigOption(
134+ configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
135+): EffectAcpSchema.SessionConfigOption | undefined {
136+ return configOptions.find((option) => option.category === "model");
137+}
138+
139+function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string {
140+ return option.category?.trim().toLowerCase() ?? "";
141+}
142+
143+function 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+
156+function 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+
170+function 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+
176+function 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+
182+function 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+
188+function 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+
201+export 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+
264+function 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+
284+function 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+
294+export 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+
323+const 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+
345+const 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+
350+function normalizeCursorConfigOptionToken(value: string | null | undefined): string {
351+ return (
352+ value
353+ ?.trim()
354+ .toLowerCase()
355+ .replace(/[\s_-]+/g, "-") ?? ""
356+ );
357+}
358+
359+function findCursorSelectOptionValue(
360+ configOption: EffectAcpSchema.SessionConfigOption | undefined,
361+ matcher: (option: CursorSessionSelectOption) => boolean,
362+): string | undefined {
363+ return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value;
364+}
365+
366+function 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+
382+export 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+
388+export 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+
451+export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) =>
452+ withCursorAcpProbeRuntime(cursorSettings, (acp) =>
453+ Effect.map(acp.start(), (started) =>
454+ buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []),
455+ ),
456+ );
457+
458+export 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+
564+export 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. */
571+const ABOUT_TIMEOUT_MS = 8_000;
572+
573+/** Strip ANSI escape sequences so we can parse plain key-value lines. */
574+function 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+ */
583+function 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+
589+export interface CursorAboutResult {
590+ readonly version: string | null;
591+ readonly status: Exclude<ServerProviderState, "disabled">;
592+ readonly auth: ServerProviderAuth;
593+ readonly message?: string;
594+}
595+
596+function 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+
603+export 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+
632+interface CursorAboutJsonPayload {
633+ readonly cliVersion?: unknown;
634+ readonly subscriptionTier?: unknown;
635+ readonly userEmail?: unknown;
636+}
637+
638+export 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+
647+export 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+
665+function 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+
673+function 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+
693+function 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+
706+function 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+
722+function hasOwn(record: object, key: string): boolean {
723+ return Object.prototype.hasOwnProperty.call(record, key);
724+}
725+
726+function 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+
736+function 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+
745+export 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+ */
798+export 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+
917+const 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+
941+const 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+
949+export 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+
1077+export 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
88 import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
99 import { CodexAdapter } from "../Services/CodexAdapter.ts";
1010 import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
11+import { CursorAdapter } from "../Services/CursorAdapter.ts";
12+import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
1113 import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
1214 import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
1315 import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
@@ -66,6 +68,23 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { · 52 unmodified lines
6668 streamEvents: Stream.empty,
6769 };
6870
71+const 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+
6988 const layer = it.layer(
7089 Layer.mergeAll(
7190 Layer.provide(
@@ -74,6 +93,7 @@ const layer = it.layer( · 2 unmodified lines
7493 Layer.succeed(CodexAdapter, fakeCodexAdapter),
7594 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),
7695 Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter),
96+ Layer.succeed(CursorAdapter, fakeCursorAdapter),
7797 ),
7898 ),
7999 NodeServices.layer,
@@ -87,12 +107,14 @@ layer("ProviderAdapterRegistryLive", (it) => { · 7 unmodified lines
87107 const codex = yield* registry.getByProvider("codex");
88108 const claude = yield* registry.getByProvider("claudeAgent");
89109 const openCode = yield* registry.getByProvider("opencode");
110+ const cursor = yield* registry.getByProvider("cursor");
90111 assert.equal(codex, fakeCodexAdapter);
91112 assert.equal(claude, fakeClaudeAdapter);
92113 assert.equal(openCode, fakeOpenCodeAdapter);
114+ assert.equal(cursor, fakeCursorAdapter);
93115
94116 const providers = yield* registry.listProviders();
95− assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]);
117+ assert.deepEqual(providers, ["codex", "claudeAgent", "opencode", "cursor"]);
96118 }),
97119 );
98120
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+8−1
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 } from "../Services/ProviderAdapterRegistry.ts";
1818 import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
1919 import { CodexAdapter } from "../Services/CodexAdapter.ts";
20+import { CursorAdapter } from "../Services/CursorAdapter.ts";
2021 import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
2122
2223 export interface ProviderAdapterRegistryLiveOptions {
@@ -26,10 +27,16 @@ export interface ProviderAdapterRegistryLiveOptions { · 3 unmodified lines
2627 const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* (
2728 options?: ProviderAdapterRegistryLiveOptions,
2829 ) {
30+ const cursorAdapterOption = yield* Effect.serviceOption(CursorAdapter);
2931 const adapters =
3032 options?.adapters !== undefined
3133 ? 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+ ];
3340 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3441
3542 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderRegistry.test.ts+200−10
@@ -30,12 +30,18 @@ import { · 29 unmodified lines
3030 readCodexConfigModelProvider,
3131 } from "./CodexProvider.ts";
3232 import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";
33−import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts";
33+import {
34+ haveProvidersChanged,
35+ mergeProviderSnapshot,
36+ ProviderRegistryLive,
37+} from "./ProviderRegistry.ts";
3438 import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
3539 import { ServerConfig } from "../../config.ts";
3640 import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
3741 import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
3842
43+process.env.T3CODE_CURSOR_ENABLED = "1";
44+
3945 // ── Test helpers ────────────────────────────────────────────────────
4046
4147 const encoder = new TextEncoder();
@@ -577,10 +583,106 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 535 unmodified lines
577583 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);
578584 });
579585
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", () =>
581674 Effect.gen(function* () {
582675 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+ );
584686 const scope = yield* Scope.make();
585687 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
586688 const providerRegistryLayer = ProviderRegistryLive.pipe(
@@ -616,11 +718,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 29 unmodified lines
616718
617719 yield* Effect.gen(function* () {
618720 const registry = yield* ProviderRegistry;
619− assert.deepStrictEqual(yield* registry.getProviders, []);
620− assert.strictEqual(spawnCount, 0);
621−
622− const refreshed = yield* registry.refresh("codex");
623721 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+ });
624733 assert.strictEqual(
625734 refreshed.find((provider) => provider.provider === "codex")?.status,
626735 "ready",
@@ -657,6 +766,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 30 unmodified lines
657766 }
658767 return { stdout: "", stderr: "spawn ENOENT", code: 1 };
659768 }
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+ }
660782 if (joined === "login status") {
661783 return { stdout: "Logged in\n", stderr: "", code: 0 };
662784 }
@@ -671,9 +793,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 8 unmodified lines
671793 yield* Effect.gen(function* () {
672794 const registry = yield* ProviderRegistry;
673795
674− const initial = yield* registry.getProviders;
675− assert.deepStrictEqual(initial, []);
676−
677796 const refreshed = yield* registry.refresh("codex");
678797 assert.strictEqual(
679798 refreshed.find((status) => status.provider === "codex")?.status,
@@ -705,6 +824,77 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 25 unmodified lines
705824 }),
706825 );
707826
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+
708898 it.effect("skips codex probes entirely when the provider is disabled", () =>
709899 Effect.gen(function* () {
710900 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
99 import { ServerConfig } from "../../config.ts";
1010 import { ClaudeProviderLive } from "./ClaudeProvider.ts";
1111 import { CodexProviderLive } from "./CodexProvider.ts";
12+import { CursorProviderLive } from "./CursorProvider.ts";
1213 import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
13−import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts";
1414 import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
15−import type { CodexProviderShape } from "../Services/CodexProvider.ts";
1615 import { CodexProvider } from "../Services/CodexProvider.ts";
17−import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts";
16+import { CursorProvider } from "../Services/CursorProvider.ts";
1817 import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
1918 import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
2019 import {
@@ -26,24 +25,67 @@ import { · 5 unmodified lines
2625 writeProviderStatusCache,
2726 } from "../providerStatusCache.ts";
2827
28+type 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+
2935 const loadProviders = (
30− codexProvider: CodexProviderShape,
31− claudeProvider: ClaudeProviderShape,
32− openCodeProvider: OpenCodeProviderShape,
33−): Effect.Effect<readonly [ServerProvider, ServerProvider, ServerProvider]> =>
34− Effect.all(
35− [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot],
36− {
37− concurrency: "unbounded",
38− },
39− );
36+ providerSources: ReadonlyArray<ProviderSnapshotSource>,
37+): Effect.Effect<ReadonlyArray<ServerProvider>> =>
38+ Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, {
39+ concurrency: "unbounded",
40+ });
41+
42+const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
43+ (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 ||
44+ model.capabilities?.supportsFastMode === true ||
45+ model.capabilities?.supportsThinkingToggle === true ||
46+ (model.capabilities?.contextWindowOptions.length ?? 0) > 0 ||
47+ (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0;
48+
49+const 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+
72+export 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+ };
4082
4183 export const haveProvidersChanged = (
4284 previousProviders: ReadonlyArray<ServerProvider>,
4385 nextProviders: ReadonlyArray<ServerProvider>,
4486 ): boolean => !Equal.equals(previousProviders, nextProviders);
4587
46−export const ProviderRegistryLive = Layer.effect(
88+const ProviderRegistryLiveBase = Layer.effect(
4789 ProviderRegistry,
4890 Effect.gen(function* () {
4991 const codexProvider = yield* CodexProvider;
@@ -52,13 +94,43 @@ export const ProviderRegistryLive = Layer.effect( · 2 unmodified lines
5294 const config = yield* ServerConfig;
5395 const fileSystem = yield* FileSystem.FileSystem;
5496 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;
55127 const changesPubSub = yield* Effect.acquireRelease(
56128 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
57129 PubSub.shutdown,
58130 );
59− const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider);
131+ const fallbackProviders = yield* loadProviders(providerSources);
60132 const cachePathByProvider = new Map(
61− PROVIDER_CACHE_IDS.map(
133+ activeProviders.map(
62134 (provider) =>
63135 [
64136 provider,
@@ -72,8 +144,9 @@ export const ProviderRegistryLive = Layer.effect( · 7 unmodified lines
72144 const fallbackByProvider = new Map(
73145 fallbackProviders.map((provider) => [provider.provider, provider] as const),
74146 );
147+
75148 const cachedProviders = yield* Effect.forEach(
76− PROVIDER_CACHE_IDS,
149+ activeProviders,
77150 (provider) => {
78151 const filePath = cachePathByProvider.get(provider)!;
79152 const fallbackProvider = fallbackByProvider.get(provider)!;
@@ -124,7 +197,10 @@ export const ProviderRegistryLive = Layer.effect( · 44 unmodified lines
124197 );
125198
126199 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+ );
128204 }
129205
130206 const providers = orderProviderSnapshots([...mergedProviders.values()]);
@@ -155,49 +231,40 @@ export const ProviderRegistryLive = Layer.effect( · 24 unmodified lines
155231 });
156232
157233 const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) {
158− switch (provider) {
159− case "codex":
160− return yield* codexProvider.refresh.pipe(
161− Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
162− );
163− case "claudeAgent":
164− return yield* claudeProvider.refresh.pipe(
165− Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
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)));
234+ if (provider) {
235+ const providerSource = providerSources.find((candidate) => candidate.provider === provider);
236+ if (!providerSource) {
237+ return yield* Ref.get(providersRef);
238+ }
239+ return yield* providerSource.refresh.pipe(
240+ Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
241+ );
189242 }
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)));
190252 });
191253
192− yield* Stream.runForEach(codexProvider.streamChanges, (provider) =>
193− syncProvider(provider),
194− ).pipe(Effect.forkScoped);
195− yield* Stream.runForEach(claudeProvider.streamChanges, (provider) =>
196− syncProvider(provider),
197− ).pipe(Effect.forkScoped);
198− yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) =>
199− syncProvider(provider),
200− ).pipe(Effect.forkScoped);
254+ yield* Effect.forEach(
255+ providerSources,
256+ (providerSource) =>
257+ Stream.runForEach(providerSource.streamChanges, (provider) => syncProvider(provider)).pipe(
258+ Effect.forkScoped,
259+ ),
260+ {
261+ concurrency: "unbounded",
262+ discard: true,
263+ },
264+ );
265+ yield* loadProviders(providerSources).pipe(
266+ Effect.flatMap((providers) => upsertProviders(providers, { publish: false })),
267+ );
201268
202269 return {
203270 getProviders: Ref.get(providersRef),
@@ -211,8 +278,15 @@ export const ProviderRegistryLive = Layer.effect( · 7 unmodified lines
211278 },
212279 } satisfies ProviderRegistryShape;
213280 }),
214−).pipe(
215− Layer.provideMerge(CodexProviderLive),
216− Layer.provideMerge(ClaudeProviderLive),
217− Layer.provideMerge(OpenCodeProviderLive),
281+);
282+
283+export const ProviderRegistryLive = Layer.unwrap(
284+ Effect.sync(() =>
285+ ProviderRegistryLiveBase.pipe(
286+ Layer.provideMerge(CursorProviderLive),
287+ Layer.provideMerge(CodexProviderLive),
288+ Layer.provideMerge(ClaudeProviderLive),
289+ Layer.provideMerge(OpenCodeProviderLive),
290+ ),
291+ ),
218292 );
apps/server/src/provider/Layers/ProviderService.test.ts+6−2
@@ -245,14 +245,17 @@ const hasMetricSnapshot = ( · 244 unmodified lines
245245 function makeProviderServiceLayer() {
246246 const codex = makeFakeCodexAdapter();
247247 const claude = makeFakeCodexAdapter("claudeAgent");
248+ const cursor = makeFakeCodexAdapter("cursor");
248249 const registry: typeof ProviderAdapterRegistry.Service = {
249250 getByProvider: (provider) =>
250251 provider === "codex"
251252 ? Effect.succeed(codex.adapter)
252253 : provider === "claudeAgent"
253254 ? Effect.succeed(claude.adapter)
254− : Effect.fail(new ProviderUnsupportedError({ provider })),
255− listProviders: () => Effect.succeed(["codex", "claudeAgent"]),
255+ : provider === "cursor"
256+ ? Effect.succeed(cursor.adapter)
257+ : Effect.fail(new ProviderUnsupportedError({ provider })),
258+ listProviders: () => Effect.succeed(["codex", "claudeAgent", "cursor"]),
256259 };
257260
258261 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
@@ -279,6 +282,7 @@ function makeProviderServiceLayer() { · 20 unmodified lines
279282 return {
280283 codex,
281284 claude,
285+ cursor,
282286 layer,
283287 };
284288 }
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+11−10
@@ -1,5 +1,5 @@
1−import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
2−import { Effect, Layer, Option } from "effect";
1+import { ProviderKind, type ThreadId } from "@t3tools/contracts";
2+import { Effect, Layer, Option, Schema } from "effect";
33
44 import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts";
55 import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
@@ -24,14 +24,15 @@ function decodeProviderKind( · 18 unmodified lines
2424 providerName: string,
2525 operation: string,
2626 ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
27− if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {
28− return Effect.succeed(providerName);
29− }
30− return Effect.fail(
31− new ProviderSessionDirectoryPersistenceError({
32− operation,
33− detail: `Unknown persisted provider '${providerName}'.`,
34− }),
27+ return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe(
28+ Effect.mapError(
29+ (cause) =>
30+ new ProviderSessionDirectoryPersistenceError({
31+ operation,
32+ detail: `Unknown persisted provider '${providerName}'.`,
33+ cause,
34+ }),
35+ ),
3536 );
3637 }
3738
apps/server/src/provider/Services/CursorAdapter.ts+12−0
@@ -0,0 +1,12 @@
1+import { Context } from "effect";
2+
3+import type { ProviderAdapterError } from "../Errors.ts";
4+import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
5+
6+export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
7+ readonly provider: "cursor";
8+}
9+
10+export 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 @@
1+import { Context } from "effect";
2+
3+import type { ServerProviderShape } from "./ServerProvider.ts";
4+
5+export interface CursorProviderShape extends ServerProviderShape {}
6+
7+export 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
2323 import type { Effect } from "effect";
2424 import type { Stream } from "effect";
2525
26−export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported";
26+export type ProviderSessionModelSwitchMode = "in-session" | "unsupported";
2727
2828 export interface ProviderAdapterCapabilities {
2929 /**
apps/server/src/provider/acp/AcpAdapterSupport.test.ts+27−0
@@ -0,0 +1,27 @@
1+import { describe, expect, it } from "vitest";
2+import * as EffectAcpErrors from "effect-acp/errors";
3+
4+import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts";
5+
6+describe("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 @@
1+import {
2+ type ProviderApprovalDecision,
3+ type ProviderKind,
4+ type ThreadId,
5+} from "@t3tools/contracts";
6+import { Schema } from "effect";
7+import * as EffectAcpErrors from "effect-acp/errors";
8+
9+import {
10+ ProviderAdapterRequestError,
11+ ProviderAdapterSessionClosedError,
12+ type ProviderAdapterError,
13+} from "../Errors.ts";
14+
15+export 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+
44+export 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 @@
1+import { RuntimeRequestId, TurnId } from "@t3tools/contracts";
2+import { describe, expect, it } from "vitest";
3+
4+import {
5+ makeAcpAssistantItemEvent,
6+ makeAcpContentDeltaEvent,
7+ makeAcpPlanUpdatedEvent,
8+ makeAcpRequestOpenedEvent,
9+ makeAcpRequestResolvedEvent,
10+ makeAcpToolCallEvent,
11+} from "./AcpCoreRuntimeEvents.ts";
12+
13+describe("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 @@
1+import {
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+
15+import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts";
16+
17+type AcpAdapterRawSource = Extract<
18+ RuntimeEventRawSource,
19+ "acp.jsonrpc" | `acp.${string}.extension`
20+>;
21+
22+interface AcpEventStamp {
23+ readonly eventId: EventId;
24+ readonly createdAt: string;
25+}
26+
27+type AcpCanonicalRequestType = Extract<
28+ CanonicalRequestType,
29+ "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown"
30+>;
31+
32+function 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+
47+function 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+
63+function 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+
79+export 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+
112+export 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+
135+export 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+
160+export 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+
194+export 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+
216+export 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 @@
1+import * as path from "node:path";
2+import * as os from "node:os";
3+import { fileURLToPath } from "node:url";
4+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5+
6+import * as NodeServices from "@effect/platform-node/NodeServices";
7+import { it } from "@effect/vitest";
8+import { Effect, Stream } from "effect";
9+import { describe, expect } from "vitest";
10+
11+import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts";
12+import type * as EffectAcpProtocol from "effect-acp/protocol";
13+
14+const __dirname = path.dirname(fileURLToPath(import.meta.url));
15+const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
16+const bunExe = "bun";
17+
18+describe("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 @@
1+import type { ProviderKind, ThreadId } from "@t3tools/contracts";
2+import { Cause, Effect } from "effect";
3+import type * as EffectAcpProtocol from "effect-acp/protocol";
4+
5+import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts";
6+import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts";
7+
8+function 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+
35+function 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+
45+export 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 @@
1+import { describe, expect, it } from "vitest";
2+
3+import type * as EffectAcpSchema from "effect-acp/schema";
4+
5+import {
6+ extractModelConfigId,
7+ mergeToolCallState,
8+ parsePermissionRequest,
9+ parseSessionModeState,
10+ parseSessionUpdateEvent,
11+} from "./AcpRuntimeModel.ts";
12+
13+describe("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 @@
1+import type * as EffectAcpSchema from "effect-acp/schema";
2+import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity";
3+import type { ToolLifecycleItemType } from "@t3tools/contracts";
4+
5+function isRecord(value: unknown): value is Record<string, unknown> {
6+ return typeof value === "object" && value !== null && !Array.isArray(value);
7+}
8+
9+export interface AcpSessionMode {
10+ readonly id: string;
11+ readonly name: string;
12+ readonly description?: string;
13+}
14+
15+export interface AcpSessionModeState {
16+ readonly currentModeId: string;
17+ readonly availableModes: ReadonlyArray<AcpSessionMode>;
18+}
19+
20+export 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+
30+export interface AcpPlanUpdate {
31+ readonly explanation?: string | null;
32+ readonly plan: ReadonlyArray<{
33+ readonly step: string;
34+ readonly status: "pending" | "inProgress" | "completed";
35+ }>;
36+}
37+
38+export interface AcpPermissionRequest {
39+ readonly kind: string | "unknown";
40+ readonly detail?: string;
41+ readonly toolCall?: AcpToolCallState;
42+}
43+
44+export 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+
74+type AcpSessionSetupResponse =
75+ | EffectAcpSchema.LoadSessionResponse
76+ | EffectAcpSchema.NewSessionResponse
77+ | EffectAcpSchema.ResumeSessionResponse;
78+
79+type AcpToolCallUpdate = Extract<
80+ EffectAcpSchema.SessionNotification["update"],
81+ { readonly sessionUpdate: "tool_call" | "tool_call_update" }
82+>;
83+
84+export 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+
95+export 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+
109+export 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+
120+export 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+
151+function 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+
163+function 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+
182+function 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+
195+function 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+
203+function 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+
221+function 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+
240+function normalizeToolKind(kind: unknown): string | undefined {
241+ return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined;
242+}
243+
244+function 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+
260+function 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+
334+function 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+
355+export 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+
379+export 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+
408+export 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 @@
1+import { Cause, Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Context, Stream } from "effect";
2+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3+import * as EffectAcpClient from "effect-acp/client";
4+import * as EffectAcpErrors from "effect-acp/errors";
5+import type * as EffectAcpSchema from "effect-acp/schema";
6+import type * as EffectAcpProtocol from "effect-acp/protocol";
7+
8+import {
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+
20+export interface AcpSpawnInput {
21+ readonly command: string;
22+ readonly args: ReadonlyArray<string>;
23+ readonly cwd?: string;
24+ readonly env?: Readonly<Record<string, string>>;
25+}
26+
27+export 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+
45+export 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+
53+export 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+
63+export 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+
105+interface AcpStartedState extends AcpSessionRuntimeStartResult {}
106+
107+type 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+
115+interface AcpAssistantSegmentState {
116+ readonly nextSegmentIndex: number;
117+ readonly activeItemId?: string;
118+}
119+
120+interface EnsureActiveAssistantSegmentResult {
121+ readonly itemId: string;
122+ readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
123+}
124+
125+export 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+
139+const 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+
542+function sessionConfigOptionsFromSetup(
543+ response:
544+ | {
545+ readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null;
546+ }
547+ | undefined,
548+): ReadonlyArray<EffectAcpSchema.SessionConfigOption> {
549+ return response?.configOptions ?? [];
550+}
551+
552+function 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+
566+const 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+
635+function 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+
648+function 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+
661+const assistantItemId = (sessionId: string, segmentIndex: number) =>
662+ `assistant:${sessionId}:segment:${segmentIndex}`;
663+
664+const 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+
702+const 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+ */
5+import * as NodeServices from "@effect/platform-node/NodeServices";
6+import { it } from "@effect/vitest";
7+import { Effect } from "effect";
8+import { describe, expect } from "vitest";
9+import type * as EffectAcpSchema from "effect-acp/schema";
10+
11+import { AcpSessionRuntime } from "./AcpSessionRuntime.ts";
12+
13+describe.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 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ extractAskQuestions,
5+ extractPlanMarkdown,
6+ extractTodosAsPlan,
7+} from "./CursorAcpExtension.ts";
8+
9+describe("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+ */
5+import type { UserInputQuestion } from "@t3tools/contracts";
6+import { Schema } from "effect";
7+
8+const CursorAskQuestionOption = Schema.Struct({
9+ id: Schema.String,
10+ label: Schema.String,
11+});
12+
13+const CursorAskQuestion = Schema.Struct({
14+ id: Schema.String,
15+ prompt: Schema.String,
16+ options: Schema.Array(CursorAskQuestionOption),
17+ allowMultiple: Schema.optional(Schema.Boolean),
18+});
19+
20+export const CursorAskQuestionRequest = Schema.Struct({
21+ toolCallId: Schema.String,
22+ title: Schema.optional(Schema.String),
23+ questions: Schema.Array(CursorAskQuestion),
24+});
25+
26+const CursorTodoStatus = Schema.String;
27+
28+const 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+
35+const CursorPlanPhase = Schema.Struct({
36+ name: Schema.String,
37+ todos: Schema.Array(CursorTodo),
38+});
39+
40+export 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+
50+export const CursorUpdateTodosRequest = Schema.Struct({
51+ toolCallId: Schema.String,
52+ todos: Schema.Array(CursorTodo),
53+ merge: Schema.Boolean,
54+});
55+
56+export 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+
74+export function extractPlanMarkdown(params: typeof CursorCreatePlanRequest.Type): string {
75+ return params.plan || "# Plan\n\n(Cursor did not supply plan text.)";
76+}
77+
78+export 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 @@
1+import { Effect } from "effect";
2+import type * as EffectAcpSchema from "effect-acp/schema";
3+import { describe, expect, it } from "vitest";
4+
5+import { applyCursorAcpModelSelection, buildCursorAcpSpawnInput } from "./CursorAcpSupport.ts";
6+
7+const 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+
53+describe("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+
79+describe("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 @@
1+import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts";
2+import { Effect, Layer, Scope } from "effect";
3+import { ChildProcessSpawner } from "effect/unstable/process";
4+import type * as EffectAcpErrors from "effect-acp/errors";
5+
6+import {
7+ CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
8+ resolveCursorAcpBaseModelId,
9+ resolveCursorAcpConfigUpdates,
10+} from "../Layers/CursorProvider.ts";
11+import {
12+ AcpSessionRuntime,
13+ type AcpSessionRuntimeOptions,
14+ type AcpSessionRuntimeShape,
15+ type AcpSpawnInput,
16+} from "./AcpSessionRuntime.ts";
17+
18+type CursorAcpRuntimeCursorSettings = Pick<CursorSettings, "apiEndpoint" | "binaryPath">;
19+
20+export 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+
28+export interface CursorAcpModelSelectionErrorContext {
29+ readonly cause: EffectAcpErrors.AcpError;
30+ readonly step: "set-config-option" | "set-model";
31+ readonly configId?: string;
32+}
33+
34+export 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+
48+export 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+
67+interface 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+
76+export 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 @@
1+import { describe, it, assert } from "@effect/vitest";
2+import type { ServerProvider } from "@t3tools/contracts";
3+import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect";
4+
5+import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";
6+
7+interface TestSettings {
8+ readonly enabled: boolean;
9+}
10+
11+const 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+
25+const 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+
38+const 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+
57+const refreshedSnapshotSecond: ServerProvider = {
58+ ...refreshedSnapshot,
59+ checkedAt: "2026-04-10T00:00:03.000Z",
60+ message: "Refreshed provider availability again.",
61+};
62+
63+const 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+
82+describe("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 @@
11 import type { ServerProvider } from "@t3tools/contracts";
2−import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect";
2+import { Duration, Effect, Equal, Fiber, PubSub, Ref, Scope, Stream } from "effect";
33 import * as Semaphore from "effect/Semaphore";
44
55 import type { ServerProviderShape } from "./Services/ServerProvider.ts";
66 import { ServerSettingsError } from "@t3tools/contracts";
77
8+interface ProviderSnapshotState {
9+ readonly snapshot: ServerProvider;
10+ readonly enrichmentGeneration: number;
11+}
12+
813 export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
914 Settings,
1015 >(input: {
@@ -13,6 +18,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 2 unmodified lines
1318 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
1419 readonly initialSnapshot: (settings: Settings) => ServerProvider;
1520 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>;
1627 readonly refreshInterval?: Duration.Input;
1728 }): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope> {
1829 const refreshSemaphore = yield* Semaphore.make(1);
@@ -22,8 +33,61 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 3 unmodified lines
2233 );
2334 const initialSettings = yield* input.getSettings;
2435 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+ });
2640 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+ });
2791
2892 const applySnapshotBase = Effect.fn("applySnapshot")(function* (
2993 nextSettings: Settings,
@@ -33,13 +97,25 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 3 unmodified lines
3397 const previousSettings = yield* Ref.get(settingsRef);
3498 if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) {
3599 yield* Ref.set(settingsRef, nextSettings);
36− return yield* Ref.get(snapshotRef);
100+ return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot));
37101 }
38102
39103 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+ });
40116 yield* Ref.set(settingsRef, nextSettings);
41− yield* Ref.set(snapshotRef, nextSnapshot);
42117 yield* PubSub.publish(changesPubSub, nextSnapshot);
118+ yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration);
43119 return nextSnapshot;
44120 });
45121 const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) =>
@@ -61,6 +137,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( · 15 unmodified lines
61137 ),
62138 ).pipe(Effect.forkScoped);
63139
140+ yield* applySnapshot(initialSettings, { forceRefresh: true }).pipe(
141+ Effect.ignoreCause({ log: true }),
142+ Effect.forkScoped,
143+ );
144+
64145 return {
65146 getSnapshot: input.getSettings.pipe(
66147 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
7373 }),
7474 );
7575
76− it("hydrates cached provider status onto current settings-derived models", () => {
76+ it("hydrates cached provider status while preserving current settings-derived models", () => {
7777 const cachedCodex = makeProvider("codex", {
7878 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+ ],
8093 message: "Cached message",
8194 skills: [
8295 {
@@ -112,6 +125,21 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 29 unmodified lines
112125 }),
113126 {
114127 ...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+ ],
115143 installed: cachedCodex.installed,
116144 version: cachedCodex.version,
117145 status: cachedCodex.status,
apps/server/src/provider/providerStatusCache.ts+10−0
@@ -6,6 +6,7 @@ export const PROVIDER_CACHE_IDS = [ · 5 unmodified lines
66 "codex",
77 "claudeAgent",
88 "opencode",
9+ "cursor",
910 ] as const satisfies ReadonlyArray<ServerProvider["provider"]>;
1011
1112 const decodeProviderStatusCache = Schema.decodeUnknownEffect(
@@ -17,6 +18,14 @@ const providerOrderRank = (provider: ServerProvider["provider"]): number => { · 5 unmodified lines
1718 return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;
1819 };
1920
21+const 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+
2029 export const orderProviderSnapshots = (
2130 providers: ReadonlyArray<ServerProvider>,
2231 ): ReadonlyArray<ServerProvider> =>
@@ -38,6 +47,7 @@ export const hydrateCachedProvider = (input: { · 15 unmodified lines
3847 const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider;
3948 const hydratedProvider: ServerProvider = {
4049 ...fallbackWithoutMessage,
50+ models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models),
4151 installed: input.cachedProvider.installed,
4252 version: input.cachedProvider.version,
4353 status: input.cachedProvider.status,
apps/server/src/server.ts+5−0
@@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD · 20 unmodified lines
2121 import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";
2222 import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";
2323 import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";
24+import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts";
2425 import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts";
2526 import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
2627 import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";
@@ -160,10 +161,14 @@ const ProviderLayerLive = Layer.unwrap( · 133 unmodified lines
160161 const openCodeAdapterLayer = makeOpenCodeAdapterLive(
161162 nativeEventLogger ? { nativeEventLogger } : undefined,
162163 );
164+ const cursorAdapterLayer = makeCursorAdapterLive(
165+ nativeEventLogger ? { nativeEventLogger } : undefined,
166+ );
163167 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
164168 Layer.provide(codexAdapterLayer),
165169 Layer.provide(claudeAdapterLayer),
166170 Layer.provide(openCodeAdapterLayer),
171+ Layer.provide(cursorAdapterLayer),
167172 Layer.provideMerge(ProviderSessionDirectoryLayerLive),
168173 );
169174 return makeProviderServiceLive(
apps/server/src/serverSettings.ts+1−1
@@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< · 104 unmodified lines
105105
106106 const ServerSettingsJson = fromLenientJson(ServerSettings);
107107
108−const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"];
108+const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"];
109109
110110 /**
111111 * 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
99 outDir: "dist",
1010 sourcemap: true,
1111 clean: true,
12− noExternal: (id) => id.startsWith("@t3tools/"),
12+ noExternal: (id) => id.startsWith("@t3tools/") || id.startsWith("effect-acp"),
1313 inlineOnly: false,
1414 banner: {
1515 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
9999 providers: {
100100 codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },
101101 claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },
102+ cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] },
102103 opencode: {
103104 enabled: true,
104105 binaryPath: "",
apps/web/src/components/chat/ChatComposer.tsx+1−0
@@ -612,6 +612,7 @@ export const ChatComposer = memo( · 611 unmodified lines
612612 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],
613613 opencode:
614614 providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [],
615+ cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [],
615616 }),
616617 [providerStatuses],
617618 );
apps/web/src/components/chat/MessagesTimeline.tsx+51−20
@@ -938,7 +938,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 937 unmodified lines
938938 const iconConfig = workToneIcon(workEntry.tone);
939939 const EntryIcon = workEntryIcon(workEntry);
940940 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;
942948 const rawCommand = workEntryRawCommand(workEntry);
943949 const displayText = preview ? `${heading} - ${preview}` : heading;
944950 const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0;
@@ -953,20 +959,20 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 8 unmodified lines
953959 <EntryIcon className="size-3" />
954960 </span>
955961 <div className="min-w-0 flex-1 overflow-hidden">
956− <div className="max-w-full">
957− <p
958− className={cn(
959− "truncate text-xs leading-5",
960− workToneClass(workEntry.tone),
961− preview ? "text-muted-foreground/70" : "",
962− )}
963− title={rawCommand ? undefined : displayText}
964− >
965− <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
966− {heading}
967− </span>
968− {preview &&
969− (rawCommand ? (
962+ {rawCommand ? (
963+ <div className="max-w-full">
964+ <p
965+ className={cn(
966+ "truncate text-xs leading-5",
967+ workToneClass(workEntry.tone),
968+ preview ? "text-muted-foreground/70" : "",
969+ )}
970+ title={displayText}
971+ >
972+ <span className={cn("text-foreground/80", workToneClass(workEntry.tone))}>
973+ {heading}
974+ </span>
975+ {preview && (
970976 <Tooltip>
971977 <TooltipTrigger
972978 closeDelay={0}
@@ -988,11 +994,36 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 15 unmodified lines
988994 </div>
989995 </TooltipPopup>
990996 </Tooltip>
991− ) : (
992− <span className="text-muted-foreground/55"> - {preview}</span>
993− ))}
994− </p>
995− </div>
997+ )}
998+ </p>
999+ </div>
1000+ ) : (
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+ )}
9961027 </div>
9971028 </div>
9981029 {hasChangedFiles && !previewIsChangedFiles && (
apps/web/src/components/chat/TraitsPicker.browser.tsx+152−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 type ModelSelection,
55 ClaudeModelOptions,
66 CodexModelOptions,
7+ CursorModelOptions,
78 DEFAULT_MODEL_BY_PROVIDER,
89 DEFAULT_SERVER_SETTINGS,
910 OpenCodeModelOptions,
@@ -475,6 +476,90 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt · 465 unmodified lines
475476 };
476477 }
477478
479+async 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+
478563 describe("TraitsPicker (Codex)", () => {
479564 afterEach(() => {
480565 document.body.innerHTML = "";
@@ -628,3 +713,70 @@ describe("TraitsPicker (OpenCode)", () => { · 147 unmodified lines
628713 });
629714 });
630715 });
716+
717+describe("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 @@
11 import {
22 type ClaudeModelOptions,
33 type CodexModelOptions,
4+ type CursorModelOptions,
45 type OpenCodeModelOptions,
56 type ProviderKind,
67 type ProviderModelOptions,
@@ -60,12 +61,22 @@ function getRawEffort( · 53 unmodified lines
6061 if (provider === "codex") {
6162 return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort);
6263 }
64+ if (provider === "cursor") {
65+ return trimOrNull((modelOptions as CursorModelOptions | undefined)?.reasoning);
66+ }
6367 if (provider === "opencode") {
6468 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant);
6569 }
6670 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);
6771 }
6872
73+function 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+
6980 function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null {
7081 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent);
7182 }
@@ -87,8 +98,8 @@ function getRawContextWindow( · 15 unmodified lines
8798 provider: ProviderKind,
8899 modelOptions: ProviderOptions | null | undefined,
89100 ): string | null {
90− if (provider === "claudeAgent") {
91− return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.contextWindow);
101+ if (modelOptions && "contextWindow" in modelOptions) {
102+ return trimOrNull(modelOptions.contextWindow);
92103 }
93104 return null;
94105 }
@@ -98,16 +109,7 @@ function buildNextOptions( · 3 unmodified lines
98109 modelOptions: ProviderOptions | null | undefined,
99110 patch: Record<string, unknown>,
100111 ): ProviderOptions {
101− if (provider === "codex") {
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;
112+ return { ...(modelOptions as Record<string, unknown> | undefined), ...patch } as ProviderOptions;
111113 }
112114
113115 function getSelectedTraits(
@@ -137,7 +139,9 @@ function getSelectedTraits( · 23 unmodified lines
137139
138140 // Thinking toggle (only for models that support it)
139141 const thinkingEnabled = caps.supportsThinkingToggle
140− ? ((modelOptions as ClaudeModelOptions | undefined)?.thinking ?? true)
142+ ? modelOptions && "thinking" in modelOptions
143+ ? modelOptions.thinking === true
144+ : null
141145 : null;
142146
143147 // Fast mode
@@ -185,6 +189,51 @@ function getSelectedTraits( · 41 unmodified lines
185189 };
186190 }
187191
192+function 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+
226+export 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+
188237 export interface TraitsMenuContentProps {
189238 provider: ProviderKind;
190239 models: ReadonlyArray<ServerProviderModel>;
@@ -219,10 +268,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 28 unmodified lines
219268 return;
220269 }
221270 setProviderModelOptions(threadTarget, provider, nextOptions, {
271+ model,
222272 persistSticky: true,
223273 });
224274 },
225− [persistence, provider, setProviderModelOptions],
275+ [model, persistence, provider, setProviderModelOptions],
226276 );
227277 const {
228278 caps,
@@ -234,10 +284,22 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 5 unmodified lines
234284 contextWindow,
235285 defaultContextWindow,
236286 ultrathinkPromptControlled,
287+ showEffort,
288+ showThinking,
289+ showFastMode,
290+ showContextWindow,
237291 ultrathinkInBodyText,
238292 agentOptions,
239293 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+ });
241303 const defaultEffort = getDefaultEffort(caps);
242304
243305 const handleEffortChange = useCallback(
@@ -262,7 +324,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 18 unmodified lines
262324 const stripped = prompt.replace(/^Ultrathink:\s*/i, "");
263325 onPromptChange(stripped);
264326 }
265− const effortKey = provider === "codex" ? "reasoningEffort" : "effort";
327+ const effortKey = getEffortKey(provider);
266328 updateModelOptions(
267329 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),
268330 );
@@ -280,18 +342,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 11 unmodified lines
280342 ],
281343 );
282344
283− if (
284− effort === null &&
285− thinkingEnabled === null &&
286− contextWindowOptions.length <= 1 &&
287− agentOptions.length === 0
288− ) {
345+ if (!hasAnyControls) {
289346 return null;
290347 }
291348
292349 return (
293350 <>
294− {effort ? (
351+ {showEffort ? (
295352 <>
296353 <MenuGroup>
297354 <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
321378 </MenuRadioGroup>
322379 </MenuGroup>
323380 </>
324− ) : thinkingEnabled !== null ? (
381+ ) : showThinking ? (
325382 <MenuGroup>
326383 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
327384 <MenuRadioGroup
@@ -337,9 +394,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 9 unmodified lines
337394 </MenuRadioGroup>
338395 </MenuGroup>
339396 ) : null}
340− {caps.supportsFastMode ? (
397+ {showFastMode ? (
341398 <>
342− <MenuDivider />
399+ {showEffort || showThinking ? <MenuDivider /> : null}
343400 <MenuGroup>
344401 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
345402 <MenuRadioGroup
@@ -356,9 +413,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 10 unmodified lines
356413 </MenuGroup>
357414 </>
358415 ) : null}
359− {contextWindowOptions.length > 1 ? (
416+ {showContextWindow ? (
360417 <>
361− <MenuDivider />
418+ {showEffort || showThinking || showFastMode ? <MenuDivider /> : null}
362419 <MenuGroup>
363420 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
364421 Context Window
@@ -431,30 +488,76 @@ export const TraitsPicker = memo(function TraitsPicker({ · 66 unmodified lines
431488 contextWindow,
432489 defaultContextWindow,
433490 ultrathinkPromptControlled,
434− selectedAgentLabel,
435− } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
491+ showEffort,
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+ );
436510
437511 const effortLabel = effort
438512 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
439513 : null;
514+ const primaryTraitLabel = ultrathinkPromptControlled
515+ ? "Ultrathink"
516+ : effortLabel
517+ ? effortLabel
518+ : thinkingEnabled === null
519+ ? null
520+ : `Thinking ${thinkingEnabled ? "On" : "Off"}`;
440521 const contextWindowLabel =
441− contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow
522+ showContextWindow && contextWindow !== defaultContextWindow
442523 ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null)
443524 : null;
444− const triggerLabel = [
445− ultrathinkPromptControlled
446− ? "Ultrathink"
447− : effortLabel
448− ? effortLabel
449− : thinkingEnabled === null
450− ? null
451− : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
452− ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),
525+ const fastOnlyControl =
526+ caps.supportsFastMode && !showEffort && !showThinking && !showContextWindow;
527+ if (
528+ !shouldRenderTraitsControls({
529+ provider,
530+ models,
531+ model,
532+ prompt,
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+ : []),
453546 ...(contextWindowLabel ? [contextWindowLabel] : []),
454547 ...(selectedAgentLabel ? [selectedAgentLabel] : []),
455− ]
456− .filter(Boolean)
457− .join(" · ");
548+ ].filter(Boolean);
549+ const triggerLabel = fastOnlyControl
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 ?? "");
458561
459562 const isCodexStyle = provider === "codex";
460563
apps/web/src/components/chat/composerProviderRegistry.tsx+54−64
@@ -14,7 +14,7 @@ import type { ReactNode } from "react"; · 13 unmodified lines
1414
1515 import type { DraftId } from "../../composerDraftStore";
1616 import { getProviderModelCapabilities } from "../../providerModels";
17−import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
17+import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1818
1919 export type ComposerProviderStateInput = {
2020 provider: ProviderKind;
@@ -33,6 +33,16 @@ export type ComposerProviderState = { · 12 unmodified lines
3333 modelPickerIconClassName?: string;
3434 };
3535
36+type 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+
3646 export type ComposerProviderControls = {
3747 showInteractionModeToggle: boolean;
3848 };
@@ -40,24 +50,8 @@ export type ComposerProviderControls = { · 1 unmodified line
4050 type ProviderRegistryEntry = {
4151 controls: ComposerProviderControls;
4252 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
43− renderTraitsMenuContent: (input: {
44− threadRef?: ScopedThreadRef;
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;
53+ renderTraitsMenuContent: (input: TraitsRenderInput) => ReactNode;
54+ renderTraitsPicker: (input: TraitsRenderInput) => ReactNode;
6155 };
6256
6357 function hasComposerTraitsTarget(input: {
@@ -67,6 +61,39 @@ function hasComposerTraitsTarget(input: { · 3 unmodified lines
6761 return input.threadRef !== undefined || input.draftId !== undefined;
6862 }
6963
64+function 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+
7097 function getProviderStateFromCapabilities(
7198 input: ComposerProviderStateInput,
7299 ): ComposerProviderState {
@@ -78,9 +105,11 @@ function getProviderStateFromCapabilities( · 5 unmodified lines
78105 ? providerOptions.effort
79106 : "reasoningEffort" in providerOptions
80107 ? providerOptions.reasoningEffort
81− : "variant" in providerOptions
82− ? providerOptions.variant
83− : null
108+ : "reasoning" in providerOptions
109+ ? providerOptions.reasoning
110+ : "variant" in providerOptions
111+ ? providerOptions.variant
112+ : null
84113 : null;
85114 const normalizedOptions = normalizeProviderModelOptionsWithCapabilities(
86115 provider,
@@ -122,54 +151,15 @@ function createProviderRegistryEntry( · 35 unmodified lines
122151 ...controls,
123152 },
124153 getState: (input) => getProviderStateFromCapabilities(input),
125− renderTraitsMenuContent: ({
126− threadRef,
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− ),
154+ renderTraitsMenuContent: (input) => renderTraitsControl(TraitsMenuContent, provider, input),
155+ renderTraitsPicker: (input) => renderTraitsControl(TraitsPicker, provider, input),
167156 };
168157 }
169158
170159 const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
171160 codex: createProviderRegistryEntry("codex"),
172161 claudeAgent: createProviderRegistryEntry("claudeAgent"),
162+ cursor: createProviderRegistryEntry("cursor"),
173163 opencode: createProviderRegistryEntry("opencode", {
174164 showInteractionModeToggle: false,
175165 }),
apps/web/src/components/settings/SettingsPanels.tsx+27−1
@@ -56,6 +56,7 @@ import { · 55 unmodified lines
5656 } from "../../store";
5757 import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
5858 import { cn } from "../../lib/utils";
59+import { Badge } from "../ui/badge";
5960 import { Button } from "../ui/button";
6061 import { Collapsible, CollapsibleContent } from "../ui/collapsible";
6162 import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
@@ -103,6 +104,7 @@ const TIMESTAMP_FORMAT_LABELS = { · 41 unmodified lines
103104 type InstallProviderSettings = {
104105 provider: ProviderKind;
105106 title: string;
107+ badgeLabel?: string;
106108 binaryPlaceholder: string;
107109 binaryDescription: ReactNode;
108110 serverUrlPlaceholder?: string;
@@ -130,6 +132,13 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ · 21 unmodified lines
130132 binaryPlaceholder: "Claude binary path",
131133 binaryDescription: "Path to the Claude binary",
132134 },
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+ },
133142 {
134143 provider: "opencode",
135144 title: "OpenCode",
@@ -530,6 +539,11 @@ export function GeneralSettingsPanel() { · 394 unmodified lines
530539 settings.providers.claudeAgent.customModels.length > 0 ||
531540 settings.providers.claudeAgent.launchArgs !== "",
532541 ),
542+ cursor: Boolean(
543+ settings.providers.cursor.binaryPath !==
544+ DEFAULT_UNIFIED_SETTINGS.providers.cursor.binaryPath ||
545+ settings.providers.cursor.customModels.length > 0,
546+ ),
533547 opencode: Boolean(
534548 settings.providers.opencode.binaryPath !==
535549 DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath ||
@@ -545,6 +559,7 @@ export function GeneralSettingsPanel() { · 9 unmodified lines
545559 >({
546560 codex: "",
547561 claudeAgent: "",
562+ cursor: "",
548563 opencode: "",
549564 });
550565 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
@@ -572,6 +587,11 @@ export function GeneralSettingsPanel() { · 21 unmodified lines
572587 const availableEditors = useServerAvailableEditors();
573588 const observability = useServerObservability();
574589 const serverProviders = useServerProviders();
590+ const visibleProviderSettings = PROVIDER_SETTINGS.filter(
591+ (providerSettings) =>
592+ providerSettings.provider !== "cursor" ||
593+ serverProviders.some((provider) => provider.provider === "cursor"),
594+ );
575595 const codexHomePath = settings.providers.codex.homePath;
576596 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
577597 const diagnosticsDescription = (() => {
@@ -736,7 +756,7 @@ export function GeneralSettingsPanel() { · 158 unmodified lines
736756 [settings, updateSettings],
737757 );
738758
739− const providerCards = PROVIDER_SETTINGS.map((providerSettings) => {
759+ const providerCards = visibleProviderSettings.map((providerSettings) => {
740760 const liveProvider = serverProviders.find(
741761 (candidate) => candidate.provider === providerSettings.provider,
742762 );
@@ -756,6 +776,7 @@ export function GeneralSettingsPanel() { · 13 unmodified lines
756776 return {
757777 provider: providerSettings.provider,
758778 title: providerSettings.title,
779+ badgeLabel: providerSettings.badgeLabel,
759780 binaryPlaceholder: providerSettings.binaryPlaceholder,
760781 binaryDescription: providerSettings.binaryDescription,
761782 serverUrlPlaceholder: providerSettings.serverUrlPlaceholder,
@@ -1153,6 +1174,11 @@ export function GeneralSettingsPanel() { · 391 unmodified lines
11531174 className={cn("size-2 shrink-0 rounded-full", providerCard.statusStyle.dot)}
11541175 />
11551176 <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}
11561182 {providerCard.versionLabel ? (
11571183 <code className="text-xs text-muted-foreground">
11581184 {providerCard.versionLabel}
apps/web/src/composerDraftStore.test.ts+73−1
@@ -91,7 +91,7 @@ function resetComposerDraftStore() { · 90 unmodified lines
9191 }
9292
9393 function modelSelection(
94− provider: "codex" | "claudeAgent",
94+ provider: "codex" | "claudeAgent" | "cursor",
9595 model: string,
9696 options?: ModelSelection["options"],
9797 ): ModelSelection {
@@ -1002,6 +1002,60 @@ describe("composerDraftStore modelSelection", () => { · 904 unmodified lines
10021002 );
10031003 });
10041004
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+
10051059 it("updates only the draft when sticky persistence is omitted", () => {
10061060 const store = useComposerDraftStore.getState();
10071061
@@ -1174,6 +1228,24 @@ describe("composerDraftStore sticky composer settings", () => { · 166 unmodified lines
11741228 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
11751229 });
11761230
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+
11771249 it("applies sticky activeProvider to new drafts", () => {
11781250 const store = useComposerDraftStore.getState();
11791251 const threadId = ThreadId.make("thread-sticky-active-provider");
apps/web/src/composerDraftStore.ts+64−13
@@ -1,7 +1,10 @@
11 import {
2+ CURSOR_REASONING_OPTIONS,
3+ DEFAULT_MODEL_BY_PROVIDER,
4+ type CursorModelOptions,
5+ type CursorReasoningOption,
26 ClaudeAgentEffort,
37 CodexReasoningEffort,
4− DEFAULT_MODEL_BY_PROVIDER,
58 type EnvironmentId,
69 ModelSelection,
710 ProjectId,
@@ -341,6 +344,7 @@ interface ComposerDraftStoreState { · 333 unmodified lines
341344 provider: ProviderKind,
342345 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
343346 options?: {
347+ model?: string | null | undefined;
344348 persistSticky?: boolean;
345349 },
346350 ) => void;
@@ -527,7 +531,9 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { · 180 unmodified lines
527531 }
528532
529533 function 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;
531537 }
532538
533539 function normalizeProviderModelOptions(
@@ -544,15 +550,22 @@ function normalizeProviderModelOptions( · 10 unmodified lines
544550 candidate?.claudeAgent && typeof candidate.claudeAgent === "object"
545551 ? (candidate.claudeAgent as Record<string, unknown>)
546552 : null;
553+ const cursorCandidate =
554+ candidate?.cursor && typeof candidate.cursor === "object"
555+ ? (candidate.cursor as Record<string, unknown>)
556+ : null;
547557 const openCodeCandidate =
548558 candidate?.opencode && typeof candidate.opencode === "object"
549559 ? (candidate.opencode as Record<string, unknown>)
550560 : null;
551561
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)
553566 ? codexCandidate.reasoningEffort
554567 : provider === "codex"
555− ? Schema.is(CodexReasoningEffort)(legacy?.effort)
568+ ? isCodexReasoningEffort(legacy?.effort)
556569 ? legacy.effort
557570 : undefined
558571 : undefined;
@@ -579,7 +592,7 @@ function normalizeProviderModelOptions( · 20 unmodified lines
579592 : claudeCandidate?.thinking === false
580593 ? false
581594 : undefined;
582− const claudeEffort = Schema.is(ClaudeAgentEffort)(claudeCandidate?.effort)
595+ const claudeEffort = isClaudeAgentEffort(claudeCandidate?.effort)
583596 ? claudeCandidate.effort
584597 : undefined;
585598 const claudeFastMode =
@@ -604,6 +617,43 @@ function normalizeProviderModelOptions( · 18 unmodified lines
604617 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),
605618 }
606619 : 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+
607657 const openCodeVariant =
608658 typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0
609659 ? openCodeCandidate.variant
@@ -620,12 +670,13 @@ function normalizeProviderModelOptions( · 10 unmodified lines
620670 }
621671 : undefined;
622672
623− if (!codex && !claude && !opencode) {
673+ if (!codex && !claude && cursor === undefined && !opencode) {
624674 return null;
625675 }
626676 return {
627677 ...(codex ? { codex } : {}),
628678 ...(claude ? { claudeAgent: claude } : {}),
679+ ...(cursor !== undefined ? { cursor } : {}),
629680 ...(opencode ? { opencode } : {}),
630681 };
631682 }
@@ -715,7 +766,7 @@ function legacyToModelSelectionByProvider( · 83 unmodified lines
715766 const result: Partial<Record<ProviderKind, ModelSelection>> = {};
716767 // Add entries from the options bag (for non-active providers)
717768 if (modelOptions) {
718− for (const provider of ["codex", "claudeAgent", "opencode"] as const) {
769+ for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) {
719770 const options = modelOptions[provider];
720771 if (options && Object.keys(options).length > 0) {
721772 result[provider] = createModelSelection(
@@ -2261,7 +2312,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 1539 unmodified lines
22612312 }
22622313 const base = existing ?? createEmptyThreadDraft();
22632314 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) {
22652316 // Only touch providers explicitly present in the input
22662317 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
22672318 const opts = normalizedOpts[provider];
@@ -2303,6 +2354,9 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 35 unmodified lines
23032354 if (normalizedProvider === null) {
23042355 return;
23052356 }
2357+ const fallbackModel =
2358+ normalizeModelSlug(options?.model, normalizedProvider) ??
2359+ DEFAULT_MODEL_BY_PROVIDER[normalizedProvider];
23062360 // Normalize just this provider's options
23072361 const normalizedOpts = normalizeProviderModelOptions(
23082362 { [normalizedProvider]: nextProviderOptions },
@@ -2320,7 +2374,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 11 unmodified lines
23202374 if (providerOpts) {
23212375 nextMap[normalizedProvider] = createModelSelection(
23222376 normalizedProvider,
2323− currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2377+ currentForProvider?.model ?? fallbackModel,
23242378 providerOpts,
23252379 );
23262380 } else if (currentForProvider?.options) {
@@ -2336,10 +2390,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 9 unmodified lines
23362390 const stickyBase =
23372391 nextStickyMap[normalizedProvider] ??
23382392 base.modelSelectionByProvider[normalizedProvider] ??
2339− createModelSelection(
2340− normalizedProvider,
2341− DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2342− );
2393+ createModelSelection(normalizedProvider, fallbackModel);
23432394 if (providerOpts) {
23442395 nextStickyMap[normalizedProvider] = createModelSelection(
23452396 normalizedProvider,
apps/web/src/modelSelection.ts+13−0
@@ -49,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf · 48 unmodified lines
4949 placeholder: "your-claude-model-slug",
5050 example: "claude-sonnet-5-0",
5151 },
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+ },
5259 opencode: {
5360 provider: "opencode",
5461 title: "OpenCode",
@@ -176,6 +183,12 @@ export function getCustomModelOptionsByProvider( · 121 unmodified lines
176183 "claudeAgent",
177184 selectedProvider === "claudeAgent" ? selectedModel : undefined,
178185 ),
186+ cursor: getAppModelOptions(
187+ settings,
188+ providers,
189+ "cursor",
190+ selectedProvider === "cursor" ? selectedModel : undefined,
191+ ),
179192 opencode: getAppModelOptions(
180193 settings,
181194 providers,
apps/web/src/providerModels.ts+38−2
@@ -1,11 +1,17 @@
11 import {
22 DEFAULT_MODEL_BY_PROVIDER,
3+ type CursorModelOptions,
34 type ModelCapabilities,
45 type ProviderKind,
56 type ServerProvider,
67 type ServerProviderModel,
78 } from "@t3tools/contracts";
8−import { normalizeModelSlug } from "@t3tools/shared/model";
9+import {
10+ hasEffortLevel,
11+ normalizeModelSlug,
12+ resolveContextWindow,
13+ trimOrNull,
14+} from "@t3tools/shared/model";
915
1016 const EMPTY_CAPABILITIES: ModelCapabilities = {
1117 reasoningEffortLevels: [],
@@ -33,7 +39,10 @@ export function isProviderEnabled( · 21 unmodified lines
3339 providers: ReadonlyArray<ServerProvider>,
3440 provider: ProviderKind,
3541 ): boolean {
36− return getProviderSnapshot(providers, provider)?.enabled ?? true;
42+ if (providers.length === 0) {
43+ return true;
44+ }
45+ return getProviderSnapshot(providers, provider)?.enabled ?? false;
3746 }
3847
3948 export function resolveSelectableProvider(
@@ -67,3 +76,30 @@ export function getDefaultServerModel( · 27 unmodified lines
6776 DEFAULT_MODEL_BY_PROVIDER[provider]
6877 );
6978 }
79+
80+export 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
921921 ]);
922922 });
923923
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+
9241117 it("collapses repeated lifecycle updates for the same tool call into one entry", () => {
9251118 const activities: OrchestrationThreadActivity[] = [
9261119 makeActivity({
@@ -1324,32 +1517,3 @@ describe("deriveActiveWorkStartedAt", () => { · 397 unmodified lines
13241517 ).toBe("2026-02-27T21:11:00.000Z");
13251518 });
13261519 });
1327−
1328−describe("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 @@
1+import * as Option from "effect/Option";
2+import * as Arr from "effect/Array";
13 import {
24 ApprovalRequestId,
35 isToolLifecycleItemType,
@@ -20,7 +22,7 @@ import type { · 16 unmodified lines
2022 TurnDiffSummary,
2123 } from "./types";
2224
23−export type ProviderPickerKind = ProviderKind | "cursor";
25+export type ProviderPickerKind = ProviderKind;
2426
2527 export const PROVIDER_OPTIONS: Array<{
2628 value: ProviderPickerKind;
@@ -30,7 +32,7 @@ export const PROVIDER_OPTIONS: Array<{ · 3 unmodified lines
3032 { value: "codex", label: "Codex", available: true },
3133 { value: "claudeAgent", label: "Claude", available: true },
3234 { value: "opencode", label: "OpenCode", available: true },
33− { value: "cursor", label: "Cursor", available: false },
35+ { value: "cursor", label: "Cursor", available: true },
3436 ];
3537
3638 export interface WorkLogEntry {
@@ -50,6 +52,7 @@ export interface WorkLogEntry { · 13 unmodified lines
5052 interface DerivedWorkLogEntry extends WorkLogEntry {
5153 activityKind: OrchestrationThreadActivity["kind"];
5254 collapseKey?: string;
55+ toolCallId?: string;
5356 }
5457
5558 export interface PendingApproval {
@@ -352,12 +355,12 @@ export function deriveActivePlanState( · 296 unmodified lines
352355 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
353356 // Prefer plan from the current turn; fall back to the most recent plan from any turn
354357 // so that TodoWrite tasks persist across follow-up messages.
355− const latest =
356− (latestTurnId
357− ? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1)
358− : undefined) ??
359− allPlanActivities.at(-1) ??
360− null;
358+ const latest = Option.firstSomeOf([
359+ ...(latestTurnId
360+ ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)
361+ : Option.none()),
362+ Arr.last(allPlanActivities),
363+ ]).pipe(Option.getOrNull);
361364 if (!latest) {
362365 return null;
363366 }
@@ -515,6 +518,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 151 unmodified lines
515518 ? payload.detail
516519 : null;
517520 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);
518530 const entry: DerivedWorkLogEntry = {
519531 id: activity.id,
520532 createdAt: activity.createdAt,
@@ -529,16 +541,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 8 unmodified lines
529541 };
530542 const itemType = extractWorkLogItemType(payload);
531543 const requestKind = extractWorkLogRequestKind(payload);
532− if (
533− !taskDetailAsLabel &&
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− }
544+ if (detail) {
545+ entry.detail = detail;
542546 }
543547 if (commandPreview.command) {
544548 entry.command = commandPreview.command;
@@ -558,6 +562,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo · 13 unmodified lines
558562 if (requestKind) {
559563 entry.requestKind = requestKind;
560564 }
565+ if (toolCallId) {
566+ entry.toolCallId = toolCallId;
567+ }
561568 const collapseKey = deriveToolLifecycleCollapseKey(entry);
562569 if (collapseKey) {
563570 entry.collapseKey = collapseKey;
@@ -593,7 +600,16 @@ function shouldCollapseToolLifecycleEntries( · 29 unmodified lines
593600 if (previous.activityKind === "tool.completed") {
594601 return false;
595602 }
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+ );
597613 }
598614
599615 function mergeDerivedWorkLogEntries(
@@ -608,6 +624,7 @@ function mergeDerivedWorkLogEntries( · 8 unmodified lines
608624 const itemType = next.itemType ?? previous.itemType;
609625 const requestKind = next.requestKind ?? previous.requestKind;
610626 const collapseKey = next.collapseKey ?? previous.collapseKey;
627+ const toolCallId = next.toolCallId ?? previous.toolCallId;
611628 return {
612629 ...previous,
613630 ...next,
@@ -619,6 +636,7 @@ function mergeDerivedWorkLogEntries( · 5 unmodified lines
619636 ...(itemType ? { itemType } : {}),
620637 ...(requestKind ? { requestKind } : {}),
621638 ...(collapseKey ? { collapseKey } : {}),
639+ ...(toolCallId ? { toolCallId } : {}),
622640 };
623641 }
624642
@@ -637,6 +655,9 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un · 12 unmodified lines
637655 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
638656 return undefined;
639657 }
658+ if (entry.toolCallId) {
659+ return `tool:${entry.toolCallId}`;
660+ }
640661 const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
641662 const detail = entry.detail?.trim() ?? "";
642663 const itemType = entry.itemType ?? "";
@@ -674,6 +695,10 @@ function asTrimmedString(value: unknown): string | null { · 31 unmodified lines
674695 return trimmed.length > 0 ? trimmed : null;
675696 }
676697
698+function asNumber(value: unknown): number | null {
699+ return typeof value === "number" && Number.isFinite(value) ? value : null;
700+}
701+
677702 function trimMatchingOuterQuotes(value: string): string {
678703 const trimmed = value.trim();
679704 if (
@@ -857,6 +882,111 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul · 177 unmodified lines
857882 return asTrimmedString(payload?.title);
858883 }
859884
885+function extractToolCallId(payload: Record<string, unknown> | null): string | null {
886+ const data = asRecord(payload?.data);
887+ return asTrimmedString(data?.toolCallId);
888+}
889+
890+function normalizeInlinePreview(value: string): string {
891+ return value.replace(/\s+/g, " ").trim();
892+}
893+
894+function 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+
901+function 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+
909+function 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+
924+function 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+
950+function 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+
962+function 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+
860990 function stripTrailingExitCode(value: string): {
861991 output: string | null;
862992 exitCode?: number | undefined;
apps/web/src/store.ts+4−4
@@ -15,12 +15,12 @@ import type { · 14 unmodified lines
1515 OrchestrationThreadShell,
1616 OrchestrationThreadActivity,
1717 ProjectId,
18− ProviderKind,
1918 ScopedProjectRef,
2019 ScopedThreadRef,
21− ThreadId,
22− TurnId,
2320 } from "@t3tools/contracts";
21+import { ProviderKind } from "@t3tools/contracts";
22+import type { ThreadId, TurnId } from "@t3tools/contracts";
23+import { Schema } from "effect";
2424 import { resolveModelSlugForProvider } from "@t3tools/shared/model";
2525 import { create } from "zustand";
2626 import {
@@ -1001,7 +1001,7 @@ function toLegacySessionStatus( · 974 unmodified lines
10011001 }
10021002
10031003 function toLegacyProvider(providerName: string | null): ProviderKind {
1004− if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {
1004+ if (Schema.is(ProviderKind)(providerName)) {
10051005 return providerName;
10061006 }
10071007 return "codex";
bun.lock+22−0
@@ -24,6 +24,7 @@ · 23 unmodified lines
2424 "@t3tools/contracts": "workspace:*",
2525 "@t3tools/shared": "workspace:*",
2626 "@types/node": "catalog:",
27+ "effect-acp": "workspace:*",
2728 "tsdown": "catalog:",
2829 "typescript": "catalog:",
2930 "vitest": "catalog:",
@@ -65,6 +66,7 @@ · 35 unmodified lines
6566 "@t3tools/web": "workspace:*",
6667 "@types/bun": "catalog:",
6768 "@types/node": "catalog:",
69+ "effect-acp": "workspace:*",
6870 "tsdown": "catalog:",
6971 "typescript": "catalog:",
7072 "vitest": "catalog:",
@@ -149,6 +151,21 @@ · 78 unmodified lines
149151 "vitest": "catalog:",
150152 },
151153 },
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+ },
152169 "packages/shared": {
153170 "name": "@t3tools/shared",
154171 "version": "0.0.0-alpha.1",
@@ -198,6 +215,7 @@ · 43 unmodified lines
198215 "catalog": {
199216 "@effect/atom-react": "4.0.0-beta.45",
200217 "@effect/language-service": "0.84.2",
218+ "@effect/openapi-generator": "4.0.0-beta.45",
201219 "@effect/platform-bun": "4.0.0-beta.45",
202220 "@effect/platform-node": "4.0.0-beta.45",
203221 "@effect/platform-node-shared": "4.0.0-beta.45",
@@ -297,6 +315,8 @@ · 93 unmodified lines
297315
298316 "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
299317
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+
300320 "@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=="],
301321
302322 "@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
10611081
10621082 "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=="],
10631083
1084+ "effect-acp": ["effect-acp@workspace:packages/effect-acp"],
1085+
10641086 "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=="],
10651087
10661088 "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 "catalog": {
1111 "effect": "4.0.0-beta.45",
1212 "@effect/atom-react": "4.0.0-beta.45",
13+ "@effect/openapi-generator": "4.0.0-beta.45",
1314 "@effect/platform-bun": "4.0.0-beta.45",
1415 "@effect/platform-node": "4.0.0-beta.45",
1516 "@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
22 import { TrimmedNonEmptyString } from "./baseSchemas.ts";
33 import type { ProviderKind } from "./orchestration.ts";
44
5−export const CodexReasoningEffort = Schema.Literals(["xhigh", "high", "medium", "low"]);
5+export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
6+export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS);
67 export type CodexReasoningEffort = typeof CodexReasoningEffort.Type;
7−export const ClaudeAgentEffort = Schema.Literals([
8+export const CLAUDE_AGENT_EFFORT_OPTIONS = [
89 "low",
910 "medium",
1011 "high",
1112 "xhigh",
1213 "max",
1314 "ultrathink",
14−]);
15+] as const;
16+export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS);
1517 export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type;
16−export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeAgentEffort;
18+export type ClaudeCodeEffort = ClaudeAgentEffort;
19+export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const;
20+export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS);
21+export type CursorReasoningOption = typeof CursorReasoningOption.Type;
22+
23+export type ProviderReasoningEffort =
24+ | CodexReasoningEffort
25+ | ClaudeAgentEffort
26+ | CursorReasoningOption;
1727
1828 export const CodexModelOptions = Schema.Struct({
1929 reasoningEffort: Schema.optional(CodexReasoningEffort),
@@ -29,6 +39,13 @@ export const ClaudeModelOptions = Schema.Struct({ · 9 unmodified lines
2939 });
3040 export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
3141
42+export 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+});
48+export type CursorModelOptions = typeof CursorModelOptions.Type;
3249 export const OpenCodeModelOptions = Schema.Struct({
3350 variant: Schema.optional(TrimmedNonEmptyString),
3451 agent: Schema.optional(TrimmedNonEmptyString),
@@ -38,6 +55,7 @@ export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; · 3 unmodified lines
3855 export const ProviderModelOptions = Schema.Struct({
3956 codex: Schema.optional(CodexModelOptions),
4057 claudeAgent: Schema.optional(ClaudeModelOptions),
58+ cursor: Schema.optional(CursorModelOptions),
4159 opencode: Schema.optional(OpenCodeModelOptions),
4260 });
4361 export type ProviderModelOptions = typeof ProviderModelOptions.Type;
@@ -70,6 +88,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; · 26 unmodified lines
7088 export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
7189 codex: "gpt-5.4",
7290 claudeAgent: "claude-sonnet-4-6",
91+ cursor: "auto",
7392 opencode: "openai/gpt-5",
7493 };
7594
@@ -79,6 +98,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; · 3 unmodified lines
7998 export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
8099 codex: "gpt-5.4-mini",
81100 claudeAgent: "claude-haiku-4-5",
101+ cursor: "composer-2",
82102 opencode: "openai/gpt-5",
83103 };
84104
@@ -107,6 +127,17 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 22 unmodified lines
107127 "claude-haiku-4.5": "claude-haiku-4-5",
108128 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
109129 },
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+ },
110141 opencode: {},
111142 };
112143
@@ -115,5 +146,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 2 unmodified lines
115146 export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {
116147 codex: "Codex",
117148 claudeAgent: "Claude",
149+ cursor: "Cursor",
118150 opencode: "OpenCode",
119151 };
packages/contracts/src/orchestration.ts+14−2
@@ -1,5 +1,10 @@
11 import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";
2−import { ClaudeModelOptions, CodexModelOptions, OpenCodeModelOptions } from "./model.ts";
2+import {
3+ ClaudeModelOptions,
4+ CodexModelOptions,
5+ CursorModelOptions,
6+ OpenCodeModelOptions,
7+} from "./model.ts";
38 import { RepositoryIdentity } from "./environment.ts";
49 import {
510 ApprovalRequestId,
@@ -25,7 +30,7 @@ export const ORCHESTRATION_WS_METHODS = { · 19 unmodified lines
2530 subscribeThread: "orchestration.subscribeThread",
2631 } as const;
2732
28−export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]);
33+export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]);
2934 export type ProviderKind = typeof ProviderKind.Type;
3035 export const ProviderApprovalPolicy = Schema.Literals([
3136 "untrusted",
@@ -57,6 +62,12 @@ export const ClaudeModelSelection = Schema.Struct({ · 25 unmodified lines
5762 });
5863 export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
5964
65+export const CursorModelSelection = Schema.Struct({
66+ provider: Schema.Literal("cursor"),
67+ model: TrimmedNonEmptyString,
68+ options: Schema.optionalKey(CursorModelOptions),
69+});
70+export type CursorModelSelection = typeof CursorModelSelection.Type;
6071 export const OpenCodeModelSelection = Schema.Struct({
6172 provider: Schema.Literal("opencode"),
6273 model: TrimmedNonEmptyString,
@@ -67,6 +78,7 @@ export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; · 4 unmodified lines
6778 export const ModelSelection = Schema.Union([
6879 CodexModelSelection,
6980 ClaudeModelSelection,
81+ CursorModelSelection,
7082 OpenCodeModelSelection,
7183 ]);
7284 export type ModelSelection = typeof ModelSelection.Type;
packages/contracts/src/provider.test.ts+20−0
@@ -68,6 +68,26 @@ describe("ProviderSessionStartInput", () => { · 67 unmodified lines
6868 expect(parsed.modelSelection.options?.fastMode).toBe(true);
6969 expect(parsed.runtimeMode).toBe("full-access");
7070 });
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+ });
7191 });
7292
7393 describe("ProviderSendTurnInput", () => {
packages/contracts/src/providerRuntime.ts+10−8
@@ -17,14 +17,16 @@ import { ProviderKind } from "./orchestration.ts"; · 16 unmodified lines
1717 const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
1818 const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);
1919
20−const RuntimeEventRawSource = Schema.Literals([
21− "codex.app-server.notification",
22− "codex.app-server.request",
23− "codex.eventmsg",
24− "claude.sdk.message",
25− "claude.sdk.permission",
26− "codex.sdk.thread-event",
27− "opencode.sdk.event",
20+const RuntimeEventRawSource = Schema.Union([
21+ Schema.Literal("codex.app-server.notification"),
22+ Schema.Literal("codex.app-server.request"),
23+ Schema.Literal("codex.eventmsg"),
24+ Schema.Literal("claude.sdk.message"),
25+ Schema.Literal("claude.sdk.permission"),
26+ Schema.Literal("codex.sdk.thread-event"),
27+ Schema.Literal("opencode.sdk.event"),
28+ Schema.Literal("acp.jsonrpc"),
29+ Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]),
2830 ]);
2931 export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
3032
packages/contracts/src/settings.ts+29−0
@@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; · 4 unmodified lines
55 import {
66 ClaudeModelOptions,
77 CodexModelOptions,
8+ CursorModelOptions,
89 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
910 OpenCodeModelOptions,
1011 } from "./model.ts";
@@ -90,6 +91,13 @@ export const ClaudeSettings = Schema.Struct({ · 79 unmodified lines
9091 });
9192 export type ClaudeSettings = typeof ClaudeSettings.Type;
9293
94+export 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+});
100+export type CursorSettings = typeof CursorSettings.Type;
93101 export const OpenCodeSettings = Schema.Struct({
94102 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
95103 binaryPath: makeBinaryPathSetting("opencode"),
@@ -124,6 +132,7 @@ export const ServerSettings = Schema.Struct({ · 28 unmodified lines
124132 providers: Schema.Struct({
125133 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
126134 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
135+ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
127136 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
128137 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
129138 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
@@ -167,6 +176,13 @@ const ClaudeModelOptionsPatch = Schema.Struct({ · 37 unmodified lines
167176 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),
168177 });
169178
179+const 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+
170186 const OpenCodeModelOptionsPatch = Schema.Struct({
171187 variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant),
172188 agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent),
@@ -183,6 +199,11 @@ const ModelSelectionPatch = Schema.Union([ · 10 unmodified lines
183199 model: Schema.optionalKey(TrimmedNonEmptyString),
184200 options: Schema.optionalKey(ClaudeModelOptionsPatch),
185201 }),
202+ Schema.Struct({
203+ provider: Schema.optionalKey(Schema.Literal("cursor")),
204+ model: Schema.optionalKey(TrimmedNonEmptyString),
205+ options: Schema.optionalKey(CursorModelOptionsPatch),
206+ }),
186207 Schema.Struct({
187208 provider: Schema.optionalKey(Schema.Literal("opencode")),
188209 model: Schema.optionalKey(TrimmedNonEmptyString),
@@ -204,6 +225,13 @@ const ClaudeSettingsPatch = Schema.Struct({ · 15 unmodified lines
204225 launchArgs: Schema.optionalKey(Schema.String),
205226 });
206227
228+const 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+
207235 const OpenCodeSettingsPatch = Schema.Struct({
208236 enabled: Schema.optionalKey(Schema.Boolean),
209237 binaryPath: Schema.optionalKey(Schema.String),
@@ -227,6 +255,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 17 unmodified lines
227255 Schema.Struct({
228256 codex: Schema.optionalKey(CodexSettingsPatch),
229257 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
258+ cursor: Schema.optionalKey(CursorSettingsPatch),
230259 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
231260 }),
232261 ),
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+
3+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4+import * as NodeServices from "@effect/platform-node/NodeServices";
5+import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";
6+import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
7+import { Command, Flag } from "effect/unstable/cli";
8+import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";
9+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10+
11+const CURRENT_SCHEMA_RELEASE = "v0.11.3";
12+
13+interface GenerateCommandError {
14+ readonly _tag: "GenerateCommandError";
15+ readonly message: string;
16+}
17+
18+interface GeneratedPaths {
19+ readonly generatedDir: string;
20+ readonly upstreamSchemaPath: string;
21+ readonly upstreamMetaPath: string;
22+ readonly schemaOutputPath: string;
23+ readonly metaOutputPath: string;
24+}
25+
26+const 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+
32+const UpstreamJsonSchemaSchema = Schema.Struct({
33+ $defs: Schema.Record(Schema.String, Schema.Json),
34+});
35+
36+const 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+
48+const 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+
55+const 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+
69+const 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+
84+const 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+
92+const 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+
103+function 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+
138+function 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+
189+const 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+
271+const 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+
279+const runtimeLayer = Layer.mergeAll(
280+ Logger.layer([Logger.consolePretty()]),
281+ NodeServices.layer,
282+ FetchHttpClient.layer,
283+);
284+
285+Command.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+
4+export 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+
21+export 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+
35+export 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+
4+import * as Schema from "effect/Schema";
5+
6+export 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+};
13+export 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+
52+export type AvailableCommandInput = {
53+ readonly _meta?: { readonly [x: string]: unknown } | null;
54+ readonly hint: string;
55+};
56+export 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+
76+export type Cost = { readonly amount: number; readonly currency: string };
77+export 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+
88+export type ElicitationContentValue = string | number | number | boolean | ReadonlyArray<string>;
89+export 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+
97+export type ElicitationFormCapabilities = {
98+ readonly _meta?: { readonly [x: string]: unknown } | null;
99+};
100+export 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+
115+export type ElicitationUrlCapabilities = {
116+ readonly _meta?: { readonly [x: string]: unknown } | null;
117+};
118+export 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+
133+export 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+ };
146+export 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+
177+export type EnumOption = { readonly const: string; readonly title: string };
178+export 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+
183+export type EnvVariable = {
184+ readonly _meta?: { readonly [x: string]: unknown } | null;
185+ readonly name: string;
186+ readonly value: string;
187+};
188+export 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+
202+export 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+};
217+export 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+
293+export type HttpHeader = {
294+ readonly _meta?: { readonly [x: string]: unknown } | null;
295+ readonly name: string;
296+ readonly value: string;
297+};
298+export 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+
312+export type Implementation = {
313+ readonly _meta?: { readonly [x: string]: unknown } | null;
314+ readonly name: string;
315+ readonly title?: string | null;
316+ readonly version: string;
317+};
318+export 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+
350+export type LogoutCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
351+export 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+
366+export type ModelInfo = {
367+ readonly _meta?: { readonly [x: string]: unknown } | null;
368+ readonly description?: string | null;
369+ readonly modelId: string;
370+ readonly name: string;
371+};
372+export 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+
398+export 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+};
404+export 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+
422+export 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+};
428+export 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+
454+export type RequestId = null | number | string;
455+export 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+
464+export type Role = "assistant" | "user";
465+export const Role = Schema.Literals(["assistant", "user"]).annotate({
466+ description: "The sender or recipient of messages and data in a conversation.",
467+});
468+
469+export type SessionCloseCapabilities = {
470+ readonly _meta?: { readonly [x: string]: unknown } | null;
471+};
472+export 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+
487+export type SessionConfigOptionCategory = "mode" | "model" | "thought_level" | string;
488+export 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+
498+export type SessionConfigSelectOption = {
499+ readonly _meta?: { readonly [x: string]: unknown } | null;
500+ readonly description?: string | null;
501+ readonly name: string;
502+ readonly value: string;
503+};
504+export 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+
526+export type SessionForkCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
527+export 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+
542+export 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+};
549+export 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+
580+export type SessionListCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
581+export 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+
596+export type SessionModeId = string;
597+export const SessionModeId = Schema.String.annotate({
598+ description: "Unique identifier for a Session Mode.",
599+});
600+
601+export type SessionResumeCapabilities = {
602+ readonly _meta?: { readonly [x: string]: unknown } | null;
603+};
604+export 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+
619+export type StringFormat = "email" | "uri" | "date" | "date-time";
620+export const StringFormat = Schema.Literals(["email", "uri", "date", "date-time"]).annotate({
621+ description: "String format types for string properties in elicitation schemas.",
622+});
623+
624+export type TerminalExitStatus = {
625+ readonly _meta?: { readonly [x: string]: unknown } | null;
626+ readonly exitCode?: number | null;
627+ readonly signal?: string | null;
628+};
629+export 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+
660+export type ToolCallLocation = {
661+ readonly _meta?: { readonly [x: string]: unknown } | null;
662+ readonly line?: number | null;
663+ readonly path: string;
664+};
665+export 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+
692+export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
693+export 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+
703+export type ToolKind =
704+ | "read"
705+ | "edit"
706+ | "delete"
707+ | "move"
708+ | "search"
709+ | "execute"
710+ | "think"
711+ | "fetch"
712+ | "switch_mode"
713+ | "other";
714+export 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+
730+export 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+};
738+export 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+
786+export 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+ };
811+export 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+
930+export type AvailableCommand = {
931+ readonly _meta?: { readonly [x: string]: unknown } | null;
932+ readonly description: string;
933+ readonly input?: AvailableCommandInput | null;
934+ readonly name: string;
935+};
936+export 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+
959+export type ElicitationCapabilities = {
960+ readonly _meta?: { readonly [x: string]: unknown } | null;
961+ readonly form?: ElicitationFormCapabilities | null;
962+ readonly url?: ElicitationUrlCapabilities | null;
963+};
964+export 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+
989+export 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+ };
1011+export 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+
1076+export type SessionModelState = {
1077+ readonly _meta?: { readonly [x: string]: unknown } | null;
1078+ readonly availableModels: ReadonlyArray<ModelInfo>;
1079+ readonly currentModelId: string;
1080+};
1081+export 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+
1103+export 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+};
1109+export 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+
1132+export type SessionConfigSelectGroup = {
1133+ readonly _meta?: { readonly [x: string]: unknown } | null;
1134+ readonly group: string;
1135+ readonly name: string;
1136+ readonly options: ReadonlyArray<SessionConfigSelectOption>;
1137+};
1138+export 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+
1157+export type SessionMode = {
1158+ readonly _meta?: { readonly [x: string]: unknown } | null;
1159+ readonly description?: string | null;
1160+ readonly id: SessionModeId;
1161+ readonly name: string;
1162+};
1163+export 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+
1181+export 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+ };
1227+export 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+
1462+export 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+ };
1501+export 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+
1598+export 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+ };
1654+export 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+
1810+export 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+ };
1832+export 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+
1909+export type SessionModeState = {
1910+ readonly _meta?: { readonly [x: string]: unknown } | null;
1911+ readonly availableModes: ReadonlyArray<SessionMode>;
1912+ readonly currentModeId: string;
1913+};
1914+export 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+
1930+export type AgentAuthCapabilities = {
1931+ readonly _meta?: { readonly [x: string]: unknown } | null;
1932+ readonly logout?: LogoutCapabilities | null;
1933+};
1934+export 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+
1955+export 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+};
1981+export 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+
2129+export 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+};
2340+export 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+
3008+export 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+};
3094+export 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+
3480+export 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 };
3570+export 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+
4081+export type AudioContent = {
4082+ readonly _meta?: { readonly [x: string]: unknown } | null;
4083+ readonly annotations?: Annotations | null;
4084+ readonly data: string;
4085+ readonly mimeType: string;
4086+};
4087+export 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+
4102+export type AuthCapabilities = {
4103+ readonly _meta?: { readonly [x: string]: unknown } | null;
4104+ readonly terminal?: boolean;
4105+};
4106+export 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+
4128+export type AuthenticateRequest = {
4129+ readonly _meta?: { readonly [x: string]: unknown } | null;
4130+ readonly methodId: string;
4131+};
4132+export 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+
4151+export type AuthenticateResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
4152+export 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+
4164+export type AuthMethodAgent = {
4165+ readonly _meta?: { readonly [x: string]: unknown } | null;
4166+ readonly description?: string | null;
4167+ readonly id: string;
4168+ readonly name: string;
4169+};
4170+export 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+
4198+export 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+};
4206+export 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+
4245+export 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+};
4253+export 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+
4292+export type AvailableCommandsUpdate = {
4293+ readonly _meta?: { readonly [x: string]: unknown } | null;
4294+ readonly availableCommands: ReadonlyArray<AvailableCommand>;
4295+};
4296+export 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+
4311+export type BlobResourceContents = {
4312+ readonly _meta?: { readonly [x: string]: unknown } | null;
4313+ readonly blob: string;
4314+ readonly mimeType?: string | null;
4315+ readonly uri: string;
4316+};
4317+export 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+
4332+export type BooleanPropertySchema = {
4333+ readonly default?: boolean | null;
4334+ readonly description?: string | null;
4335+ readonly title?: string | null;
4336+};
4337+export 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+
4355+export type CancelNotification = {
4356+ readonly _meta?: { readonly [x: string]: unknown } | null;
4357+ readonly sessionId: string;
4358+};
4359+export 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+
4378+export type CancelRequestNotification = {
4379+ readonly _meta?: { readonly [x: string]: unknown } | null;
4380+ readonly requestId: null | number | string;
4381+};
4382+export 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+
4405+export 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+};
4419+export 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+
4499+export 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+};
4506+export 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+
4544+export 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+};
4630+export 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 @@
1+import * as Effect from "effect/Effect";
2+import * as Schema from "effect/Schema";
3+import * as SchemaIssue from "effect/SchemaIssue";
4+import { RpcClientError } from "effect/unstable/rpc";
5+
6+import * as AcpSchema from "../_generated/schema.gen.ts";
7+import * as AcpError from "../errors.ts";
8+
9+const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
10+
11+export 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+
28+export 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+
45+export 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+
62+export 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+
80+const encoder = new TextEncoder();
81+
82+const JsonRpcId = Schema.Union([Schema.Number, Schema.String]);
83+const JsonRpcHeaders = Schema.Array(Schema.Unknown);
84+
85+export 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+
94+export 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+
101+export 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+
108+export 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 @@
1+import * as Queue from "effect/Queue";
2+import * as Stream from "effect/Stream";
3+import * as Cause from "effect/Cause";
4+import * as Effect from "effect/Effect";
5+import * as Sink from "effect/Sink";
6+import * as Stdio from "effect/Stdio";
7+import { ChildProcessSpawner } from "effect/unstable/process";
8+
9+import * as AcpError from "../errors.ts";
10+
11+const encoder = new TextEncoder();
12+const decoder = new TextDecoder();
13+
14+export 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+
25+export 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+
44+export 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 @@
1+import * as Deferred from "effect/Deferred";
2+import * as Effect from "effect/Effect";
3+import * as Exit from "effect/Exit";
4+import * as Fiber from "effect/Fiber";
5+import * as Layer from "effect/Layer";
6+import * as Queue from "effect/Queue";
7+import * as Ref from "effect/Ref";
8+import * as Schema from "effect/Schema";
9+import * as Scope from "effect/Scope";
10+
11+import { assert, it } from "@effect/vitest";
12+
13+import * as AcpAgent from "./agent.ts";
14+import * as AcpSchema from "./_generated/schema.gen.ts";
15+import {
16+ encodeJsonl,
17+ jsonRpcNotification,
18+ jsonRpcRequest,
19+ jsonRpcResponse,
20+} from "./_internal/shared.ts";
21+import { makeInMemoryStdio } from "./_internal/stdio.ts";
22+
23+const RequestPermissionRequest = jsonRpcRequest(
24+ "session/request_permission",
25+ AcpSchema.RequestPermissionRequest,
26+);
27+const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest);
28+const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
29+const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
30+const SessionCancelNotification = jsonRpcNotification(
31+ "session/cancel",
32+ AcpSchema.CancelNotification,
33+);
34+const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number }));
35+const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
36+const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
37+
38+it.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.