authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-17 16:21:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-17 16:21:31-07:00
log9c64f12ea033eaff8ddb3fb1bdd53b2a2ff24db9
treee4304f4f2b2234ae3bf8e4ddaa6e7e9bb02db79c
parent0f184c28b056b977593fa7f850217063d3043642
signature Signed by PGP key B5690EEEBB952194

Add ACP support with Cursor provider (#1355)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius@macmini.local> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

98 files changed, 26731 insertions(+), 847 deletions(-)

apps/desktop/package.json+1
......@@ -23,6 +23,7 @@
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([
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
......@@ -43,6 +43,7 @@
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 created+590
......@@ -0,0 +1,590 @@
1#!/usr/bin/env bun
2import { appendFileSync } from "node:fs";
3
4import * as Effect from "effect/Effect";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
8
9import * as EffectAcpAgent from "effect-acp/agent";
10import * as AcpError from "effect-acp/errors";
11import type * as AcpSchema from "effect-acp/schema";
12
13const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
14const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
15const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
16const emitInterleavedAssistantToolCalls =
17 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
18const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
19const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
20const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
21const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
22const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
23const sessionId = "mock-session-1";
24
25let currentModeId = "ask";
26let currentModelId = "default";
27let parameterizedModelPicker = false;
28let currentReasoning = "medium";
29let currentContext = "272k";
30let currentFast = false;
31const cancelledSessions = new Set<string>();
32
33function logExit(reason: string): void {
34 if (!exitLogPath) {
35 return;
36 }
37 appendFileSync(exitLogPath, `${reason}\n`, "utf8");
38}
39
40process.once("SIGTERM", () => {
41 logExit("SIGTERM");
42 process.exit(0);
43});
44
45process.once("SIGINT", () => {
46 logExit("SIGINT");
47 process.exit(0);
48});
49
50process.once("exit", (code) => {
51 logExit(`exit:${code}`);
52});
53
54function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
55 if (parameterizedModelPicker) {
56 const baseOptions: Array<AcpSchema.SessionConfigOption> = [
57 {
58 id: "mode",
59 name: "Mode",
60 category: "mode",
61 type: "select",
62 currentValue: currentModeId,
63 options: availableModes.map((mode) => ({
64 value: mode.id,
65 name: mode.name,
66 ...(mode.description ? { description: mode.description } : {}),
67 })),
68 },
69 {
70 id: "model",
71 name: "Model",
72 category: "model",
73 type: "select",
74 currentValue: currentModelId,
75 options: [
76 { value: "default", name: "Auto" },
77 { value: "composer-2", name: "Composer 2" },
78 { value: "gpt-5.4", name: "GPT-5.4" },
79 { value: "claude-opus-4-6", name: "Opus 4.6" },
80 ],
81 },
82 ];
83
84 switch (currentModelId) {
85 case "gpt-5.4":
86 return [
87 ...baseOptions,
88 {
89 id: "reasoning",
90 name: "Reasoning",
91 category: "thought_level",
92 type: "select",
93 currentValue: currentReasoning,
94 options: [
95 { value: "none", name: "None" },
96 { value: "low", name: "Low" },
97 { value: "medium", name: "Medium" },
98 { value: "high", name: "High" },
99 { value: "extra-high", name: "Extra High" },
100 ],
101 },
102 {
103 id: "context",
104 name: "Context",
105 category: "model_config",
106 type: "select",
107 currentValue: currentContext,
108 options: [
109 { value: "272k", name: "272K" },
110 { value: "1m", name: "1M" },
111 ],
112 },
113 {
114 id: "fast",
115 name: "Fast",
116 category: "model_config",
117 type: "select",
118 currentValue: String(currentFast),
119 options: [
120 { value: "false", name: "Off" },
121 { value: "true", name: "Fast" },
122 ],
123 },
124 ];
125 case "composer-2":
126 return [
127 ...baseOptions,
128 {
129 id: "fast",
130 name: "Fast",
131 category: "model_config",
132 type: "select",
133 currentValue: String(currentFast),
134 options: [
135 { value: "false", name: "Off" },
136 { value: "true", name: "Fast" },
137 ],
138 },
139 ];
140 case "claude-opus-4-6":
141 return [
142 ...baseOptions,
143 {
144 id: "reasoning",
145 name: "Reasoning",
146 category: "thought_level",
147 type: "select",
148 currentValue: currentReasoning,
149 options: [
150 { value: "low", name: "Low" },
151 { value: "medium", name: "Medium" },
152 { value: "high", name: "High" },
153 ],
154 },
155 {
156 id: "thinking",
157 name: "Thinking",
158 category: "model_config",
159 type: "boolean",
160 currentValue: true,
161 },
162 ];
163 default:
164 return baseOptions;
165 }
166 }
167
168 return [
169 {
170 id: "model",
171 name: "Model",
172 category: "model",
173 type: "select" as const,
174 currentValue: currentModelId,
175 options: [
176 { value: "default", name: "Auto" },
177 { value: "composer-2", name: "Composer 2" },
178 { value: "composer-2[fast=true]", name: "Composer 2 Fast" },
179 { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" },
180 ],
181 },
182 ];
183}
184
185const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [
186 {
187 id: "ask",
188 name: "Ask",
189 description: "Request permission before making any changes",
190 },
191 {
192 id: "architect",
193 name: "Architect",
194 description: "Design and plan software systems without implementation",
195 },
196 {
197 id: "code",
198 name: "Code",
199 description: "Write and modify code with full tool access",
200 },
201];
202
203function modeState(): AcpSchema.SessionModeState {
204 return {
205 currentModeId,
206 availableModes,
207 };
208}
209
210const program = Effect.gen(function* () {
211 const agent = yield* EffectAcpAgent.AcpAgent;
212
213 yield* agent.handleInitialize((request) =>
214 Effect.sync(() => {
215 parameterizedModelPicker =
216 request.clientCapabilities?._meta?.parameterizedModelPicker === true;
217 return {
218 protocolVersion: 1,
219 agentCapabilities: { loadSession: true },
220 };
221 }),
222 );
223
224 yield* agent.handleAuthenticate(() => Effect.succeed({}));
225
226 yield* agent.handleCreateSession(() =>
227 Effect.succeed({
228 sessionId,
229 modes: modeState(),
230 configOptions: configOptions(),
231 }),
232 );
233
234 yield* agent.handleLoadSession((request) =>
235 agent.client
236 .sessionUpdate({
237 sessionId: String(request.sessionId ?? sessionId),
238 update: {
239 sessionUpdate: "user_message_chunk",
240 content: { type: "text", text: "replay" },
241 },
242 })
243 .pipe(
244 Effect.as({
245 modes: modeState(),
246 configOptions: configOptions(),
247 }),
248 ),
249 );
250
251 yield* agent.handleSetSessionConfigOption((request) =>
252 Effect.gen(function* () {
253 if (exitOnSetConfigOption) {
254 return yield* Effect.sync(() => {
255 process.exit(7);
256 });
257 }
258 if (failSetConfigOption) {
259 return yield* AcpError.AcpRequestError.invalidParams(
260 "Mock invalid params for session/set_config_option",
261 {
262 method: "session/set_config_option",
263 params: request,
264 },
265 );
266 }
267 if (request.configId === "mode" && typeof request.value === "string") {
268 currentModeId = request.value;
269 }
270 if (request.configId === "model" && typeof request.value === "string") {
271 currentModelId = request.value;
272 }
273 if (request.configId === "reasoning" && typeof request.value === "string") {
274 currentReasoning = request.value;
275 }
276 if (request.configId === "context" && typeof request.value === "string") {
277 currentContext = request.value;
278 }
279 if (request.configId === "fast") {
280 currentFast = request.value === true || request.value === "true";
281 }
282 return {
283 configOptions: configOptions(),
284 };
285 }),
286 );
287
288 yield* agent.handleCancel(({ sessionId }) =>
289 Effect.sync(() => {
290 cancelledSessions.add(String(sessionId ?? "mock-session-1"));
291 }),
292 );
293
294 yield* agent.handlePrompt((request) =>
295 Effect.gen(function* () {
296 const requestedSessionId = String(request.sessionId ?? sessionId);
297
298 if (emitInterleavedAssistantToolCalls) {
299 const toolCallId = "tool-call-1";
300
301 yield* agent.client.sessionUpdate({
302 sessionId: requestedSessionId,
303 update: {
304 sessionUpdate: "agent_message_chunk",
305 content: { type: "text", text: "before tool" },
306 },
307 });
308
309 yield* agent.client.sessionUpdate({
310 sessionId: requestedSessionId,
311 update: {
312 sessionUpdate: "tool_call",
313 toolCallId,
314 title: "Terminal",
315 kind: "execute",
316 status: "pending",
317 rawInput: {
318 command: ["echo", "hello"],
319 },
320 },
321 });
322
323 yield* agent.client.sessionUpdate({
324 sessionId: requestedSessionId,
325 update: {
326 sessionUpdate: "tool_call_update",
327 toolCallId,
328 status: "completed",
329 rawOutput: {
330 exitCode: 0,
331 stdout: "hello",
332 stderr: "",
333 },
334 },
335 });
336
337 yield* agent.client.sessionUpdate({
338 sessionId: requestedSessionId,
339 update: {
340 sessionUpdate: "agent_message_chunk",
341 content: { type: "text", text: "after tool" },
342 },
343 });
344
345 return { stopReason: "end_turn" };
346 }
347
348 if (emitToolCalls) {
349 const toolCallId = "tool-call-1";
350
351 yield* agent.client.sessionUpdate({
352 sessionId: requestedSessionId,
353 update: {
354 sessionUpdate: "tool_call",
355 toolCallId,
356 title: "Terminal",
357 kind: "execute",
358 status: "pending",
359 rawInput: {
360 command: ["cat", "server/package.json"],
361 },
362 },
363 });
364
365 yield* agent.client.sessionUpdate({
366 sessionId: requestedSessionId,
367 update: {
368 sessionUpdate: "tool_call_update",
369 toolCallId,
370 status: "in_progress",
371 },
372 });
373
374 const permission = yield* agent.client.requestPermission({
375 sessionId: requestedSessionId,
376 toolCall: {
377 toolCallId,
378 title: "`cat server/package.json`",
379 kind: "execute",
380 status: "pending",
381 content: [
382 {
383 type: "content",
384 content: {
385 type: "text",
386 text: "Not in allowlist: cat server/package.json",
387 },
388 },
389 ],
390 },
391 options: [
392 { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
393 { optionId: "allow-always", name: "Allow always", kind: "allow_always" },
394 { optionId: "reject-once", name: "Reject", kind: "reject_once" },
395 ],
396 });
397
398 const cancelled =
399 cancelledSessions.delete(requestedSessionId) ||
400 permission.outcome.outcome === "cancelled";
401
402 yield* agent.client.sessionUpdate({
403 sessionId: requestedSessionId,
404 update: {
405 sessionUpdate: "tool_call_update",
406 toolCallId,
407 title: "Terminal",
408 kind: "execute",
409 status: "completed",
410 rawOutput: {
411 exitCode: 0,
412 stdout: '{ "name": "t3" }',
413 stderr: "",
414 },
415 },
416 });
417
418 yield* agent.client.sessionUpdate({
419 sessionId: requestedSessionId,
420 update: {
421 sessionUpdate: "agent_message_chunk",
422 content: { type: "text", text: "hello from mock" },
423 },
424 });
425
426 return { stopReason: cancelled ? "cancelled" : "end_turn" };
427 }
428
429 if (emitGenericToolPlaceholders) {
430 const toolCallId = "tool-call-generic-1";
431
432 yield* agent.client.sessionUpdate({
433 sessionId: requestedSessionId,
434 update: {
435 sessionUpdate: "tool_call",
436 toolCallId,
437 title: "Read File",
438 kind: "read",
439 status: "pending",
440 rawInput: {},
441 },
442 });
443
444 yield* agent.client.sessionUpdate({
445 sessionId: requestedSessionId,
446 update: {
447 sessionUpdate: "tool_call_update",
448 toolCallId,
449 status: "in_progress",
450 },
451 });
452
453 yield* agent.client.sessionUpdate({
454 sessionId: requestedSessionId,
455 update: {
456 sessionUpdate: "tool_call_update",
457 toolCallId,
458 status: "completed",
459 rawOutput: {
460 content: "package.json\n",
461 },
462 },
463 });
464
465 return { stopReason: "end_turn" };
466 }
467
468 if (emitAskQuestion) {
469 yield* agent.client.extRequest("cursor/ask_question", {
470 toolCallId: "ask-question-tool-call-1",
471 title: "Question",
472 questions: [
473 {
474 id: "scope",
475 prompt: "Which scope?",
476 options: [
477 { id: "workspace", label: "Workspace" },
478 { id: "session", label: "Session" },
479 ],
480 },
481 ],
482 });
483
484 return { stopReason: "end_turn" };
485 }
486
487 yield* agent.client.sessionUpdate({
488 sessionId: requestedSessionId,
489 update: {
490 sessionUpdate: "plan",
491 entries: [
492 {
493 content: "Inspect mock ACP state",
494 priority: "high",
495 status: "completed",
496 },
497 {
498 content: "Implement the requested change",
499 priority: "high",
500 status: "in_progress",
501 },
502 ],
503 },
504 });
505
506 yield* agent.client.sessionUpdate({
507 sessionId: requestedSessionId,
508 update: {
509 sessionUpdate: "agent_message_chunk",
510 content: { type: "text", text: promptResponseText ?? "hello from mock" },
511 },
512 });
513
514 return { stopReason: "end_turn" };
515 }),
516 );
517
518 yield* agent.handleUnknownExtRequest((method, params) => {
519 if (method !== "session/mode/set") {
520 return Effect.fail(AcpError.AcpRequestError.methodNotFound(method));
521 }
522
523 const nextModeId =
524 typeof params === "object" &&
525 params !== null &&
526 "modeId" in params &&
527 typeof params.modeId === "string"
528 ? params.modeId
529 : typeof params === "object" &&
530 params !== null &&
531 "mode" in params &&
532 typeof params.mode === "string"
533 ? params.mode
534 : undefined;
535 const requestedSessionId =
536 typeof params === "object" &&
537 params !== null &&
538 "sessionId" in params &&
539 typeof params.sessionId === "string"
540 ? params.sessionId
541 : sessionId;
542
543 if (typeof nextModeId === "string" && nextModeId.trim()) {
544 currentModeId = nextModeId.trim();
545 return agent.client
546 .sessionUpdate({
547 sessionId: requestedSessionId,
548 update: {
549 sessionUpdate: "current_mode_update",
550 currentModeId,
551 },
552 })
553 .pipe(Effect.as({}));
554 }
555
556 return Effect.succeed({});
557 });
558
559 return yield* Effect.never;
560}).pipe(
561 Effect.provide(
562 EffectAcpAgent.layerStdio(
563 requestLogPath
564 ? {
565 logIncoming: true,
566 logger: (event) => {
567 if (event.direction !== "incoming" || event.stage !== "raw") {
568 return Effect.void;
569 }
570 if (typeof event.payload !== "string") {
571 return Effect.void;
572 }
573 const payload = event.payload;
574 return Effect.sync(() => {
575 appendFileSync(
576 requestLogPath,
577 payload.endsWith("\n") ? payload : `${payload}\n`,
578 "utf8",
579 );
580 });
581 },
582 }
583 : {},
584 ),
585 ),
586 Effect.scoped,
587 Effect.provide(NodeServices.layer),
588);
589
590NodeRuntime.runMain(program);
apps/server/scripts/cursor-acp-model-mismatch-probe.ts created+435
......@@ -0,0 +1,435 @@
1import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2import process from "node:process";
3import readline from "node:readline";
4
5type JsonPrimitive = null | boolean | number | string;
6type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
7
8type JsonRpcId = number | string;
9
10type JsonRpcMessage = {
11 jsonrpc?: string;
12 id?: JsonRpcId;
13 method?: string;
14 params?: JsonValue;
15 result?: JsonValue;
16 error?: JsonValue;
17 headers?: JsonValue;
18};
19
20type SelectLeafOption = {
21 value: string;
22 label?: string;
23 name?: string;
24};
25
26type SelectGroupOption = {
27 label?: string;
28 name?: string;
29 options: SelectLeafOption[];
30};
31
32type SessionConfigOption = {
33 id: string;
34 name?: string;
35 category?: string;
36 type?: string;
37 options?: Array<SelectLeafOption | SelectGroupOption>;
38};
39
40type SessionNewResult = {
41 sessionId: string;
42 configOptions?: SessionConfigOption[];
43};
44
45type SetConfigResult = {
46 configOptions?: SessionConfigOption[];
47};
48
49type PendingRequest = {
50 method: string;
51 resolve: (value: JsonValue | undefined) => void;
52 reject: (error: Error) => void;
53};
54
55const targetCwd = process.argv[2] ?? process.cwd();
56const targetModel = process.argv[3] ?? "gpt-5.4";
57const promptText = process.argv[4] ?? "helo";
58const targetReasoning = process.env.CURSOR_REASONING ?? "";
59const targetContext = process.env.CURSOR_CONTEXT ?? "";
60const targetFast = process.env.CURSOR_FAST ?? "";
61const agentBin = process.env.CURSOR_AGENT_BIN ?? "agent";
62const promptWaitMs = Number(process.env.CURSOR_PROMPT_WAIT_MS ?? "4000");
63const requestTimeoutMs = Number(process.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000");
64
65function logSection(title: string, value: unknown) {
66 process.stdout.write(`\n=== ${title} ===\n`);
67 process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
68}
69
70function fail(message: string): never {
71 throw new Error(message);
72}
73
74function asString(value: JsonValue | undefined): string | null {
75 return typeof value === "string" ? value : null;
76}
77
78function flattenSelectValues(option: SessionConfigOption | undefined): string[] {
79 if (!option || option.type !== "select" || !Array.isArray(option.options)) {
80 return [];
81 }
82
83 const values: string[] = [];
84 for (const entry of option.options) {
85 if (!entry || typeof entry !== "object") {
86 continue;
87 }
88 if ("value" in entry && typeof entry.value === "string") {
89 values.push(entry.value);
90 continue;
91 }
92 if ("options" in entry && Array.isArray(entry.options)) {
93 for (const nested of entry.options) {
94 if (nested && typeof nested === "object" && typeof nested.value === "string") {
95 values.push(nested.value);
96 }
97 }
98 }
99 }
100 return values;
101}
102
103function findConfigOption(
104 configOptions: SessionConfigOption[],
105 predicate: (option: SessionConfigOption) => boolean,
106): SessionConfigOption | undefined {
107 return configOptions.find(predicate);
108}
109
110function matchesKeyword(option: SessionConfigOption, keyword: string): boolean {
111 const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase();
112 return haystack.includes(keyword.toLowerCase());
113}
114
115function sleep(ms: number) {
116 return new Promise<void>((resolve) => {
117 setTimeout(resolve, ms);
118 });
119}
120
121class JsonRpcChild {
122 readonly child: ChildProcessWithoutNullStreams;
123 readonly pending = new Map<JsonRpcId, PendingRequest>();
124 nextId = 1;
125 closed = false;
126
127 constructor(bin: string, args: string[], cwd: string) {
128 this.child = spawn(bin, args, {
129 cwd,
130 shell: process.platform === "win32",
131 stdio: ["pipe", "pipe", "pipe"],
132 env: process.env,
133 });
134
135 this.child.on("exit", (code, signal) => {
136 this.closed = true;
137 const detail = `ACP process exited (code=${String(code)}, signal=${String(signal)})`;
138 for (const pending of this.pending.values()) {
139 pending.reject(new Error(`${detail} while waiting for ${pending.method}`));
140 }
141 this.pending.clear();
142 });
143
144 this.child.on("error", (error) => {
145 this.closed = true;
146 for (const pending of this.pending.values()) {
147 pending.reject(error);
148 }
149 this.pending.clear();
150 });
151
152 const stdout = readline.createInterface({ input: this.child.stdout });
153 stdout.on("line", (line) => {
154 void this.handleStdoutLine(line);
155 });
156
157 const stderr = readline.createInterface({ input: this.child.stderr });
158 stderr.on("line", (line) => {
159 process.stdout.write(`[stderr] ${line}\n`);
160 });
161 }
162
163 write(message: JsonRpcMessage) {
164 if (this.closed) {
165 fail("ACP process is already closed.");
166 }
167 const payload = JSON.stringify({
168 jsonrpc: "2.0",
169 headers: [],
170 ...message,
171 });
172 process.stdout.write(`>>> ${payload}\n`);
173 this.child.stdin.write(`${payload}\n`);
174 }
175
176 async request(method: string, params: JsonValue, timeoutMs = requestTimeoutMs) {
177 const id = this.nextId++;
178
179 const responsePromise = new Promise<JsonValue | undefined>((resolve, reject) => {
180 const timeout = setTimeout(() => {
181 this.pending.delete(id);
182 reject(new Error(`Timed out waiting for ${method} response after ${timeoutMs}ms.`));
183 }, timeoutMs);
184
185 this.pending.set(id, {
186 method,
187 resolve: (value) => {
188 clearTimeout(timeout);
189 resolve(value);
190 },
191 reject: (error) => {
192 clearTimeout(timeout);
193 reject(error);
194 },
195 });
196 });
197
198 this.write({
199 id,
200 method,
201 params,
202 });
203
204 return responsePromise;
205 }
206
207 notify(method: string, params: JsonValue) {
208 this.write({
209 method,
210 params,
211 });
212 }
213
214 respond(id: JsonRpcId, result: JsonValue) {
215 this.write({
216 id,
217 result,
218 });
219 }
220
221 respondError(id: JsonRpcId, code: number, message: string) {
222 this.write({
223 id,
224 error: {
225 code,
226 message,
227 },
228 });
229 }
230
231 async handleStdoutLine(line: string) {
232 if (line.trim().length === 0) {
233 return;
234 }
235
236 process.stdout.write(`<<< ${line}\n`);
237
238 let message: JsonRpcMessage;
239 try {
240 message = JSON.parse(line) as JsonRpcMessage;
241 } catch (error) {
242 process.stdout.write(`[parse-error] ${(error as Error).message}\n`);
243 return;
244 }
245
246 if (typeof message.id !== "undefined" && !message.method) {
247 const pending = this.pending.get(message.id);
248 if (!pending) {
249 return;
250 }
251 this.pending.delete(message.id);
252 if (typeof message.error !== "undefined") {
253 pending.reject(
254 new Error(`RPC ${pending.method} failed: ${JSON.stringify(message.error, null, 2)}`),
255 );
256 return;
257 }
258 pending.resolve(message.result);
259 return;
260 }
261
262 if (message.method === "session/request_permission" && typeof message.id !== "undefined") {
263 this.respond(message.id, {
264 outcome: {
265 outcome: "selected",
266 optionId: "allow",
267 },
268 });
269 return;
270 }
271
272 if (typeof message.id !== "undefined" && message.id !== "") {
273 this.respondError(
274 message.id,
275 -32601,
276 `Unhandled server request: ${message.method ?? "unknown"}`,
277 );
278 }
279 }
280
281 async close() {
282 if (this.closed) {
283 return;
284 }
285 this.child.kill("SIGTERM");
286 await sleep(250);
287 if (!this.closed) {
288 this.child.kill("SIGKILL");
289 }
290 }
291}
292
293async function setSelectOptionIfAdvertised(
294 rpc: JsonRpcChild,
295 sessionId: string,
296 configOptions: SessionConfigOption[],
297 predicate: (option: SessionConfigOption) => boolean,
298 value: string,
299 label: string,
300) {
301 if (value.length === 0) {
302 return configOptions;
303 }
304
305 const option = findConfigOption(configOptions, predicate);
306 const values = flattenSelectValues(option);
307 if (!option || !values.includes(value)) {
308 logSection(`SKIP_${label}`, {
309 requestedValue: value,
310 availableValues: values,
311 });
312 return configOptions;
313 }
314
315 const response = (await rpc.request("session/set_config_option", {
316 sessionId,
317 configId: option.id,
318 value,
319 })) as SetConfigResult | null | undefined;
320
321 logSection(`SET_${label}_RESPONSE`, response);
322 return response?.configOptions ?? configOptions;
323}
324
325async function main() {
326 const rpc = new JsonRpcChild(agentBin, ["acp"], targetCwd);
327
328 try {
329 const initializeResponse = await rpc.request("initialize", {
330 protocolVersion: 1,
331 clientCapabilities: {
332 fs: { readTextFile: false, writeTextFile: false },
333 terminal: false,
334 _meta: {
335 parameterizedModelPicker: true,
336 },
337 },
338 clientInfo: {
339 name: "cursor-acp-model-mismatch-probe",
340 version: "0.0.0",
341 },
342 });
343 logSection("INITIALIZE_RESPONSE", initializeResponse);
344
345 const authenticateResponse = await rpc.request("authenticate", {
346 methodId: "cursor_login",
347 });
348 logSection("AUTHENTICATE_RESPONSE", authenticateResponse);
349
350 const sessionResponse = (await rpc.request("session/new", {
351 cwd: targetCwd,
352 mcpServers: [],
353 })) as SessionNewResult;
354 logSection("SESSION_NEW_RESPONSE", sessionResponse);
355
356 const sessionId = asString(sessionResponse.sessionId);
357 if (!sessionId) {
358 fail("session/new did not return a sessionId.");
359 }
360
361 let configOptions = sessionResponse.configOptions ?? [];
362 const modelConfig = findConfigOption(configOptions, (option) => option.category === "model");
363 const advertisedModels = flattenSelectValues(modelConfig);
364 logSection("ADVERTISED_MODEL_VALUES", advertisedModels);
365
366 if (!modelConfig || modelConfig.type !== "select") {
367 fail("Cursor ACP did not expose a select-type model config option.");
368 }
369
370 if (!advertisedModels.includes(targetModel)) {
371 fail(
372 `Cursor ACP did not advertise model ${JSON.stringify(targetModel)}. Advertised values: ${advertisedModels.join(", ")}`,
373 );
374 }
375
376 const setModelResponse = (await rpc.request("session/set_config_option", {
377 sessionId,
378 configId: modelConfig.id,
379 value: targetModel,
380 })) as SetConfigResult | null | undefined;
381 logSection("SET_MODEL_RESPONSE", setModelResponse);
382
383 configOptions = setModelResponse?.configOptions ?? configOptions;
384
385 configOptions = await setSelectOptionIfAdvertised(
386 rpc,
387 sessionId,
388 configOptions,
389 (option) => option.category === "thought_level",
390 targetReasoning,
391 "REASONING",
392 );
393
394 configOptions = await setSelectOptionIfAdvertised(
395 rpc,
396 sessionId,
397 configOptions,
398 (option) => option.category === "model_config" && matchesKeyword(option, "context"),
399 targetContext,
400 "CONTEXT",
401 );
402
403 configOptions = await setSelectOptionIfAdvertised(
404 rpc,
405 sessionId,
406 configOptions,
407 (option) => option.category === "model_config" && matchesKeyword(option, "fast"),
408 targetFast,
409 "FAST",
410 );
411
412 const promptResponse = await rpc.request("session/prompt", {
413 sessionId,
414 prompt: [
415 {
416 type: "text",
417 text: promptText,
418 },
419 ],
420 });
421 logSection("PROMPT_RESPONSE", promptResponse);
422
423 await sleep(promptWaitMs);
424 rpc.notify("session/cancel", { sessionId });
425 } finally {
426 await rpc.close();
427 }
428}
429
430void main().catch((error: unknown) => {
431 process.stderr.write(
432 `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
433 );
434 process.exitCode = 1;
435});
apps/server/src/checkpointing/Layers/CheckpointStore.ts+3
......@@ -19,6 +19,8 @@ import { GitCore } from "../../git/Services/GitCore.ts";
1919import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
2020import { CheckpointRef } from "@t3tools/contracts";
2121
22const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
23
2224const 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* () {
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";
1111import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
1313import { ClaudeModelSelection } from "@t3tools/contracts";
14import { resolveApiModelId } from "@t3tools/shared/model";
1514import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1615
1716import { TextGenerationError } from "@t3tools/contracts";
......@@ -30,6 +29,7 @@ import {
3029 toJsonSchemaObject,
3130} from "../Utils.ts";
3231import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
32import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts";
3333import { ServerSettingsService } from "../../serverSettings.ts";
3434import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
3535
......@@ -110,7 +110,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
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 created+298
......@@ -0,0 +1,298 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { fileURLToPath } from "node:url";
4import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";
8import { Effect, Layer } from "effect";
9import { expect } from "vitest";
10
11import { ServerSettingsError } from "@t3tools/contracts";
12
13import { ServerConfig } from "../../config.ts";
14import { TextGeneration } from "../Services/TextGeneration.ts";
15import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
16import { ServerSettingsService } from "../../serverSettings.ts";
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
20
21function shellSingleQuote(value: string): string {
22 return `'${value.replaceAll("'", `'"'"'`)}'`;
23}
24
25const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe(
26 Layer.provideMerge(ServerSettingsService.layerTest()),
27 Layer.provideMerge(
28 ServerConfig.layerTest(process.cwd(), {
29 prefix: "t3code-cursor-text-generation-test-",
30 }),
31 ),
32 Layer.provideMerge(NodeServices.layer),
33);
34
35function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
36 const binDir = path.join(dir, "bin");
37 const agentPath = path.join(binDir, "agent");
38 mkdirSync(binDir, { recursive: true });
39 writeFileSync(
40 agentPath,
41 [
42 "#!/bin/sh",
43 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
44 'if [ "$1" != "acp" ]; then',
45 ' printf "%s\\n" "unexpected args: $*" >&2',
46 " exit 11",
47 "fi",
48 `exec bun ${JSON.stringify(mockAgentPath)}`,
49 "",
50 ].join("\n"),
51 "utf8",
52 );
53 chmodSync(agentPath, 0o755);
54 return agentPath;
55}
56
57function withFakeAcpAgent<A, E, R>(
58 env: Record<string, string>,
59 effect: Effect.Effect<A, E, R>,
60): Effect.Effect<A, E | ServerSettingsError, R | ServerSettingsService> {
61 return Effect.gen(function* () {
62 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
63 const agentPath = makeAcpAgentWrapper(tempDir, env);
64 const serverSettings = yield* ServerSettingsService;
65 const previousSettings = yield* serverSettings.getSettings;
66
67 yield* serverSettings.updateSettings({
68 providers: {
69 cursor: {
70 binaryPath: agentPath,
71 },
72 },
73 });
74
75 return yield* effect.pipe(
76 Effect.ensuring(
77 serverSettings
78 .updateSettings({
79 providers: {
80 cursor: {
81 binaryPath: previousSettings.providers.cursor.binaryPath,
82 },
83 },
84 })
85 .pipe(
86 Effect.catch(() => Effect.void),
87 Effect.ensuring(
88 Effect.sync(() => {
89 rmSync(tempDir, { recursive: true, force: true });
90 }),
91 ),
92 Effect.asVoid,
93 ),
94 ),
95 );
96 });
97}
98
99function waitForFileContent(path: string): Effect.Effect<string> {
100 return Effect.promise(async () => {
101 const deadline = Date.now() + 5_000;
102 for (;;) {
103 try {
104 return readFileSync(path, "utf8");
105 } catch (error) {
106 if (Date.now() >= deadline) {
107 throw error instanceof Error ? error : new Error(String(error));
108 }
109 }
110 await new Promise((resolve) => setTimeout(resolve, 25));
111 }
112 });
113}
114
115it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
116 it.effect("uses ACP model config options instead of raw CLI model ids", () => {
117 const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-"));
118 const requestLogPath = path.join(requestLogDir, "requests.ndjson");
119
120 return withFakeAcpAgent(
121 {
122 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
123 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
124 subject: "Add generated commit message",
125 body: "- verify cursor acp model config path",
126 }),
127 },
128 Effect.gen(function* () {
129 const textGeneration = yield* TextGeneration;
130
131 const generated = yield* textGeneration.generateCommitMessage({
132 cwd: process.cwd(),
133 branch: "feature/cursor-text-generation",
134 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
135 stagedPatch:
136 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
137 modelSelection: {
138 provider: "cursor",
139 model: "gpt-5.4",
140 options: {
141 reasoning: "xhigh",
142 fastMode: true,
143 contextWindow: "1m",
144 },
145 },
146 });
147
148 expect(generated.subject).toBe("Add generated commit message");
149 expect(generated.body).toBe("- verify cursor acp model config path");
150
151 const requests = readFileSync(requestLogPath, "utf8")
152 .trim()
153 .split("\n")
154 .filter((line) => line.length > 0)
155 .map((line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> });
156
157 expect(
158 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
159 ).toMatchObject({
160 _meta: {
161 parameterizedModelPicker: true,
162 },
163 });
164 expect(
165 requests.some(
166 (request) =>
167 request.method === "session/set_config_option" &&
168 request.params?.configId === "model" &&
169 request.params?.value === "gpt-5.4",
170 ),
171 ).toBe(true);
172 expect(
173 requests.some(
174 (request) =>
175 request.method === "session/set_config_option" &&
176 request.params?.configId === "reasoning" &&
177 request.params?.value === "extra-high",
178 ),
179 ).toBe(true);
180 expect(
181 requests.some(
182 (request) =>
183 request.method === "session/set_config_option" &&
184 request.params?.configId === "context" &&
185 request.params?.value === "1m",
186 ),
187 ).toBe(true);
188 expect(
189 requests.some(
190 (request) =>
191 request.method === "session/set_config_option" &&
192 request.params?.configId === "fast" &&
193 request.params?.value === "true",
194 ),
195 ).toBe(true);
196 expect(
197 requests.find((request) => request.method === "session/prompt")?.params?.prompt,
198 ).toEqual(
199 expect.arrayContaining([
200 expect.objectContaining({
201 type: "text",
202 text: expect.stringContaining("Staged patch:"),
203 }),
204 ]),
205 );
206
207 rmSync(requestLogDir, { recursive: true, force: true });
208 }),
209 );
210 });
211
212 it.effect("accepts json objects with extra assistant text around them", () =>
213 withFakeAcpAgent(
214 {
215 T3_ACP_PROMPT_RESPONSE_TEXT:
216 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
217 },
218 Effect.gen(function* () {
219 const textGeneration = yield* TextGeneration;
220
221 const generated = yield* textGeneration.generateCommitMessage({
222 cwd: process.cwd(),
223 branch: "feature/cursor-noisy-json",
224 stagedSummary: "M README.md",
225 stagedPatch: "diff --git a/README.md b/README.md",
226 modelSelection: {
227 provider: "cursor",
228 model: "composer-2",
229 },
230 });
231
232 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
233 expect(generated.body).toBe("");
234 }),
235 ),
236 );
237
238 it.effect("generates thread titles through Cursor ACP text generation", () =>
239 withFakeAcpAgent(
240 {
241 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
242 title: '"Trim reconnect spinner status after resume."',
243 }),
244 },
245 Effect.gen(function* () {
246 const textGeneration = yield* TextGeneration;
247
248 const generated = yield* textGeneration.generateThreadTitle({
249 cwd: process.cwd(),
250 message: "Fix the reconnect spinner after a resumed session.",
251 modelSelection: {
252 provider: "cursor",
253 model: "composer-2",
254 },
255 });
256
257 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
258 }),
259 ),
260 );
261
262 it.effect("closes the ACP child process after text generation completes", () => {
263 const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-"));
264 const exitLogPath = path.join(exitLogDir, "exit.log");
265
266 return withFakeAcpAgent(
267 {
268 T3_ACP_EXIT_LOG_PATH: exitLogPath,
269 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
270 subject: "Close runtime after generation",
271 body: "",
272 }),
273 },
274 Effect.gen(function* () {
275 const textGeneration = yield* TextGeneration;
276
277 const generated = yield* textGeneration.generateCommitMessage({
278 cwd: process.cwd(),
279 branch: "feature/cursor-runtime-close",
280 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
281 stagedPatch:
282 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
283 modelSelection: {
284 provider: "cursor",
285 model: "composer-2",
286 },
287 });
288
289 expect(generated.subject).toBe("Close runtime after generation");
290
291 const exitLog = yield* waitForFileContent(exitLogPath);
292 expect(exitLog).toContain("exit:0");
293
294 rmSync(exitLogDir, { recursive: true, force: true });
295 }),
296 );
297 });
298});
apps/server/src/git/Layers/CursorTextGeneration.ts created+352
......@@ -0,0 +1,352 @@
1import { Effect, Layer, Option, Ref, Schema } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";
3
4import { CursorModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6
7import { TextGenerationError } from "@t3tools/contracts";
8import {
9 type ThreadTitleGenerationResult,
10 type TextGenerationShape,
11 TextGeneration,
12} from "../Services/TextGeneration.ts";
13import {
14 buildBranchNamePrompt,
15 buildCommitMessagePrompt,
16 buildPrContentPrompt,
17 buildThreadTitlePrompt,
18} from "../Prompts.ts";
19import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts";
20import {
21 applyCursorAcpModelSelection,
22 makeCursorAcpRuntime,
23} from "../../provider/acp/CursorAcpSupport.ts";
24import { ServerSettingsService } from "../../serverSettings.ts";
25
26const CURSOR_TIMEOUT_MS = 180_000;
27
28function extractJsonObject(raw: string): string {
29 const trimmed = raw.trim();
30 if (trimmed.length === 0) {
31 return trimmed;
32 }
33
34 const start = trimmed.indexOf("{");
35 if (start < 0) {
36 return trimmed;
37 }
38
39 let depth = 0;
40 let inString = false;
41 let escaping = false;
42 for (let index = start; index < trimmed.length; index += 1) {
43 const char = trimmed[index];
44 if (inString) {
45 if (escaping) {
46 escaping = false;
47 } else if (char === "\\") {
48 escaping = true;
49 } else if (char === '"') {
50 inString = false;
51 }
52 continue;
53 }
54
55 if (char === '"') {
56 inString = true;
57 continue;
58 }
59
60 if (char === "{") {
61 depth += 1;
62 continue;
63 }
64
65 if (char === "}") {
66 depth -= 1;
67 if (depth === 0) {
68 return trimmed.slice(start, index + 1);
69 }
70 }
71 }
72
73 return trimmed.slice(start);
74}
75
76function mapCursorAcpError(
77 operation:
78 | "generateCommitMessage"
79 | "generatePrContent"
80 | "generateBranchName"
81 | "generateThreadTitle",
82 detail: string,
83 cause: unknown,
84): TextGenerationError {
85 return new TextGenerationError({
86 operation,
87 detail,
88 ...(cause !== undefined ? { cause } : {}),
89 });
90}
91
92function isTextGenerationError(error: unknown): error is TextGenerationError {
93 return (
94 typeof error === "object" &&
95 error !== null &&
96 "_tag" in error &&
97 error._tag === "TextGenerationError"
98 );
99}
100
101const makeCursorTextGeneration = Effect.gen(function* () {
102 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
103 const serverSettingsService = yield* Effect.service(ServerSettingsService);
104
105 const runCursorJson = <S extends Schema.Top>({
106 operation,
107 cwd,
108 prompt,
109 outputSchemaJson,
110 modelSelection,
111 }: {
112 operation:
113 | "generateCommitMessage"
114 | "generatePrContent"
115 | "generateBranchName"
116 | "generateThreadTitle";
117 cwd: string;
118 prompt: string;
119 outputSchemaJson: S;
120 modelSelection: CursorModelSelection;
121 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
122 Effect.gen(function* () {
123 const cursorSettings = yield* Effect.map(
124 serverSettingsService.getSettings,
125 (settings) => settings.providers.cursor,
126 ).pipe(Effect.catch(() => Effect.undefined));
127
128 const outputRef = yield* Ref.make("");
129 const runtime = yield* makeCursorAcpRuntime({
130 cursorSettings,
131 childProcessSpawner: commandSpawner,
132 cwd,
133 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
134 });
135
136 yield* runtime.handleSessionUpdate((notification) => {
137 const update = notification.update;
138 if (update.sessionUpdate !== "agent_message_chunk") {
139 return Effect.void;
140 }
141 const content = update.content;
142 if (content.type !== "text") {
143 return Effect.void;
144 }
145 return Ref.update(outputRef, (current) => current + content.text);
146 });
147
148 const promptResult = yield* Effect.gen(function* () {
149 yield* runtime.start();
150 yield* Effect.ignore(runtime.setMode("ask"));
151 yield* applyCursorAcpModelSelection({
152 runtime,
153 model: modelSelection.model,
154 modelOptions: modelSelection.options,
155 mapError: ({ cause, configId, step }) =>
156 mapCursorAcpError(
157 operation,
158 step === "set-config-option"
159 ? `Failed to set Cursor ACP config option "${configId}" for text generation.`
160 : "Failed to set Cursor ACP base model for text generation.",
161 cause,
162 ),
163 });
164
165 return yield* runtime.prompt({
166 prompt: [{ type: "text", text: prompt }],
167 });
168 }).pipe(
169 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
170 Effect.flatMap(
171 Option.match({
172 onNone: () =>
173 Effect.fail(
174 new TextGenerationError({
175 operation,
176 detail: "Cursor Agent request timed out.",
177 }),
178 ),
179 onSome: (value) => Effect.succeed(value),
180 }),
181 ),
182 Effect.mapError((cause) =>
183 isTextGenerationError(cause)
184 ? cause
185 : mapCursorAcpError(operation, "Cursor ACP request failed.", cause),
186 ),
187 );
188
189 const rawResult = (yield* Ref.get(outputRef)).trim();
190 if (!rawResult) {
191 return yield* new TextGenerationError({
192 operation,
193 detail:
194 promptResult.stopReason === "cancelled"
195 ? "Cursor ACP request was cancelled."
196 : "Cursor Agent returned empty output.",
197 });
198 }
199
200 return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))(
201 extractJsonObject(rawResult),
202 ).pipe(
203 Effect.catchTag("SchemaError", (cause) =>
204 Effect.fail(
205 new TextGenerationError({
206 operation,
207 detail: "Cursor Agent returned invalid structured output.",
208 cause,
209 }),
210 ),
211 ),
212 );
213 }).pipe(
214 Effect.mapError((cause) =>
215 isTextGenerationError(cause)
216 ? cause
217 : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause),
218 ),
219 Effect.scoped,
220 );
221
222 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
223 "CursorTextGeneration.generateCommitMessage",
224 )(function* (input) {
225 const { prompt, outputSchema } = buildCommitMessagePrompt({
226 branch: input.branch,
227 stagedSummary: input.stagedSummary,
228 stagedPatch: input.stagedPatch,
229 includeBranch: input.includeBranch === true,
230 });
231
232 if (input.modelSelection.provider !== "cursor") {
233 return yield* new TextGenerationError({
234 operation: "generateCommitMessage",
235 detail: "Invalid model selection.",
236 });
237 }
238
239 const generated = yield* runCursorJson({
240 operation: "generateCommitMessage",
241 cwd: input.cwd,
242 prompt,
243 outputSchemaJson: outputSchema,
244 modelSelection: input.modelSelection,
245 });
246
247 return {
248 subject: sanitizeCommitSubject(generated.subject),
249 body: generated.body.trim(),
250 ...("branch" in generated && typeof generated.branch === "string"
251 ? { branch: sanitizeFeatureBranchName(generated.branch) }
252 : {}),
253 };
254 });
255
256 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
257 "CursorTextGeneration.generatePrContent",
258 )(function* (input) {
259 const { prompt, outputSchema } = buildPrContentPrompt({
260 baseBranch: input.baseBranch,
261 headBranch: input.headBranch,
262 commitSummary: input.commitSummary,
263 diffSummary: input.diffSummary,
264 diffPatch: input.diffPatch,
265 });
266
267 if (input.modelSelection.provider !== "cursor") {
268 return yield* new TextGenerationError({
269 operation: "generatePrContent",
270 detail: "Invalid model selection.",
271 });
272 }
273
274 const generated = yield* runCursorJson({
275 operation: "generatePrContent",
276 cwd: input.cwd,
277 prompt,
278 outputSchemaJson: outputSchema,
279 modelSelection: input.modelSelection,
280 });
281
282 return {
283 title: sanitizePrTitle(generated.title),
284 body: generated.body.trim(),
285 };
286 });
287
288 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
289 "CursorTextGeneration.generateBranchName",
290 )(function* (input) {
291 const { prompt, outputSchema } = buildBranchNamePrompt({
292 message: input.message,
293 attachments: input.attachments,
294 });
295
296 if (input.modelSelection.provider !== "cursor") {
297 return yield* new TextGenerationError({
298 operation: "generateBranchName",
299 detail: "Invalid model selection.",
300 });
301 }
302
303 const generated = yield* runCursorJson({
304 operation: "generateBranchName",
305 cwd: input.cwd,
306 prompt,
307 outputSchemaJson: outputSchema,
308 modelSelection: input.modelSelection,
309 });
310
311 return {
312 branch: sanitizeBranchFragment(generated.branch),
313 };
314 });
315
316 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
317 "CursorTextGeneration.generateThreadTitle",
318 )(function* (input) {
319 const { prompt, outputSchema } = buildThreadTitlePrompt({
320 message: input.message,
321 attachments: input.attachments,
322 });
323
324 if (input.modelSelection.provider !== "cursor") {
325 return yield* new TextGenerationError({
326 operation: "generateThreadTitle",
327 detail: "Invalid model selection.",
328 });
329 }
330
331 const generated = yield* runCursorJson({
332 operation: "generateThreadTitle",
333 cwd: input.cwd,
334 prompt,
335 outputSchemaJson: outputSchema,
336 modelSelection: input.modelSelection,
337 });
338
339 return {
340 title: sanitizeThreadTitle(generated.title),
341 } satisfies ThreadTitleGenerationResult;
342 });
343
344 return {
345 generateCommitMessage,
346 generatePrContent,
347 generateBranchName,
348 generateThreadTitle,
349 } satisfies TextGenerationShape;
350});
351
352export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration);
apps/server/src/git/Layers/RoutingTextGeneration.ts+22-1
......@@ -18,6 +18,7 @@ import {
1818} from "../Services/TextGeneration.ts";
1919import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
2020import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21import { CursorTextGenerationLive } from "./CursorTextGeneration.ts";
2122import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
2223
2324// ---------------------------------------------------------------------------
......@@ -32,6 +33,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>(
3233 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
3334) {}
3435
36class CursorTextGen extends Context.Service<CursorTextGen, TextGenerationShape>()(
37 "t3/git/Layers/RoutingTextGeneration/CursorTextGen",
38) {}
39
3540class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
3641 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
3742) {}
......@@ -43,10 +48,17 @@ class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationSha
4348const 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(
7385 }),
7486).pipe(Layer.provide(ClaudeTextGenerationLive));
7587
88const InternalCursorLayer = Layer.effect(
89 CursorTextGen,
90 Effect.gen(function* () {
91 const svc = yield* TextGeneration;
92 return svc;
93 }),
94).pipe(Layer.provide(CursorTextGenerationLive));
95
7696const InternalOpenCodeLayer = Layer.effect(
7797 OpenCodeTextGen,
7898 Effect.gen(function* () {
......@@ -87,5 +107,6 @@ export const RoutingTextGenerationLive = Layer.effect(
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";
1313import type { TextGenerationError } from "@t3tools/contracts";
1414
1515/** Providers that support git text generation (commit messages, PR content, branch names). */
16export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";
16export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
1717
1818export interface CommitMessageGenerationInput {
1919 cwd: string;
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1-1
......@@ -266,7 +266,7 @@ describe("ProviderCommandReactor", () => {
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
8989 : false;
9090}
9191
92function findProviderAdapterRequestError(
93 cause: Cause.Cause<ProviderServiceError>,
94): ProviderAdapterRequestError | undefined {
95 const failReason = cause.reasons.find(Cause.isFailReason);
96 return Schema.is(ProviderAdapterRequestError)(failReason?.error) ? failReason.error : undefined;
97}
98
9299function 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
106113}
107114
108115function 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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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
......@@ -723,6 +723,146 @@ describe("ProviderRuntimeIngestion", () => {
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", () => {
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
3232const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId =>
3333 CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
3434
35interface AssistantSegmentState {
36 baseKey: string;
37 nextSegmentIndex: number;
38 activeMessageId: MessageId | null;
39}
40
3541const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000;
3642const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120);
3743const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
......@@ -83,6 +89,10 @@ function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string
8389 return trimmed;
8490}
8591
92function hasRenderableAssistantText(text: string | undefined): boolean {
93 return (text?.trim().length ?? 0) > 0;
94}
95
8696function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string {
8797 return `plan:${threadId}:turn:${turnId}`;
8898}
......@@ -98,6 +108,15 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId
98108 return `plan:${threadId}:event:${event.eventId}`;
99109}
100110
111function assistantSegmentBaseKeyFromEvent(event: ProviderRuntimeEvent): string {
112 return String(event.itemId ?? event.turnId ?? event.eventId);
113}
114
115function assistantSegmentMessageId(baseKey: string, segmentIndex: number): MessageId {
116 return MessageId.make(
117 segmentIndex === 0 ? `assistant:${baseKey}` : `assistant:${baseKey}:segment:${segmentIndex}`,
118 );
119}
101120function buildContextWindowActivityPayload(
102121 event: ProviderRuntimeEvent,
103122): ThreadTokenUsageSnapshot | undefined {
......@@ -501,7 +520,7 @@ function runtimeEventToActivities(
501520 return [];
502521}
503522
504const make = Effect.fn("make")(function* () {
523const 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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
12671568
12681569export 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 {
4242 type UserInputQuestion,
4343 ClaudeAgentEffort,
4444} from "@t3tools/contracts";
45import {
46 applyClaudePromptEffortPrefix,
47 resolveApiModelId,
48 resolveEffort,
49 trimOrNull,
50} from "@t3tools/shared/model";
45import { applyClaudePromptEffortPrefix, resolveEffort, trimOrNull } from "@t3tools/shared/model";
5146import {
5247 Cause,
5348 DateTime,
......@@ -66,7 +61,7 @@ import {
6661import { resolveAttachmentPath } from "../../attachmentStore.ts";
6762import { ServerConfig } from "../../config.ts";
6863import { ServerSettingsService } from "../../serverSettings.ts";
69import { getClaudeModelCapabilities } from "./ClaudeProvider.ts";
64import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts";
7065import {
7166 ProviderAdapterProcessError,
7267 ProviderAdapterRequestError,
......@@ -2829,7 +2824,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
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* (
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* (
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
......@@ -1,5 +1,6 @@
11import type {
22 ClaudeSettings,
3 ClaudeModelSelection,
34 ModelCapabilities,
45 ServerProvider,
56 ServerProviderModel,
......@@ -163,6 +164,14 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo
163164 );
164165}
165166
167export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string {
168 switch (modelSelection.options?.contextWindow) {
169 case "1m":
170 return `${modelSelection.model}[1m]`;
171 default:
172 return modelSelection.model;
173 }
174}
166175export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
167176 readonly status: Exclude<ServerProviderState, "disabled">;
168177 readonly auth: Pick<ServerProviderAuth, "status">;
apps/server/src/provider/Layers/CursorAdapter.test.ts created+1174
......@@ -0,0 +1,1174 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4import { fileURLToPath } from "node:url";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";
8import { Deferred, Effect, Fiber, Layer, Stream } from "effect";
9
10import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts";
11
12import { ServerConfig } from "../../config.ts";
13import { ServerSettingsService } from "../../serverSettings.ts";
14import { CursorAdapter } from "../Services/CursorAdapter.ts";
15import { makeCursorAdapterLive } from "./CursorAdapter.ts";
16
17const __dirname = path.dirname(fileURLToPath(import.meta.url));
18const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
19const bunExe = "bun";
20
21async function makeMockAgentWrapper(
22 extraEnv?: Record<string, string>,
23 options?: { initialDelaySeconds?: number },
24) {
25 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-"));
26 const wrapperPath = path.join(dir, "fake-agent.sh");
27 const envExports = Object.entries(extraEnv ?? {})
28 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
29 .join("\n");
30 const script = `#!/bin/sh
31${envExports}
32${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""}
33exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
34`;
35 await writeFile(wrapperPath, script, "utf8");
36 await chmod(wrapperPath, 0o755);
37 return wrapperPath;
38}
39
40async function makeProbeWrapper(
41 requestLogPath: string,
42 argvLogPath: string,
43 extraEnv?: Record<string, string>,
44) {
45 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-"));
46 const wrapperPath = path.join(dir, "fake-agent.sh");
47 const envExports = Object.entries(extraEnv ?? {})
48 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
49 .join("\n");
50 const script = `#!/bin/sh
51printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)}
52printf '\n' >> ${JSON.stringify(argvLogPath)}
53export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)}
54${envExports}
55exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
56`;
57 await writeFile(wrapperPath, script, "utf8");
58 await chmod(wrapperPath, 0o755);
59 return wrapperPath;
60}
61
62async function readArgvLog(filePath: string) {
63 const raw = await readFile(filePath, "utf8");
64 return raw
65 .split("\n")
66 .map((line) => line.trim())
67 .filter((line) => line.length > 0)
68 .map((line) => line.split("\t").filter((token) => token.length > 0));
69}
70
71async function readJsonLines(filePath: string) {
72 const raw = await readFile(filePath, "utf8");
73 return raw
74 .split("\n")
75 .map((line) => line.trim())
76 .filter((line) => line.length > 0)
77 .map((line) => JSON.parse(line) as Record<string, unknown>);
78}
79
80async function waitForFileContent(filePath: string, attempts = 40) {
81 for (let attempt = 0; attempt < attempts; attempt += 1) {
82 try {
83 const raw = await readFile(filePath, "utf8");
84 if (raw.trim().length > 0) {
85 return raw;
86 }
87 } catch {}
88 await new Promise((resolve) => setTimeout(resolve, 50));
89 }
90 throw new Error(`Timed out waiting for file content at ${filePath}`);
91}
92
93const cursorAdapterTestLayer = it.layer(
94 makeCursorAdapterLive().pipe(
95 Layer.provideMerge(ServerSettingsService.layerTest()),
96 Layer.provideMerge(
97 ServerConfig.layerTest(process.cwd(), {
98 prefix: "t3code-cursor-adapter-test-",
99 }),
100 ),
101 Layer.provideMerge(NodeServices.layer),
102 ),
103);
104
105cursorAdapterTestLayer("CursorAdapterLive", (it) => {
106 it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
107 Effect.gen(function* () {
108 const adapter = yield* CursorAdapter;
109 const settings = yield* ServerSettingsService;
110 const threadId = ThreadId.make("cursor-mock-thread");
111
112 const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper());
113 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
114
115 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
116 Stream.runCollect,
117 Effect.forkChild,
118 );
119
120 const session = yield* adapter.startSession({
121 threadId,
122 provider: "cursor",
123 cwd: process.cwd(),
124 runtimeMode: "full-access",
125 modelSelection: { provider: "cursor", model: "default" },
126 });
127
128 assert.equal(session.provider, "cursor");
129 assert.deepStrictEqual(session.resumeCursor, {
130 schemaVersion: 1,
131 sessionId: "mock-session-1",
132 });
133
134 yield* adapter.sendTurn({
135 threadId,
136 input: "hello mock",
137 attachments: [],
138 });
139
140 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
141 const types = runtimeEvents.map((e) => e.type);
142
143 for (const t of [
144 "session.started",
145 "session.state.changed",
146 "thread.started",
147 "turn.started",
148 "turn.plan.updated",
149 "item.started",
150 "content.delta",
151 "item.completed",
152 "turn.completed",
153 ] as const) {
154 assert.include(types, t);
155 }
156
157 const assistantStarted = runtimeEvents.find(
158 (event) => event.type === "item.started" && event.payload.itemType === "assistant_message",
159 );
160 assert.isDefined(assistantStarted);
161
162 const delta = runtimeEvents.find((e) => e.type === "content.delta");
163 assert.isDefined(delta);
164 if (delta?.type === "content.delta") {
165 assert.equal(delta.payload.delta, "hello from mock");
166 assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/);
167 }
168
169 const assistantCompleted = runtimeEvents.find(
170 (event) =>
171 event.type === "item.completed" && event.payload.itemType === "assistant_message",
172 );
173 assert.isDefined(assistantCompleted);
174
175 const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated");
176 assert.isDefined(planUpdate);
177 if (planUpdate?.type === "turn.plan.updated") {
178 assert.deepStrictEqual(planUpdate.payload.plan, [
179 { step: "Inspect mock ACP state", status: "completed" },
180 { step: "Implement the requested change", status: "inProgress" },
181 ]);
182 }
183
184 yield* adapter.stopSession(threadId);
185 }),
186 );
187
188 it.effect("closes the ACP child process when a session stops", () =>
189 Effect.gen(function* () {
190 const adapter = yield* CursorAdapter;
191 const settings = yield* ServerSettingsService;
192 const threadId = ThreadId.make("cursor-stop-session-close");
193 const tempDir = yield* Effect.promise(() =>
194 mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")),
195 );
196 const exitLogPath = path.join(tempDir, "exit.log");
197
198 const wrapperPath = yield* Effect.promise(() =>
199 makeMockAgentWrapper({
200 T3_ACP_EXIT_LOG_PATH: exitLogPath,
201 }),
202 );
203 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
204
205 yield* adapter.startSession({
206 threadId,
207 provider: "cursor",
208 cwd: process.cwd(),
209 runtimeMode: "full-access",
210 modelSelection: { provider: "cursor", model: "default" },
211 });
212
213 yield* adapter.stopSession(threadId);
214
215 const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath));
216 assert.include(exitLog, "SIGTERM");
217 }),
218 );
219
220 it.effect(
221 "serializes concurrent startSession calls for the same thread and closes the replaced ACP session",
222 () =>
223 Effect.gen(function* () {
224 const adapter = yield* CursorAdapter;
225 const settings = yield* ServerSettingsService;
226 const threadId = ThreadId.make("cursor-concurrent-start-session");
227 const tempDir = yield* Effect.promise(() =>
228 mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")),
229 );
230 const exitLogPath = path.join(tempDir, "exit.log");
231
232 const wrapperPath = yield* Effect.promise(() =>
233 makeMockAgentWrapper(
234 {
235 T3_ACP_EXIT_LOG_PATH: exitLogPath,
236 },
237 { initialDelaySeconds: 0.2 },
238 ),
239 );
240 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
241
242 const [firstSession, secondSession] = yield* Effect.all(
243 [
244 adapter.startSession({
245 threadId,
246 provider: "cursor",
247 cwd: process.cwd(),
248 runtimeMode: "full-access",
249 modelSelection: { provider: "cursor", model: "default" },
250 }),
251 adapter.startSession({
252 threadId,
253 provider: "cursor",
254 cwd: process.cwd(),
255 runtimeMode: "full-access",
256 modelSelection: { provider: "cursor", model: "default" },
257 }),
258 ],
259 { concurrency: "unbounded" },
260 );
261
262 assert.equal(firstSession.threadId, threadId);
263 assert.equal(secondSession.threadId, threadId);
264
265 yield* adapter.stopSession(threadId);
266
267 const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath));
268 assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2);
269 }),
270 );
271
272 it.effect("rejects startSession when provider mismatches", () =>
273 Effect.gen(function* () {
274 const adapter = yield* CursorAdapter;
275 const result = yield* adapter
276 .startSession({
277 threadId: ThreadId.make("bad-provider"),
278 provider: "codex",
279 cwd: process.cwd(),
280 runtimeMode: "full-access",
281 })
282 .pipe(Effect.result);
283
284 assert.equal(result._tag, "Failure");
285 }),
286 );
287
288 it.effect("maps app plan mode onto the ACP plan session mode", () =>
289 Effect.gen(function* () {
290 const adapter = yield* CursorAdapter;
291 const serverSettings = yield* ServerSettingsService;
292 const threadId = ThreadId.make("cursor-plan-mode-probe");
293 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
294 const requestLogPath = path.join(tempDir, "requests.ndjson");
295 const argvLogPath = path.join(tempDir, "argv.txt");
296 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
297 const wrapperPath = yield* Effect.promise(() =>
298 makeProbeWrapper(requestLogPath, argvLogPath),
299 );
300 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
301
302 yield* adapter.startSession({
303 threadId,
304 provider: "cursor",
305 cwd: process.cwd(),
306 runtimeMode: "full-access",
307 modelSelection: { provider: "cursor", model: "composer-2" },
308 });
309
310 yield* adapter.sendTurn({
311 threadId,
312 input: "plan this change",
313 attachments: [],
314 interactionMode: "plan",
315 });
316 yield* adapter.stopSession(threadId);
317
318 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
319 const modeRequest = requests
320 .toReversed()
321 .find(
322 (entry) =>
323 entry.method === "session/set_mode" ||
324 (entry.method === "session/set_config_option" &&
325 (entry.params as Record<string, unknown> | undefined)?.configId === "mode"),
326 );
327 assert.isDefined(modeRequest);
328 assert.equal(
329 (modeRequest?.params as Record<string, unknown> | undefined)?.sessionId,
330 "mock-session-1",
331 );
332 assert.include(
333 ["architect", "plan"],
334 String(
335 (modeRequest?.params as Record<string, unknown> | undefined)?.modeId ??
336 (modeRequest?.params as Record<string, unknown> | undefined)?.value,
337 ),
338 );
339 }),
340 );
341
342 it.effect(
343 "applies initial model and mode configuration during startSession and skips repeating it on first send",
344 () =>
345 Effect.gen(function* () {
346 const adapter = yield* CursorAdapter;
347 const serverSettings = yield* ServerSettingsService;
348 const threadId = ThreadId.make("cursor-initial-config-probe");
349 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
350 const requestLogPath = path.join(tempDir, "requests.ndjson");
351 const argvLogPath = path.join(tempDir, "argv.txt");
352 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
353 const wrapperPath = yield* Effect.promise(() =>
354 makeProbeWrapper(requestLogPath, argvLogPath),
355 );
356 yield* serverSettings.updateSettings({
357 providers: { cursor: { binaryPath: wrapperPath } },
358 });
359
360 const modelSelection = {
361 provider: "cursor" as const,
362 model: "gpt-5.4",
363 options: {
364 reasoning: "xhigh" as const,
365 contextWindow: "1m",
366 fastMode: true,
367 },
368 };
369
370 yield* adapter.startSession({
371 threadId,
372 provider: "cursor",
373 cwd: process.cwd(),
374 runtimeMode: "full-access",
375 modelSelection,
376 });
377
378 yield* Effect.promise(() => waitForFileContent(requestLogPath));
379
380 const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath));
381 const configIdsAfterStart = requestsAfterStart.flatMap((entry) =>
382 entry.method === "session/set_config_option" &&
383 typeof (entry.params as Record<string, unknown> | undefined)?.configId === "string"
384 ? [String((entry.params as Record<string, unknown>).configId)]
385 : [],
386 );
387 assert.deepStrictEqual(configIdsAfterStart, [
388 "model",
389 "reasoning",
390 "context",
391 "fast",
392 "mode",
393 ]);
394
395 yield* adapter.sendTurn({
396 threadId,
397 input: "hello mock",
398 attachments: [],
399 modelSelection,
400 interactionMode: "default",
401 });
402 yield* adapter.stopSession(threadId);
403
404 const finalRequests = yield* Effect.promise(() => readJsonLines(requestLogPath));
405 const finalConfigIds = finalRequests.flatMap((entry) =>
406 entry.method === "session/set_config_option" &&
407 typeof (entry.params as Record<string, unknown> | undefined)?.configId === "string"
408 ? [String((entry.params as Record<string, unknown>).configId)]
409 : [],
410 );
411 assert.deepStrictEqual(finalConfigIds, ["model", "reasoning", "context", "fast", "mode"]);
412 assert.equal(finalRequests.filter((entry) => entry.method === "session/prompt").length, 1);
413 }),
414 );
415
416 it.effect(
417 "streams ACP tool calls and approvals on the active turn in approval-required mode",
418 () =>
419 Effect.gen(function* () {
420 const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS;
421 process.env.T3_ACP_EMIT_TOOL_CALLS = "1";
422
423 const adapter = yield* CursorAdapter;
424 const serverSettings = yield* ServerSettingsService;
425 const threadId = ThreadId.make("cursor-tool-call-probe");
426 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
427 const settledEventTypes = new Set<string>();
428 const settledEventsReady = yield* Deferred.make<void>();
429
430 const wrapperPath = yield* Effect.promise(() =>
431 makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }),
432 );
433 yield* serverSettings.updateSettings({
434 providers: { cursor: { binaryPath: wrapperPath } },
435 });
436
437 yield* Stream.runForEach(adapter.streamEvents, (event) =>
438 Effect.gen(function* () {
439 runtimeEvents.push(event);
440 if (String(event.threadId) !== String(threadId)) {
441 return;
442 }
443 if (event.type === "request.opened" && event.requestId) {
444 yield* adapter.respondToRequest(
445 threadId,
446 ApprovalRequestId.make(String(event.requestId)),
447 "accept",
448 );
449 }
450 if (
451 event.type === "turn.completed" ||
452 (event.type === "item.completed" && event.payload.itemType === "command_execution") ||
453 event.type === "content.delta"
454 ) {
455 settledEventTypes.add(event.type);
456 if (settledEventTypes.size === 3) {
457 yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie);
458 }
459 }
460 }),
461 ).pipe(Effect.forkChild);
462
463 const program = Effect.gen(function* () {
464 yield* adapter.startSession({
465 threadId,
466 provider: "cursor",
467 cwd: process.cwd(),
468 runtimeMode: "approval-required",
469 modelSelection: { provider: "cursor", model: "default" },
470 });
471
472 const turn = yield* adapter.sendTurn({
473 threadId,
474 input: "run a tool call",
475 attachments: [],
476 });
477 yield* Deferred.await(settledEventsReady);
478
479 const threadEvents = runtimeEvents.filter(
480 (event) => String(event.threadId) === String(threadId),
481 );
482 assert.includeMembers(
483 threadEvents.map((event) => event.type),
484 [
485 "session.started",
486 "session.state.changed",
487 "thread.started",
488 "turn.started",
489 "request.opened",
490 "request.resolved",
491 "item.updated",
492 "item.completed",
493 "content.delta",
494 "turn.completed",
495 ],
496 );
497
498 const turnEvents = threadEvents.filter(
499 (event) => String(event.turnId) === String(turn.turnId),
500 );
501 const toolUpdates = turnEvents.filter((event) => event.type === "item.updated");
502 // ACP updates can arrive either as distinct pending + in-progress events
503 // or as a single coalesced in-progress update before approval resolves.
504 assert.isAtLeast(toolUpdates.length, 1);
505 for (const toolUpdate of toolUpdates) {
506 if (toolUpdate.type !== "item.updated") {
507 continue;
508 }
509 assert.equal(toolUpdate.payload.itemType, "command_execution");
510 assert.equal(toolUpdate.payload.status, "inProgress");
511 assert.equal(toolUpdate.payload.detail, "cat server/package.json");
512 assert.equal(String(toolUpdate.itemId), "tool-call-1");
513 }
514
515 const requestOpened = turnEvents.find((event) => event.type === "request.opened");
516 assert.isDefined(requestOpened);
517 if (requestOpened?.type === "request.opened") {
518 assert.equal(String(requestOpened.turnId), String(turn.turnId));
519 assert.equal(requestOpened.payload.requestType, "exec_command_approval");
520 assert.equal(requestOpened.payload.detail, "cat server/package.json");
521 }
522
523 const requestResolved = turnEvents.find((event) => event.type === "request.resolved");
524 assert.isDefined(requestResolved);
525 if (requestResolved?.type === "request.resolved") {
526 assert.equal(String(requestResolved.turnId), String(turn.turnId));
527 assert.equal(requestResolved.payload.requestType, "exec_command_approval");
528 assert.equal(requestResolved.payload.decision, "accept");
529 }
530
531 const toolCompleted = turnEvents.find(
532 (event) =>
533 event.type === "item.completed" && event.payload.itemType === "command_execution",
534 );
535 assert.isDefined(toolCompleted);
536 if (toolCompleted?.type === "item.completed") {
537 assert.equal(String(toolCompleted.turnId), String(turn.turnId));
538 assert.equal(toolCompleted.payload.itemType, "command_execution");
539 assert.equal(toolCompleted.payload.status, "completed");
540 assert.equal(toolCompleted.payload.detail, "cat server/package.json");
541 assert.equal(String(toolCompleted.itemId), "tool-call-1");
542 }
543
544 const contentDelta = turnEvents.find((event) => event.type === "content.delta");
545 assert.isDefined(contentDelta);
546 if (contentDelta?.type === "content.delta") {
547 assert.equal(String(contentDelta.turnId), String(turn.turnId));
548 assert.equal(contentDelta.payload.delta, "hello from mock");
549 assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0");
550 }
551 });
552
553 yield* program.pipe(
554 Effect.ensuring(
555 Effect.sync(() => {
556 if (previousEmitToolCalls === undefined) {
557 delete process.env.T3_ACP_EMIT_TOOL_CALLS;
558 } else {
559 process.env.T3_ACP_EMIT_TOOL_CALLS = previousEmitToolCalls;
560 }
561 }),
562 ),
563 );
564 }).pipe(
565 Effect.provide(
566 makeCursorAdapterLive().pipe(
567 Layer.provideMerge(ServerSettingsService.layerTest()),
568 Layer.provideMerge(
569 ServerConfig.layerTest(process.cwd(), {
570 prefix: "t3code-cursor-adapter-test-",
571 }),
572 ),
573 Layer.provideMerge(NodeServices.layer),
574 ),
575 ),
576 ),
577 );
578
579 it.effect(
580 "auto-approves ACP tool permissions in full-access mode without approval runtime events",
581 () =>
582 Effect.gen(function* () {
583 const adapter = yield* CursorAdapter;
584 const serverSettings = yield* ServerSettingsService;
585 const threadId = ThreadId.make("cursor-full-access-auto-approve");
586 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
587 const settledEventTypes = new Set<string>();
588 const settledEventsReady = yield* Deferred.make<void>();
589 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
590 const requestLogPath = path.join(tempDir, "requests.ndjson");
591 const argvLogPath = path.join(tempDir, "argv.txt");
592 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
593 const wrapperPath = yield* Effect.promise(() =>
594 makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }),
595 );
596 yield* serverSettings.updateSettings({
597 providers: { cursor: { binaryPath: wrapperPath } },
598 });
599
600 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
601 Effect.gen(function* () {
602 runtimeEvents.push(event);
603 if (String(event.threadId) !== String(threadId)) {
604 return;
605 }
606 if (
607 event.type === "turn.completed" ||
608 (event.type === "item.completed" && event.payload.itemType === "command_execution") ||
609 event.type === "content.delta"
610 ) {
611 settledEventTypes.add(event.type);
612 if (settledEventTypes.size === 3) {
613 yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie);
614 }
615 }
616 }),
617 ).pipe(Effect.forkChild);
618
619 yield* adapter.startSession({
620 threadId,
621 provider: "cursor",
622 cwd: process.cwd(),
623 runtimeMode: "full-access",
624 modelSelection: { provider: "cursor", model: "default" },
625 });
626
627 const turn = yield* adapter.sendTurn({
628 threadId,
629 input: "run a tool call",
630 attachments: [],
631 });
632
633 yield* Deferred.await(settledEventsReady);
634 yield* Fiber.interrupt(runtimeEventsFiber);
635
636 const turnEvents = runtimeEvents.filter(
637 (event) =>
638 String(event.threadId) === String(threadId) &&
639 String(event.turnId) === String(turn.turnId),
640 );
641 assert.notInclude(
642 turnEvents.map((event) => event.type),
643 "request.opened",
644 );
645 assert.notInclude(
646 turnEvents.map((event) => event.type),
647 "request.resolved",
648 );
649 assert.includeMembers(
650 turnEvents.map((event) => event.type),
651 ["item.updated", "item.completed", "content.delta", "turn.completed"],
652 );
653
654 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
655 const permissionResponse = requests.find(
656 (entry) =>
657 !("method" in entry) &&
658 typeof entry.result === "object" &&
659 entry.result !== null &&
660 "outcome" in entry.result &&
661 typeof entry.result.outcome === "object" &&
662 entry.result.outcome !== null &&
663 "outcome" in entry.result.outcome &&
664 entry.result.outcome.outcome === "selected" &&
665 "optionId" in entry.result.outcome &&
666 entry.result.outcome.optionId === "allow-always",
667 );
668 assert.isDefined(permissionResponse);
669
670 yield* adapter.stopSession(threadId);
671 }),
672 );
673
674 it.effect("segments assistant messages around ACP tool activity in full-access mode", () =>
675 Effect.gen(function* () {
676 const adapter = yield* CursorAdapter;
677 const serverSettings = yield* ServerSettingsService;
678 const threadId = ThreadId.make("cursor-assistant-tool-segmentation");
679 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
680 const settledEventTypes = new Set<string>();
681 const settledEventsReady = yield* Deferred.make<void>();
682
683 const wrapperPath = yield* Effect.promise(() =>
684 makeMockAgentWrapper({ T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1" }),
685 );
686 yield* serverSettings.updateSettings({
687 providers: { cursor: { binaryPath: wrapperPath } },
688 });
689
690 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
691 Effect.gen(function* () {
692 runtimeEvents.push(event);
693 if (String(event.threadId) !== String(threadId)) {
694 return;
695 }
696 if (
697 event.type === "content.delta" ||
698 (event.type === "item.completed" && event.payload.itemType === "command_execution") ||
699 event.type === "turn.completed"
700 ) {
701 if (event.type === "content.delta") {
702 settledEventTypes.add(`delta:${event.payload.delta}`);
703 } else {
704 settledEventTypes.add(event.type);
705 }
706 if (
707 settledEventTypes.has("delta:before tool") &&
708 settledEventTypes.has("delta:after tool") &&
709 settledEventTypes.has("item.completed") &&
710 settledEventTypes.has("turn.completed")
711 ) {
712 yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie);
713 }
714 }
715 }),
716 ).pipe(Effect.forkChild);
717
718 yield* adapter.startSession({
719 threadId,
720 provider: "cursor",
721 cwd: process.cwd(),
722 runtimeMode: "full-access",
723 modelSelection: { provider: "cursor", model: "default" },
724 });
725
726 const turn = yield* adapter.sendTurn({
727 threadId,
728 input: "run an interleaved tool call",
729 attachments: [],
730 });
731
732 yield* Deferred.await(settledEventsReady);
733 yield* Fiber.interrupt(runtimeEventsFiber);
734
735 const turnEvents = runtimeEvents.filter(
736 (event) =>
737 String(event.threadId) === String(threadId) &&
738 String(event.turnId) === String(turn.turnId),
739 );
740 const firstAssistantStartIndex = turnEvents.findIndex(
741 (event) => event.type === "item.started" && event.payload.itemType === "assistant_message",
742 );
743 const firstAssistantDeltaIndex = turnEvents.findIndex(
744 (event) => event.type === "content.delta" && event.payload.delta === "before tool",
745 );
746 const assistantBoundaryIndex = turnEvents.findIndex(
747 (event) =>
748 event.type === "item.completed" && event.payload.itemType === "assistant_message",
749 );
750 const toolUpdateIndex = turnEvents.findIndex(
751 (event) => event.type === "item.updated" && event.payload.itemType === "command_execution",
752 );
753 const toolCompletedIndex = turnEvents.findIndex(
754 (event) =>
755 event.type === "item.completed" && event.payload.itemType === "command_execution",
756 );
757 const secondAssistantStartIndex = turnEvents.findIndex(
758 (event, index) =>
759 index > toolCompletedIndex &&
760 event.type === "item.started" &&
761 event.payload.itemType === "assistant_message",
762 );
763 const secondAssistantDeltaIndex = turnEvents.findIndex(
764 (event) => event.type === "content.delta" && event.payload.delta === "after tool",
765 );
766
767 assert.isAtLeast(firstAssistantStartIndex, 0);
768 assert.isAtLeast(firstAssistantDeltaIndex, 0);
769 assert.isAtLeast(assistantBoundaryIndex, 0);
770 assert.isAtLeast(toolUpdateIndex, 0);
771 assert.isAtLeast(toolCompletedIndex, 0);
772 assert.isAtLeast(secondAssistantStartIndex, 0);
773 assert.isAtLeast(secondAssistantDeltaIndex, 0);
774 assert.isBelow(firstAssistantStartIndex, firstAssistantDeltaIndex);
775 assert.isBelow(firstAssistantDeltaIndex, assistantBoundaryIndex);
776 assert.isBelow(assistantBoundaryIndex, toolUpdateIndex);
777 assert.isBelow(toolUpdateIndex, toolCompletedIndex);
778 assert.isBelow(toolCompletedIndex, secondAssistantStartIndex);
779 assert.isBelow(secondAssistantStartIndex, secondAssistantDeltaIndex);
780
781 const assistantStarts = turnEvents.filter(
782 (event) => event.type === "item.started" && event.payload.itemType === "assistant_message",
783 );
784 const assistantDeltas = turnEvents.filter((event) => event.type === "content.delta");
785 assert.lengthOf(assistantStarts, 2);
786 assert.lengthOf(assistantDeltas, 2);
787 if (
788 assistantStarts[0]?.type === "item.started" &&
789 assistantStarts[1]?.type === "item.started" &&
790 assistantDeltas[0]?.type === "content.delta" &&
791 assistantDeltas[1]?.type === "content.delta"
792 ) {
793 assert.notEqual(String(assistantStarts[0].itemId), String(assistantStarts[1].itemId));
794 assert.equal(String(assistantDeltas[0].itemId), String(assistantStarts[0].itemId));
795 assert.equal(String(assistantDeltas[1].itemId), String(assistantStarts[1].itemId));
796 }
797
798 yield* adapter.stopSession(threadId);
799 }),
800 );
801
802 it.effect("cancels pending ACP approvals and marks the turn cancelled when interrupted", () =>
803 Effect.gen(function* () {
804 const adapter = yield* CursorAdapter;
805 const serverSettings = yield* ServerSettingsService;
806 const threadId = ThreadId.make("cursor-cancel-probe");
807 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
808 const requestLogPath = path.join(tempDir, "requests.ndjson");
809 const argvLogPath = path.join(tempDir, "argv.txt");
810 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
811 const wrapperPath = yield* Effect.promise(() =>
812 makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }),
813 );
814 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
815
816 const requestResolvedReady = yield* Deferred.make<ProviderRuntimeEvent>();
817 const turnCompletedReady = yield* Deferred.make<ProviderRuntimeEvent>();
818 let interrupted = false;
819
820 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
821 Effect.gen(function* () {
822 if (String(event.threadId) !== String(threadId)) {
823 return;
824 }
825 if (event.type === "request.opened" && !interrupted) {
826 interrupted = true;
827 yield* adapter.interruptTurn(threadId);
828 return;
829 }
830 if (event.type === "request.resolved") {
831 yield* Deferred.succeed(requestResolvedReady, event).pipe(Effect.ignore);
832 return;
833 }
834 if (event.type === "turn.completed") {
835 yield* Deferred.succeed(turnCompletedReady, event).pipe(Effect.ignore);
836 }
837 }),
838 ).pipe(Effect.forkChild);
839
840 yield* adapter.startSession({
841 threadId,
842 provider: "cursor",
843 cwd: process.cwd(),
844 runtimeMode: "approval-required",
845 modelSelection: { provider: "cursor", model: "default" },
846 });
847
848 const sendTurnFiber = yield* adapter
849 .sendTurn({
850 threadId,
851 input: "cancel this turn",
852 attachments: [],
853 })
854 .pipe(Effect.forkChild);
855
856 const requestResolved = yield* Deferred.await(requestResolvedReady);
857 const turnCompleted = yield* Deferred.await(turnCompletedReady);
858 yield* Fiber.join(sendTurnFiber);
859 yield* Fiber.interrupt(runtimeEventsFiber);
860
861 assert.equal(requestResolved.type, "request.resolved");
862 if (requestResolved.type === "request.resolved") {
863 assert.equal(requestResolved.payload.decision, "cancel");
864 }
865
866 assert.equal(turnCompleted.type, "turn.completed");
867 if (turnCompleted.type === "turn.completed") {
868 assert.equal(turnCompleted.payload.state, "cancelled");
869 assert.equal(turnCompleted.payload.stopReason, "cancelled");
870 }
871
872 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
873 assert.isTrue(requests.some((entry) => entry.method === "session/cancel"));
874 assert.isTrue(
875 requests.some(
876 (entry) =>
877 !("method" in entry) &&
878 typeof entry.result === "object" &&
879 entry.result !== null &&
880 "outcome" in entry.result &&
881 typeof entry.result.outcome === "object" &&
882 entry.result.outcome !== null &&
883 "outcome" in entry.result.outcome &&
884 entry.result.outcome.outcome === "cancelled",
885 ),
886 );
887
888 yield* adapter.stopSession(threadId);
889 }),
890 );
891 it.effect("stopping a session settles pending approval waits", () =>
892 Effect.gen(function* () {
893 const adapter = yield* CursorAdapter;
894 const serverSettings = yield* ServerSettingsService;
895 const threadId = ThreadId.make("cursor-stop-pending-approval");
896 const approvalRequested = yield* Deferred.make<void>();
897
898 const wrapperPath = yield* Effect.promise(() =>
899 makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }),
900 );
901 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
902
903 yield* Stream.runForEach(adapter.streamEvents, (event) => {
904 if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") {
905 return Effect.void;
906 }
907 return Deferred.succeed(approvalRequested, undefined).pipe(Effect.ignore);
908 }).pipe(Effect.forkChild);
909
910 yield* adapter.startSession({
911 threadId,
912 provider: "cursor",
913 cwd: process.cwd(),
914 runtimeMode: "approval-required",
915 modelSelection: { provider: "cursor", model: "default" },
916 });
917
918 const sendTurnFiber = yield* adapter
919 .sendTurn({
920 threadId,
921 input: "run a tool call and then stop",
922 attachments: [],
923 })
924 .pipe(Effect.forkChild);
925
926 yield* Deferred.await(approvalRequested);
927 yield* adapter.stopSession(threadId);
928 yield* Fiber.await(sendTurnFiber);
929
930 assert.equal(yield* adapter.hasSession(threadId), false);
931 }),
932 );
933
934 it.effect("stopping a session settles pending user-input waits", () =>
935 Effect.gen(function* () {
936 const adapter = yield* CursorAdapter;
937 const serverSettings = yield* ServerSettingsService;
938 const threadId = ThreadId.make("cursor-stop-pending-user-input");
939 const userInputRequested = yield* Deferred.make<void>();
940
941 const wrapperPath = yield* Effect.promise(() =>
942 makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }),
943 );
944 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
945
946 yield* Stream.runForEach(adapter.streamEvents, (event) => {
947 if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") {
948 return Effect.void;
949 }
950 return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore);
951 }).pipe(Effect.forkChild);
952
953 yield* adapter.startSession({
954 threadId,
955 provider: "cursor",
956 cwd: process.cwd(),
957 runtimeMode: "full-access",
958 modelSelection: { provider: "cursor", model: "default" },
959 });
960
961 const sendTurnFiber = yield* adapter
962 .sendTurn({
963 threadId,
964 input: "ask me a question and then stop",
965 attachments: [],
966 })
967 .pipe(Effect.forkChild);
968
969 yield* Deferred.await(userInputRequested);
970 yield* adapter.stopSession(threadId);
971 yield* Fiber.await(sendTurnFiber);
972
973 assert.equal(yield* adapter.hasSession(threadId), false);
974 }),
975 );
976
977 it.effect("interrupting a session settles pending user-input waits", () =>
978 Effect.gen(function* () {
979 const adapter = yield* CursorAdapter;
980 const serverSettings = yield* ServerSettingsService;
981 const threadId = ThreadId.make("cursor-interrupt-pending-user-input");
982 const userInputRequested = yield* Deferred.make<void>();
983
984 const wrapperPath = yield* Effect.promise(() =>
985 makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }),
986 );
987 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
988
989 yield* Stream.runForEach(adapter.streamEvents, (event) => {
990 if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") {
991 return Effect.void;
992 }
993 return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore);
994 }).pipe(Effect.forkChild);
995
996 yield* adapter.startSession({
997 threadId,
998 provider: "cursor",
999 cwd: process.cwd(),
1000 runtimeMode: "full-access",
1001 modelSelection: { provider: "cursor", model: "default" },
1002 });
1003
1004 const sendTurnFiber = yield* adapter
1005 .sendTurn({
1006 threadId,
1007 input: "ask me a question and then interrupt",
1008 attachments: [],
1009 })
1010 .pipe(Effect.forkChild);
1011
1012 yield* Deferred.await(userInputRequested);
1013 yield* adapter.interruptTurn(threadId);
1014 yield* Fiber.await(sendTurnFiber);
1015
1016 assert.equal(yield* adapter.hasSession(threadId), true);
1017 yield* adapter.stopSession(threadId);
1018 }),
1019 );
1020
1021 it.effect("broadcasts runtime events to multiple stream consumers", () =>
1022 Effect.gen(function* () {
1023 const adapter = yield* CursorAdapter;
1024 const settings = yield* ServerSettingsService;
1025 const threadId = ThreadId.make("cursor-runtime-event-broadcast");
1026
1027 const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper());
1028 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
1029
1030 const firstConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe(
1031 Stream.runCollect,
1032 Effect.forkChild,
1033 );
1034 const secondConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe(
1035 Stream.runCollect,
1036 Effect.forkChild,
1037 );
1038
1039 yield* adapter.startSession({
1040 threadId,
1041 provider: "cursor",
1042 cwd: process.cwd(),
1043 runtimeMode: "full-access",
1044 modelSelection: { provider: "cursor", model: "default" },
1045 });
1046
1047 const firstEvents = Array.from(yield* Fiber.join(firstConsumer));
1048 const secondEvents = Array.from(yield* Fiber.join(secondConsumer));
1049
1050 assert.deepStrictEqual(
1051 firstEvents.map((event) => event.type),
1052 ["session.started", "session.state.changed", "thread.started"],
1053 );
1054 assert.deepStrictEqual(
1055 secondEvents.map((event) => event.type),
1056 ["session.started", "session.state.changed", "thread.started"],
1057 );
1058
1059 yield* adapter.stopSession(threadId);
1060 }),
1061 );
1062
1063 it.effect("switches model in-session via session/set_config_option", () =>
1064 Effect.gen(function* () {
1065 const adapter = yield* CursorAdapter;
1066 const serverSettings = yield* ServerSettingsService;
1067 const threadId = ThreadId.make("cursor-model-switch");
1068 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
1069 const requestLogPath = path.join(tempDir, "requests.ndjson");
1070 const argvLogPath = path.join(tempDir, "argv.txt");
1071 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
1072 const wrapperPath = yield* Effect.promise(() =>
1073 makeProbeWrapper(requestLogPath, argvLogPath),
1074 );
1075 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
1076
1077 yield* adapter.startSession({
1078 threadId,
1079 provider: "cursor",
1080 cwd: process.cwd(),
1081 runtimeMode: "full-access",
1082 modelSelection: { provider: "cursor", model: "composer-2" },
1083 });
1084
1085 yield* adapter.sendTurn({
1086 threadId,
1087 input: "first turn",
1088 attachments: [],
1089 });
1090
1091 yield* adapter.sendTurn({
1092 threadId,
1093 input: "second turn after switching model",
1094 attachments: [],
1095 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } },
1096 });
1097
1098 const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath));
1099 assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn");
1100 assert.deepStrictEqual(argvRuns[0], ["acp"]);
1101
1102 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
1103 const setConfigRequests = requests.filter(
1104 (entry) =>
1105 entry.method === "session/set_config_option" &&
1106 (entry.params as Record<string, unknown> | undefined)?.configId === "model",
1107 );
1108 assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option");
1109 assert.equal((setConfigRequests[0]?.params as Record<string, unknown>)?.value, "composer-2");
1110
1111 const fastConfigRequests = requests.filter(
1112 (entry) =>
1113 entry.method === "session/set_config_option" &&
1114 (entry.params as Record<string, unknown> | undefined)?.configId === "fast",
1115 );
1116 assert.isAbove(fastConfigRequests.length, 0, "should apply fast mode as a separate config");
1117 const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1];
1118 assert.equal((lastFastConfig?.params as Record<string, unknown>)?.value, "true");
1119
1120 yield* adapter.stopSession(threadId);
1121 }),
1122 );
1123
1124 it.effect("clears prior fast mode in-session when the next turn sets fastMode: false", () =>
1125 Effect.gen(function* () {
1126 const adapter = yield* CursorAdapter;
1127 const serverSettings = yield* ServerSettingsService;
1128 const threadId = ThreadId.make("cursor-fast-mode-reset");
1129 const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-")));
1130 const requestLogPath = path.join(tempDir, "requests.ndjson");
1131 const argvLogPath = path.join(tempDir, "argv.txt");
1132 yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8"));
1133 const wrapperPath = yield* Effect.promise(() =>
1134 makeProbeWrapper(requestLogPath, argvLogPath),
1135 );
1136 yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
1137
1138 yield* adapter.startSession({
1139 threadId,
1140 provider: "cursor",
1141 cwd: process.cwd(),
1142 runtimeMode: "full-access",
1143 modelSelection: { provider: "cursor", model: "composer-2" },
1144 });
1145
1146 yield* adapter.sendTurn({
1147 threadId,
1148 input: "first turn with fast mode",
1149 attachments: [],
1150 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } },
1151 });
1152
1153 yield* adapter.sendTurn({
1154 threadId,
1155 input: "second turn without fast mode",
1156 attachments: [],
1157 modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } },
1158 });
1159
1160 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
1161 const fastConfigRequests = requests.filter(
1162 (entry) =>
1163 entry.method === "session/set_config_option" &&
1164 (entry.params as Record<string, unknown> | undefined)?.configId === "fast",
1165 );
1166 assert.isAtLeast(fastConfigRequests.length, 2, "should set fast mode on and then off");
1167
1168 const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1];
1169 assert.equal((lastFastConfig?.params as Record<string, unknown>)?.value, "false");
1170
1171 yield* adapter.stopSession(threadId);
1172 }),
1173 );
1174});
apps/server/src/provider/Layers/CursorAdapter.ts created+1057
......@@ -0,0 +1,1057 @@
1/**
2 * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP.
3 *
4 * @module CursorAdapterLive
5 */
6import * as nodePath from "node:path";
7
8import {
9 ApprovalRequestId,
10 type CursorModelOptions,
11 EventId,
12 type ProviderApprovalDecision,
13 type ProviderInteractionMode,
14 type ProviderRuntimeEvent,
15 type ProviderSession,
16 type ProviderUserInputAnswers,
17 RuntimeRequestId,
18 type RuntimeMode,
19 type ThreadId,
20 TurnId,
21} from "@t3tools/contracts";
22import {
23 DateTime,
24 Deferred,
25 Effect,
26 Exit,
27 Fiber,
28 FileSystem,
29 Layer,
30 Option,
31 PubSub,
32 Random,
33 Scope,
34 Semaphore,
35 Stream,
36 SynchronizedRef,
37} from "effect";
38import { ChildProcessSpawner } from "effect/unstable/process";
39import type * as EffectAcpSchema from "effect-acp/schema";
40
41import { resolveAttachmentPath } from "../../attachmentStore.ts";
42import { ServerConfig } from "../../config.ts";
43import { ServerSettingsService } from "../../serverSettings.ts";
44import {
45 ProviderAdapterProcessError,
46 ProviderAdapterRequestError,
47 ProviderAdapterSessionNotFoundError,
48 ProviderAdapterValidationError,
49} from "../Errors.ts";
50import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
51import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts";
52import {
53 makeAcpAssistantItemEvent,
54 makeAcpContentDeltaEvent,
55 makeAcpPlanUpdatedEvent,
56 makeAcpRequestOpenedEvent,
57 makeAcpRequestResolvedEvent,
58 makeAcpToolCallEvent,
59} from "../acp/AcpCoreRuntimeEvents.ts";
60import {
61 type AcpSessionMode,
62 type AcpSessionModeState,
63 parsePermissionRequest,
64} from "../acp/AcpRuntimeModel.ts";
65import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts";
66import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts";
67import {
68 CursorAskQuestionRequest,
69 CursorCreatePlanRequest,
70 CursorUpdateTodosRequest,
71 extractAskQuestions,
72 extractPlanMarkdown,
73 extractTodosAsPlan,
74} from "../acp/CursorAcpExtension.ts";
75import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts";
76import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
77import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
78
79const PROVIDER = "cursor" as const;
80const CURSOR_RESUME_VERSION = 1 as const;
81const ACP_PLAN_MODE_ALIASES = ["plan", "architect"];
82const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"];
83const ACP_APPROVAL_MODE_ALIASES = ["ask"];
84
85export interface CursorAdapterLiveOptions {
86 readonly nativeEventLogPath?: string;
87 readonly nativeEventLogger?: EventNdjsonLogger;
88}
89
90interface PendingApproval {
91 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
92 readonly kind: string | "unknown";
93}
94
95interface PendingUserInput {
96 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
97}
98
99interface CursorSessionContext {
100 readonly threadId: ThreadId;
101 session: ProviderSession;
102 readonly scope: Scope.Closeable;
103 readonly acp: AcpSessionRuntimeShape;
104 notificationFiber: Fiber.Fiber<void, never> | undefined;
105 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
106 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
107 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
108 lastPlanFingerprint: string | undefined;
109 activeTurnId: TurnId | undefined;
110 stopped: boolean;
111}
112
113function settlePendingApprovalsAsCancelled(
114 pendingApprovals: ReadonlyMap<ApprovalRequestId, PendingApproval>,
115): Effect.Effect<void> {
116 const pendingEntries = Array.from(pendingApprovals.values());
117 return Effect.forEach(
118 pendingEntries,
119 (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore),
120 {
121 discard: true,
122 },
123 );
124}
125
126function settlePendingUserInputsAsEmptyAnswers(
127 pendingUserInputs: ReadonlyMap<ApprovalRequestId, PendingUserInput>,
128): Effect.Effect<void> {
129 const pendingEntries = Array.from(pendingUserInputs.values());
130 return Effect.forEach(
131 pendingEntries,
132 (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore),
133 {
134 discard: true,
135 },
136 );
137}
138
139function isRecord(value: unknown): value is Record<string, unknown> {
140 return typeof value === "object" && value !== null && !Array.isArray(value);
141}
142
143function parseCursorResume(raw: unknown): { sessionId: string } | undefined {
144 if (!isRecord(raw)) return undefined;
145 if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined;
146 if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined;
147 return { sessionId: raw.sessionId.trim() };
148}
149
150function normalizeModeSearchText(mode: AcpSessionMode): string {
151 return [mode.id, mode.name, mode.description]
152 .filter((value): value is string => typeof value === "string" && value.length > 0)
153 .join(" ")
154 .toLowerCase()
155 .replace(/[^a-z0-9]+/g, " ")
156 .trim();
157}
158
159function findModeByAliases(
160 modes: ReadonlyArray<AcpSessionMode>,
161 aliases: ReadonlyArray<string>,
162): AcpSessionMode | undefined {
163 const normalizedAliases = aliases.map((alias) => alias.toLowerCase());
164 for (const alias of normalizedAliases) {
165 const exact = modes.find((mode) => {
166 const id = mode.id.toLowerCase();
167 const name = mode.name.toLowerCase();
168 return id === alias || name === alias;
169 });
170 if (exact) {
171 return exact;
172 }
173 }
174 for (const alias of normalizedAliases) {
175 const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias));
176 if (partial) {
177 return partial;
178 }
179 }
180 return undefined;
181}
182
183function isPlanMode(mode: AcpSessionMode): boolean {
184 return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined;
185}
186
187function resolveRequestedModeId(input: {
188 readonly interactionMode: ProviderInteractionMode | undefined;
189 readonly runtimeMode: RuntimeMode;
190 readonly modeState: AcpSessionModeState | undefined;
191}): string | undefined {
192 const modeState = input.modeState;
193 if (!modeState) {
194 return undefined;
195 }
196
197 if (input.interactionMode === "plan") {
198 return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id;
199 }
200
201 if (input.runtimeMode === "approval-required") {
202 return (
203 findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
204 findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
205 modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
206 modeState.currentModeId
207 );
208 }
209
210 return (
211 findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
212 findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
213 modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
214 modeState.currentModeId
215 );
216}
217
218function applyRequestedSessionConfiguration<E>(input: {
219 readonly runtime: AcpSessionRuntimeShape;
220 readonly runtimeMode: RuntimeMode;
221 readonly interactionMode: ProviderInteractionMode | undefined;
222 readonly modelSelection:
223 | {
224 readonly model: string;
225 readonly options?: CursorModelOptions | null | undefined;
226 }
227 | undefined;
228 readonly mapError: (context: {
229 readonly cause: import("effect-acp/errors").AcpError;
230 readonly method: "session/set_config_option" | "session/set_mode";
231 }) => E;
232}): Effect.Effect<void, E> {
233 return Effect.gen(function* () {
234 if (input.modelSelection) {
235 yield* applyCursorAcpModelSelection({
236 runtime: input.runtime,
237 model: input.modelSelection.model,
238 modelOptions: input.modelSelection.options,
239 mapError: ({ cause }) =>
240 input.mapError({
241 cause,
242 method: "session/set_config_option",
243 }),
244 });
245 }
246
247 const requestedModeId = resolveRequestedModeId({
248 interactionMode: input.interactionMode,
249 runtimeMode: input.runtimeMode,
250 modeState: yield* input.runtime.getModeState,
251 });
252 if (!requestedModeId) {
253 return;
254 }
255
256 yield* input.runtime.setMode(requestedModeId).pipe(
257 Effect.mapError((cause) =>
258 input.mapError({
259 cause,
260 method: "session/set_mode",
261 }),
262 ),
263 );
264 });
265}
266
267function selectAutoApprovedPermissionOption(
268 request: EffectAcpSchema.RequestPermissionRequest,
269): string | undefined {
270 const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always");
271 if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) {
272 return allowAlwaysOption.optionId.trim();
273 }
274
275 const allowOnceOption = request.options.find((option) => option.kind === "allow_once");
276 if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) {
277 return allowOnceOption.optionId.trim();
278 }
279
280 return undefined;
281}
282
283function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
284 return Effect.gen(function* () {
285 const fileSystem = yield* FileSystem.FileSystem;
286 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
287 const serverConfig = yield* Effect.service(ServerConfig);
288 const serverSettingsService = yield* ServerSettingsService;
289 const nativeEventLogger =
290 options?.nativeEventLogger ??
291 (options?.nativeEventLogPath !== undefined
292 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
293 stream: "native",
294 })
295 : undefined);
296 const managedNativeEventLogger =
297 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
298
299 const sessions = new Map<ThreadId, CursorSessionContext>();
300 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
301 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
302
303 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
304 const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id));
305 const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso });
306
307 const offerRuntimeEvent = (event: ProviderRuntimeEvent) =>
308 PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid);
309
310 const getThreadSemaphore = (threadId: string) =>
311 SynchronizedRef.modifyEffect(threadLocksRef, (current) => {
312 const existing: Option.Option<Semaphore.Semaphore> = Option.fromNullishOr(
313 current.get(threadId),
314 );
315 return Option.match(existing, {
316 onNone: () =>
317 Semaphore.make(1).pipe(
318 Effect.map((semaphore) => {
319 const next = new Map(current);
320 next.set(threadId, semaphore);
321 return [semaphore, next] as const;
322 }),
323 ),
324 onSome: (semaphore) => Effect.succeed([semaphore, current] as const),
325 });
326 });
327
328 const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) =>
329 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
330
331 const logNative = (
332 threadId: ThreadId,
333 method: string,
334 payload: unknown,
335 _source: "acp.jsonrpc" | "acp.cursor.extension",
336 ) =>
337 Effect.gen(function* () {
338 if (!nativeEventLogger) return;
339 const observedAt = new Date().toISOString();
340 yield* nativeEventLogger.write(
341 {
342 observedAt,
343 event: {
344 id: crypto.randomUUID(),
345 kind: "notification",
346 provider: PROVIDER,
347 createdAt: observedAt,
348 method,
349 threadId,
350 payload,
351 },
352 },
353 threadId,
354 );
355 });
356
357 const emitPlanUpdate = (
358 ctx: CursorSessionContext,
359 payload: {
360 readonly explanation?: string | null;
361 readonly plan: ReadonlyArray<{
362 readonly step: string;
363 readonly status: "pending" | "inProgress" | "completed";
364 }>;
365 },
366 rawPayload: unknown,
367 source: "acp.jsonrpc" | "acp.cursor.extension",
368 method: string,
369 ) =>
370 Effect.gen(function* () {
371 const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${JSON.stringify(payload)}`;
372 if (ctx.lastPlanFingerprint === fingerprint) {
373 return;
374 }
375 ctx.lastPlanFingerprint = fingerprint;
376 yield* offerRuntimeEvent(
377 makeAcpPlanUpdatedEvent({
378 stamp: yield* makeEventStamp(),
379 provider: PROVIDER,
380 threadId: ctx.threadId,
381 turnId: ctx.activeTurnId,
382 payload,
383 source,
384 method,
385 rawPayload,
386 }),
387 );
388 });
389
390 const requireSession = (
391 threadId: ThreadId,
392 ): Effect.Effect<CursorSessionContext, ProviderAdapterSessionNotFoundError> => {
393 const ctx = sessions.get(threadId);
394 if (!ctx || ctx.stopped) {
395 return Effect.fail(
396 new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }),
397 );
398 }
399 return Effect.succeed(ctx);
400 };
401
402 const stopSessionInternal = (ctx: CursorSessionContext) =>
403 Effect.gen(function* () {
404 if (ctx.stopped) return;
405 ctx.stopped = true;
406 yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
407 yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
408 if (ctx.notificationFiber) {
409 yield* Fiber.interrupt(ctx.notificationFiber);
410 }
411 yield* Effect.ignore(Scope.close(ctx.scope, Exit.void));
412 sessions.delete(ctx.threadId);
413 yield* offerRuntimeEvent({
414 type: "session.exited",
415 ...(yield* makeEventStamp()),
416 provider: PROVIDER,
417 threadId: ctx.threadId,
418 payload: { exitKind: "graceful" },
419 });
420 });
421
422 const startSession: CursorAdapterShape["startSession"] = (input) =>
423 withThreadLock(
424 input.threadId,
425 Effect.gen(function* () {
426 if (input.provider !== undefined && input.provider !== PROVIDER) {
427 return yield* new ProviderAdapterValidationError({
428 provider: PROVIDER,
429 operation: "startSession",
430 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
431 });
432 }
433 if (!input.cwd?.trim()) {
434 return yield* new ProviderAdapterValidationError({
435 provider: PROVIDER,
436 operation: "startSession",
437 issue: "cwd is required and must be non-empty.",
438 });
439 }
440
441 const cwd = nodePath.resolve(input.cwd.trim());
442 const cursorModelSelection =
443 input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined;
444 const existing = sessions.get(input.threadId);
445 if (existing && !existing.stopped) {
446 yield* stopSessionInternal(existing);
447 }
448
449 const cursorSettings = yield* serverSettingsService.getSettings.pipe(
450 Effect.map((settings) => settings.providers.cursor),
451 Effect.mapError(
452 (error) =>
453 new ProviderAdapterProcessError({
454 provider: PROVIDER,
455 threadId: input.threadId,
456 detail: error.message,
457 cause: error,
458 }),
459 ),
460 );
461
462 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
463 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
464 const sessionScope = yield* Scope.make("sequential");
465 let sessionScopeTransferred = false;
466 yield* Effect.addFinalizer(() =>
467 sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
468 );
469 let ctx!: CursorSessionContext;
470
471 const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId;
472 const acpNativeLoggers = makeAcpNativeLoggers({
473 nativeEventLogger,
474 provider: PROVIDER,
475 threadId: input.threadId,
476 });
477
478 const acp = yield* makeCursorAcpRuntime({
479 cursorSettings,
480 childProcessSpawner,
481 cwd,
482 ...(resumeSessionId ? { resumeSessionId } : {}),
483 clientInfo: { name: "t3-code", version: "0.0.0" },
484 ...acpNativeLoggers,
485 }).pipe(
486 Effect.provideService(Scope.Scope, sessionScope),
487 Effect.mapError(
488 (cause) =>
489 new ProviderAdapterProcessError({
490 provider: PROVIDER,
491 threadId: input.threadId,
492 detail: cause.message,
493 cause,
494 }),
495 ),
496 );
497 const started = yield* Effect.gen(function* () {
498 yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) =>
499 Effect.gen(function* () {
500 yield* logNative(
501 input.threadId,
502 "cursor/ask_question",
503 params,
504 "acp.cursor.extension",
505 );
506 const requestId = ApprovalRequestId.make(crypto.randomUUID());
507 const runtimeRequestId = RuntimeRequestId.make(requestId);
508 const answers = yield* Deferred.make<ProviderUserInputAnswers>();
509 pendingUserInputs.set(requestId, { answers });
510 yield* offerRuntimeEvent({
511 type: "user-input.requested",
512 ...(yield* makeEventStamp()),
513 provider: PROVIDER,
514 threadId: input.threadId,
515 turnId: ctx?.activeTurnId,
516 requestId: runtimeRequestId,
517 payload: { questions: extractAskQuestions(params) },
518 raw: {
519 source: "acp.cursor.extension",
520 method: "cursor/ask_question",
521 payload: params,
522 },
523 });
524 const resolved = yield* Deferred.await(answers);
525 pendingUserInputs.delete(requestId);
526 yield* offerRuntimeEvent({
527 type: "user-input.resolved",
528 ...(yield* makeEventStamp()),
529 provider: PROVIDER,
530 threadId: input.threadId,
531 turnId: ctx?.activeTurnId,
532 requestId: runtimeRequestId,
533 payload: { answers: resolved },
534 });
535 return { answers: resolved };
536 }),
537 );
538 yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) =>
539 Effect.gen(function* () {
540 yield* logNative(
541 input.threadId,
542 "cursor/create_plan",
543 params,
544 "acp.cursor.extension",
545 );
546 yield* offerRuntimeEvent({
547 type: "turn.proposed.completed",
548 ...(yield* makeEventStamp()),
549 provider: PROVIDER,
550 threadId: input.threadId,
551 turnId: ctx?.activeTurnId,
552 payload: { planMarkdown: extractPlanMarkdown(params) },
553 raw: {
554 source: "acp.cursor.extension",
555 method: "cursor/create_plan",
556 payload: params,
557 },
558 });
559 return { accepted: true } as const;
560 }),
561 );
562 yield* acp.handleExtNotification(
563 "cursor/update_todos",
564 CursorUpdateTodosRequest,
565 (params) =>
566 Effect.gen(function* () {
567 yield* logNative(
568 input.threadId,
569 "cursor/update_todos",
570 params,
571 "acp.cursor.extension",
572 );
573 if (ctx) {
574 yield* emitPlanUpdate(
575 ctx,
576 extractTodosAsPlan(params),
577 params,
578 "acp.cursor.extension",
579 "cursor/update_todos",
580 );
581 }
582 }),
583 );
584 yield* acp.handleRequestPermission((params) =>
585 Effect.gen(function* () {
586 yield* logNative(
587 input.threadId,
588 "session/request_permission",
589 params,
590 "acp.jsonrpc",
591 );
592 if (input.runtimeMode === "full-access") {
593 const autoApprovedOptionId = selectAutoApprovedPermissionOption(params);
594 if (autoApprovedOptionId !== undefined) {
595 return {
596 outcome: {
597 outcome: "selected" as const,
598 optionId: autoApprovedOptionId,
599 },
600 };
601 }
602 }
603 const permissionRequest = parsePermissionRequest(params);
604 const requestId = ApprovalRequestId.make(crypto.randomUUID());
605 const runtimeRequestId = RuntimeRequestId.make(requestId);
606 const decision = yield* Deferred.make<ProviderApprovalDecision>();
607 pendingApprovals.set(requestId, {
608 decision,
609 kind: permissionRequest.kind,
610 });
611 yield* offerRuntimeEvent(
612 makeAcpRequestOpenedEvent({
613 stamp: yield* makeEventStamp(),
614 provider: PROVIDER,
615 threadId: input.threadId,
616 turnId: ctx?.activeTurnId,
617 requestId: runtimeRequestId,
618 permissionRequest,
619 detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000),
620 args: params,
621 source: "acp.jsonrpc",
622 method: "session/request_permission",
623 rawPayload: params,
624 }),
625 );
626 const resolved = yield* Deferred.await(decision);
627 pendingApprovals.delete(requestId);
628 yield* offerRuntimeEvent(
629 makeAcpRequestResolvedEvent({
630 stamp: yield* makeEventStamp(),
631 provider: PROVIDER,
632 threadId: input.threadId,
633 turnId: ctx?.activeTurnId,
634 requestId: runtimeRequestId,
635 permissionRequest,
636 decision: resolved,
637 }),
638 );
639 return {
640 outcome:
641 resolved === "cancel"
642 ? ({ outcome: "cancelled" } as const)
643 : {
644 outcome: "selected" as const,
645 optionId: acpPermissionOutcome(resolved),
646 },
647 };
648 }),
649 );
650 return yield* acp.start();
651 }).pipe(
652 Effect.mapError((error) =>
653 mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error),
654 ),
655 );
656
657 yield* applyRequestedSessionConfiguration({
658 runtime: acp,
659 runtimeMode: input.runtimeMode,
660 interactionMode: undefined,
661 modelSelection: cursorModelSelection,
662 mapError: ({ cause, method }) =>
663 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
664 });
665
666 const now = yield* nowIso;
667 const session: ProviderSession = {
668 provider: PROVIDER,
669 status: "ready",
670 runtimeMode: input.runtimeMode,
671 cwd,
672 model: cursorModelSelection?.model,
673 threadId: input.threadId,
674 resumeCursor: {
675 schemaVersion: CURSOR_RESUME_VERSION,
676 sessionId: started.sessionId,
677 },
678 createdAt: now,
679 updatedAt: now,
680 };
681
682 ctx = {
683 threadId: input.threadId,
684 session,
685 scope: sessionScope,
686 acp,
687 notificationFiber: undefined,
688 pendingApprovals,
689 pendingUserInputs,
690 turns: [],
691 lastPlanFingerprint: undefined,
692 activeTurnId: undefined,
693 stopped: false,
694 };
695
696 const nf = yield* Stream.runDrain(
697 Stream.mapEffect(acp.getEvents(), (event) =>
698 Effect.gen(function* () {
699 switch (event._tag) {
700 case "ModeChanged":
701 return;
702 case "AssistantItemStarted":
703 yield* offerRuntimeEvent(
704 makeAcpAssistantItemEvent({
705 stamp: yield* makeEventStamp(),
706 provider: PROVIDER,
707 threadId: ctx.threadId,
708 turnId: ctx.activeTurnId,
709 itemId: event.itemId,
710 lifecycle: "item.started",
711 }),
712 );
713 return;
714 case "AssistantItemCompleted":
715 yield* offerRuntimeEvent(
716 makeAcpAssistantItemEvent({
717 stamp: yield* makeEventStamp(),
718 provider: PROVIDER,
719 threadId: ctx.threadId,
720 turnId: ctx.activeTurnId,
721 itemId: event.itemId,
722 lifecycle: "item.completed",
723 }),
724 );
725 return;
726 case "PlanUpdated":
727 yield* logNative(
728 ctx.threadId,
729 "session/update",
730 event.rawPayload,
731 "acp.jsonrpc",
732 );
733 yield* emitPlanUpdate(
734 ctx,
735 event.payload,
736 event.rawPayload,
737 "acp.jsonrpc",
738 "session/update",
739 );
740 return;
741 case "ToolCallUpdated":
742 yield* logNative(
743 ctx.threadId,
744 "session/update",
745 event.rawPayload,
746 "acp.jsonrpc",
747 );
748 yield* offerRuntimeEvent(
749 makeAcpToolCallEvent({
750 stamp: yield* makeEventStamp(),
751 provider: PROVIDER,
752 threadId: ctx.threadId,
753 turnId: ctx.activeTurnId,
754 toolCall: event.toolCall,
755 rawPayload: event.rawPayload,
756 }),
757 );
758 return;
759 case "ContentDelta":
760 yield* logNative(
761 ctx.threadId,
762 "session/update",
763 event.rawPayload,
764 "acp.jsonrpc",
765 );
766 yield* offerRuntimeEvent(
767 makeAcpContentDeltaEvent({
768 stamp: yield* makeEventStamp(),
769 provider: PROVIDER,
770 threadId: ctx.threadId,
771 turnId: ctx.activeTurnId,
772 ...(event.itemId ? { itemId: event.itemId } : {}),
773 text: event.text,
774 rawPayload: event.rawPayload,
775 }),
776 );
777 return;
778 }
779 }),
780 ),
781 ).pipe(Effect.forkChild);
782
783 ctx.notificationFiber = nf;
784 sessions.set(input.threadId, ctx);
785 sessionScopeTransferred = true;
786
787 yield* offerRuntimeEvent({
788 type: "session.started",
789 ...(yield* makeEventStamp()),
790 provider: PROVIDER,
791 threadId: input.threadId,
792 payload: { resume: started.initializeResult },
793 });
794 yield* offerRuntimeEvent({
795 type: "session.state.changed",
796 ...(yield* makeEventStamp()),
797 provider: PROVIDER,
798 threadId: input.threadId,
799 payload: { state: "ready", reason: "Cursor ACP session ready" },
800 });
801 yield* offerRuntimeEvent({
802 type: "thread.started",
803 ...(yield* makeEventStamp()),
804 provider: PROVIDER,
805 threadId: input.threadId,
806 payload: { providerThreadId: started.sessionId },
807 });
808
809 return session;
810 }).pipe(Effect.scoped),
811 );
812
813 const sendTurn: CursorAdapterShape["sendTurn"] = (input) =>
814 Effect.gen(function* () {
815 const ctx = yield* requireSession(input.threadId);
816 const turnId = TurnId.make(crypto.randomUUID());
817 const turnModelSelection =
818 input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined;
819 const model = turnModelSelection?.model ?? ctx.session.model;
820 const resolvedModel = resolveCursorAcpBaseModelId(model);
821 yield* applyRequestedSessionConfiguration({
822 runtime: ctx.acp,
823 runtimeMode: ctx.session.runtimeMode,
824 interactionMode: input.interactionMode,
825 modelSelection:
826 model === undefined
827 ? undefined
828 : {
829 model,
830 options: turnModelSelection?.options,
831 },
832 mapError: ({ cause, method }) =>
833 mapAcpToAdapterError(PROVIDER, input.threadId, method, cause),
834 });
835 ctx.activeTurnId = turnId;
836 ctx.lastPlanFingerprint = undefined;
837 ctx.session = {
838 ...ctx.session,
839 activeTurnId: turnId,
840 updatedAt: yield* nowIso,
841 };
842
843 yield* offerRuntimeEvent({
844 type: "turn.started",
845 ...(yield* makeEventStamp()),
846 provider: PROVIDER,
847 threadId: input.threadId,
848 turnId,
849 payload: { model: resolvedModel },
850 });
851
852 const promptParts: Array<EffectAcpSchema.ContentBlock> = [];
853 if (input.input?.trim()) {
854 promptParts.push({ type: "text", text: input.input.trim() });
855 }
856 if (input.attachments && input.attachments.length > 0) {
857 for (const attachment of input.attachments) {
858 const attachmentPath = resolveAttachmentPath({
859 attachmentsDir: serverConfig.attachmentsDir,
860 attachment,
861 });
862 if (!attachmentPath) {
863 return yield* new ProviderAdapterRequestError({
864 provider: PROVIDER,
865 method: "session/prompt",
866 detail: `Invalid attachment id '${attachment.id}'.`,
867 });
868 }
869 const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
870 Effect.mapError(
871 (cause) =>
872 new ProviderAdapterRequestError({
873 provider: PROVIDER,
874 method: "session/prompt",
875 detail: cause.message,
876 cause,
877 }),
878 ),
879 );
880 promptParts.push({
881 type: "image",
882 data: Buffer.from(bytes).toString("base64"),
883 mimeType: attachment.mimeType,
884 });
885 }
886 }
887
888 if (promptParts.length === 0) {
889 return yield* new ProviderAdapterValidationError({
890 provider: PROVIDER,
891 operation: "sendTurn",
892 issue: "Turn requires non-empty text or attachments.",
893 });
894 }
895
896 const result = yield* ctx.acp
897 .prompt({
898 prompt: promptParts,
899 })
900 .pipe(
901 Effect.mapError((error) =>
902 mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error),
903 ),
904 );
905
906 ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] });
907 ctx.session = {
908 ...ctx.session,
909 activeTurnId: turnId,
910 updatedAt: yield* nowIso,
911 model: resolvedModel,
912 };
913
914 yield* offerRuntimeEvent({
915 type: "turn.completed",
916 ...(yield* makeEventStamp()),
917 provider: PROVIDER,
918 threadId: input.threadId,
919 turnId,
920 payload: {
921 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
922 stopReason: result.stopReason ?? null,
923 },
924 });
925
926 return {
927 threadId: input.threadId,
928 turnId,
929 resumeCursor: ctx.session.resumeCursor,
930 };
931 });
932
933 const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) =>
934 Effect.gen(function* () {
935 const ctx = yield* requireSession(threadId);
936 yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals);
937 yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs);
938 yield* Effect.ignore(
939 ctx.acp.cancel.pipe(
940 Effect.mapError((error) =>
941 mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error),
942 ),
943 ),
944 );
945 });
946
947 const respondToRequest: CursorAdapterShape["respondToRequest"] = (
948 threadId,
949 requestId,
950 decision,
951 ) =>
952 Effect.gen(function* () {
953 const ctx = yield* requireSession(threadId);
954 const pending = ctx.pendingApprovals.get(requestId);
955 if (!pending) {
956 return yield* new ProviderAdapterRequestError({
957 provider: PROVIDER,
958 method: "session/request_permission",
959 detail: `Unknown pending approval request: ${requestId}`,
960 });
961 }
962 yield* Deferred.succeed(pending.decision, decision);
963 });
964
965 const respondToUserInput: CursorAdapterShape["respondToUserInput"] = (
966 threadId,
967 requestId,
968 answers,
969 ) =>
970 Effect.gen(function* () {
971 const ctx = yield* requireSession(threadId);
972 const pending = ctx.pendingUserInputs.get(requestId);
973 if (!pending) {
974 return yield* new ProviderAdapterRequestError({
975 provider: PROVIDER,
976 method: "cursor/ask_question",
977 detail: `Unknown pending user-input request: ${requestId}`,
978 });
979 }
980 yield* Deferred.succeed(pending.answers, answers);
981 });
982
983 const readThread: CursorAdapterShape["readThread"] = (threadId) =>
984 Effect.gen(function* () {
985 const ctx = yield* requireSession(threadId);
986 return { threadId, turns: ctx.turns };
987 });
988
989 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) =>
990 Effect.gen(function* () {
991 const ctx = yield* requireSession(threadId);
992 if (!Number.isInteger(numTurns) || numTurns < 1) {
993 return yield* new ProviderAdapterValidationError({
994 provider: PROVIDER,
995 operation: "rollbackThread",
996 issue: "numTurns must be an integer >= 1.",
997 });
998 }
999 const nextLength = Math.max(0, ctx.turns.length - numTurns);
1000 ctx.turns.splice(nextLength);
1001 return { threadId, turns: ctx.turns };
1002 });
1003
1004 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
1005 withThreadLock(
1006 threadId,
1007 Effect.gen(function* () {
1008 const ctx = yield* requireSession(threadId);
1009 yield* stopSessionInternal(ctx);
1010 }),
1011 );
1012
1013 const listSessions: CursorAdapterShape["listSessions"] = () =>
1014 Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session })));
1015
1016 const hasSession: CursorAdapterShape["hasSession"] = (threadId) =>
1017 Effect.sync(() => {
1018 const c = sessions.get(threadId);
1019 return c !== undefined && !c.stopped;
1020 });
1021
1022 const stopAll: CursorAdapterShape["stopAll"] = () =>
1023 Effect.forEach(sessions.values(), stopSessionInternal, { discard: true });
1024
1025 yield* Effect.addFinalizer(() =>
1026 Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe(
1027 Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)),
1028 Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
1029 ),
1030 );
1031
1032 const streamEvents = Stream.fromPubSub(runtimeEventPubSub);
1033
1034 return {
1035 provider: PROVIDER,
1036 capabilities: { sessionModelSwitch: "in-session" },
1037 startSession,
1038 sendTurn,
1039 interruptTurn,
1040 readThread,
1041 rollbackThread,
1042 respondToRequest,
1043 respondToUserInput,
1044 stopSession,
1045 listSessions,
1046 hasSession,
1047 stopAll,
1048 streamEvents,
1049 } satisfies CursorAdapterShape;
1050 });
1051}
1052
1053export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter());
1054
1055export function makeCursorAdapterLive(opts?: CursorAdapterLiveOptions) {
1056 return Layer.effect(CursorAdapter, makeCursorAdapter(opts));
1057}
apps/server/src/provider/Layers/CursorProvider.test.ts created+687
......@@ -0,0 +1,687 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
4import { fileURLToPath } from "node:url";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { Effect } from "effect";
8import { describe, expect, it } from "vitest";
9import type * as EffectAcpSchema from "effect-acp/schema";
10import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts";
11
12import {
13 buildCursorProviderSnapshot,
14 buildCursorCapabilitiesFromConfigOptions,
15 buildCursorDiscoveredModelsFromConfigOptions,
16 discoverCursorModelCapabilitiesViaAcp,
17 discoverCursorModelsViaAcp,
18 getCursorFallbackModels,
19 getCursorParameterizedModelPickerUnsupportedMessage,
20 parseCursorAboutOutput,
21 parseCursorCliConfigChannel,
22 parseCursorVersionDate,
23 resolveCursorAcpBaseModelId,
24 resolveCursorAcpConfigUpdates,
25} from "./CursorProvider.ts";
26
27const __dirname = path.dirname(fileURLToPath(import.meta.url));
28const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
29
30async function makeMockAgentWrapper(extraEnv?: Record<string, string>) {
31 const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-"));
32 const wrapperPath = path.join(dir, "fake-agent.sh");
33 const envExports = Object.entries(extraEnv ?? {})
34 .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
35 .join("\n");
36 const script = `#!/bin/sh
37${envExports}
38exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@"
39`;
40 await writeFile(wrapperPath, script, "utf8");
41 await chmod(wrapperPath, 0o755);
42 return wrapperPath;
43}
44
45async function waitForFileContent(filePath: string, attempts = 40): Promise<string> {
46 for (let attempt = 0; attempt < attempts; attempt += 1) {
47 try {
48 const content = await readFile(filePath, "utf8");
49 if (content.trim().length > 0) {
50 return content;
51 }
52 } catch {}
53 await new Promise((resolve) => setTimeout(resolve, 50));
54 }
55 throw new Error(`Timed out waiting for file content at ${filePath}`);
56}
57
58const parameterizedGpt54ConfigOptions = [
59 {
60 type: "select",
61 currentValue: "gpt-5.4-medium-fast",
62 options: [{ name: "GPT-5.4", value: "gpt-5.4-medium-fast" }],
63 category: "model",
64 id: "model",
65 name: "Model",
66 },
67 {
68 type: "select",
69 currentValue: "medium",
70 options: [
71 { name: "None", value: "none" },
72 { name: "Low", value: "low" },
73 { name: "Medium", value: "medium" },
74 { name: "High", value: "high" },
75 { name: "Extra High", value: "extra-high" },
76 ],
77 category: "thought_level",
78 id: "reasoning",
79 name: "Reasoning",
80 },
81 {
82 type: "select",
83 currentValue: "272k",
84 options: [
85 { name: "272K", value: "272k" },
86 { name: "1M", value: "1m" },
87 ],
88 category: "model_config",
89 id: "context",
90 name: "Context",
91 },
92 {
93 type: "select",
94 currentValue: "false",
95 options: [
96 { name: "Off", value: "false" },
97 { name: "Fast", value: "true" },
98 ],
99 category: "model_config",
100 id: "fast",
101 name: "Fast",
102 },
103] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
104
105const parameterizedClaudeConfigOptions = [
106 {
107 type: "select",
108 currentValue: "claude-4.6-opus-high-thinking",
109 options: [{ name: "Opus 4.6", value: "claude-4.6-opus-high-thinking" }],
110 category: "model",
111 id: "model",
112 name: "Model",
113 },
114 {
115 type: "select",
116 currentValue: "high",
117 options: [
118 { name: "Low", value: "low" },
119 { name: "Medium", value: "medium" },
120 { name: "High", value: "high" },
121 ],
122 category: "thought_level",
123 id: "reasoning",
124 name: "Reasoning",
125 },
126 {
127 type: "boolean",
128 currentValue: true,
129 category: "model_config",
130 id: "thinking",
131 name: "Thinking",
132 },
133] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
134
135const parameterizedClaudeModelOptionConfigOptions = [
136 {
137 type: "select",
138 currentValue: "claude-opus-4-6",
139 options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }],
140 category: "model",
141 id: "model",
142 name: "Model",
143 },
144 {
145 type: "select",
146 currentValue: "high",
147 options: [
148 { name: "Low", value: "low" },
149 { name: "Medium", value: "medium" },
150 { name: "High", value: "high" },
151 ],
152 category: "thought_level",
153 id: "reasoning",
154 name: "Reasoning",
155 },
156 {
157 type: "select",
158 currentValue: "max",
159 options: [
160 { name: "Low", value: "low" },
161 { name: "Medium", value: "medium" },
162 { name: "High", value: "high" },
163 { name: "Max", value: "max" },
164 ],
165 category: "model_option",
166 id: "effort",
167 name: "Effort",
168 },
169 {
170 type: "select",
171 currentValue: "true",
172 options: [
173 { name: "Off", value: "false" },
174 { name: "Fast", value: "true" },
175 ],
176 category: "model_config",
177 id: "fast",
178 name: "Fast",
179 },
180 {
181 type: "select",
182 currentValue: "true",
183 options: [
184 { name: "Off", value: "false" },
185 { name: ":icon-brain:", value: "true" },
186 ],
187 category: "model_config",
188 id: "thinking",
189 name: "Thinking",
190 },
191] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
192
193const sessionNewCursorConfigOptions = [
194 {
195 type: "select",
196 currentValue: "agent",
197 options: [
198 { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" },
199 ],
200 category: "mode",
201 id: "mode",
202 name: "Mode",
203 description: "Controls how the agent executes tasks",
204 },
205 {
206 type: "select",
207 currentValue: "composer-2",
208 options: [
209 { name: "Auto", value: "default" },
210 { name: "Composer 2", value: "composer-2" },
211 { name: "GPT-5.4", value: "gpt-5.4" },
212 { name: "Sonnet 4.6", value: "claude-sonnet-4-6" },
213 { name: "Opus 4.6", value: "claude-opus-4-6" },
214 { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" },
215 ],
216 category: "model",
217 id: "model",
218 name: "Model",
219 description: "Controls which model is used for responses",
220 },
221 {
222 type: "select",
223 currentValue: "true",
224 options: [
225 { name: "Off", value: "false" },
226 { name: "Fast", value: "true" },
227 ],
228 category: "model_config",
229 id: "fast",
230 name: "Fast",
231 description: "Faster speeds.",
232 },
233] satisfies ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
234
235const baseCursorSettings: CursorSettings = {
236 enabled: true,
237 binaryPath: "agent",
238 apiEndpoint: "",
239 customModels: [],
240};
241
242const emptyCapabilities = {
243 reasoningEffortLevels: [],
244 supportsFastMode: false,
245 supportsThinkingToggle: false,
246 contextWindowOptions: [],
247 promptInjectedEffortLevels: [],
248} as const;
249
250describe("getCursorFallbackModels", () => {
251 it("does not publish any built-in cursor models before ACP discovery", () => {
252 expect(
253 getCursorFallbackModels({
254 customModels: ["internal/cursor-model"],
255 }).map((model) => model.slug),
256 ).toEqual(["internal/cursor-model"]);
257 });
258});
259
260describe("buildCursorProviderSnapshot", () => {
261 it("downgrades ready status to warning when ACP model discovery times out", () => {
262 expect(
263 buildCursorProviderSnapshot({
264 checkedAt: "2026-01-01T00:00:00.000Z",
265 cursorSettings: baseCursorSettings,
266 parsed: {
267 version: "2026.04.09-f2b0fcd",
268 status: "ready",
269 auth: { status: "authenticated", type: "Team", label: "Cursor Team Subscription" },
270 },
271 discoveryWarning: "Cursor ACP model discovery timed out after 15000ms.",
272 }),
273 ).toMatchObject({
274 status: "warning",
275 message: "Cursor ACP model discovery timed out after 15000ms.",
276 models: [],
277 });
278 });
279
280 it("preserves provider error state while appending discovery warnings", () => {
281 expect(
282 buildCursorProviderSnapshot({
283 checkedAt: "2026-01-01T00:00:00.000Z",
284 cursorSettings: {
285 ...baseCursorSettings,
286 customModels: ["claude-sonnet-4-6"],
287 },
288 parsed: {
289 version: "2026.04.09-f2b0fcd",
290 status: "error",
291 auth: { status: "unauthenticated" },
292 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
293 },
294 discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.",
295 }),
296 ).toMatchObject({
297 status: "error",
298 message:
299 "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.",
300 models: [
301 {
302 slug: "claude-sonnet-4-6",
303 isCustom: true,
304 },
305 ],
306 });
307 });
308});
309
310describe("buildCursorCapabilitiesFromConfigOptions", () => {
311 it("derives model capabilities from parameterized Cursor ACP config options", () => {
312 expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({
313 reasoningEffortLevels: [
314 { value: "low", label: "Low" },
315 { value: "medium", label: "Medium", isDefault: true },
316 { value: "high", label: "High" },
317 { value: "xhigh", label: "Extra High" },
318 ],
319 supportsFastMode: true,
320 supportsThinkingToggle: false,
321 contextWindowOptions: [
322 { value: "272k", label: "272K", isDefault: true },
323 { value: "1m", label: "1M" },
324 ],
325 promptInjectedEffortLevels: [],
326 });
327 });
328
329 it("detects boolean thinking toggles from model_config options", () => {
330 expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({
331 reasoningEffortLevels: [
332 { value: "low", label: "Low" },
333 { value: "medium", label: "Medium" },
334 { value: "high", label: "High", isDefault: true },
335 ],
336 supportsFastMode: false,
337 supportsThinkingToggle: true,
338 contextWindowOptions: [],
339 promptInjectedEffortLevels: [],
340 });
341 });
342
343 it("prefers the newer model_option effort control over legacy thought_level", () => {
344 expect(
345 buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions),
346 ).toEqual({
347 reasoningEffortLevels: [
348 { value: "low", label: "Low" },
349 { value: "medium", label: "Medium" },
350 { value: "high", label: "High" },
351 { value: "max", label: "Max", isDefault: true },
352 ],
353 supportsFastMode: true,
354 supportsThinkingToggle: true,
355 contextWindowOptions: [],
356 promptInjectedEffortLevels: [],
357 });
358 });
359});
360
361describe("buildCursorDiscoveredModelsFromConfigOptions", () => {
362 it("publishes ACP model choices immediately from session/new config options", () => {
363 expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([
364 {
365 slug: "default",
366 name: "Auto",
367 isCustom: false,
368 capabilities: {
369 reasoningEffortLevels: [],
370 supportsFastMode: false,
371 supportsThinkingToggle: false,
372 contextWindowOptions: [],
373 promptInjectedEffortLevels: [],
374 },
375 },
376 {
377 slug: "composer-2",
378 name: "Composer 2",
379 isCustom: false,
380 capabilities: {
381 reasoningEffortLevels: [],
382 supportsFastMode: true,
383 supportsThinkingToggle: false,
384 contextWindowOptions: [],
385 promptInjectedEffortLevels: [],
386 },
387 },
388 {
389 slug: "gpt-5.4",
390 name: "GPT-5.4",
391 isCustom: false,
392 capabilities: {
393 reasoningEffortLevels: [],
394 supportsFastMode: false,
395 supportsThinkingToggle: false,
396 contextWindowOptions: [],
397 promptInjectedEffortLevels: [],
398 },
399 },
400 {
401 slug: "claude-sonnet-4-6",
402 name: "Sonnet 4.6",
403 isCustom: false,
404 capabilities: {
405 reasoningEffortLevels: [],
406 supportsFastMode: false,
407 supportsThinkingToggle: false,
408 contextWindowOptions: [],
409 promptInjectedEffortLevels: [],
410 },
411 },
412 {
413 slug: "claude-opus-4-6",
414 name: "Opus 4.6",
415 isCustom: false,
416 capabilities: {
417 reasoningEffortLevels: [],
418 supportsFastMode: false,
419 supportsThinkingToggle: false,
420 contextWindowOptions: [],
421 promptInjectedEffortLevels: [],
422 },
423 },
424 {
425 slug: "gpt-5.3-codex-spark",
426 name: "Codex 5.3 Spark",
427 isCustom: false,
428 capabilities: {
429 reasoningEffortLevels: [],
430 supportsFastMode: false,
431 supportsThinkingToggle: false,
432 contextWindowOptions: [],
433 promptInjectedEffortLevels: [],
434 },
435 },
436 ]);
437 });
438});
439
440describe("discoverCursorModelsViaAcp", () => {
441 it("keeps the ACP probe runtime alive long enough to discover models", async () => {
442 const wrapperPath = await makeMockAgentWrapper();
443
444 const models = await Effect.runPromise(
445 discoverCursorModelsViaAcp({
446 enabled: true,
447 binaryPath: wrapperPath,
448 apiEndpoint: "",
449 customModels: [],
450 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
451 );
452
453 expect(models.map((model) => model.slug)).toEqual([
454 "default",
455 "composer-2",
456 "gpt-5.4",
457 "claude-opus-4-6",
458 ]);
459 });
460
461 it("closes the ACP probe runtime after discovery completes", async () => {
462 const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-"));
463 const exitLogPath = path.join(tempDir, "exit.log");
464 const wrapperPath = await makeMockAgentWrapper({
465 T3_ACP_EXIT_LOG_PATH: exitLogPath,
466 });
467
468 await Effect.runPromise(
469 discoverCursorModelsViaAcp({
470 enabled: true,
471 binaryPath: wrapperPath,
472 apiEndpoint: "",
473 customModels: [],
474 }).pipe(Effect.provide(NodeServices.layer)),
475 );
476
477 const exitLog = await waitForFileContent(exitLogPath);
478 expect(exitLog).toContain("SIGTERM");
479 });
480});
481
482describe("discoverCursorModelCapabilitiesViaAcp", () => {
483 it("closes all ACP probe runtimes after capability enrichment completes", async () => {
484 const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-"));
485 const exitLogPath = path.join(tempDir, "exit.log");
486 const wrapperPath = await makeMockAgentWrapper({
487 T3_ACP_EXIT_LOG_PATH: exitLogPath,
488 });
489 const existingModels: ReadonlyArray<ServerProviderModel> = [
490 { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities },
491 { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities },
492 { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities },
493 {
494 slug: "claude-opus-4-6",
495 name: "Opus 4.6",
496 isCustom: false,
497 capabilities: emptyCapabilities,
498 },
499 ];
500
501 const models = await Effect.runPromise(
502 discoverCursorModelCapabilitiesViaAcp(
503 {
504 enabled: true,
505 binaryPath: wrapperPath,
506 apiEndpoint: "",
507 customModels: [],
508 },
509 existingModels,
510 ).pipe(Effect.provide(NodeServices.layer)),
511 );
512
513 expect(models.map((model) => model.slug)).toEqual([
514 "default",
515 "composer-2",
516 "gpt-5.4",
517 "claude-opus-4-6",
518 ]);
519
520 const exitLog = await waitForFileContent(exitLogPath);
521 expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4);
522 });
523});
524
525describe("parseCursorAboutOutput", () => {
526 it("parses json about output and forwards subscription metadata", () => {
527 expect(
528 parseCursorAboutOutput({
529 code: 0,
530 stdout: JSON.stringify({
531 cliVersion: "2026.04.09-f2b0fcd",
532 subscriptionTier: "Team",
533 userEmail: "jmarminge@gmail.com",
534 }),
535 stderr: "",
536 }),
537 ).toEqual({
538 version: "2026.04.09-f2b0fcd",
539 status: "ready",
540 auth: {
541 status: "authenticated",
542 type: "Team",
543 label: "Cursor Team Subscription",
544 },
545 });
546 });
547
548 it("treats json about output with a logged-out email as unauthenticated", () => {
549 expect(
550 parseCursorAboutOutput({
551 code: 0,
552 stdout: JSON.stringify({
553 cliVersion: "2026.04.09-f2b0fcd",
554 subscriptionTier: "Team",
555 userEmail: "Not logged in",
556 }),
557 stderr: "",
558 }),
559 ).toEqual({
560 version: "2026.04.09-f2b0fcd",
561 status: "error",
562 auth: {
563 status: "unauthenticated",
564 },
565 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
566 });
567 });
568
569 it("treats json about output with a null email as unauthenticated", () => {
570 expect(
571 parseCursorAboutOutput({
572 code: 0,
573 stdout: JSON.stringify({
574 cliVersion: "2026.04.09-f2b0fcd",
575 subscriptionTier: null,
576 userEmail: null,
577 }),
578 stderr: "",
579 }),
580 ).toEqual({
581 version: "2026.04.09-f2b0fcd",
582 status: "error",
583 auth: {
584 status: "unauthenticated",
585 },
586 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
587 });
588 });
589});
590
591describe("Cursor parameterized model picker preview gating", () => {
592 it("parses Cursor CLI version dates from build versions", () => {
593 expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408);
594 expect(parseCursorVersionDate("2026.04.09")).toBe(20260409);
595 expect(parseCursorVersionDate("not-a-version")).toBeUndefined();
596 });
597
598 it("parses the Cursor CLI channel from cli-config.json", () => {
599 expect(parseCursorCliConfigChannel('{ "channel": "lab" }')).toBe("lab");
600 expect(parseCursorCliConfigChannel('{ "channel": "stable" }')).toBe("stable");
601 expect(parseCursorCliConfigChannel('{ "version": 1 }')).toBeUndefined();
602 expect(parseCursorCliConfigChannel("not-json")).toBeUndefined();
603 });
604
605 it("returns no warning when the preview requirements are met", () => {
606 expect(
607 getCursorParameterizedModelPickerUnsupportedMessage({
608 version: "2026.04.08-c4e73a3",
609 channel: "lab",
610 }),
611 ).toBeUndefined();
612 });
613
614 it("explains when the Cursor Agent version is too old", () => {
615 expect(
616 getCursorParameterizedModelPickerUnsupportedMessage({
617 version: "2026.04.07-c4e73a3",
618 channel: "lab",
619 }),
620 ).toContain("too old");
621 });
622
623 it("explains when the Cursor Agent channel is not lab", () => {
624 expect(
625 getCursorParameterizedModelPickerUnsupportedMessage({
626 version: "2026.04.08-c4e73a3",
627 channel: "stable",
628 }),
629 ).toContain("lab channel");
630 });
631});
632
633describe("resolveCursorAcpBaseModelId", () => {
634 it("drops bracket traits without rewriting raw ACP model ids", () => {
635 expect(resolveCursorAcpBaseModelId("gpt-5.4[reasoning=medium,context=272k]")).toBe("gpt-5.4");
636 expect(resolveCursorAcpBaseModelId("gpt-5.4-medium-fast")).toBe("gpt-5.4-medium-fast");
637 expect(resolveCursorAcpBaseModelId("claude-4.6-opus-high-thinking")).toBe(
638 "claude-4.6-opus-high-thinking",
639 );
640 expect(resolveCursorAcpBaseModelId("composer-2")).toBe("composer-2");
641 expect(resolveCursorAcpBaseModelId("auto")).toBe("auto");
642 });
643});
644
645describe("resolveCursorAcpConfigUpdates", () => {
646 it("maps Cursor model options onto separate ACP config option updates", () => {
647 expect(
648 resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, {
649 reasoning: "xhigh",
650 fastMode: true,
651 contextWindow: "1m",
652 }),
653 ).toEqual([
654 { configId: "reasoning", value: "extra-high" },
655 { configId: "context", value: "1m" },
656 { configId: "fast", value: "true" },
657 ]);
658 });
659
660 it("maps boolean thinking toggles when the model exposes them separately", () => {
661 expect(
662 resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, {
663 thinking: false,
664 }),
665 ).toEqual([{ configId: "thinking", value: false }]);
666 });
667
668 it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => {
669 expect(
670 resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, {
671 fastMode: false,
672 }),
673 ).toEqual([{ configId: "fast", value: "false" }]);
674 });
675
676 it("writes Cursor effort changes through the newer model_option config when available", () => {
677 expect(
678 resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, {
679 reasoning: "max",
680 thinking: false,
681 }),
682 ).toEqual([
683 { configId: "effort", value: "max" },
684 { configId: "thinking", value: "false" },
685 ]);
686 });
687});
apps/server/src/provider/Layers/CursorProvider.ts created+1136
......@@ -0,0 +1,1136 @@
1import * as nodeFs from "node:fs";
2import * as nodeOs from "node:os";
3import * as nodePath from "node:path";
4
5import type {
6 CursorModelOptions,
7 CursorSettings,
8 ModelCapabilities,
9 ServerProvider,
10 ServerProviderAuth,
11 ServerProviderModel,
12 ServerProviderState,
13 ServerSettingsError,
14} from "@t3tools/contracts";
15import type * as EffectAcpSchema from "effect-acp/schema";
16import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect";
17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18
19import {
20 buildServerProvider,
21 collectStreamAsString,
22 isCommandMissingCause,
23 providerModelsFromSettings,
24 type CommandResult,
25} from "../providerSnapshot.ts";
26import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
27import { CursorProvider } from "../Services/CursorProvider.ts";
28import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts";
29import { ServerSettingsService } from "../../serverSettings.ts";
30
31const PROVIDER = "cursor" as const;
32const EMPTY_CAPABILITIES: ModelCapabilities = {
33 reasoningEffortLevels: [],
34 supportsFastMode: false,
35 supportsThinkingToggle: false,
36 contextWindowOptions: [],
37 promptInjectedEffortLevels: [],
38};
39
40const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
41const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds";
42const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4;
43const CURSOR_REFRESH_INTERVAL = "1 hour";
44const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08;
45export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = {
46 _meta: {
47 parameterizedModelPicker: true,
48 },
49} satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
50
51function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider {
52 const checkedAt = new Date().toISOString();
53 const models = getCursorFallbackModels(cursorSettings);
54
55 if (!cursorSettings.enabled) {
56 return buildServerProvider({
57 provider: PROVIDER,
58 enabled: false,
59 checkedAt,
60 models,
61 probe: {
62 installed: false,
63 version: null,
64 status: "warning",
65 auth: { status: "unknown" },
66 message: "Cursor is disabled in T3 Code settings.",
67 },
68 });
69 }
70
71 return buildServerProvider({
72 provider: PROVIDER,
73 enabled: true,
74 checkedAt,
75 models,
76 probe: {
77 installed: true,
78 version: null,
79 status: "warning",
80 auth: { status: "unknown" },
81 message: "Checking Cursor Agent availability...",
82 },
83 });
84}
85
86interface CursorSessionSelectOption {
87 readonly value: string;
88 readonly name: string;
89}
90
91interface CursorAcpDiscoveredModel {
92 readonly slug: string;
93 readonly name: string;
94 readonly capabilities: ModelCapabilities;
95}
96
97function flattenSessionConfigSelectOptions(
98 configOption: EffectAcpSchema.SessionConfigOption | undefined,
99): ReadonlyArray<CursorSessionSelectOption> {
100 if (!configOption || configOption.type !== "select") {
101 return [];
102 }
103 return configOption.options.flatMap((entry) =>
104 "value" in entry
105 ? [{ value: entry.value.trim(), name: entry.name.trim() } satisfies CursorSessionSelectOption]
106 : entry.options.map(
107 (option) =>
108 ({
109 value: option.value.trim(),
110 name: option.name.trim(),
111 }) satisfies CursorSessionSelectOption,
112 ),
113 );
114}
115
116function normalizeCursorReasoningValue(value: string | null | undefined): string | undefined {
117 const normalized = value?.trim().toLowerCase();
118 switch (normalized) {
119 case "low":
120 case "medium":
121 case "high":
122 case "max":
123 return normalized;
124 case "xhigh":
125 case "extra-high":
126 case "extra high":
127 return "xhigh";
128 default:
129 return undefined;
130 }
131}
132
133function findCursorModelConfigOption(
134 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
135): EffectAcpSchema.SessionConfigOption | undefined {
136 return configOptions.find((option) => option.category === "model");
137}
138
139function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string {
140 return option.category?.trim().toLowerCase() ?? "";
141}
142
143function isCursorEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
144 const id = option.id.trim().toLowerCase();
145 const name = option.name.trim().toLowerCase();
146 return (
147 id === "effort" ||
148 id === "reasoning" ||
149 name === "effort" ||
150 name === "reasoning" ||
151 name.includes("effort") ||
152 name.includes("reasoning")
153 );
154}
155
156function findCursorEffortConfigOption(
157 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
158): EffectAcpSchema.SessionConfigOption | undefined {
159 const candidates = configOptions.filter(
160 (option) => option.type === "select" && isCursorEffortConfigOption(option),
161 );
162 return (
163 candidates.find((option) => getCursorConfigOptionCategory(option) === "model_option") ??
164 candidates.find((option) => option.id.trim().toLowerCase() === "effort") ??
165 candidates.find((option) => getCursorConfigOptionCategory(option) === "thought_level") ??
166 candidates[0]
167 );
168}
169
170function isCursorContextConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
171 const id = option.id.trim().toLowerCase();
172 const name = option.name.trim().toLowerCase();
173 return id === "context" || id === "context_size" || name.includes("context");
174}
175
176function isCursorFastConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
177 const id = option.id.trim().toLowerCase();
178 const name = option.name.trim().toLowerCase();
179 return id === "fast" || name === "fast" || name.includes("fast mode");
180}
181
182function isCursorThinkingConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
183 const id = option.id.trim().toLowerCase();
184 const name = option.name.trim().toLowerCase();
185 return id === "thinking" || name.includes("thinking");
186}
187
188function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean {
189 if (option.type === "boolean") {
190 return true;
191 }
192 if (option.type !== "select") {
193 return false;
194 }
195 const values = new Set(
196 flattenSessionConfigSelectOptions(option).map((entry) => entry.value.trim().toLowerCase()),
197 );
198 return values.has("true") && values.has("false");
199}
200
201export function buildCursorCapabilitiesFromConfigOptions(
202 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
203): ModelCapabilities {
204 if (!configOptions || configOptions.length === 0) {
205 return EMPTY_CAPABILITIES;
206 }
207
208 const reasoningConfig = findCursorEffortConfigOption(configOptions);
209 const reasoningEffortLevels =
210 reasoningConfig?.type === "select"
211 ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => {
212 const normalizedValue = normalizeCursorReasoningValue(entry.value);
213 if (!normalizedValue) {
214 return [];
215 }
216 return [
217 {
218 value: normalizedValue,
219 label: entry.name,
220 ...(normalizeCursorReasoningValue(reasoningConfig.currentValue) === normalizedValue
221 ? { isDefault: true }
222 : {}),
223 },
224 ];
225 })
226 : [];
227
228 const contextOption = configOptions.find(
229 (option) => option.category === "model_config" && isCursorContextConfigOption(option),
230 );
231 const contextWindowOptions =
232 contextOption?.type === "select"
233 ? flattenSessionConfigSelectOptions(contextOption).map((entry) => {
234 if (contextOption.currentValue === entry.value) {
235 return {
236 value: entry.value,
237 label: entry.name,
238 isDefault: true,
239 };
240 }
241 return {
242 value: entry.value,
243 label: entry.name,
244 };
245 })
246 : [];
247
248 const fastOption = configOptions.find(
249 (option) => option.category === "model_config" && isCursorFastConfigOption(option),
250 );
251 const thinkingOption = configOptions.find(
252 (option) => option.category === "model_config" && isCursorThinkingConfigOption(option),
253 );
254
255 return {
256 reasoningEffortLevels,
257 supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false,
258 supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false,
259 contextWindowOptions,
260 promptInjectedEffortLevels: [],
261 };
262}
263
264function buildCursorDiscoveredModels(
265 discoveredModels: ReadonlyArray<CursorAcpDiscoveredModel>,
266): ReadonlyArray<ServerProviderModel> {
267 const seen = new Set<string>();
268 return discoveredModels.flatMap((model) => {
269 if (!model.slug || seen.has(model.slug)) {
270 return [];
271 }
272 seen.add(model.slug);
273 return [
274 {
275 slug: model.slug,
276 name: model.name,
277 isCustom: false,
278 capabilities: model.capabilities,
279 } satisfies ServerProviderModel,
280 ];
281 });
282}
283
284function hasCursorModelCapabilities(model: Pick<ServerProviderModel, "capabilities">): boolean {
285 return (
286 (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 ||
287 model.capabilities?.supportsFastMode === true ||
288 model.capabilities?.supportsThinkingToggle === true ||
289 (model.capabilities?.contextWindowOptions.length ?? 0) > 0 ||
290 (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0
291 );
292}
293
294export function buildCursorDiscoveredModelsFromConfigOptions(
295 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
296): ReadonlyArray<ServerProviderModel> {
297 if (!configOptions || configOptions.length === 0) {
298 return [];
299 }
300
301 const modelOption = findCursorModelConfigOption(configOptions);
302 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
303 if (!modelOption || modelChoices.length === 0) {
304 return [];
305 }
306
307 const currentModelValue =
308 modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined;
309 const currentModelCapabilities = buildCursorCapabilitiesFromConfigOptions(configOptions);
310
311 return buildCursorDiscoveredModels(
312 modelChoices.map((modelChoice) => ({
313 slug: modelChoice.value.trim(),
314 name: modelChoice.name.trim(),
315 capabilities:
316 currentModelValue === modelChoice.value.trim()
317 ? currentModelCapabilities
318 : EMPTY_CAPABILITIES,
319 })),
320 );
321}
322
323const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) =>
324 Effect.gen(function* () {
325 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
326 const acpContext = yield* Layer.build(
327 AcpSessionRuntime.layer({
328 spawn: {
329 command: cursorSettings.binaryPath,
330 args: [
331 ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []),
332 "acp",
333 ],
334 cwd: process.cwd(),
335 },
336 cwd: process.cwd(),
337 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
338 authMethodId: "cursor_login",
339 clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
340 }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))),
341 );
342 return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext));
343 });
344
345const withCursorAcpProbeRuntime = <A, E, R>(
346 cursorSettings: CursorSettings,
347 useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect<A, E, R>,
348) => makeCursorAcpProbeRuntime(cursorSettings).pipe(Effect.flatMap(useRuntime), Effect.scoped);
349
350function normalizeCursorConfigOptionToken(value: string | null | undefined): string {
351 return (
352 value
353 ?.trim()
354 .toLowerCase()
355 .replace(/[\s_-]+/g, "-") ?? ""
356 );
357}
358
359function findCursorSelectOptionValue(
360 configOption: EffectAcpSchema.SessionConfigOption | undefined,
361 matcher: (option: CursorSessionSelectOption) => boolean,
362): string | undefined {
363 return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value;
364}
365
366function findCursorBooleanConfigValue(
367 configOption: EffectAcpSchema.SessionConfigOption | undefined,
368 requested: boolean,
369): string | boolean | undefined {
370 if (!configOption) {
371 return undefined;
372 }
373 if (configOption.type === "boolean") {
374 return requested;
375 }
376 return findCursorSelectOptionValue(
377 configOption,
378 (option) => normalizeCursorConfigOptionToken(option.value) === String(requested),
379 );
380}
381
382export function resolveCursorAcpBaseModelId(model: string | null | undefined): string {
383 const trimmed = model?.trim();
384 const base = trimmed && trimmed.length > 0 ? trimmed : "default";
385 return base.includes("[") ? base.slice(0, base.indexOf("[")) : base;
386}
387
388export function resolveCursorAcpConfigUpdates(
389 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
390 modelOptions: CursorModelOptions | null | undefined,
391): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> {
392 if (!configOptions || configOptions.length === 0) {
393 return [];
394 }
395
396 const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = [];
397
398 const reasoningOption = findCursorEffortConfigOption(configOptions);
399 const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning);
400 if (reasoningOption && requestedReasoning) {
401 const value = findCursorSelectOptionValue(reasoningOption, (option) => {
402 const normalizedValue = normalizeCursorReasoningValue(option.value);
403 const normalizedName = normalizeCursorReasoningValue(option.name);
404 return normalizedValue === requestedReasoning || normalizedName === requestedReasoning;
405 });
406 if (value) {
407 updates.push({ configId: reasoningOption.id, value });
408 }
409 }
410
411 const contextOption = configOptions.find(
412 (option) => option.category === "model_config" && isCursorContextConfigOption(option),
413 );
414 if (contextOption && modelOptions?.contextWindow) {
415 const value = findCursorSelectOptionValue(
416 contextOption,
417 (option) =>
418 normalizeCursorConfigOptionToken(option.value) ===
419 normalizeCursorConfigOptionToken(modelOptions.contextWindow) ||
420 normalizeCursorConfigOptionToken(option.name) ===
421 normalizeCursorConfigOptionToken(modelOptions.contextWindow),
422 );
423 if (value) {
424 updates.push({ configId: contextOption.id, value });
425 }
426 }
427
428 const fastOption = configOptions.find(
429 (option) => option.category === "model_config" && isCursorFastConfigOption(option),
430 );
431 if (fastOption && typeof modelOptions?.fastMode === "boolean") {
432 const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode);
433 if (value !== undefined) {
434 updates.push({ configId: fastOption.id, value });
435 }
436 }
437
438 const thinkingOption = configOptions.find(
439 (option) => option.category === "model_config" && isCursorThinkingConfigOption(option),
440 );
441 if (thinkingOption && typeof modelOptions?.thinking === "boolean") {
442 const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking);
443 if (value !== undefined) {
444 updates.push({ configId: thinkingOption.id, value });
445 }
446 }
447
448 return updates;
449}
450
451export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) =>
452 withCursorAcpProbeRuntime(cursorSettings, (acp) =>
453 Effect.map(acp.start(), (started) =>
454 buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []),
455 ),
456 );
457
458export const discoverCursorModelCapabilitiesViaAcp = (
459 cursorSettings: CursorSettings,
460 existingModels: ReadonlyArray<ServerProviderModel>,
461) =>
462 withCursorAcpProbeRuntime(cursorSettings, (acp) =>
463 Effect.gen(function* () {
464 const started = yield* acp.start();
465 const initialConfigOptions = started.sessionSetupResult.configOptions ?? [];
466 const modelOption = findCursorModelConfigOption(initialConfigOptions);
467 const modelChoices = flattenSessionConfigSelectOptions(modelOption);
468 if (!modelOption || modelChoices.length === 0) {
469 return [];
470 }
471
472 const currentModelValue =
473 modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined;
474 const capabilitiesBySlug = new Map<string, ModelCapabilities>();
475 if (currentModelValue) {
476 capabilitiesBySlug.set(
477 currentModelValue,
478 buildCursorCapabilitiesFromConfigOptions(initialConfigOptions),
479 );
480 }
481
482 const targetModelSlugs = new Set(
483 existingModels
484 .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model))
485 .map((model) => model.slug),
486 );
487 if (targetModelSlugs.size === 0) {
488 return buildCursorDiscoveredModels(
489 modelChoices.map((modelChoice) => ({
490 slug: modelChoice.value.trim(),
491 name: modelChoice.name.trim(),
492 capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES,
493 })),
494 );
495 }
496
497 const probedCapabilities = yield* Effect.forEach(
498 modelChoices,
499 (modelChoice) => {
500 const modelSlug = modelChoice.value.trim();
501 if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) {
502 return Effect.void.pipe(
503 Effect.as<readonly [string, ModelCapabilities] | undefined>(undefined),
504 );
505 }
506
507 return withCursorAcpProbeRuntime(cursorSettings, (probeAcp) =>
508 Effect.gen(function* () {
509 const probeStarted = yield* probeAcp.start();
510 const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? [];
511 const probeModelOption = findCursorModelConfigOption(probeConfigOptions);
512 const probeCurrentModelValue =
513 probeModelOption?.type === "select"
514 ? probeModelOption.currentValue?.trim() || undefined
515 : undefined;
516 yield* Effect.annotateCurrentSpan({
517 "cursor.acp.model.value": modelSlug,
518 "cursor.acp.model.currentValue": probeCurrentModelValue,
519 "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id,
520 });
521 const nextConfigOptions =
522 probeCurrentModelValue === modelSlug
523 ? probeConfigOptions
524 : yield* probeAcp
525 .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug)
526 .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions));
527 return [
528 modelSlug,
529 buildCursorCapabilitiesFromConfigOptions(nextConfigOptions),
530 ] as const;
531 }),
532 ).pipe(
533 Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT),
534 Effect.retry({ times: 3 }),
535 Effect.withSpan("cursor-acp-model-capability-probe"),
536 Effect.catchCause((cause) =>
537 Effect.logWarning("Cursor ACP capability probe failed", {
538 modelSlug,
539 cause: Cause.pretty(cause),
540 }),
541 ),
542 );
543 },
544 { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY },
545 );
546
547 for (const entry of probedCapabilities) {
548 if (!entry) {
549 continue;
550 }
551 capabilitiesBySlug.set(entry[0], entry[1]);
552 }
553
554 return buildCursorDiscoveredModels(
555 modelChoices.map((modelChoice) => ({
556 slug: modelChoice.value.trim(),
557 name: modelChoice.name.trim(),
558 capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES,
559 })),
560 );
561 }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})),
562 );
563
564export function getCursorFallbackModels(
565 cursorSettings: Pick<CursorSettings, "customModels">,
566): ReadonlyArray<ServerProviderModel> {
567 return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES);
568}
569
570/** Timeout for `agent about` — it's slower than a simple `--version` probe. */
571const ABOUT_TIMEOUT_MS = 8_000;
572
573/** Strip ANSI escape sequences so we can parse plain key-value lines. */
574function stripAnsi(text: string): string {
575 // eslint-disable-next-line no-control-regex
576 return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, "");
577}
578
579/**
580 * Extract a value from `agent about` key-value output.
581 * Lines look like: `CLI Version 2026.03.20-44cb435`
582 */
583function extractAboutField(plain: string, key: string): string | undefined {
584 const regex = new RegExp(`^${key}\\s{2,}(.+)$`, "mi");
585 const match = regex.exec(plain);
586 return match?.[1]?.trim();
587}
588
589export interface CursorAboutResult {
590 readonly version: string | null;
591 readonly status: Exclude<ServerProviderState, "disabled">;
592 readonly auth: ServerProviderAuth;
593 readonly message?: string;
594}
595
596function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined {
597 const parts = messages
598 .map((message) => message?.trim())
599 .filter((message): message is string => Boolean(message));
600 return parts.length > 0 ? parts.join(" ") : undefined;
601}
602
603export function buildCursorProviderSnapshot(input: {
604 readonly checkedAt: string;
605 readonly cursorSettings: CursorSettings;
606 readonly parsed: CursorAboutResult;
607 readonly discoveredModels?: ReadonlyArray<ServerProviderModel>;
608 readonly discoveryWarning?: string;
609}): ServerProvider {
610 const message = joinProviderMessages(input.parsed.message, input.discoveryWarning);
611 return buildServerProvider({
612 provider: PROVIDER,
613 enabled: input.cursorSettings.enabled,
614 checkedAt: input.checkedAt,
615 models: providerModelsFromSettings(
616 input.discoveredModels ?? [],
617 PROVIDER,
618 input.cursorSettings.customModels,
619 EMPTY_CAPABILITIES,
620 ),
621 probe: {
622 installed: true,
623 version: input.parsed.version,
624 status:
625 input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status,
626 auth: input.parsed.auth,
627 ...(message ? { message } : {}),
628 },
629 });
630}
631
632interface CursorAboutJsonPayload {
633 readonly cliVersion?: unknown;
634 readonly subscriptionTier?: unknown;
635 readonly userEmail?: unknown;
636}
637
638export function parseCursorVersionDate(version: string | null | undefined): number | undefined {
639 const match = version?.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})(?:\b|-|$)/);
640 if (!match) {
641 return undefined;
642 }
643 const [, year, month, day] = match;
644 return Number(`${year}${month}${day}`);
645}
646
647export function parseCursorCliConfigChannel(raw: string): string | undefined {
648 try {
649 const parsed = JSON.parse(raw) as unknown;
650 if (
651 typeof parsed === "object" &&
652 parsed !== null &&
653 "channel" in parsed &&
654 typeof parsed.channel === "string"
655 ) {
656 const channel = parsed.channel.trim().toLowerCase();
657 return channel.length > 0 ? channel : undefined;
658 }
659 } catch {
660 return undefined;
661 }
662 return undefined;
663}
664
665function toTitleCaseWords(value: string): string {
666 return value
667 .split(/[\s_-]+/g)
668 .filter((part) => part.length > 0)
669 .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
670 .join(" ");
671}
672
673function cursorSubscriptionLabel(subscriptionType: string | undefined): string | undefined {
674 const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, "");
675 if (!normalized) return undefined;
676
677 switch (normalized) {
678 case "team":
679 return "Team";
680 case "pro":
681 return "Pro";
682 case "free":
683 return "Free";
684 case "business":
685 return "Business";
686 case "enterprise":
687 return "Enterprise";
688 default:
689 return toTitleCaseWords(subscriptionType!);
690 }
691}
692
693function cursorAuthMetadata(
694 subscriptionType: string | undefined,
695): Pick<ServerProviderAuth, "label" | "type"> | undefined {
696 if (!subscriptionType) {
697 return undefined;
698 }
699 const subscriptionLabel = cursorSubscriptionLabel(subscriptionType);
700 return {
701 type: subscriptionType,
702 label: `Cursor ${subscriptionLabel ?? toTitleCaseWords(subscriptionType)} Subscription`,
703 };
704}
705
706function parseCursorAboutJsonPayload(raw: string): CursorAboutJsonPayload | undefined {
707 const trimmed = raw.trim();
708 if (!trimmed.startsWith("{")) {
709 return undefined;
710 }
711 try {
712 const parsed = JSON.parse(trimmed) as unknown;
713 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
714 return undefined;
715 }
716 return parsed as CursorAboutJsonPayload;
717 } catch {
718 return undefined;
719 }
720}
721
722function hasOwn(record: object, key: string): boolean {
723 return Object.prototype.hasOwnProperty.call(record, key);
724}
725
726function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean {
727 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
728 return (
729 lowerOutput.includes("unknown option '--format'") ||
730 lowerOutput.includes("unexpected argument '--format'") ||
731 lowerOutput.includes("unrecognized option '--format'") ||
732 lowerOutput.includes("unknown argument '--format'")
733 );
734}
735
736function readCursorCliConfigChannel(): string | undefined {
737 try {
738 const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json");
739 return parseCursorCliConfigChannel(nodeFs.readFileSync(configPath, "utf8"));
740 } catch {
741 return undefined;
742 }
743}
744
745export function getCursorParameterizedModelPickerUnsupportedMessage(input: {
746 readonly version: string | null | undefined;
747 readonly channel: string | null | undefined;
748}): string | undefined {
749 const reasons: Array<string> = [];
750 const versionDate = parseCursorVersionDate(input.version);
751 if (
752 versionDate !== undefined &&
753 versionDate < CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE
754 ) {
755 reasons.push(
756 `Cursor Agent CLI version ${input.version} is too old for Cursor ACP parameterized model picker`,
757 );
758 }
759
760 const normalizedChannel = input.channel?.trim().toLowerCase();
761 if (
762 normalizedChannel !== undefined &&
763 normalizedChannel.length > 0 &&
764 normalizedChannel !== "lab"
765 ) {
766 reasons.push(
767 `Cursor Agent CLI channel is ${JSON.stringify(input.channel)}, but parameterized model picker is only available on the lab channel`,
768 );
769 }
770
771 if (reasons.length === 0) {
772 return undefined;
773 }
774
775 return `${reasons.join(". ")}. Run \`agent set-channel lab && agent update\` and use Cursor Agent CLI 2026.04.08 or newer.`;
776}
777
778/**
779 * Parse the output of `agent about` to extract version and authentication
780 * status in a single probe.
781 *
782 * Example output (logged in):
783 * ```
784 * About Cursor CLI
785 *
786 * CLI Version 2026.03.20-44cb435
787 * User Email user@example.com
788 * ```
789 *
790 * Example output (logged out):
791 * ```
792 * About Cursor CLI
793 *
794 * CLI Version 2026.03.20-44cb435
795 * User Email Not logged in
796 * ```
797 */
798export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult {
799 const jsonPayload = parseCursorAboutJsonPayload(result.stdout);
800 if (jsonPayload) {
801 const version =
802 typeof jsonPayload.cliVersion === "string" ? jsonPayload.cliVersion.trim() : null;
803 const hasUserEmailField = hasOwn(jsonPayload, "userEmail");
804 const userEmail =
805 typeof jsonPayload.userEmail === "string" ? jsonPayload.userEmail.trim() : undefined;
806 const subscriptionType =
807 typeof jsonPayload.subscriptionTier === "string"
808 ? jsonPayload.subscriptionTier.trim()
809 : undefined;
810 const authMetadata = cursorAuthMetadata(subscriptionType);
811
812 if (hasUserEmailField && jsonPayload.userEmail == null) {
813 return {
814 version,
815 status: "error",
816 auth: { status: "unauthenticated" },
817 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
818 };
819 }
820
821 if (!userEmail) {
822 if (result.code === 0) {
823 return {
824 version,
825 status: "ready",
826 auth: {
827 status: "unknown",
828 ...authMetadata,
829 },
830 };
831 }
832 return {
833 version,
834 status: "warning",
835 auth: { status: "unknown" },
836 message: "Could not verify Cursor Agent authentication status.",
837 };
838 }
839
840 const lowerEmail = userEmail.toLowerCase();
841 if (
842 lowerEmail === "not logged in" ||
843 lowerEmail.includes("login required") ||
844 lowerEmail.includes("authentication required")
845 ) {
846 return {
847 version,
848 status: "error",
849 auth: { status: "unauthenticated" },
850 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
851 };
852 }
853
854 return {
855 version,
856 status: "ready",
857 auth: {
858 status: "authenticated",
859 ...authMetadata,
860 },
861 };
862 }
863
864 const combined = `${result.stdout}\n${result.stderr}`;
865 const lowerOutput = combined.toLowerCase();
866
867 // If the command itself isn't recognised, we're on an old CLI version.
868 if (
869 lowerOutput.includes("unknown command") ||
870 lowerOutput.includes("unrecognized command") ||
871 lowerOutput.includes("unexpected argument")
872 ) {
873 return {
874 version: null,
875 status: "warning",
876 auth: { status: "unknown" },
877 message: "The `agent about` command is unavailable in this version of the Cursor Agent CLI.",
878 };
879 }
880
881 const plain = stripAnsi(combined);
882 const version = extractAboutField(plain, "CLI Version") ?? null;
883 const userEmail = extractAboutField(plain, "User Email");
884
885 // Determine auth from the User Email field.
886 if (userEmail === undefined) {
887 // Field missing entirely — can't determine auth.
888 if (result.code === 0) {
889 return { version, status: "ready", auth: { status: "unknown" } };
890 }
891 return {
892 version,
893 status: "warning",
894 auth: { status: "unknown" },
895 message: "Could not verify Cursor Agent authentication status.",
896 };
897 }
898
899 const lowerEmail = userEmail.toLowerCase();
900 if (
901 lowerEmail === "not logged in" ||
902 lowerEmail.includes("login required") ||
903 lowerEmail.includes("authentication required")
904 ) {
905 return {
906 version,
907 status: "error",
908 auth: { status: "unauthenticated" },
909 message: "Cursor Agent is not authenticated. Run `agent login` and try again.",
910 };
911 }
912
913 // Any non-empty email value means authenticated.
914 return { version, status: "ready", auth: { status: "authenticated" } };
915}
916
917const runCursorCommand = (args: ReadonlyArray<string>) =>
918 Effect.gen(function* () {
919 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
920 const cursorSettings = yield* Effect.service(ServerSettingsService).pipe(
921 Effect.flatMap((service) => service.getSettings),
922 Effect.map((settings) => settings.providers.cursor),
923 );
924 const command = ChildProcess.make(cursorSettings.binaryPath, [...args], {
925 shell: process.platform === "win32",
926 });
927
928 const child = yield* spawner.spawn(command);
929 const [stdout, stderr, exitCode] = yield* Effect.all(
930 [
931 collectStreamAsString(child.stdout),
932 collectStreamAsString(child.stderr),
933 child.exitCode.pipe(Effect.map(Number)),
934 ],
935 { concurrency: "unbounded" },
936 );
937
938 return { stdout, stderr, code: exitCode } satisfies CommandResult;
939 }).pipe(Effect.scoped);
940
941const runCursorAboutCommand = Effect.gen(function* () {
942 const jsonResult = yield* runCursorCommand(["about", "--format", "json"]);
943 if (!isCursorAboutJsonFormatUnsupported(jsonResult)) {
944 return jsonResult;
945 }
946 return yield* runCursorCommand(["about"]);
947});
948
949export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(
950 function* (): Effect.fn.Return<
951 ServerProvider,
952 ServerSettingsError,
953 ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService
954 > {
955 const cursorSettings = yield* Effect.service(ServerSettingsService).pipe(
956 Effect.flatMap((service) => service.getSettings),
957 Effect.map((settings) => settings.providers.cursor),
958 );
959 const checkedAt = new Date().toISOString();
960 const fallbackModels = getCursorFallbackModels(cursorSettings);
961
962 if (!cursorSettings.enabled) {
963 return buildServerProvider({
964 provider: PROVIDER,
965 enabled: false,
966 checkedAt,
967 models: fallbackModels,
968 probe: {
969 installed: false,
970 version: null,
971 status: "warning",
972 auth: { status: "unknown" },
973 message: "Cursor is disabled in T3 Code settings.",
974 },
975 });
976 }
977
978 // Single `agent about` probe: returns version + auth status in one call.
979 const aboutProbe = yield* runCursorAboutCommand.pipe(
980 Effect.timeoutOption(ABOUT_TIMEOUT_MS),
981 Effect.result,
982 );
983
984 if (Result.isFailure(aboutProbe)) {
985 const error = aboutProbe.failure;
986 return buildServerProvider({
987 provider: PROVIDER,
988 enabled: cursorSettings.enabled,
989 checkedAt,
990 models: fallbackModels,
991 probe: {
992 installed: !isCommandMissingCause(error),
993 version: null,
994 status: "error",
995 auth: { status: "unknown" },
996 message: isCommandMissingCause(error)
997 ? "Cursor Agent CLI (`agent`) is not installed or not on PATH."
998 : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
999 },
1000 });
1001 }
1002
1003 if (Option.isNone(aboutProbe.success)) {
1004 return buildServerProvider({
1005 provider: PROVIDER,
1006 enabled: cursorSettings.enabled,
1007 checkedAt,
1008 models: fallbackModels,
1009 probe: {
1010 installed: true,
1011 version: null,
1012 status: "error",
1013 auth: { status: "unknown" },
1014 message: "Cursor Agent CLI is installed but timed out while running `agent about`.",
1015 },
1016 });
1017 }
1018
1019 const parsed = parseCursorAboutOutput(aboutProbe.success.value);
1020 const parameterizedModelPickerUnsupportedMessage =
1021 getCursorParameterizedModelPickerUnsupportedMessage({
1022 version: parsed.version,
1023 channel: readCursorCliConfigChannel(),
1024 });
1025 if (parameterizedModelPickerUnsupportedMessage) {
1026 return buildServerProvider({
1027 provider: PROVIDER,
1028 enabled: cursorSettings.enabled,
1029 checkedAt,
1030 models: fallbackModels,
1031 probe: {
1032 installed: true,
1033 version: parsed.version,
1034 status: "error",
1035 auth: parsed.auth,
1036 message:
1037 parsed.auth.status === "unauthenticated" && parsed.message
1038 ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}`
1039 : parameterizedModelPickerUnsupportedMessage,
1040 },
1041 });
1042 }
1043 let discoveredModels = Option.none<ReadonlyArray<ServerProviderModel>>();
1044 let discoveryWarning: string | undefined;
1045 if (parsed.auth.status !== "unauthenticated") {
1046 const discoveryExit = yield* Effect.exit(
1047 discoverCursorModelsViaAcp(cursorSettings).pipe(
1048 Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
1049 ),
1050 );
1051 if (Exit.isFailure(discoveryExit)) {
1052 yield* Effect.logWarning("Cursor ACP model discovery failed", {
1053 cause: Cause.pretty(discoveryExit.cause),
1054 });
1055 discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details.";
1056 } else if (Option.isNone(discoveryExit.value)) {
1057 discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`;
1058 } else if (discoveryExit.value.value.length === 0) {
1059 discoveryWarning = "Cursor ACP model discovery returned no built-in models.";
1060 } else {
1061 discoveredModels = discoveryExit.value;
1062 }
1063 }
1064 return buildCursorProviderSnapshot({
1065 checkedAt,
1066 cursorSettings,
1067 parsed,
1068 discoveredModels: Option.getOrElse(
1069 Option.filter(discoveredModels, (models) => models.length > 0),
1070 () => [] as const,
1071 ),
1072 ...(discoveryWarning ? { discoveryWarning } : {}),
1073 });
1074 },
1075);
1076
1077export const CursorProviderLive = Layer.effect(
1078 CursorProvider,
1079 Effect.gen(function* () {
1080 const serverSettings = yield* ServerSettingsService;
1081 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1082
1083 const checkProvider = checkCursorProviderStatus().pipe(
1084 Effect.provideService(ServerSettingsService, serverSettings),
1085 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1086 );
1087
1088 return yield* makeManagedServerProvider<CursorSettings>({
1089 getSettings: serverSettings.getSettings.pipe(
1090 Effect.map((settings) => settings.providers.cursor),
1091 Effect.orDie,
1092 ),
1093 streamSettings: serverSettings.streamChanges.pipe(
1094 Stream.map((settings) => settings.providers.cursor),
1095 ),
1096 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
1097 initialSnapshot: buildInitialCursorProviderSnapshot,
1098 checkProvider,
1099 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => {
1100 if (
1101 !settings.enabled ||
1102 snapshot.auth.status === "unauthenticated" ||
1103 !snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model))
1104 ) {
1105 return Effect.void;
1106 }
1107
1108 return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe(
1109 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1110 Effect.flatMap((discoveredModels) => {
1111 if (discoveredModels.length === 0) {
1112 return Effect.void;
1113 }
1114
1115 return publishSnapshot({
1116 ...snapshot,
1117 models: providerModelsFromSettings(
1118 discoveredModels,
1119 PROVIDER,
1120 settings.customModels,
1121 EMPTY_CAPABILITIES,
1122 ),
1123 });
1124 }),
1125 Effect.catchCause((cause) =>
1126 Effect.logWarning("Cursor ACP background capability enrichment failed", {
1127 models: snapshot.models.map((model) => model.slug),
1128 cause: Cause.pretty(cause),
1129 }).pipe(Effect.asVoid),
1130 ),
1131 );
1132 },
1133 refreshInterval: CURSOR_REFRESH_INTERVAL,
1134 });
1135 }),
1136);
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+23-1
......@@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
88import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
99import { CodexAdapter } from "../Services/CodexAdapter.ts";
1010import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
11import { CursorAdapter } from "../Services/CursorAdapter.ts";
12import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
1113import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
1214import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
1315import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
......@@ -66,6 +68,23 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = {
6668 streamEvents: Stream.empty,
6769};
6870
71const fakeCursorAdapter: CursorAdapterShape = {
72 provider: "cursor",
73 capabilities: { sessionModelSwitch: "in-session" },
74 startSession: vi.fn(),
75 sendTurn: vi.fn(),
76 interruptTurn: vi.fn(),
77 respondToRequest: vi.fn(),
78 respondToUserInput: vi.fn(),
79 stopSession: vi.fn(),
80 listSessions: vi.fn(),
81 hasSession: vi.fn(),
82 readThread: vi.fn(),
83 rollbackThread: vi.fn(),
84 stopAll: vi.fn(),
85 streamEvents: Stream.empty,
86};
87
6988const layer = it.layer(
7089 Layer.mergeAll(
7190 Layer.provide(
......@@ -74,6 +93,7 @@ const layer = it.layer(
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) => {
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 {
1717} from "../Services/ProviderAdapterRegistry.ts";
1818import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
1919import { CodexAdapter } from "../Services/CodexAdapter.ts";
20import { CursorAdapter } from "../Services/CursorAdapter.ts";
2021import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
2122
2223export interface ProviderAdapterRegistryLiveOptions {
......@@ -26,10 +27,16 @@ export interface ProviderAdapterRegistryLiveOptions {
2627const 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 {
3030 readCodexConfigModelProvider,
3131} from "./CodexProvider.ts";
3232import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";
33import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts";
33import {
34 haveProvidersChanged,
35 mergeProviderSnapshot,
36 ProviderRegistryLive,
37} from "./ProviderRegistry.ts";
3438import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
3539import { ServerConfig } from "../../config.ts";
3640import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
3741import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
3842
43process.env.T3CODE_CURSOR_ENABLED = "1";
44
3945// ── Test helpers ────────────────────────────────────────────────────
4046
4147const encoder = new TextEncoder();
......@@ -577,10 +583,106 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
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()))(
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()))(
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()))(
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()))(
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
99import { ServerConfig } from "../../config.ts";
1010import { ClaudeProviderLive } from "./ClaudeProvider.ts";
1111import { CodexProviderLive } from "./CodexProvider.ts";
12import { CursorProviderLive } from "./CursorProvider.ts";
1213import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
13import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts";
1414import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
15import type { CodexProviderShape } from "../Services/CodexProvider.ts";
1615import { CodexProvider } from "../Services/CodexProvider.ts";
17import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts";
16import { CursorProvider } from "../Services/CursorProvider.ts";
1817import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
1918import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
2019import {
......@@ -26,24 +25,67 @@ import {
2625 writeProviderStatusCache,
2726} from "../providerStatusCache.ts";
2827
28type ProviderSnapshotSource = {
29 readonly provider: ProviderKind;
30 readonly getSnapshot: Effect.Effect<ServerProvider>;
31 readonly refresh: Effect.Effect<ServerProvider>;
32 readonly streamChanges: Stream.Stream<ServerProvider>;
33};
34
2935const 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
42const 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
49const mergeProviderModels = (
50 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
51 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
52): ReadonlyArray<ServerProvider["models"][number]> => {
53 if (nextModels.length === 0 && previousModels.length > 0) {
54 return previousModels;
55 }
56
57 const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
58 const mergedModels = nextModels.map((model) => {
59 const previousModel = previousBySlug.get(model.slug);
60 if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) {
61 return model;
62 }
63 return {
64 ...model,
65 capabilities: previousModel.capabilities,
66 };
67 });
68 const nextSlugs = new Set(nextModels.map((model) => model.slug));
69 return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))];
70};
71
72export const mergeProviderSnapshot = (
73 previousProvider: ServerProvider | undefined,
74 nextProvider: ServerProvider,
75): ServerProvider =>
76 !previousProvider
77 ? nextProvider
78 : {
79 ...nextProvider,
80 models: mergeProviderModels(previousProvider.models, nextProvider.models),
81 };
4082
4183export const haveProvidersChanged = (
4284 previousProviders: ReadonlyArray<ServerProvider>,
4385 nextProviders: ReadonlyArray<ServerProvider>,
4486): boolean => !Equal.equals(previousProviders, nextProviders);
4587
46export const ProviderRegistryLive = Layer.effect(
88const ProviderRegistryLiveBase = Layer.effect(
4789 ProviderRegistry,
4890 Effect.gen(function* () {
4991 const codexProvider = yield* CodexProvider;
......@@ -52,13 +94,43 @@ export const ProviderRegistryLive = Layer.effect(
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(
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(
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(
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(
211278 },
212279 } satisfies ProviderRegistryShape;
213280 }),
214).pipe(
215 Layer.provideMerge(CodexProviderLive),
216 Layer.provideMerge(ClaudeProviderLive),
217 Layer.provideMerge(OpenCodeProviderLive),
281);
282
283export 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 = (
245245function 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() {
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 @@
1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";
1import { ProviderKind, type ThreadId } from "@t3tools/contracts";
2import { Effect, Layer, Option, Schema } from "effect";
33
44import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts";
55import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
......@@ -24,14 +24,15 @@ function decodeProviderKind(
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 created+12
......@@ -0,0 +1,12 @@
1import { Context } from "effect";
2
3import type { ProviderAdapterError } from "../Errors.ts";
4import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
5
6export interface CursorAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
7 readonly provider: "cursor";
8}
9
10export class CursorAdapter extends Context.Service<CursorAdapter, CursorAdapterShape>()(
11 "t3/provider/Services/CursorAdapter",
12) {}
apps/server/src/provider/Services/CursorProvider.ts created+9
......@@ -0,0 +1,9 @@
1import { Context } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider.ts";
4
5export interface CursorProviderShape extends ServerProviderShape {}
6
7export class CursorProvider extends Context.Service<CursorProvider, CursorProviderShape>()(
8 "t3/provider/Services/CursorProvider",
9) {}
apps/server/src/provider/Services/ProviderAdapter.ts+1-1
......@@ -23,7 +23,7 @@ import type {
2323import type { Effect } from "effect";
2424import type { Stream } from "effect";
2525
26export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported";
26export type ProviderSessionModelSwitchMode = "in-session" | "unsupported";
2727
2828export interface ProviderAdapterCapabilities {
2929 /**
apps/server/src/provider/acp/AcpAdapterSupport.test.ts created+27
......@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vitest";
2import * as EffectAcpErrors from "effect-acp/errors";
3
4import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts";
5
6describe("AcpAdapterSupport", () => {
7 it("maps ACP approval decisions to permission outcomes", () => {
8 expect(acpPermissionOutcome("accept")).toBe("allow-once");
9 expect(acpPermissionOutcome("acceptForSession")).toBe("allow-always");
10 expect(acpPermissionOutcome("decline")).toBe("reject-once");
11 });
12
13 it("maps ACP request errors to provider adapter request errors", () => {
14 const error = mapAcpToAdapterError(
15 "cursor",
16 "thread-1" as never,
17 "session/prompt",
18 new EffectAcpErrors.AcpRequestError({
19 code: -32602,
20 errorMessage: "Invalid params",
21 }),
22 );
23
24 expect(error._tag).toBe("ProviderAdapterRequestError");
25 expect(error.message).toContain("Invalid params");
26 });
27});
apps/server/src/provider/acp/AcpAdapterSupport.ts created+54
......@@ -0,0 +1,54 @@
1import {
2 type ProviderApprovalDecision,
3 type ProviderKind,
4 type ThreadId,
5} from "@t3tools/contracts";
6import { Schema } from "effect";
7import * as EffectAcpErrors from "effect-acp/errors";
8
9import {
10 ProviderAdapterRequestError,
11 ProviderAdapterSessionClosedError,
12 type ProviderAdapterError,
13} from "../Errors.ts";
14
15export function mapAcpToAdapterError(
16 provider: ProviderKind,
17 threadId: ThreadId,
18 method: string,
19 error: EffectAcpErrors.AcpError,
20): ProviderAdapterError {
21 if (Schema.is(EffectAcpErrors.AcpProcessExitedError)(error)) {
22 return new ProviderAdapterSessionClosedError({
23 provider,
24 threadId,
25 cause: error,
26 });
27 }
28 if (Schema.is(EffectAcpErrors.AcpRequestError)(error)) {
29 return new ProviderAdapterRequestError({
30 provider,
31 method,
32 detail: error.message,
33 cause: error,
34 });
35 }
36 return new ProviderAdapterRequestError({
37 provider,
38 method,
39 detail: error.message,
40 cause: error,
41 });
42}
43
44export function acpPermissionOutcome(decision: ProviderApprovalDecision): string {
45 switch (decision) {
46 case "acceptForSession":
47 return "allow-always";
48 case "accept":
49 return "allow-once";
50 case "decline":
51 default:
52 return "reject-once";
53 }
54}
apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts created+155
......@@ -0,0 +1,155 @@
1import { RuntimeRequestId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import {
5 makeAcpAssistantItemEvent,
6 makeAcpContentDeltaEvent,
7 makeAcpPlanUpdatedEvent,
8 makeAcpRequestOpenedEvent,
9 makeAcpRequestResolvedEvent,
10 makeAcpToolCallEvent,
11} from "./AcpCoreRuntimeEvents.ts";
12
13describe("AcpCoreRuntimeEvents", () => {
14 it("maps ACP permission requests to canonical runtime events", () => {
15 const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" };
16 const turnId = TurnId.make("turn-1");
17 const permissionRequest = {
18 kind: "execute" as const,
19 detail: "cat package.json",
20 toolCall: {
21 toolCallId: "tool-1",
22 kind: "execute",
23 status: "pending" as const,
24 command: "cat package.json",
25 detail: "cat package.json",
26 data: { toolCallId: "tool-1", kind: "execute" },
27 },
28 };
29
30 expect(
31 makeAcpRequestOpenedEvent({
32 stamp,
33 provider: "cursor",
34 threadId: "thread-1" as never,
35 turnId,
36 requestId: RuntimeRequestId.make("request-1"),
37 permissionRequest,
38 detail: "cat package.json",
39 args: { command: ["cat", "package.json"] },
40 source: "acp.jsonrpc",
41 method: "session/request_permission",
42 rawPayload: { sessionId: "session-1" },
43 }),
44 ).toMatchObject({
45 type: "request.opened",
46 payload: {
47 requestType: "exec_command_approval",
48 detail: "cat package.json",
49 },
50 });
51
52 expect(
53 makeAcpRequestResolvedEvent({
54 stamp,
55 provider: "cursor",
56 threadId: "thread-1" as never,
57 turnId,
58 requestId: RuntimeRequestId.make("request-1"),
59 permissionRequest,
60 decision: "accept",
61 }),
62 ).toMatchObject({
63 type: "request.resolved",
64 payload: {
65 requestType: "exec_command_approval",
66 decision: "accept",
67 },
68 });
69 });
70
71 it("maps ACP core plan, tool-call, and content updates", () => {
72 const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" };
73 const turnId = TurnId.make("turn-1");
74
75 expect(
76 makeAcpPlanUpdatedEvent({
77 stamp,
78 provider: "cursor",
79 threadId: "thread-1" as never,
80 turnId,
81 payload: {
82 plan: [{ step: "Inspect state", status: "inProgress" }],
83 },
84 source: "acp.cursor.extension",
85 method: "cursor/update_todos",
86 rawPayload: { todos: [] },
87 }),
88 ).toMatchObject({
89 type: "turn.plan.updated",
90 raw: {
91 method: "cursor/update_todos",
92 },
93 });
94
95 expect(
96 makeAcpToolCallEvent({
97 stamp,
98 provider: "cursor",
99 threadId: "thread-1" as never,
100 turnId,
101 toolCall: {
102 toolCallId: "tool-1",
103 kind: "execute",
104 status: "completed",
105 title: "Terminal",
106 detail: "bun run test",
107 data: { command: "bun run test" },
108 },
109 rawPayload: { sessionId: "session-1" },
110 }),
111 ).toMatchObject({
112 type: "item.completed",
113 payload: {
114 itemType: "command_execution",
115 status: "completed",
116 },
117 });
118
119 expect(
120 makeAcpContentDeltaEvent({
121 stamp,
122 provider: "cursor",
123 threadId: "thread-1" as never,
124 turnId,
125 itemId: "assistant:session-1:segment:0",
126 text: "hello",
127 rawPayload: { sessionId: "session-1" },
128 }),
129 ).toMatchObject({
130 type: "content.delta",
131 itemId: "assistant:session-1:segment:0",
132 payload: {
133 delta: "hello",
134 },
135 });
136
137 expect(
138 makeAcpAssistantItemEvent({
139 stamp,
140 provider: "cursor",
141 threadId: "thread-1" as never,
142 turnId,
143 itemId: "assistant:session-1:segment:0",
144 lifecycle: "item.started",
145 }),
146 ).toMatchObject({
147 type: "item.started",
148 itemId: "assistant:session-1:segment:0",
149 payload: {
150 itemType: "assistant_message",
151 status: "inProgress",
152 },
153 });
154 });
155});
apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts created+242
......@@ -0,0 +1,242 @@
1import {
2 type RuntimeEventRawSource,
3 RuntimeItemId,
4 type CanonicalRequestType,
5 type EventId,
6 type ProviderApprovalDecision,
7 type ProviderKind,
8 type ProviderRuntimeEvent,
9 type RuntimeRequestId,
10 type ThreadId,
11 type ToolLifecycleItemType,
12 type TurnId,
13} from "@t3tools/contracts";
14
15import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts";
16
17type AcpAdapterRawSource = Extract<
18 RuntimeEventRawSource,
19 "acp.jsonrpc" | `acp.${string}.extension`
20>;
21
22interface AcpEventStamp {
23 readonly eventId: EventId;
24 readonly createdAt: string;
25}
26
27type AcpCanonicalRequestType = Extract<
28 CanonicalRequestType,
29 "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown"
30>;
31
32function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType {
33 switch (kind) {
34 case "execute":
35 return "exec_command_approval";
36 case "read":
37 return "file_read_approval";
38 case "edit":
39 case "delete":
40 case "move":
41 return "file_change_approval";
42 default:
43 return "unknown";
44 }
45}
46
47function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType {
48 switch (kind) {
49 case "execute":
50 return "command_execution";
51 case "edit":
52 case "delete":
53 case "move":
54 return "file_change";
55 case "search":
56 case "fetch":
57 return "web_search";
58 default:
59 return "dynamic_tool_call";
60 }
61}
62
63function runtimeItemStatusFromAcpToolStatus(
64 status: AcpToolCallState["status"],
65): "inProgress" | "completed" | "failed" | undefined {
66 switch (status) {
67 case "pending":
68 case "inProgress":
69 return "inProgress";
70 case "completed":
71 return "completed";
72 case "failed":
73 return "failed";
74 default:
75 return undefined;
76 }
77}
78
79export function makeAcpRequestOpenedEvent(input: {
80 readonly stamp: AcpEventStamp;
81 readonly provider: ProviderKind;
82 readonly threadId: ThreadId;
83 readonly turnId: TurnId | undefined;
84 readonly requestId: RuntimeRequestId;
85 readonly permissionRequest: AcpPermissionRequest;
86 readonly detail: string;
87 readonly args: unknown;
88 readonly source: AcpAdapterRawSource;
89 readonly method: string;
90 readonly rawPayload: unknown;
91}): ProviderRuntimeEvent {
92 return {
93 type: "request.opened",
94 ...input.stamp,
95 provider: input.provider,
96 threadId: input.threadId,
97 turnId: input.turnId,
98 requestId: input.requestId,
99 payload: {
100 requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind),
101 detail: input.detail,
102 args: input.args,
103 },
104 raw: {
105 source: input.source,
106 method: input.method,
107 payload: input.rawPayload,
108 },
109 };
110}
111
112export function makeAcpRequestResolvedEvent(input: {
113 readonly stamp: AcpEventStamp;
114 readonly provider: ProviderKind;
115 readonly threadId: ThreadId;
116 readonly turnId: TurnId | undefined;
117 readonly requestId: RuntimeRequestId;
118 readonly permissionRequest: AcpPermissionRequest;
119 readonly decision: ProviderApprovalDecision;
120}): ProviderRuntimeEvent {
121 return {
122 type: "request.resolved",
123 ...input.stamp,
124 provider: input.provider,
125 threadId: input.threadId,
126 turnId: input.turnId,
127 requestId: input.requestId,
128 payload: {
129 requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind),
130 decision: input.decision,
131 },
132 };
133}
134
135export function makeAcpPlanUpdatedEvent(input: {
136 readonly stamp: AcpEventStamp;
137 readonly provider: ProviderKind;
138 readonly threadId: ThreadId;
139 readonly turnId: TurnId | undefined;
140 readonly payload: AcpPlanUpdate;
141 readonly source: AcpAdapterRawSource;
142 readonly method: string;
143 readonly rawPayload: unknown;
144}): ProviderRuntimeEvent {
145 return {
146 type: "turn.plan.updated",
147 ...input.stamp,
148 provider: input.provider,
149 threadId: input.threadId,
150 turnId: input.turnId,
151 payload: input.payload,
152 raw: {
153 source: input.source,
154 method: input.method,
155 payload: input.rawPayload,
156 },
157 };
158}
159
160export function makeAcpToolCallEvent(input: {
161 readonly stamp: AcpEventStamp;
162 readonly provider: ProviderKind;
163 readonly threadId: ThreadId;
164 readonly turnId: TurnId | undefined;
165 readonly toolCall: AcpToolCallState;
166 readonly rawPayload: unknown;
167}): ProviderRuntimeEvent {
168 const runtimeStatus = runtimeItemStatusFromAcpToolStatus(input.toolCall.status);
169 return {
170 type:
171 input.toolCall.status === "completed" || input.toolCall.status === "failed"
172 ? "item.completed"
173 : "item.updated",
174 ...input.stamp,
175 provider: input.provider,
176 threadId: input.threadId,
177 turnId: input.turnId,
178 itemId: RuntimeItemId.make(input.toolCall.toolCallId),
179 payload: {
180 itemType: canonicalItemTypeFromAcpToolKind(input.toolCall.kind),
181 ...(runtimeStatus ? { status: runtimeStatus } : {}),
182 ...(input.toolCall.title ? { title: input.toolCall.title } : {}),
183 ...(input.toolCall.detail ? { detail: input.toolCall.detail } : {}),
184 ...(Object.keys(input.toolCall.data).length > 0 ? { data: input.toolCall.data } : {}),
185 },
186 raw: {
187 source: "acp.jsonrpc",
188 method: "session/update",
189 payload: input.rawPayload,
190 },
191 };
192}
193
194export function makeAcpAssistantItemEvent(input: {
195 readonly stamp: AcpEventStamp;
196 readonly provider: ProviderKind;
197 readonly threadId: ThreadId;
198 readonly turnId: TurnId | undefined;
199 readonly itemId: string;
200 readonly lifecycle: "item.started" | "item.completed";
201}): ProviderRuntimeEvent {
202 return {
203 type: input.lifecycle,
204 ...input.stamp,
205 provider: input.provider,
206 threadId: input.threadId,
207 turnId: input.turnId,
208 itemId: RuntimeItemId.make(input.itemId),
209 payload: {
210 itemType: "assistant_message",
211 status: input.lifecycle === "item.completed" ? "completed" : "inProgress",
212 },
213 };
214}
215
216export function makeAcpContentDeltaEvent(input: {
217 readonly stamp: AcpEventStamp;
218 readonly provider: ProviderKind;
219 readonly threadId: ThreadId;
220 readonly turnId: TurnId | undefined;
221 readonly itemId?: string;
222 readonly text: string;
223 readonly rawPayload: unknown;
224}): ProviderRuntimeEvent {
225 return {
226 type: "content.delta",
227 ...input.stamp,
228 provider: input.provider,
229 threadId: input.threadId,
230 turnId: input.turnId,
231 ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),
232 payload: {
233 streamKind: "assistant_text",
234 delta: input.text,
235 },
236 raw: {
237 source: "acp.jsonrpc",
238 method: "session/update",
239 payload: input.rawPayload,
240 },
241 };
242}
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts created+395
......@@ -0,0 +1,395 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { fileURLToPath } from "node:url";
4import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";
8import { Effect, Stream } from "effect";
9import { describe, expect } from "vitest";
10
11import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts";
12import type * as EffectAcpProtocol from "effect-acp/protocol";
13
14const __dirname = path.dirname(fileURLToPath(import.meta.url));
15const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
16const bunExe = "bun";
17
18describe("AcpSessionRuntime", () => {
19 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {
20 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
21 return Effect.gen(function* () {
22 const runtime = yield* AcpSessionRuntime;
23 yield* runtime.start();
24
25 const initializeStarted = requestEvents.find(
26 (event) => event.method === "initialize" && event.status === "started",
27 );
28 expect(initializeStarted?.payload).toMatchObject({
29 protocolVersion: 1,
30 clientCapabilities: {
31 fs: { readTextFile: false, writeTextFile: false },
32 terminal: false,
33 _meta: { parameterizedModelPicker: true },
34 },
35 });
36 }).pipe(
37 Effect.provide(
38 AcpSessionRuntime.layer({
39 spawn: {
40 command: bunExe,
41 args: [mockAgentPath],
42 },
43 cwd: process.cwd(),
44 clientCapabilities: {
45 _meta: {
46 parameterizedModelPicker: true,
47 },
48 },
49 clientInfo: { name: "t3-test", version: "0.0.0" },
50 authMethodId: "test",
51 requestLogger: (event) =>
52 Effect.sync(() => {
53 requestEvents.push(event);
54 }),
55 }),
56 ),
57 Effect.scoped,
58 Effect.provide(NodeServices.layer),
59 );
60 });
61
62 it.effect("starts a session, prompts, and emits normalized events against the mock agent", () =>
63 Effect.gen(function* () {
64 const runtime = yield* AcpSessionRuntime;
65 const started = yield* runtime.start();
66
67 expect(started.initializeResult).toMatchObject({ protocolVersion: 1 });
68 expect(started.sessionId).toBe("mock-session-1");
69
70 const promptResult = yield* runtime.prompt({
71 prompt: [{ type: "text", text: "hi" }],
72 });
73 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
74
75 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)));
76 expect(notes).toHaveLength(4);
77 expect(notes.map((note) => note._tag)).toEqual([
78 "PlanUpdated",
79 "AssistantItemStarted",
80 "ContentDelta",
81 "AssistantItemCompleted",
82 ]);
83 const planUpdate = notes.find((note) => note._tag === "PlanUpdated");
84 expect(planUpdate?._tag).toBe("PlanUpdated");
85 if (planUpdate?._tag === "PlanUpdated") {
86 expect(planUpdate.payload.plan).toHaveLength(2);
87 }
88 const assistantStart = notes[1];
89 const assistantDelta = notes[2];
90 if (
91 assistantStart?._tag === "AssistantItemStarted" &&
92 assistantDelta?._tag === "ContentDelta"
93 ) {
94 expect(assistantDelta.itemId).toBe(assistantStart.itemId);
95 }
96 }).pipe(
97 Effect.provide(
98 AcpSessionRuntime.layer({
99 spawn: {
100 command: bunExe,
101 args: [mockAgentPath],
102 },
103 cwd: process.cwd(),
104 clientInfo: { name: "t3-test", version: "0.0.0" },
105 authMethodId: "test",
106 }),
107 ),
108 Effect.scoped,
109 Effect.provide(NodeServices.layer),
110 ),
111 );
112
113 it.effect("segments assistant text around ACP tool calls", () =>
114 Effect.gen(function* () {
115 const runtime = yield* AcpSessionRuntime;
116 yield* runtime.start();
117
118 const promptResult = yield* runtime.prompt({
119 prompt: [{ type: "text", text: "hi" }],
120 });
121 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
122
123 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7)));
124 expect(notes.map((note) => note._tag)).toEqual([
125 "AssistantItemStarted",
126 "ContentDelta",
127 "AssistantItemCompleted",
128 "ToolCallUpdated",
129 "ToolCallUpdated",
130 "AssistantItemStarted",
131 "ContentDelta",
132 ]);
133
134 const firstStarted = notes[0];
135 const firstDelta = notes[1];
136 const firstCompleted = notes[2];
137 const secondStarted = notes[5];
138 const secondDelta = notes[6];
139 expect(firstStarted?._tag).toBe("AssistantItemStarted");
140 expect(firstCompleted?._tag).toBe("AssistantItemCompleted");
141 expect(secondStarted?._tag).toBe("AssistantItemStarted");
142 if (
143 firstStarted?._tag === "AssistantItemStarted" &&
144 firstDelta?._tag === "ContentDelta" &&
145 firstCompleted?._tag === "AssistantItemCompleted" &&
146 secondStarted?._tag === "AssistantItemStarted" &&
147 secondDelta?._tag === "ContentDelta"
148 ) {
149 expect(firstDelta.itemId).toBe(firstStarted.itemId);
150 expect(firstCompleted.itemId).toBe(firstStarted.itemId);
151 expect(secondStarted.itemId).not.toBe(firstStarted.itemId);
152 expect(secondDelta.itemId).toBe(secondStarted.itemId);
153 }
154 }).pipe(
155 Effect.provide(
156 AcpSessionRuntime.layer({
157 spawn: {
158 command: bunExe,
159 args: [mockAgentPath],
160 env: {
161 T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1",
162 },
163 },
164 cwd: process.cwd(),
165 clientInfo: { name: "t3-test", version: "0.0.0" },
166 authMethodId: "test",
167 }),
168 ),
169 Effect.scoped,
170 Effect.provide(NodeServices.layer),
171 ),
172 );
173
174 it.effect("suppresses generic placeholder tool updates until completion", () =>
175 Effect.gen(function* () {
176 const runtime = yield* AcpSessionRuntime;
177 yield* runtime.start();
178
179 const promptResult = yield* runtime.prompt({
180 prompt: [{ type: "text", text: "hi" }],
181 });
182 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
183
184 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1)));
185 expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]);
186 const toolCall = notes[0];
187 expect(toolCall?._tag).toBe("ToolCallUpdated");
188 if (toolCall?._tag === "ToolCallUpdated") {
189 expect(toolCall.toolCall.status).toBe("completed");
190 expect(toolCall.toolCall.title).toBe("Read file");
191 }
192 }).pipe(
193 Effect.provide(
194 AcpSessionRuntime.layer({
195 spawn: {
196 command: bunExe,
197 args: [mockAgentPath],
198 env: {
199 T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1",
200 },
201 },
202 cwd: process.cwd(),
203 clientInfo: { name: "t3-test", version: "0.0.0" },
204 authMethodId: "test",
205 }),
206 ),
207 Effect.scoped,
208 Effect.provide(NodeServices.layer),
209 ),
210 );
211
212 it.effect("logs ACP requests from the shared runtime", () => {
213 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
214 return Effect.gen(function* () {
215 const runtime = yield* AcpSessionRuntime;
216 yield* runtime.start();
217
218 yield* runtime.setModel("composer-2");
219 yield* runtime.prompt({
220 prompt: [{ type: "text", text: "hi" }],
221 });
222
223 expect(
224 requestEvents.some(
225 (event) => event.method === "session/set_config_option" && event.status === "started",
226 ),
227 ).toBe(true);
228 expect(
229 requestEvents.some(
230 (event) => event.method === "session/set_config_option" && event.status === "succeeded",
231 ),
232 ).toBe(true);
233 expect(
234 requestEvents.some(
235 (event) => event.method === "session/prompt" && event.status === "started",
236 ),
237 ).toBe(true);
238 expect(
239 requestEvents.some(
240 (event) => event.method === "session/prompt" && event.status === "succeeded",
241 ),
242 ).toBe(true);
243 }).pipe(
244 Effect.provide(
245 AcpSessionRuntime.layer({
246 authMethodId: "test",
247 spawn: {
248 command: bunExe,
249 args: [mockAgentPath],
250 },
251 cwd: process.cwd(),
252 clientInfo: { name: "t3-test", version: "0.0.0" },
253 requestLogger: (event) =>
254 Effect.sync(() => {
255 requestEvents.push(event);
256 }),
257 }),
258 ),
259 Effect.scoped,
260 Effect.provide(NodeServices.layer),
261 );
262 });
263
264 it.effect("skips no-op session config writes when the requested value is already active", () => {
265 const requestEvents: Array<AcpSessionRequestLogEvent> = [];
266 return Effect.gen(function* () {
267 const runtime = yield* AcpSessionRuntime;
268 yield* runtime.start();
269
270 yield* runtime.setConfigOption("model", "default");
271 yield* runtime.setMode("ask");
272
273 expect(
274 requestEvents.some(
275 (event) => event.method === "session/set_config_option" && event.status === "started",
276 ),
277 ).toBe(false);
278 }).pipe(
279 Effect.provide(
280 AcpSessionRuntime.layer({
281 authMethodId: "test",
282 spawn: {
283 command: bunExe,
284 args: [mockAgentPath],
285 },
286 cwd: process.cwd(),
287 clientInfo: { name: "t3-test", version: "0.0.0" },
288 requestLogger: (event) =>
289 Effect.sync(() => {
290 requestEvents.push(event);
291 }),
292 }),
293 ),
294 Effect.scoped,
295 Effect.provide(NodeServices.layer),
296 );
297 });
298
299 it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => {
300 const protocolEvents: Array<EffectAcpProtocol.AcpProtocolLogEvent> = [];
301 return Effect.gen(function* () {
302 const runtime = yield* AcpSessionRuntime;
303 yield* runtime.start();
304
305 yield* runtime.prompt({
306 prompt: [{ type: "text", text: "hi" }],
307 });
308
309 expect(
310 protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"),
311 ).toBe(true);
312 expect(
313 protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"),
314 ).toBe(true);
315 expect(
316 protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"),
317 ).toBe(true);
318 expect(
319 protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"),
320 ).toBe(true);
321 }).pipe(
322 Effect.provide(
323 AcpSessionRuntime.layer({
324 authMethodId: "test",
325 spawn: {
326 command: bunExe,
327 args: [mockAgentPath],
328 },
329 cwd: process.cwd(),
330 clientInfo: { name: "t3-test", version: "0.0.0" },
331 protocolLogging: {
332 logIncoming: true,
333 logOutgoing: true,
334 logger: (event) =>
335 Effect.sync(() => {
336 protocolEvents.push(event);
337 }),
338 },
339 }),
340 ),
341 Effect.scoped,
342 Effect.provide(NodeServices.layer),
343 );
344 });
345
346 it.effect("rejects invalid config option values before sending session/set_config_option", () => {
347 const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-"));
348 const requestLogPath = path.join(tempDir, "requests.ndjson");
349 return Effect.gen(function* () {
350 const runtime = yield* AcpSessionRuntime;
351 yield* runtime.start();
352
353 const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip);
354 expect(error._tag).toBe("AcpRequestError");
355 if (error._tag === "AcpRequestError") {
356 expect(error.code).toBe(-32602);
357 expect(error.message).toContain(
358 'Invalid value "composer-2[fast=false]" for session config option "model"',
359 );
360 expect(error.message).toContain("composer-2[fast=true]");
361 }
362
363 const recordedRequests = readFileSync(requestLogPath, "utf8")
364 .trim()
365 .split("\n")
366 .filter((line) => line.length > 0)
367 .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } });
368 expect(
369 recordedRequests.some(
370 (message) =>
371 message.method === "session/set_config_option" &&
372 message.params?.value === "composer-2[fast=false]",
373 ),
374 ).toBe(false);
375 }).pipe(
376 Effect.provide(
377 AcpSessionRuntime.layer({
378 authMethodId: "test",
379 spawn: {
380 command: bunExe,
381 args: [mockAgentPath],
382 env: {
383 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
384 },
385 },
386 cwd: process.cwd(),
387 clientInfo: { name: "t3-test", version: "0.0.0" },
388 }),
389 ),
390 Effect.scoped,
391 Effect.provide(NodeServices.layer),
392 Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))),
393 );
394 });
395});
apps/server/src/provider/acp/AcpNativeLogging.ts created+76
......@@ -0,0 +1,76 @@
1import type { ProviderKind, ThreadId } from "@t3tools/contracts";
2import { Cause, Effect } from "effect";
3import type * as EffectAcpProtocol from "effect-acp/protocol";
4
5import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts";
6import type { AcpSessionRequestLogEvent, AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts";
7
8function writeNativeAcpLog(input: {
9 readonly nativeEventLogger: EventNdjsonLogger | undefined;
10 readonly provider: ProviderKind;
11 readonly threadId: ThreadId;
12 readonly kind: "request" | "protocol";
13 readonly payload: unknown;
14}): Effect.Effect<void, never> {
15 return Effect.gen(function* () {
16 if (!input.nativeEventLogger) return;
17 const observedAt = new Date().toISOString();
18 yield* input.nativeEventLogger.write(
19 {
20 observedAt,
21 event: {
22 id: crypto.randomUUID(),
23 kind: input.kind,
24 provider: input.provider,
25 createdAt: observedAt,
26 threadId: input.threadId,
27 payload: input.payload,
28 },
29 },
30 input.threadId,
31 );
32 });
33}
34
35function formatRequestLogPayload(event: AcpSessionRequestLogEvent) {
36 return {
37 method: event.method,
38 status: event.status,
39 request: event.payload,
40 ...(event.result !== undefined ? { result: event.result } : {}),
41 ...(event.cause !== undefined ? { cause: Cause.pretty(event.cause) } : {}),
42 };
43}
44
45export function makeAcpNativeLoggers(input: {
46 readonly nativeEventLogger: EventNdjsonLogger | undefined;
47 readonly provider: ProviderKind;
48 readonly threadId: ThreadId;
49}): Pick<AcpSessionRuntimeOptions, "requestLogger" | "protocolLogging"> {
50 return {
51 requestLogger: (event) =>
52 writeNativeAcpLog({
53 nativeEventLogger: input.nativeEventLogger,
54 provider: input.provider,
55 threadId: input.threadId,
56 kind: "request",
57 payload: formatRequestLogPayload(event),
58 }),
59 ...(input.nativeEventLogger
60 ? {
61 protocolLogging: {
62 logIncoming: true,
63 logOutgoing: true,
64 logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) =>
65 writeNativeAcpLog({
66 nativeEventLogger: input.nativeEventLogger,
67 provider: input.provider,
68 threadId: input.threadId,
69 kind: "protocol",
70 payload: event,
71 }),
72 } satisfies NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>,
73 }
74 : {}),
75 };
76}
apps/server/src/provider/acp/AcpRuntimeModel.test.ts created+286
......@@ -0,0 +1,286 @@
1import { describe, expect, it } from "vitest";
2
3import type * as EffectAcpSchema from "effect-acp/schema";
4
5import {
6 extractModelConfigId,
7 mergeToolCallState,
8 parsePermissionRequest,
9 parseSessionModeState,
10 parseSessionUpdateEvent,
11} from "./AcpRuntimeModel.ts";
12
13describe("AcpRuntimeModel", () => {
14 it("parses session mode state from typed ACP session setup responses", () => {
15 const modeState = parseSessionModeState({
16 sessionId: "session-1",
17 modes: {
18 currentModeId: " code ",
19 availableModes: [
20 { id: " ask ", name: " Ask ", description: " Request approval " },
21 { id: " code ", name: " Code " },
22 ],
23 },
24 configOptions: [],
25 } satisfies EffectAcpSchema.NewSessionResponse);
26
27 expect(modeState).toEqual({
28 currentModeId: "code",
29 availableModes: [
30 { id: "ask", name: "Ask", description: "Request approval" },
31 { id: "code", name: "Code" },
32 ],
33 });
34 });
35
36 it("extracts the model config id from typed ACP config options", () => {
37 const modelConfigId = extractModelConfigId({
38 sessionId: "session-1",
39 configOptions: [
40 {
41 id: "approval",
42 name: "Approval Mode",
43 category: "permission",
44 type: "select",
45 currentValue: "ask",
46 options: [{ value: "ask", name: "Ask" }],
47 },
48 {
49 id: "model",
50 name: "Model",
51 category: "model",
52 type: "select",
53 currentValue: "default",
54 options: [{ value: "default", name: "Auto" }],
55 },
56 ],
57 } satisfies EffectAcpSchema.NewSessionResponse);
58
59 expect(modelConfigId).toBe("model");
60 });
61
62 it("projects typed ACP tool call updates into runtime events", () => {
63 const created = parseSessionUpdateEvent({
64 sessionId: "session-1",
65 update: {
66 sessionUpdate: "tool_call",
67 toolCallId: "tool-1",
68 title: "Terminal",
69 kind: "execute",
70 status: "pending",
71 rawInput: {
72 executable: "bun",
73 args: ["run", "typecheck"],
74 },
75 content: [
76 {
77 type: "content",
78 content: {
79 type: "text",
80 text: "Running checks",
81 },
82 },
83 ],
84 },
85 } satisfies EffectAcpSchema.SessionNotification);
86
87 expect(created.events).toEqual([
88 {
89 _tag: "ToolCallUpdated",
90 toolCall: {
91 toolCallId: "tool-1",
92 kind: "execute",
93 title: "Ran command",
94 status: "pending",
95 command: "bun run typecheck",
96 detail: "bun run typecheck",
97 data: {
98 toolCallId: "tool-1",
99 kind: "execute",
100 command: "bun run typecheck",
101 rawInput: {
102 executable: "bun",
103 args: ["run", "typecheck"],
104 },
105 content: [
106 {
107 type: "content",
108 content: {
109 type: "text",
110 text: "Running checks",
111 },
112 },
113 ],
114 },
115 },
116 rawPayload: {
117 sessionId: "session-1",
118 update: {
119 sessionUpdate: "tool_call",
120 toolCallId: "tool-1",
121 title: "Terminal",
122 kind: "execute",
123 status: "pending",
124 rawInput: {
125 executable: "bun",
126 args: ["run", "typecheck"],
127 },
128 content: [
129 {
130 type: "content",
131 content: {
132 type: "text",
133 text: "Running checks",
134 },
135 },
136 ],
137 },
138 },
139 },
140 ]);
141
142 const updated = parseSessionUpdateEvent({
143 sessionId: "session-1",
144 update: {
145 sessionUpdate: "tool_call_update",
146 toolCallId: "tool-1",
147 status: "completed",
148 rawOutput: { exitCode: 0 },
149 },
150 } satisfies EffectAcpSchema.SessionNotification);
151
152 expect(updated.events).toHaveLength(1);
153 expect(updated.events[0]?._tag).toBe("ToolCallUpdated");
154 const createdEvent = created.events[0];
155 const updatedEvent = updated.events[0];
156 if (createdEvent?._tag === "ToolCallUpdated" && updatedEvent?._tag === "ToolCallUpdated") {
157 expect(mergeToolCallState(createdEvent.toolCall, updatedEvent.toolCall)).toMatchObject({
158 toolCallId: "tool-1",
159 status: "completed",
160 title: "Ran command",
161 detail: "bun run typecheck",
162 command: "bun run typecheck",
163 });
164 }
165 });
166
167 it("trims padded current mode updates before emitting a mode change", () => {
168 const result = parseSessionUpdateEvent({
169 sessionId: "session-1",
170 update: {
171 sessionUpdate: "current_mode_update",
172 currentModeId: " code ",
173 },
174 } satisfies EffectAcpSchema.SessionNotification);
175
176 expect(result.modeId).toBe("code");
177 expect(result.events).toEqual([
178 {
179 _tag: "ModeChanged",
180 modeId: "code",
181 },
182 ]);
183 });
184
185 it("projects typed ACP plan and content updates", () => {
186 const planResult = parseSessionUpdateEvent({
187 sessionId: "session-1",
188 update: {
189 sessionUpdate: "plan",
190 entries: [
191 { content: " Inspect state ", priority: "high", status: "completed" },
192 { content: "", priority: "medium", status: "in_progress" },
193 ],
194 },
195 } satisfies EffectAcpSchema.SessionNotification);
196
197 expect(planResult.events).toEqual([
198 {
199 _tag: "PlanUpdated",
200 payload: {
201 plan: [
202 { step: "Inspect state", status: "completed" },
203 { step: "Step 2", status: "inProgress" },
204 ],
205 },
206 rawPayload: {
207 sessionId: "session-1",
208 update: {
209 sessionUpdate: "plan",
210 entries: [
211 { content: " Inspect state ", priority: "high", status: "completed" },
212 { content: "", priority: "medium", status: "in_progress" },
213 ],
214 },
215 },
216 },
217 ]);
218
219 const contentResult = parseSessionUpdateEvent({
220 sessionId: "session-1",
221 update: {
222 sessionUpdate: "agent_message_chunk",
223 content: {
224 type: "text",
225 text: "hello from acp",
226 },
227 },
228 } satisfies EffectAcpSchema.SessionNotification);
229
230 expect(contentResult.events).toEqual([
231 {
232 _tag: "ContentDelta",
233 text: "hello from acp",
234 rawPayload: {
235 sessionId: "session-1",
236 update: {
237 sessionUpdate: "agent_message_chunk",
238 content: {
239 type: "text",
240 text: "hello from acp",
241 },
242 },
243 },
244 },
245 ]);
246 });
247
248 it("keeps permission request parsing compatible with loose extension payloads", () => {
249 const request = parsePermissionRequest({
250 sessionId: "session-1",
251 options: [
252 {
253 optionId: "allow-once",
254 name: "Allow once",
255 kind: "allow_once",
256 },
257 ],
258 toolCall: {
259 toolCallId: "tool-1",
260 title: "`cat package.json`",
261 kind: "execute",
262 status: "pending",
263 content: [
264 {
265 type: "content",
266 content: {
267 type: "text",
268 text: "Not in allowlist",
269 },
270 },
271 ],
272 },
273 });
274
275 expect(request).toMatchObject({
276 kind: "execute",
277 detail: "cat package.json",
278 toolCall: {
279 toolCallId: "tool-1",
280 kind: "execute",
281 status: "pending",
282 command: "cat package.json",
283 },
284 });
285 });
286});
apps/server/src/provider/acp/AcpRuntimeModel.ts created+482
......@@ -0,0 +1,482 @@
1import type * as EffectAcpSchema from "effect-acp/schema";
2import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity";
3import type { ToolLifecycleItemType } from "@t3tools/contracts";
4
5function isRecord(value: unknown): value is Record<string, unknown> {
6 return typeof value === "object" && value !== null && !Array.isArray(value);
7}
8
9export interface AcpSessionMode {
10 readonly id: string;
11 readonly name: string;
12 readonly description?: string;
13}
14
15export interface AcpSessionModeState {
16 readonly currentModeId: string;
17 readonly availableModes: ReadonlyArray<AcpSessionMode>;
18}
19
20export interface AcpToolCallState {
21 readonly toolCallId: string;
22 readonly kind?: string;
23 readonly title?: string;
24 readonly status?: "pending" | "inProgress" | "completed" | "failed";
25 readonly command?: string;
26 readonly detail?: string;
27 readonly data: Record<string, unknown>;
28}
29
30export interface AcpPlanUpdate {
31 readonly explanation?: string | null;
32 readonly plan: ReadonlyArray<{
33 readonly step: string;
34 readonly status: "pending" | "inProgress" | "completed";
35 }>;
36}
37
38export interface AcpPermissionRequest {
39 readonly kind: string | "unknown";
40 readonly detail?: string;
41 readonly toolCall?: AcpToolCallState;
42}
43
44export type AcpParsedSessionEvent =
45 | {
46 readonly _tag: "ModeChanged";
47 readonly modeId: string;
48 }
49 | {
50 readonly _tag: "AssistantItemStarted";
51 readonly itemId: string;
52 }
53 | {
54 readonly _tag: "AssistantItemCompleted";
55 readonly itemId: string;
56 }
57 | {
58 readonly _tag: "PlanUpdated";
59 readonly payload: AcpPlanUpdate;
60 readonly rawPayload: unknown;
61 }
62 | {
63 readonly _tag: "ToolCallUpdated";
64 readonly toolCall: AcpToolCallState;
65 readonly rawPayload: unknown;
66 }
67 | {
68 readonly _tag: "ContentDelta";
69 readonly itemId?: string;
70 readonly text: string;
71 readonly rawPayload: unknown;
72 };
73
74type AcpSessionSetupResponse =
75 | EffectAcpSchema.LoadSessionResponse
76 | EffectAcpSchema.NewSessionResponse
77 | EffectAcpSchema.ResumeSessionResponse;
78
79type AcpToolCallUpdate = Extract<
80 EffectAcpSchema.SessionNotification["update"],
81 { readonly sessionUpdate: "tool_call" | "tool_call_update" }
82>;
83
84export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined {
85 const configOptions = sessionResponse.configOptions;
86 if (!configOptions) return undefined;
87 for (const opt of configOptions) {
88 if (opt.category === "model" && opt.id.trim().length > 0) {
89 return opt.id.trim();
90 }
91 }
92 return undefined;
93}
94
95export function findSessionConfigOption(
96 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null | undefined,
97 configId: string,
98): EffectAcpSchema.SessionConfigOption | undefined {
99 if (!configOptions) {
100 return undefined;
101 }
102 const normalizedConfigId = configId.trim();
103 if (!normalizedConfigId) {
104 return undefined;
105 }
106 return configOptions.find((option) => option.id.trim() === normalizedConfigId);
107}
108
109export function collectSessionConfigOptionValues(
110 configOption: EffectAcpSchema.SessionConfigOption,
111): ReadonlyArray<string> {
112 if (configOption.type !== "select") {
113 return [];
114 }
115 return configOption.options.flatMap((entry) =>
116 "value" in entry ? [entry.value] : entry.options.map((option) => option.value),
117 );
118}
119
120export function parseSessionModeState(
121 sessionResponse: AcpSessionSetupResponse,
122): AcpSessionModeState | undefined {
123 const modes = sessionResponse.modes;
124 if (!modes) return undefined;
125 const currentModeId = modes.currentModeId.trim();
126 if (!currentModeId) {
127 return undefined;
128 }
129 const availableModes = modes.availableModes
130 .map((mode) => {
131 const id = mode.id.trim();
132 const name = mode.name.trim();
133 if (!id || !name) {
134 return undefined;
135 }
136 const description = mode.description?.trim() || undefined;
137 return description !== undefined
138 ? ({ id, name, description } satisfies AcpSessionMode)
139 : ({ id, name } satisfies AcpSessionMode);
140 })
141 .filter((mode): mode is AcpSessionMode => mode !== undefined);
142 if (availableModes.length === 0) {
143 return undefined;
144 }
145 return {
146 currentModeId,
147 availableModes,
148 };
149}
150
151function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" {
152 switch (raw) {
153 case "completed":
154 return "completed";
155 case "in_progress":
156 case "inProgress":
157 return "inProgress";
158 default:
159 return "pending";
160 }
161}
162
163function normalizeToolCallStatus(
164 raw: unknown,
165 fallback?: "pending" | "inProgress" | "completed" | "failed",
166): "pending" | "inProgress" | "completed" | "failed" | undefined {
167 switch (raw) {
168 case "pending":
169 return "pending";
170 case "in_progress":
171 case "inProgress":
172 return "inProgress";
173 case "completed":
174 return "completed";
175 case "failed":
176 return "failed";
177 default:
178 return fallback;
179 }
180}
181
182function normalizeCommandValue(value: unknown): string | undefined {
183 if (typeof value === "string" && value.trim().length > 0) {
184 return value.trim();
185 }
186 if (!Array.isArray(value)) {
187 return undefined;
188 }
189 const parts = value
190 .map((entry) => (typeof entry === "string" && entry.trim().length > 0 ? entry.trim() : null))
191 .filter((entry): entry is string => entry !== null);
192 return parts.length > 0 ? parts.join(" ") : undefined;
193}
194
195function extractCommandFromTitle(title: string | undefined): string | undefined {
196 if (!title) {
197 return undefined;
198 }
199 const match = /`([^`]+)`/.exec(title);
200 return match?.[1]?.trim() || undefined;
201}
202
203function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined {
204 if (isRecord(rawInput)) {
205 const directCommand = normalizeCommandValue(rawInput.command);
206 if (directCommand) {
207 return directCommand;
208 }
209 const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : "";
210 const args = normalizeCommandValue(rawInput.args);
211 if (executable && args) {
212 return `${executable} ${args}`;
213 }
214 if (executable) {
215 return executable;
216 }
217 }
218 return extractCommandFromTitle(title);
219}
220
221function extractTextContentFromToolCallContent(
222 content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined,
223): string | undefined {
224 if (!content) return undefined;
225 const chunks = content
226 .map((entry) => {
227 if (entry.type !== "content") {
228 return undefined;
229 }
230 const nestedContent = entry.content;
231 if (nestedContent.type !== "text") {
232 return undefined;
233 }
234 return nestedContent.text.trim().length > 0 ? nestedContent.text.trim() : undefined;
235 })
236 .filter((entry): entry is string => entry !== undefined);
237 return chunks.length > 0 ? chunks.join("\n") : undefined;
238}
239
240function normalizeToolKind(kind: unknown): string | undefined {
241 return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined;
242}
243
244function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType {
245 switch (kind) {
246 case "execute":
247 return "command_execution";
248 case "edit":
249 case "delete":
250 case "move":
251 return "file_change";
252 case "search":
253 case "fetch":
254 return "web_search";
255 default:
256 return "dynamic_tool_call";
257 }
258}
259
260function makeToolCallState(
261 input: {
262 readonly toolCallId: string;
263 readonly title?: string | null | undefined;
264 readonly kind?: EffectAcpSchema.ToolKind | null | undefined;
265 readonly status?: EffectAcpSchema.ToolCallStatus | null | undefined;
266 readonly rawInput?: unknown;
267 readonly rawOutput?: unknown;
268 readonly content?: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined;
269 readonly locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation> | null | undefined;
270 },
271 options?: {
272 readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed";
273 },
274): AcpToolCallState | undefined {
275 const toolCallId = input.toolCallId.trim();
276 if (!toolCallId) {
277 return undefined;
278 }
279 const title = input.title?.trim() || undefined;
280 const command = extractToolCallCommand(input.rawInput, title);
281 const textContent = extractTextContentFromToolCallContent(input.content);
282 const normalizedTitle =
283 title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call"
284 ? title
285 : undefined;
286 const data: Record<string, unknown> = { toolCallId };
287 const kind = normalizeToolKind(input.kind);
288 if (kind) {
289 data.kind = kind;
290 }
291 if (command) {
292 data.command = command;
293 }
294 if (input.rawInput !== undefined) {
295 data.rawInput = input.rawInput;
296 }
297 if (input.rawOutput !== undefined) {
298 data.rawOutput = input.rawOutput;
299 }
300 if (input.content !== undefined) {
301 data.content = input.content;
302 }
303 if (input.locations !== undefined) {
304 data.locations = input.locations;
305 }
306 const fallbackDetail = command ?? normalizedTitle ?? textContent;
307 const hasPresentationSeed =
308 title !== undefined ||
309 kind !== undefined ||
310 command !== undefined ||
311 normalizedTitle !== undefined ||
312 textContent !== undefined;
313 const presentation = hasPresentationSeed
314 ? deriveToolActivityPresentation({
315 itemType: canonicalItemTypeFromAcpToolKind(kind),
316 title,
317 detail: fallbackDetail,
318 data,
319 fallbackSummary: title ?? "Tool",
320 })
321 : undefined;
322 const status = normalizeToolCallStatus(input.status, options?.fallbackStatus);
323 return {
324 toolCallId,
325 ...(kind ? { kind } : {}),
326 ...(presentation?.summary ? { title: presentation.summary } : {}),
327 ...(status ? { status } : {}),
328 ...(command ? { command } : {}),
329 ...(presentation?.detail ? { detail: presentation.detail } : {}),
330 data,
331 };
332}
333
334function parseTypedToolCallState(
335 event: AcpToolCallUpdate,
336 options?: {
337 readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed";
338 },
339): AcpToolCallState | undefined {
340 return makeToolCallState(
341 {
342 toolCallId: event.toolCallId,
343 title: event.title,
344 kind: event.kind,
345 status: event.status,
346 rawInput: event.rawInput,
347 rawOutput: event.rawOutput,
348 content: event.content,
349 locations: event.locations,
350 },
351 options,
352 );
353}
354
355export function mergeToolCallState(
356 previous: AcpToolCallState | undefined,
357 next: AcpToolCallState,
358): AcpToolCallState {
359 const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined;
360 const kind = nextKind ?? previous?.kind;
361 const title = next.title ?? previous?.title;
362 const status = next.status ?? previous?.status;
363 const command = next.command ?? previous?.command;
364 const detail = next.detail ?? previous?.detail;
365 return {
366 toolCallId: next.toolCallId,
367 ...(kind ? { kind } : {}),
368 ...(title ? { title } : {}),
369 ...(status ? { status } : {}),
370 ...(command ? { command } : {}),
371 ...(detail ? { detail } : {}),
372 data: {
373 ...previous?.data,
374 ...next.data,
375 },
376 };
377}
378
379export function parsePermissionRequest(
380 params: EffectAcpSchema.RequestPermissionRequest,
381): AcpPermissionRequest {
382 const toolCall = makeToolCallState(
383 {
384 toolCallId: params.toolCall.toolCallId,
385 title: params.toolCall.title,
386 kind: params.toolCall.kind,
387 status: params.toolCall.status,
388 rawInput: params.toolCall.rawInput,
389 rawOutput: params.toolCall.rawOutput,
390 content: params.toolCall.content,
391 locations: params.toolCall.locations,
392 },
393 { fallbackStatus: "pending" },
394 );
395 const kind = normalizeToolKind(params.toolCall.kind) ?? "unknown";
396 const detail =
397 toolCall?.command ??
398 toolCall?.title ??
399 toolCall?.detail ??
400 (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined);
401 return {
402 kind,
403 ...(detail ? { detail } : {}),
404 ...(toolCall ? { toolCall } : {}),
405 };
406}
407
408export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): {
409 readonly modeId?: string;
410 readonly events: ReadonlyArray<AcpParsedSessionEvent>;
411} {
412 const upd = params.update;
413 const events: Array<AcpParsedSessionEvent> = [];
414 let modeId: string | undefined;
415
416 switch (upd.sessionUpdate) {
417 case "current_mode_update": {
418 modeId = upd.currentModeId.trim();
419 if (modeId) {
420 events.push({
421 _tag: "ModeChanged",
422 modeId,
423 });
424 }
425 break;
426 }
427 case "plan": {
428 const plan = upd.entries.map((entry, index) => ({
429 step: entry.content.trim().length > 0 ? entry.content.trim() : `Step ${index + 1}`,
430 status: normalizePlanStepStatus(entry.status),
431 }));
432 if (plan.length > 0) {
433 events.push({
434 _tag: "PlanUpdated",
435 payload: {
436 plan,
437 },
438 rawPayload: params,
439 });
440 }
441 break;
442 }
443 case "tool_call": {
444 const toolCall = parseTypedToolCallState(upd, {
445 fallbackStatus: "pending",
446 });
447 if (toolCall) {
448 events.push({
449 _tag: "ToolCallUpdated",
450 toolCall,
451 rawPayload: params,
452 });
453 }
454 break;
455 }
456 case "tool_call_update": {
457 const toolCall = parseTypedToolCallState(upd);
458 if (toolCall) {
459 events.push({
460 _tag: "ToolCallUpdated",
461 toolCall,
462 rawPayload: params,
463 });
464 }
465 break;
466 }
467 case "agent_message_chunk": {
468 if (upd.content.type === "text" && upd.content.text.length > 0) {
469 events.push({
470 _tag: "ContentDelta",
471 text: upd.content.text,
472 rawPayload: params,
473 });
474 }
475 break;
476 }
477 default:
478 break;
479 }
480
481 return { ...(modeId !== undefined ? { modeId } : {}), events };
482}
apps/server/src/provider/acp/AcpSessionRuntime.ts created+722
......@@ -0,0 +1,722 @@
1import { Cause, Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Context, Stream } from "effect";
2import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3import * as EffectAcpClient from "effect-acp/client";
4import * as EffectAcpErrors from "effect-acp/errors";
5import type * as EffectAcpSchema from "effect-acp/schema";
6import type * as EffectAcpProtocol from "effect-acp/protocol";
7
8import {
9 collectSessionConfigOptionValues,
10 extractModelConfigId,
11 findSessionConfigOption,
12 mergeToolCallState,
13 parseSessionModeState,
14 parseSessionUpdateEvent,
15 type AcpParsedSessionEvent,
16 type AcpSessionModeState,
17 type AcpToolCallState,
18} from "./AcpRuntimeModel.ts";
19
20export interface AcpSpawnInput {
21 readonly command: string;
22 readonly args: ReadonlyArray<string>;
23 readonly cwd?: string;
24 readonly env?: Readonly<Record<string, string>>;
25}
26
27export interface AcpSessionRuntimeOptions {
28 readonly spawn: AcpSpawnInput;
29 readonly cwd: string;
30 readonly resumeSessionId?: string;
31 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
32 readonly clientInfo: {
33 readonly name: string;
34 readonly version: string;
35 };
36 readonly authMethodId: string;
37 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
38 readonly protocolLogging?: {
39 readonly logIncoming?: boolean;
40 readonly logOutgoing?: boolean;
41 readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
42 };
43}
44
45export interface AcpSessionRequestLogEvent {
46 readonly method: string;
47 readonly payload: unknown;
48 readonly status: "started" | "succeeded" | "failed";
49 readonly result?: unknown;
50 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;
51}
52
53export interface AcpSessionRuntimeStartResult {
54 readonly sessionId: string;
55 readonly initializeResult: EffectAcpSchema.InitializeResponse;
56 readonly sessionSetupResult:
57 | EffectAcpSchema.LoadSessionResponse
58 | EffectAcpSchema.NewSessionResponse
59 | EffectAcpSchema.ResumeSessionResponse;
60 readonly modelConfigId: string | undefined;
61}
62
63export interface AcpSessionRuntimeShape {
64 readonly handleRequestPermission: EffectAcpClient.AcpClientShape["handleRequestPermission"];
65 readonly handleElicitation: EffectAcpClient.AcpClientShape["handleElicitation"];
66 readonly handleReadTextFile: EffectAcpClient.AcpClientShape["handleReadTextFile"];
67 readonly handleWriteTextFile: EffectAcpClient.AcpClientShape["handleWriteTextFile"];
68 readonly handleCreateTerminal: EffectAcpClient.AcpClientShape["handleCreateTerminal"];
69 readonly handleTerminalOutput: EffectAcpClient.AcpClientShape["handleTerminalOutput"];
70 readonly handleTerminalWaitForExit: EffectAcpClient.AcpClientShape["handleTerminalWaitForExit"];
71 readonly handleTerminalKill: EffectAcpClient.AcpClientShape["handleTerminalKill"];
72 readonly handleTerminalRelease: EffectAcpClient.AcpClientShape["handleTerminalRelease"];
73 readonly handleSessionUpdate: EffectAcpClient.AcpClientShape["handleSessionUpdate"];
74 readonly handleElicitationComplete: EffectAcpClient.AcpClientShape["handleElicitationComplete"];
75 readonly handleUnknownExtRequest: EffectAcpClient.AcpClientShape["handleUnknownExtRequest"];
76 readonly handleUnknownExtNotification: EffectAcpClient.AcpClientShape["handleUnknownExtNotification"];
77 readonly handleExtRequest: EffectAcpClient.AcpClientShape["handleExtRequest"];
78 readonly handleExtNotification: EffectAcpClient.AcpClientShape["handleExtNotification"];
79 readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
80 readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>;
81 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;
82 readonly getConfigOptions: Effect.Effect<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
83 readonly prompt: (
84 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
85 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
86 readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>;
87 readonly setMode: (
88 modeId: string,
89 ) => Effect.Effect<EffectAcpSchema.SetSessionModeResponse, EffectAcpErrors.AcpError>;
90 readonly setConfigOption: (
91 configId: string,
92 value: string | boolean,
93 ) => Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError>;
94 readonly setModel: (model: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
95 readonly request: (
96 method: string,
97 payload: unknown,
98 ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;
99 readonly notify: (
100 method: string,
101 payload: unknown,
102 ) => Effect.Effect<void, EffectAcpErrors.AcpError>;
103}
104
105interface AcpStartedState extends AcpSessionRuntimeStartResult {}
106
107type AcpStartState =
108 | { readonly _tag: "NotStarted" }
109 | {
110 readonly _tag: "Starting";
111 readonly deferred: Deferred.Deferred<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
112 }
113 | { readonly _tag: "Started"; readonly result: AcpStartedState };
114
115interface AcpAssistantSegmentState {
116 readonly nextSegmentIndex: number;
117 readonly activeItemId?: string;
118}
119
120interface EnsureActiveAssistantSegmentResult {
121 readonly itemId: string;
122 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
123}
124
125export class AcpSessionRuntime extends Context.Service<AcpSessionRuntime, AcpSessionRuntimeShape>()(
126 "t3/provider/acp/AcpSessionRuntime",
127) {
128 static layer(
129 options: AcpSessionRuntimeOptions,
130 ): Layer.Layer<
131 AcpSessionRuntime,
132 EffectAcpErrors.AcpError,
133 ChildProcessSpawner.ChildProcessSpawner
134 > {
135 return Layer.effect(AcpSessionRuntime, makeAcpSessionRuntime(options));
136 }
137}
138
139const makeAcpSessionRuntime = (
140 options: AcpSessionRuntimeOptions,
141): Effect.Effect<
142 AcpSessionRuntimeShape,
143 EffectAcpErrors.AcpError,
144 ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
145> =>
146 Effect.gen(function* () {
147 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
148 const runtimeScope = yield* Scope.Scope;
149 const eventQueue = yield* Queue.unbounded<AcpParsedSessionEvent>();
150 const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined);
151 const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallState>());
152 const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });
153 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
154 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });
155
156 const logRequest = (event: AcpSessionRequestLogEvent) =>
157 options.requestLogger ? options.requestLogger(event) : Effect.void;
158
159 const runLoggedRequest = <A>(
160 method: string,
161 payload: unknown,
162 effect: Effect.Effect<A, EffectAcpErrors.AcpError>,
163 ): Effect.Effect<A, EffectAcpErrors.AcpError> =>
164 logRequest({ method, payload, status: "started" }).pipe(
165 Effect.flatMap(() =>
166 effect.pipe(
167 Effect.tap((result) =>
168 logRequest({
169 method,
170 payload,
171 status: "succeeded",
172 result,
173 }),
174 ),
175 Effect.onError((cause) =>
176 logRequest({
177 method,
178 payload,
179 status: "failed",
180 cause,
181 }),
182 ),
183 ),
184 ),
185 );
186
187 const child = yield* spawner
188 .spawn(
189 ChildProcess.make(options.spawn.command, [...options.spawn.args], {
190 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
191 ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}),
192 shell: process.platform === "win32",
193 }),
194 )
195 .pipe(
196 Effect.provideService(Scope.Scope, runtimeScope),
197 Effect.mapError(
198 (cause) =>
199 new EffectAcpErrors.AcpSpawnError({
200 command: options.spawn.command,
201 cause,
202 }),
203 ),
204 );
205
206 const acpContext = yield* Layer.build(
207 EffectAcpClient.layerChildProcess(child, {
208 ...(options.protocolLogging?.logIncoming !== undefined
209 ? { logIncoming: options.protocolLogging.logIncoming }
210 : {}),
211 ...(options.protocolLogging?.logOutgoing !== undefined
212 ? { logOutgoing: options.protocolLogging.logOutgoing }
213 : {}),
214 ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}),
215 }),
216 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));
217
218 const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext));
219
220 yield* acp.handleSessionUpdate((notification) =>
221 handleSessionUpdate({
222 queue: eventQueue,
223 modeStateRef,
224 toolCallsRef,
225 assistantSegmentRef,
226 params: notification,
227 }),
228 );
229
230 const initializeClientCapabilities = {
231 fs: {
232 readTextFile: false,
233 writeTextFile: false,
234 ...options.clientCapabilities?.fs,
235 },
236 terminal: options.clientCapabilities?.terminal ?? false,
237 ...(options.clientCapabilities?.auth ? { auth: options.clientCapabilities.auth } : {}),
238 ...(options.clientCapabilities?.elicitation
239 ? { elicitation: options.clientCapabilities.elicitation }
240 : {}),
241 ...(options.clientCapabilities?._meta ? { _meta: options.clientCapabilities._meta } : {}),
242 } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
243
244 const getStartedState = Effect.gen(function* () {
245 const state = yield* Ref.get(startStateRef);
246 if (state._tag === "Started") {
247 return state.result;
248 }
249 return yield* new EffectAcpErrors.AcpTransportError({
250 detail: "ACP session runtime has not been started",
251 cause: new Error("ACP session runtime has not been started"),
252 });
253 });
254
255 const validateConfigOptionValue = (
256 configId: string,
257 value: string | boolean,
258 ): Effect.Effect<void, EffectAcpErrors.AcpError> =>
259 Effect.gen(function* () {
260 const configOption = findSessionConfigOption(yield* Ref.get(configOptionsRef), configId);
261 if (!configOption) {
262 return;
263 }
264 if (configOption.type === "boolean") {
265 if (typeof value === "boolean") {
266 return;
267 }
268 return yield* new EffectAcpErrors.AcpRequestError({
269 code: -32602,
270 errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected boolean`,
271 data: {
272 configId: configOption.id,
273 expectedType: "boolean",
274 receivedValue: value,
275 },
276 });
277 }
278 if (typeof value !== "string") {
279 return yield* new EffectAcpErrors.AcpRequestError({
280 code: -32602,
281 errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected string`,
282 data: {
283 configId: configOption.id,
284 expectedType: "string",
285 receivedValue: value,
286 },
287 });
288 }
289 const allowedValues = collectSessionConfigOptionValues(configOption);
290 if (allowedValues.includes(value)) {
291 return;
292 }
293 return yield* new EffectAcpErrors.AcpRequestError({
294 code: -32602,
295 errorMessage: `Invalid value ${JSON.stringify(value)} for session config option "${configOption.id}": expected one of ${allowedValues.join(", ")}`,
296 data: {
297 configId: configOption.id,
298 allowedValues,
299 receivedValue: value,
300 },
301 });
302 });
303
304 const updateConfigOptions = (
305 response:
306 | EffectAcpSchema.SetSessionConfigOptionResponse
307 | EffectAcpSchema.LoadSessionResponse
308 | EffectAcpSchema.NewSessionResponse
309 | EffectAcpSchema.ResumeSessionResponse,
310 ): Effect.Effect<void> => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response));
311
312 const updateCurrentModeId = (modeId: string): Effect.Effect<void> =>
313 Ref.update(modeStateRef, (current) =>
314 current ? { ...current, currentModeId: modeId } : current,
315 );
316
317 const setConfigOption = (
318 configId: string,
319 value: string | boolean,
320 ): Effect.Effect<EffectAcpSchema.SetSessionConfigOptionResponse, EffectAcpErrors.AcpError> =>
321 validateConfigOptionValue(configId, value).pipe(
322 Effect.flatMap(() => getStartedState),
323 Effect.flatMap((started) =>
324 Ref.get(configOptionsRef).pipe(
325 Effect.flatMap((configOptions) => {
326 const existing = findSessionConfigOption(configOptions, configId);
327 if (existing && configOptionCurrentValueMatches(existing, value)) {
328 return Effect.succeed({
329 configOptions,
330 } satisfies EffectAcpSchema.SetSessionConfigOptionResponse);
331 }
332 const requestPayload =
333 typeof value === "boolean"
334 ? ({
335 sessionId: started.sessionId,
336 configId,
337 type: "boolean",
338 value,
339 } satisfies EffectAcpSchema.SetSessionConfigOptionRequest)
340 : ({
341 sessionId: started.sessionId,
342 configId,
343 value: String(value),
344 } satisfies EffectAcpSchema.SetSessionConfigOptionRequest);
345 return runLoggedRequest(
346 "session/set_config_option",
347 requestPayload,
348 acp.agent.setSessionConfigOption(requestPayload),
349 ).pipe(Effect.tap((response) => updateConfigOptions(response)));
350 }),
351 ),
352 ),
353 );
354
355 const startOnce = Effect.gen(function* () {
356 const initializePayload = {
357 protocolVersion: 1,
358 clientCapabilities: initializeClientCapabilities,
359 clientInfo: options.clientInfo,
360 } satisfies EffectAcpSchema.InitializeRequest;
361
362 const initializeResult = yield* runLoggedRequest(
363 "initialize",
364 initializePayload,
365 acp.agent.initialize(initializePayload),
366 );
367
368 const authenticatePayload = {
369 methodId: options.authMethodId,
370 } satisfies EffectAcpSchema.AuthenticateRequest;
371
372 yield* runLoggedRequest(
373 "authenticate",
374 authenticatePayload,
375 acp.agent.authenticate(authenticatePayload),
376 );
377
378 let sessionId: string;
379 let sessionSetupResult:
380 | EffectAcpSchema.LoadSessionResponse
381 | EffectAcpSchema.NewSessionResponse
382 | EffectAcpSchema.ResumeSessionResponse;
383 if (options.resumeSessionId) {
384 const loadPayload = {
385 sessionId: options.resumeSessionId,
386 cwd: options.cwd,
387 mcpServers: [],
388 } satisfies EffectAcpSchema.LoadSessionRequest;
389 const resumed = yield* runLoggedRequest(
390 "session/load",
391 loadPayload,
392 acp.agent.loadSession(loadPayload),
393 ).pipe(Effect.exit);
394 if (Exit.isSuccess(resumed)) {
395 sessionId = options.resumeSessionId;
396 sessionSetupResult = resumed.value;
397 } else {
398 const createPayload = {
399 cwd: options.cwd,
400 mcpServers: [],
401 } satisfies EffectAcpSchema.NewSessionRequest;
402 const created = yield* runLoggedRequest(
403 "session/new",
404 createPayload,
405 acp.agent.createSession(createPayload),
406 );
407 sessionId = created.sessionId;
408 sessionSetupResult = created;
409 }
410 } else {
411 const createPayload = {
412 cwd: options.cwd,
413 mcpServers: [],
414 } satisfies EffectAcpSchema.NewSessionRequest;
415 const created = yield* runLoggedRequest(
416 "session/new",
417 createPayload,
418 acp.agent.createSession(createPayload),
419 );
420 sessionId = created.sessionId;
421 sessionSetupResult = created;
422 }
423
424 yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
425 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
426
427 const nextState = {
428 sessionId,
429 initializeResult,
430 sessionSetupResult,
431 modelConfigId: extractModelConfigId(sessionSetupResult),
432 } satisfies AcpStartedState;
433 return nextState;
434 });
435
436 const start = Effect.gen(function* () {
437 const deferred = yield* Deferred.make<
438 AcpSessionRuntimeStartResult,
439 EffectAcpErrors.AcpError
440 >();
441 const effect = yield* Ref.modify(startStateRef, (state) => {
442 switch (state._tag) {
443 case "Started":
444 return [Effect.succeed(state.result), state] as const;
445 case "Starting":
446 return [Deferred.await(state.deferred), state] as const;
447 case "NotStarted":
448 return [
449 startOnce.pipe(
450 Effect.tap((result) =>
451 Ref.set(startStateRef, { _tag: "Started", result }).pipe(
452 Effect.andThen(Deferred.succeed(deferred, result)),
453 ),
454 ),
455 Effect.onError((cause) =>
456 Deferred.failCause(deferred, cause).pipe(
457 Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })),
458 ),
459 ),
460 ),
461 { _tag: "Starting", deferred } satisfies AcpStartState,
462 ] as const;
463 }
464 });
465 return yield* effect;
466 });
467
468 return {
469 handleRequestPermission: acp.handleRequestPermission,
470 handleElicitation: acp.handleElicitation,
471 handleReadTextFile: acp.handleReadTextFile,
472 handleWriteTextFile: acp.handleWriteTextFile,
473 handleCreateTerminal: acp.handleCreateTerminal,
474 handleTerminalOutput: acp.handleTerminalOutput,
475 handleTerminalWaitForExit: acp.handleTerminalWaitForExit,
476 handleTerminalKill: acp.handleTerminalKill,
477 handleTerminalRelease: acp.handleTerminalRelease,
478 handleSessionUpdate: acp.handleSessionUpdate,
479 handleElicitationComplete: acp.handleElicitationComplete,
480 handleUnknownExtRequest: acp.handleUnknownExtRequest,
481 handleUnknownExtNotification: acp.handleUnknownExtNotification,
482 handleExtRequest: acp.handleExtRequest,
483 handleExtNotification: acp.handleExtNotification,
484 start: () => start,
485 getEvents: () => Stream.fromQueue(eventQueue),
486 getModeState: Ref.get(modeStateRef),
487 getConfigOptions: Ref.get(configOptionsRef),
488 prompt: (payload) =>
489 getStartedState.pipe(
490 Effect.flatMap((started) => {
491 const requestPayload = {
492 sessionId: started.sessionId,
493 ...payload,
494 } satisfies EffectAcpSchema.PromptRequest;
495 return closeActiveAssistantSegment({
496 queue: eventQueue,
497 assistantSegmentRef,
498 }).pipe(
499 Effect.andThen(
500 runLoggedRequest(
501 "session/prompt",
502 requestPayload,
503 acp.agent.prompt(requestPayload),
504 ),
505 ),
506 Effect.tap(() =>
507 closeActiveAssistantSegment({
508 queue: eventQueue,
509 assistantSegmentRef,
510 }),
511 ),
512 );
513 }),
514 ),
515 cancel: getStartedState.pipe(
516 Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })),
517 ),
518 setMode: (modeId) =>
519 Ref.get(modeStateRef).pipe(
520 Effect.flatMap((modeState) => {
521 if (modeState?.currentModeId === modeId) {
522 return Effect.succeed({} satisfies EffectAcpSchema.SetSessionModeResponse);
523 }
524 return setConfigOption("mode", modeId).pipe(
525 Effect.tap(() => updateCurrentModeId(modeId)),
526 Effect.as({} satisfies EffectAcpSchema.SetSessionModeResponse),
527 );
528 }),
529 ),
530 setConfigOption,
531 setModel: (model) =>
532 getStartedState.pipe(
533 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
534 Effect.asVoid,
535 ),
536 request: (method, payload) =>
537 runLoggedRequest(method, payload, acp.raw.request(method, payload)),
538 notify: acp.raw.notify,
539 } satisfies AcpSessionRuntimeShape;
540 });
541
542function sessionConfigOptionsFromSetup(
543 response:
544 | {
545 readonly configOptions?: ReadonlyArray<EffectAcpSchema.SessionConfigOption> | null;
546 }
547 | undefined,
548): ReadonlyArray<EffectAcpSchema.SessionConfigOption> {
549 return response?.configOptions ?? [];
550}
551
552function configOptionCurrentValueMatches(
553 configOption: EffectAcpSchema.SessionConfigOption,
554 value: string | boolean,
555): boolean {
556 const currentValue = configOption.currentValue;
557 if (configOption.type === "boolean") {
558 return currentValue === value;
559 }
560 if (typeof currentValue !== "string") {
561 return false;
562 }
563 return currentValue.trim() === String(value).trim();
564}
565
566const handleSessionUpdate = ({
567 queue,
568 modeStateRef,
569 toolCallsRef,
570 assistantSegmentRef,
571 params,
572}: {
573 readonly queue: Queue.Queue<AcpParsedSessionEvent>;
574 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
575 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>;
576 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
577 readonly params: EffectAcpSchema.SessionNotification;
578}): Effect.Effect<void> =>
579 Effect.gen(function* () {
580 const parsed = parseSessionUpdateEvent(params);
581 if (parsed.modeId) {
582 yield* Ref.update(modeStateRef, (current) =>
583 current === undefined ? current : updateModeState(current, parsed.modeId!),
584 );
585 }
586 for (const event of parsed.events) {
587 if (event._tag === "ToolCallUpdated") {
588 yield* closeActiveAssistantSegment({
589 queue,
590 assistantSegmentRef,
591 });
592 const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => {
593 const previous = current.get(event.toolCall.toolCallId);
594 const nextToolCall = mergeToolCallState(previous, event.toolCall);
595 const next = new Map(current);
596 if (nextToolCall.status === "completed" || nextToolCall.status === "failed") {
597 next.delete(nextToolCall.toolCallId);
598 } else {
599 next.set(nextToolCall.toolCallId, nextToolCall);
600 }
601 return [{ previous, merged: nextToolCall }, next] as const;
602 });
603 if (!shouldEmitToolCallUpdate(previous, merged)) {
604 continue;
605 }
606 yield* Queue.offer(queue, {
607 _tag: "ToolCallUpdated",
608 toolCall: merged,
609 rawPayload: event.rawPayload,
610 });
611 continue;
612 }
613 if (event._tag === "ContentDelta") {
614 if (event.text.trim().length === 0) {
615 const assistantSegmentState = yield* Ref.get(assistantSegmentRef);
616 if (!assistantSegmentState.activeItemId) {
617 continue;
618 }
619 }
620 const itemId = yield* ensureActiveAssistantSegment({
621 queue,
622 assistantSegmentRef,
623 sessionId: params.sessionId,
624 });
625 yield* Queue.offer(queue, {
626 ...event,
627 itemId,
628 });
629 continue;
630 }
631 yield* Queue.offer(queue, event);
632 }
633 });
634
635function updateModeState(modeState: AcpSessionModeState, nextModeId: string): AcpSessionModeState {
636 const normalized = nextModeId.trim();
637 if (!normalized) {
638 return modeState;
639 }
640 return modeState.availableModes.some((mode) => mode.id === normalized)
641 ? {
642 ...modeState,
643 currentModeId: normalized,
644 }
645 : modeState;
646}
647
648function shouldEmitToolCallUpdate(
649 previous: AcpToolCallState | undefined,
650 next: AcpToolCallState,
651): boolean {
652 if (next.status === "completed" || next.status === "failed") {
653 return true;
654 }
655 if (!next.detail) {
656 return false;
657 }
658 return previous === undefined || previous.title !== next.title || previous.detail !== next.detail;
659}
660
661const assistantItemId = (sessionId: string, segmentIndex: number) =>
662 `assistant:${sessionId}:segment:${segmentIndex}`;
663
664const ensureActiveAssistantSegment = ({
665 queue,
666 assistantSegmentRef,
667 sessionId,
668}: {
669 readonly queue: Queue.Queue<AcpParsedSessionEvent>;
670 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
671 readonly sessionId: string;
672}) =>
673 Ref.modify<AcpAssistantSegmentState, EnsureActiveAssistantSegmentResult>(
674 assistantSegmentRef,
675 (current) => {
676 if (current.activeItemId) {
677 return [{ itemId: current.activeItemId }, current] as const;
678 }
679 const itemId = assistantItemId(sessionId, current.nextSegmentIndex);
680 return [
681 {
682 itemId,
683 startedEvent: {
684 _tag: "AssistantItemStarted",
685 itemId,
686 } satisfies Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>,
687 },
688 {
689 nextSegmentIndex: current.nextSegmentIndex + 1,
690 activeItemId: itemId,
691 } satisfies AcpAssistantSegmentState,
692 ] as const;
693 },
694 ).pipe(
695 Effect.flatMap((result) =>
696 result.startedEvent
697 ? Queue.offer(queue, result.startedEvent).pipe(Effect.as(result.itemId))
698 : Effect.succeed(result.itemId),
699 ),
700 );
701
702const closeActiveAssistantSegment = ({
703 queue,
704 assistantSegmentRef,
705}: {
706 readonly queue: Queue.Queue<AcpParsedSessionEvent>;
707 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
708}) =>
709 Ref.modify(assistantSegmentRef, (current) => {
710 if (!current.activeItemId) {
711 return [undefined, current] as const;
712 }
713 return [
714 {
715 _tag: "AssistantItemCompleted",
716 itemId: current.activeItemId,
717 } satisfies AcpParsedSessionEvent,
718 {
719 nextSegmentIndex: current.nextSegmentIndex,
720 } satisfies AcpAssistantSegmentState,
721 ] as const;
722 }).pipe(Effect.flatMap((event) => (event ? Queue.offer(queue, event) : Effect.void)));
apps/server/src/provider/acp/CursorAcpCliProbe.test.ts created+148
......@@ -0,0 +1,148 @@
1/**
2 * Optional integration check against a real `agent acp` install.
3 * Enable with: T3_CURSOR_ACP_PROBE=1 bun run test --filter CursorAcpCliProbe
4 */
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";
7import { Effect } from "effect";
8import { describe, expect } from "vitest";
9import type * as EffectAcpSchema from "effect-acp/schema";
10
11import { AcpSessionRuntime } from "./AcpSessionRuntime.ts";
12
13describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => {
14 it.effect("initialize and authenticate against real agent acp", () =>
15 Effect.gen(function* () {
16 const runtime = yield* AcpSessionRuntime;
17 const started = yield* runtime.start();
18 expect(started.initializeResult).toBeDefined();
19 }).pipe(
20 Effect.provide(
21 AcpSessionRuntime.layer({
22 spawn: {
23 command: "agent",
24 args: ["acp"],
25 cwd: process.cwd(),
26 },
27 cwd: process.cwd(),
28 clientCapabilities: {
29 _meta: {
30 parameterizedModelPicker: true,
31 },
32 },
33 clientInfo: { name: "t3-probe", version: "0.0.0" },
34 authMethodId: "cursor_login",
35 }),
36 ),
37 Effect.scoped,
38 Effect.provide(NodeServices.layer),
39 ),
40 );
41
42 it.effect("session/new returns configOptions with a model selector", () =>
43 Effect.gen(function* () {
44 const runtime = yield* AcpSessionRuntime;
45 const started = yield* runtime.start();
46 const result = started.sessionSetupResult;
47 console.log("session/new result:", JSON.stringify(result, null, 2));
48
49 expect(typeof started.sessionId).toBe("string");
50
51 const configOptions = result.configOptions;
52 console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2));
53
54 if (Array.isArray(configOptions)) {
55 const modelConfig = configOptions.find((opt) => opt.category === "model");
56 const parameterizedOptions = configOptions.filter(
57 (opt) =>
58 opt.category === "thought_level" ||
59 opt.category === "model_option" ||
60 opt.category === "model_config",
61 );
62 console.log("Model config option:", JSON.stringify(modelConfig, null, 2));
63 console.log(
64 "Parameterized model config options:",
65 JSON.stringify(parameterizedOptions, null, 2),
66 );
67 expect(modelConfig).toBeDefined();
68 expect(typeof modelConfig?.id).toBe("string");
69 }
70 }).pipe(
71 Effect.provide(
72 AcpSessionRuntime.layer({
73 authMethodId: "cursor_login",
74 spawn: {
75 command: "agent",
76 args: ["acp"],
77 cwd: process.cwd(),
78 },
79 cwd: process.cwd(),
80 clientCapabilities: {
81 _meta: {
82 parameterizedModelPicker: true,
83 },
84 },
85 clientInfo: { name: "t3-probe", version: "0.0.0" },
86 }),
87 ),
88 Effect.scoped,
89 Effect.provide(NodeServices.layer),
90 ),
91 );
92
93 it.effect("session/set_config_option switches the model in-session", () =>
94 Effect.gen(function* () {
95 const runtime = yield* AcpSessionRuntime;
96 const started = yield* runtime.start();
97 const newResult = started.sessionSetupResult;
98
99 const configOptions = newResult.configOptions;
100 let modelConfigId = "model";
101 if (Array.isArray(configOptions)) {
102 const modelConfig = configOptions.find((opt) => opt.category === "model");
103 if (typeof modelConfig?.id === "string") {
104 modelConfigId = modelConfig.id;
105 }
106 }
107
108 const setResult: EffectAcpSchema.SetSessionConfigOptionResponse =
109 yield* runtime.setConfigOption(modelConfigId, "gpt-5.4");
110
111 console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2));
112
113 if (Array.isArray(setResult.configOptions)) {
114 const modelConfig = setResult.configOptions.find((opt) => opt.category === "model");
115 const parameterizedOptions = setResult.configOptions.filter(
116 (opt) =>
117 opt.category === "thought_level" ||
118 opt.category === "model_option" ||
119 opt.category === "model_config",
120 );
121 if (modelConfig?.type === "select") {
122 expect(modelConfig.currentValue).toBe("gpt-5.4");
123 }
124 expect(parameterizedOptions.length).toBeGreaterThan(0);
125 }
126 }).pipe(
127 Effect.provide(
128 AcpSessionRuntime.layer({
129 authMethodId: "cursor_login",
130 spawn: {
131 command: "agent",
132 args: ["acp"],
133 cwd: process.cwd(),
134 },
135 cwd: process.cwd(),
136 clientCapabilities: {
137 _meta: {
138 parameterizedModelPicker: true,
139 },
140 },
141 clientInfo: { name: "t3-probe", version: "0.0.0" },
142 }),
143 ),
144 Effect.scoped,
145 Effect.provide(NodeServices.layer),
146 ),
147 );
148});
apps/server/src/provider/acp/CursorAcpExtension.test.ts created+108
......@@ -0,0 +1,108 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 extractAskQuestions,
5 extractPlanMarkdown,
6 extractTodosAsPlan,
7} from "./CursorAcpExtension.ts";
8
9describe("CursorAcpExtension", () => {
10 it("extracts ask-question prompts from the real Cursor ACP payload shape", () => {
11 const questions = extractAskQuestions({
12 toolCallId: "ask-1",
13 title: "Need input",
14 questions: [
15 {
16 id: "language",
17 prompt: "Which language should I use?",
18 options: [
19 { id: "ts", label: "TypeScript" },
20 { id: "rs", label: "Rust" },
21 ],
22 allowMultiple: false,
23 },
24 ],
25 });
26
27 expect(questions).toEqual([
28 {
29 id: "language",
30 header: "Question",
31 question: "Which language should I use?",
32 multiSelect: false,
33 options: [
34 { label: "TypeScript", description: "TypeScript" },
35 { label: "Rust", description: "Rust" },
36 ],
37 },
38 ]);
39 });
40
41 it("defaults ask-question multi-select to false when Cursor omits allowMultiple", () => {
42 const questions = extractAskQuestions({
43 toolCallId: "ask-2",
44 questions: [
45 {
46 id: "mode",
47 prompt: "Which mode should I use?",
48 options: [
49 { id: "agent", label: "Agent" },
50 { id: "plan", label: "Plan" },
51 ],
52 },
53 ],
54 });
55
56 expect(questions).toEqual([
57 {
58 id: "mode",
59 header: "Question",
60 question: "Which mode should I use?",
61 multiSelect: false,
62 options: [
63 { label: "Agent", description: "Agent" },
64 { label: "Plan", description: "Plan" },
65 ],
66 },
67 ]);
68 });
69
70 it("extracts plan markdown from the real Cursor create-plan payload shape", () => {
71 const planMarkdown = extractPlanMarkdown({
72 toolCallId: "plan-1",
73 name: "Refactor parser",
74 overview: "Tighten ACP parsing",
75 plan: "# Plan\n\n1. Add schemas\n2. Remove casts",
76 todos: [
77 { id: "t1", content: "Add schemas", status: "in_progress" },
78 { id: "t2", content: "Remove casts", status: "pending" },
79 ],
80 isProject: false,
81 });
82
83 expect(planMarkdown).toBe("# Plan\n\n1. Add schemas\n2. Remove casts");
84 });
85
86 it("projects todo updates into a plan shape and drops invalid entries", () => {
87 expect(
88 extractTodosAsPlan({
89 toolCallId: "todos-1",
90 todos: [
91 { id: "1", content: "Inspect state", status: "completed" },
92 { id: "2", content: " Apply fix ", status: "in_progress" },
93 { id: "3", title: "Fallback title", status: "pending" },
94 { id: "4", content: "Unknown status", status: "weird_status" },
95 { id: "5", content: " " },
96 ],
97 merge: true,
98 }),
99 ).toEqual({
100 plan: [
101 { step: "Inspect state", status: "completed" },
102 { step: "Apply fix", status: "inProgress" },
103 { step: "Fallback title", status: "pending" },
104 { step: "Unknown status", status: "pending" },
105 ],
106 });
107 });
108});
apps/server/src/provider/acp/CursorAcpExtension.ts created+99
......@@ -0,0 +1,99 @@
1/**
2 * Public Docs: https://cursor.com/docs/cli/acp#cursor-extension-methods
3 * Additional reference provided by the Cursor team: https://anysphere.enterprise.slack.com/files/U068SSJE141/F0APT1HSZRP/cursor-acp-extension-method-schemas.md
4 */
5import type { UserInputQuestion } from "@t3tools/contracts";
6import { Schema } from "effect";
7
8const CursorAskQuestionOption = Schema.Struct({
9 id: Schema.String,
10 label: Schema.String,
11});
12
13const CursorAskQuestion = Schema.Struct({
14 id: Schema.String,
15 prompt: Schema.String,
16 options: Schema.Array(CursorAskQuestionOption),
17 allowMultiple: Schema.optional(Schema.Boolean),
18});
19
20export const CursorAskQuestionRequest = Schema.Struct({
21 toolCallId: Schema.String,
22 title: Schema.optional(Schema.String),
23 questions: Schema.Array(CursorAskQuestion),
24});
25
26const CursorTodoStatus = Schema.String;
27
28const CursorTodo = Schema.Struct({
29 id: Schema.optional(Schema.String),
30 content: Schema.optional(Schema.String),
31 title: Schema.optional(Schema.String),
32 status: Schema.optional(CursorTodoStatus),
33});
34
35const CursorPlanPhase = Schema.Struct({
36 name: Schema.String,
37 todos: Schema.Array(CursorTodo),
38});
39
40export const CursorCreatePlanRequest = Schema.Struct({
41 toolCallId: Schema.String,
42 name: Schema.optional(Schema.String),
43 overview: Schema.optional(Schema.String),
44 plan: Schema.String,
45 todos: Schema.Array(CursorTodo),
46 isProject: Schema.optional(Schema.Boolean),
47 phases: Schema.optional(Schema.Array(CursorPlanPhase)),
48});
49
50export const CursorUpdateTodosRequest = Schema.Struct({
51 toolCallId: Schema.String,
52 todos: Schema.Array(CursorTodo),
53 merge: Schema.Boolean,
54});
55
56export function extractAskQuestions(
57 params: typeof CursorAskQuestionRequest.Type,
58): ReadonlyArray<UserInputQuestion> {
59 return params.questions.map((question) => ({
60 id: question.id,
61 header: "Question",
62 question: question.prompt,
63 multiSelect: question.allowMultiple === true,
64 options:
65 question.options.length > 0
66 ? question.options.map((option) => ({
67 label: option.label,
68 description: option.label,
69 }))
70 : [{ label: "OK", description: "Continue" }],
71 }));
72}
73
74export function extractPlanMarkdown(params: typeof CursorCreatePlanRequest.Type): string {
75 return params.plan || "# Plan\n\n(Cursor did not supply plan text.)";
76}
77
78export function extractTodosAsPlan(params: typeof CursorUpdateTodosRequest.Type): {
79 readonly explanation?: string;
80 readonly plan: ReadonlyArray<{
81 readonly step: string;
82 readonly status: "pending" | "inProgress" | "completed";
83 }>;
84} {
85 const plan = params.todos.flatMap((todo) => {
86 const step = todo.content?.trim() ?? todo.title?.trim() ?? "";
87 if (step === "") {
88 return [];
89 }
90 const status: "pending" | "inProgress" | "completed" =
91 todo.status === "completed"
92 ? "completed"
93 : todo.status === "in_progress" || todo.status === "inProgress"
94 ? "inProgress"
95 : "pending";
96 return [{ step, status }];
97 });
98 return { plan };
99}
apps/server/src/provider/acp/CursorAcpSupport.test.ts created+123
......@@ -0,0 +1,123 @@
1import { Effect } from "effect";
2import type * as EffectAcpSchema from "effect-acp/schema";
3import { describe, expect, it } from "vitest";
4
5import { applyCursorAcpModelSelection, buildCursorAcpSpawnInput } from "./CursorAcpSupport.ts";
6
7const parameterizedGpt54ConfigOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> = [
8 {
9 id: "model",
10 name: "Model",
11 category: "model",
12 type: "select",
13 currentValue: "gpt-5.4-medium-fast",
14 options: [{ value: "gpt-5.4-medium-fast", name: "GPT-5.4" }],
15 },
16 {
17 id: "reasoning",
18 name: "Reasoning",
19 category: "thought_level",
20 type: "select",
21 currentValue: "medium",
22 options: [
23 { value: "low", name: "Low" },
24 { value: "medium", name: "Medium" },
25 { value: "high", name: "High" },
26 { value: "extra-high", name: "Extra High" },
27 ],
28 },
29 {
30 id: "context",
31 name: "Context",
32 category: "model_config",
33 type: "select",
34 currentValue: "272k",
35 options: [
36 { value: "272k", name: "272K" },
37 { value: "1m", name: "1M" },
38 ],
39 },
40 {
41 id: "fast",
42 name: "Fast",
43 category: "model_config",
44 type: "select",
45 currentValue: "false",
46 options: [
47 { value: "false", name: "Off" },
48 { value: "true", name: "Fast" },
49 ],
50 },
51];
52
53describe("buildCursorAcpSpawnInput", () => {
54 it("builds the default Cursor ACP command", () => {
55 expect(buildCursorAcpSpawnInput(undefined, "/tmp/project")).toEqual({
56 command: "agent",
57 args: ["acp"],
58 cwd: "/tmp/project",
59 });
60 });
61
62 it("includes the configured api endpoint when present", () => {
63 expect(
64 buildCursorAcpSpawnInput(
65 {
66 binaryPath: "/usr/local/bin/agent",
67 apiEndpoint: "http://localhost:3000",
68 },
69 "/tmp/project",
70 ),
71 ).toEqual({
72 command: "/usr/local/bin/agent",
73 args: ["-e", "http://localhost:3000", "acp"],
74 cwd: "/tmp/project",
75 });
76 });
77});
78
79describe("applyCursorAcpModelSelection", () => {
80 it("sets the base model before applying separate config options", async () => {
81 const calls: Array<
82 | { readonly type: "model"; readonly value: string }
83 | { readonly type: "config"; readonly configId: string; readonly value: string | boolean }
84 > = [];
85
86 const runtime = {
87 getConfigOptions: Effect.succeed(parameterizedGpt54ConfigOptions),
88 setModel: (value: string) =>
89 Effect.sync(() => {
90 calls.push({ type: "model", value });
91 }),
92 setConfigOption: (configId: string, value: string | boolean) =>
93 Effect.sync(() => {
94 calls.push({ type: "config", configId, value });
95 }),
96 };
97
98 await Effect.runPromise(
99 applyCursorAcpModelSelection({
100 runtime,
101 model: "gpt-5.4-medium-fast[reasoning=medium,context=272k]",
102 modelOptions: {
103 reasoning: "xhigh",
104 contextWindow: "1m",
105 fastMode: true,
106 },
107 mapError: ({ step, configId, cause }) =>
108 new Error(
109 step === "set-config-option"
110 ? `failed to set config option ${configId}: ${cause.message}`
111 : `failed to set model: ${cause.message}`,
112 ),
113 }),
114 );
115
116 expect(calls).toEqual([
117 { type: "model", value: "gpt-5.4-medium-fast" },
118 { type: "config", configId: "reasoning", value: "extra-high" },
119 { type: "config", configId: "context", value: "1m" },
120 { type: "config", configId: "fast", value: "true" },
121 ]);
122 });
123});
apps/server/src/provider/acp/CursorAcpSupport.ts created+108
......@@ -0,0 +1,108 @@
1import { type CursorModelOptions, type CursorSettings } from "@t3tools/contracts";
2import { Effect, Layer, Scope } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import type * as EffectAcpErrors from "effect-acp/errors";
5
6import {
7 CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
8 resolveCursorAcpBaseModelId,
9 resolveCursorAcpConfigUpdates,
10} from "../Layers/CursorProvider.ts";
11import {
12 AcpSessionRuntime,
13 type AcpSessionRuntimeOptions,
14 type AcpSessionRuntimeShape,
15 type AcpSpawnInput,
16} from "./AcpSessionRuntime.ts";
17
18type CursorAcpRuntimeCursorSettings = Pick<CursorSettings, "apiEndpoint" | "binaryPath">;
19
20export interface CursorAcpRuntimeInput extends Omit<
21 AcpSessionRuntimeOptions,
22 "authMethodId" | "clientCapabilities" | "spawn"
23> {
24 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
25 readonly cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined;
26}
27
28export interface CursorAcpModelSelectionErrorContext {
29 readonly cause: EffectAcpErrors.AcpError;
30 readonly step: "set-config-option" | "set-model";
31 readonly configId?: string;
32}
33
34export function buildCursorAcpSpawnInput(
35 cursorSettings: CursorAcpRuntimeCursorSettings | null | undefined,
36 cwd: string,
37): AcpSpawnInput {
38 return {
39 command: cursorSettings?.binaryPath || "agent",
40 args: [
41 ...(cursorSettings?.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []),
42 "acp",
43 ],
44 cwd,
45 };
46}
47
48export const makeCursorAcpRuntime = (
49 input: CursorAcpRuntimeInput,
50): Effect.Effect<AcpSessionRuntimeShape, EffectAcpErrors.AcpError, Scope.Scope> =>
51 Effect.gen(function* () {
52 const acpContext = yield* Layer.build(
53 AcpSessionRuntime.layer({
54 ...input,
55 spawn: buildCursorAcpSpawnInput(input.cursorSettings, input.cwd),
56 authMethodId: "cursor_login",
57 clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES,
58 }).pipe(
59 Layer.provide(
60 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
61 ),
62 ),
63 );
64 return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext));
65 });
66
67interface CursorAcpModelSelectionRuntime {
68 readonly getConfigOptions: AcpSessionRuntimeShape["getConfigOptions"];
69 readonly setConfigOption: (
70 configId: string,
71 value: string | boolean,
72 ) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;
73 readonly setModel: (model: string) => Effect.Effect<unknown, EffectAcpErrors.AcpError>;
74}
75
76export function applyCursorAcpModelSelection<E>(input: {
77 readonly runtime: CursorAcpModelSelectionRuntime;
78 readonly model: string | null | undefined;
79 readonly modelOptions: CursorModelOptions | null | undefined;
80 readonly mapError: (context: CursorAcpModelSelectionErrorContext) => E;
81}): Effect.Effect<void, E> {
82 return Effect.gen(function* () {
83 yield* input.runtime.setModel(resolveCursorAcpBaseModelId(input.model)).pipe(
84 Effect.mapError((cause) =>
85 input.mapError({
86 cause,
87 step: "set-model",
88 }),
89 ),
90 );
91
92 const configUpdates = resolveCursorAcpConfigUpdates(
93 yield* input.runtime.getConfigOptions,
94 input.modelOptions,
95 );
96 for (const update of configUpdates) {
97 yield* input.runtime.setConfigOption(update.configId, update.value).pipe(
98 Effect.mapError((cause) =>
99 input.mapError({
100 cause,
101 step: "set-config-option",
102 configId: update.configId,
103 }),
104 ),
105 );
106 }
107 });
108}
apps/server/src/provider/makeManagedServerProvider.test.ts created+263
......@@ -0,0 +1,263 @@
1import { describe, it, assert } from "@effect/vitest";
2import type { ServerProvider } from "@t3tools/contracts";
3import { Deferred, Effect, Fiber, PubSub, Ref, Stream } from "effect";
4
5import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";
6
7interface TestSettings {
8 readonly enabled: boolean;
9}
10
11const initialSnapshot: ServerProvider = {
12 provider: "codex",
13 enabled: true,
14 installed: true,
15 version: null,
16 status: "warning",
17 auth: { status: "unknown" },
18 checkedAt: "2026-04-10T00:00:00.000Z",
19 message: "Checking provider availability...",
20 models: [],
21 slashCommands: [],
22 skills: [],
23};
24
25const refreshedSnapshot: ServerProvider = {
26 provider: "codex",
27 enabled: true,
28 installed: true,
29 version: "1.0.0",
30 status: "ready",
31 auth: { status: "authenticated" },
32 checkedAt: "2026-04-10T00:00:01.000Z",
33 models: [],
34 slashCommands: [],
35 skills: [],
36};
37
38const enrichedSnapshot: ServerProvider = {
39 ...refreshedSnapshot,
40 checkedAt: "2026-04-10T00:00:02.000Z",
41 models: [
42 {
43 slug: "composer-2",
44 name: "Composer 2",
45 isCustom: false,
46 capabilities: {
47 reasoningEffortLevels: [],
48 supportsFastMode: true,
49 supportsThinkingToggle: false,
50 contextWindowOptions: [],
51 promptInjectedEffortLevels: [],
52 },
53 },
54 ],
55};
56
57const refreshedSnapshotSecond: ServerProvider = {
58 ...refreshedSnapshot,
59 checkedAt: "2026-04-10T00:00:03.000Z",
60 message: "Refreshed provider availability again.",
61};
62
63const enrichedSnapshotSecond: ServerProvider = {
64 ...refreshedSnapshotSecond,
65 checkedAt: "2026-04-10T00:00:04.000Z",
66 models: [
67 {
68 slug: "gpt-5.4",
69 name: "GPT-5.4",
70 isCustom: false,
71 capabilities: {
72 reasoningEffortLevels: [],
73 supportsFastMode: false,
74 supportsThinkingToggle: false,
75 contextWindowOptions: [],
76 promptInjectedEffortLevels: [],
77 },
78 },
79 ],
80};
81
82describe("makeManagedServerProvider", () => {
83 it.effect(
84 "runs the initial provider check in the background and streams the refreshed snapshot",
85 () =>
86 Effect.scoped(
87 Effect.gen(function* () {
88 const checkCalls = yield* Ref.make(0);
89 const releaseCheck = yield* Deferred.make<void>();
90 const provider = yield* makeManagedServerProvider<TestSettings>({
91 getSettings: Effect.succeed({ enabled: true }),
92 streamSettings: Stream.empty,
93 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
94 initialSnapshot: () => initialSnapshot,
95 checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe(
96 Effect.flatMap(() => Deferred.await(releaseCheck)),
97 Effect.as(refreshedSnapshot),
98 ),
99 refreshInterval: "1 hour",
100 });
101
102 const initial = yield* provider.getSnapshot;
103 assert.deepStrictEqual(initial, initialSnapshot);
104
105 const updatesFiber = yield* Stream.take(provider.streamChanges, 1).pipe(
106 Stream.runCollect,
107 Effect.forkChild,
108 );
109 yield* Effect.yieldNow;
110
111 yield* Deferred.succeed(releaseCheck, undefined);
112
113 const updates = Array.from(yield* Fiber.join(updatesFiber));
114 const latest = yield* provider.getSnapshot;
115
116 assert.deepStrictEqual(updates, [refreshedSnapshot]);
117 assert.deepStrictEqual(latest, refreshedSnapshot);
118 assert.strictEqual(yield* Ref.get(checkCalls), 1);
119 }),
120 ),
121 );
122
123 it.effect("reruns the provider check when streamed settings change", () =>
124 Effect.scoped(
125 Effect.gen(function* () {
126 const settingsRef = yield* Ref.make<TestSettings>({ enabled: true });
127 const settingsChanges = yield* PubSub.unbounded<TestSettings>();
128 const checkCalls = yield* Ref.make(0);
129 const releaseInitialCheck = yield* Deferred.make<void>();
130 const releaseSettingsCheck = yield* Deferred.make<void>();
131 const provider = yield* makeManagedServerProvider<TestSettings>({
132 getSettings: Ref.get(settingsRef),
133 streamSettings: Stream.fromPubSub(settingsChanges),
134 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
135 initialSnapshot: () => initialSnapshot,
136 checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
137 Effect.flatMap((count) =>
138 count === 1
139 ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot))
140 : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)),
141 ),
142 ),
143 refreshInterval: "1 hour",
144 });
145
146 const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe(
147 Stream.runCollect,
148 Effect.forkChild,
149 );
150 yield* Effect.yieldNow;
151
152 yield* Deferred.succeed(releaseInitialCheck, undefined);
153 yield* Ref.set(settingsRef, { enabled: false });
154 yield* PubSub.publish(settingsChanges, { enabled: false });
155 yield* Deferred.succeed(releaseSettingsCheck, undefined);
156
157 const updates = Array.from(yield* Fiber.join(updatesFiber));
158 const latest = yield* provider.getSnapshot;
159
160 assert.deepStrictEqual(updates, [refreshedSnapshot, refreshedSnapshotSecond]);
161 assert.deepStrictEqual(latest, refreshedSnapshotSecond);
162 assert.strictEqual(yield* Ref.get(checkCalls), 2);
163 }),
164 ),
165 );
166
167 it.effect("streams supplemental snapshot updates after the base provider check completes", () =>
168 Effect.scoped(
169 Effect.gen(function* () {
170 const releaseEnrichment = yield* Deferred.make<void>();
171 const releaseCheck = yield* Deferred.make<void>();
172 const provider = yield* makeManagedServerProvider<TestSettings>({
173 getSettings: Effect.succeed({ enabled: true }),
174 streamSettings: Stream.empty,
175 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
176 initialSnapshot: () => initialSnapshot,
177 checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)),
178 enrichSnapshot: ({ publishSnapshot }) =>
179 Deferred.await(releaseEnrichment).pipe(
180 Effect.flatMap(() => publishSnapshot(enrichedSnapshot)),
181 ),
182 refreshInterval: "1 hour",
183 });
184
185 const updatesFiber = yield* Stream.take(provider.streamChanges, 2).pipe(
186 Stream.runCollect,
187 Effect.forkChild,
188 );
189 yield* Effect.yieldNow;
190
191 yield* Deferred.succeed(releaseCheck, undefined);
192
193 yield* Deferred.succeed(releaseEnrichment, undefined);
194
195 const updates = Array.from(yield* Fiber.join(updatesFiber));
196 const latest = yield* provider.getSnapshot;
197
198 assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]);
199 assert.deepStrictEqual(latest, enrichedSnapshot);
200 }),
201 ),
202 );
203
204 it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () =>
205 Effect.scoped(
206 Effect.gen(function* () {
207 const publishCallbacks: Array<(snapshot: ServerProvider) => Effect.Effect<void>> = [];
208 const refreshCount = yield* Ref.make(0);
209 const firstCallbackReady = yield* Deferred.make<void>();
210 const secondCallbackReady = yield* Deferred.make<void>();
211 const allowFirstRefresh = yield* Deferred.make<void>();
212 const provider = yield* makeManagedServerProvider<TestSettings>({
213 getSettings: Effect.succeed({ enabled: true }),
214 streamSettings: Stream.empty,
215 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
216 initialSnapshot: () => initialSnapshot,
217 checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe(
218 Effect.flatMap((count) =>
219 count === 1
220 ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot))
221 : Effect.succeed(refreshedSnapshotSecond),
222 ),
223 ),
224 enrichSnapshot: ({ publishSnapshot }) =>
225 Effect.gen(function* () {
226 publishCallbacks.push(publishSnapshot);
227 if (publishCallbacks.length === 1) {
228 yield* Deferred.succeed(firstCallbackReady, undefined).pipe(Effect.ignore);
229 } else if (publishCallbacks.length === 2) {
230 yield* Deferred.succeed(secondCallbackReady, undefined).pipe(Effect.ignore);
231 }
232 }),
233 refreshInterval: "1 hour",
234 });
235
236 const updatesFiber = yield* Stream.take(provider.streamChanges, 3).pipe(
237 Stream.runCollect,
238 Effect.forkChild,
239 );
240 yield* Effect.yieldNow;
241
242 yield* Deferred.succeed(allowFirstRefresh, undefined);
243 yield* Deferred.await(firstCallbackReady);
244
245 yield* provider.refresh;
246 yield* Deferred.await(secondCallbackReady);
247
248 yield* publishCallbacks[0]!(enrichedSnapshot);
249 yield* publishCallbacks[1]!(enrichedSnapshotSecond);
250
251 const updates = Array.from(yield* Fiber.join(updatesFiber));
252 const latest = yield* provider.getSnapshot;
253
254 assert.deepStrictEqual(updates, [
255 refreshedSnapshot,
256 refreshedSnapshotSecond,
257 enrichedSnapshotSecond,
258 ]);
259 assert.deepStrictEqual(latest, enrichedSnapshotSecond);
260 }),
261 ),
262 );
263});
apps/server/src/provider/makeManagedServerProvider.ts+85-4
......@@ -1,10 +1,15 @@
11import type { ServerProvider } from "@t3tools/contracts";
2import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect";
2import { Duration, Effect, Equal, Fiber, PubSub, Ref, Scope, Stream } from "effect";
33import * as Semaphore from "effect/Semaphore";
44
55import type { ServerProviderShape } from "./Services/ServerProvider.ts";
66import { ServerSettingsError } from "@t3tools/contracts";
77
8interface ProviderSnapshotState {
9 readonly snapshot: ServerProvider;
10 readonly enrichmentGeneration: number;
11}
12
813export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
914 Settings,
1015>(input: {
......@@ -13,6 +18,12 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
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")(
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")(
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")(
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) => {
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) => {
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
......@@ -6,6 +6,7 @@ export const PROVIDER_CACHE_IDS = [
66 "codex",
77 "claudeAgent",
88 "opencode",
9 "cursor",
910] as const satisfies ReadonlyArray<ServerProvider["provider"]>;
1011
1112const decodeProviderStatusCache = Schema.decodeUnknownEffect(
......@@ -17,6 +18,14 @@ const providerOrderRank = (provider: ServerProvider["provider"]): number => {
1718 return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;
1819};
1920
21const mergeProviderModels = (
22 fallbackModels: ReadonlyArray<ServerProvider["models"][number]>,
23 cachedModels: ReadonlyArray<ServerProvider["models"][number]>,
24): ReadonlyArray<ServerProvider["models"][number]> => {
25 const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug));
26 return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))];
27};
28
2029export const orderProviderSnapshots = (
2130 providers: ReadonlyArray<ServerProvider>,
2231): ReadonlyArray<ServerProvider> =>
......@@ -38,6 +47,7 @@ export const hydrateCachedProvider = (input: {
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
......@@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
2121import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";
2222import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";
2323import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";
24import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter.ts";
2425import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts";
2526import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
2627import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";
......@@ -160,10 +161,14 @@ const ProviderLayerLive = Layer.unwrap(
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<
105105
106106const ServerSettingsJson = fromLenientJson(ServerSettings);
107107
108const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"];
108const 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({
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
......@@ -99,6 +99,7 @@ function createBaseServerConfig(): ServerConfig {
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
......@@ -612,6 +612,7 @@ export const ChatComposer = memo(
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: {
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: {
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: {
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
......@@ -4,6 +4,7 @@ import {
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
475476 };
476477}
477478
479async function mountCursorPicker(props: { model?: string; options?: CursorModelOptions }) {
480 const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.cursor;
481 const cursorThreadId = ThreadId.make("thread-cursor-traits");
482 const cursorThreadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, cursorThreadId);
483 const cursorThreadKey = scopedThreadKey(cursorThreadRef);
484 const host = document.createElement("div");
485 document.body.append(host);
486
487 useComposerDraftStore.setState({
488 draftsByThreadKey: {
489 [cursorThreadKey]: {
490 prompt: "",
491 images: [],
492 nonPersistedImageIds: [],
493 persistedAttachments: [],
494 terminalContexts: [],
495 modelSelectionByProvider: {
496 cursor: {
497 provider: "cursor",
498 model,
499 ...(props.options ? { options: props.options } : {}),
500 },
501 },
502 activeProvider: "cursor",
503 runtimeMode: null,
504 interactionMode: null,
505 },
506 },
507 draftThreadsByThreadKey: {},
508 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
509 });
510
511 const screen = await render(
512 <TraitsPicker
513 provider="cursor"
514 models={[
515 {
516 slug: "composer-2",
517 name: "Composer 2",
518 isCustom: false,
519 capabilities: {
520 reasoningEffortLevels: [],
521 supportsFastMode: true,
522 supportsThinkingToggle: false,
523 contextWindowOptions: [],
524 promptInjectedEffortLevels: [],
525 },
526 },
527 {
528 slug: "claude-opus-4-6",
529 name: "Opus 4.6",
530 isCustom: false,
531 capabilities: {
532 reasoningEffortLevels: [],
533 supportsFastMode: true,
534 supportsThinkingToggle: false,
535 contextWindowOptions: [
536 { value: "200k", label: "200K", isDefault: true },
537 { value: "1m", label: "1M" },
538 ],
539 promptInjectedEffortLevels: [],
540 },
541 },
542 ]}
543 threadRef={cursorThreadRef}
544 model={model}
545 prompt=""
546 modelOptions={props.options}
547 onPromptChange={() => {}}
548 />,
549 { container: host },
550 );
551
552 const cleanup = async () => {
553 await screen.unmount();
554 host.remove();
555 };
556
557 return {
558 [Symbol.asyncDispose]: cleanup,
559 cleanup,
560 };
561}
562
478563describe("TraitsPicker (Codex)", () => {
479564 afterEach(() => {
480565 document.body.innerHTML = "";
......@@ -628,3 +713,70 @@ describe("TraitsPicker (OpenCode)", () => {
628713 });
629714 });
630715});
716
717describe("TraitsPicker (Cursor)", () => {
718 afterEach(() => {
719 document.body.innerHTML = "";
720 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
721 useComposerDraftStore.setState({
722 draftsByThreadKey: {},
723 draftThreadsByThreadKey: {},
724 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
725 stickyModelSelectionByProvider: {},
726 });
727 });
728
729 it("uses the selected fast mode menu label for the trigger in fast-only state", async () => {
730 await using _ = await mountCursorPicker({
731 model: "composer-2",
732 options: { fastMode: false },
733 });
734
735 await vi.waitFor(() => {
736 const text = document.body.textContent ?? "";
737 expect(text).toContain("Normal");
738 });
739
740 await page.getByRole("button").click();
741
742 await vi.waitFor(() => {
743 const text = document.body.textContent ?? "";
744 expect(text).toContain("Fast Mode");
745 expect(text).toContain("off");
746 expect(text).toContain("on");
747 });
748 });
749
750 it("shows Normal for Cursor Opus 4.6 when fast mode and context window are both at defaults", async () => {
751 await using _ = await mountCursorPicker({
752 model: "claude-opus-4-6",
753 options: { fastMode: false },
754 });
755
756 await vi.waitFor(() => {
757 const text = document.body.textContent ?? "";
758 expect(text).toContain("Normal");
759 });
760
761 await page.getByRole("button").click();
762
763 await vi.waitFor(() => {
764 const text = document.body.textContent ?? "";
765 expect(text).toContain("Fast Mode");
766 expect(text).toContain("Context Window");
767 expect(text).toContain("200K (default)");
768 expect(text).toContain("1M");
769 });
770 });
771
772 it("shows Normal · 1M for Cursor Opus 4.6 when fast mode is off and context window is overridden", async () => {
773 await using _ = await mountCursorPicker({
774 model: "claude-opus-4-6",
775 options: { fastMode: false, contextWindow: "1m" },
776 });
777
778 await vi.waitFor(() => {
779 expect(document.body.textContent ?? "").toContain("Normal · 1M");
780 });
781 });
782});
apps/web/src/components/chat/TraitsPicker.tsx+146-43
......@@ -1,6 +1,7 @@
11import {
22 type ClaudeModelOptions,
33 type CodexModelOptions,
4 type CursorModelOptions,
45 type OpenCodeModelOptions,
56 type ProviderKind,
67 type ProviderModelOptions,
......@@ -60,12 +61,22 @@ function getRawEffort(
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
73function getEffortKey(provider: ProviderKind): string {
74 if (provider === "codex") return "reasoningEffort";
75 if (provider === "cursor") return "reasoning";
76 if (provider === "opencode") return "variant";
77 return "effort";
78}
79
6980function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null {
7081 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent);
7182}
......@@ -87,8 +98,8 @@ function getRawContextWindow(
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(
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
113115function getSelectedTraits(
......@@ -137,7 +139,9 @@ function getSelectedTraits(
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(
185189 };
186190}
187191
192function getTraitsSectionVisibility(input: {
193 provider: ProviderKind;
194 models: ReadonlyArray<ServerProviderModel>;
195 model: string | null | undefined;
196 prompt: string;
197 modelOptions: ProviderOptions | null | undefined;
198 allowPromptInjectedEffort?: boolean;
199}) {
200 const selected = getSelectedTraits(
201 input.provider,
202 input.models,
203 input.model,
204 input.prompt,
205 input.modelOptions,
206 input.allowPromptInjectedEffort ?? true,
207 );
208
209 const showEffort = selected.effort !== null;
210 const showThinking = selected.thinkingEnabled !== null;
211 const showFastMode = selected.caps.supportsFastMode;
212 const showContextWindow = selected.contextWindowOptions.length > 1;
213 const showAgent = selected.agentOptions.length > 0;
214
215 return {
216 ...selected,
217 showEffort,
218 showThinking,
219 showFastMode,
220 showContextWindow,
221 showAgent,
222 hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow || showAgent,
223 };
224}
225
226export function shouldRenderTraitsControls(input: {
227 provider: ProviderKind;
228 models: ReadonlyArray<ServerProviderModel>;
229 model: string | null | undefined;
230 prompt: string;
231 modelOptions: ProviderOptions | null | undefined;
232 allowPromptInjectedEffort?: boolean;
233}): boolean {
234 return getTraitsSectionVisibility(input).hasAnyControls;
235}
236
188237export interface TraitsMenuContentProps {
189238 provider: ProviderKind;
190239 models: ReadonlyArray<ServerProviderModel>;
......@@ -219,10 +268,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
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({
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({
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({
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({
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({
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({
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({
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";
1414
1515import type { DraftId } from "../../composerDraftStore";
1616import { getProviderModelCapabilities } from "../../providerModels";
17import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
17import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1818
1919export type ComposerProviderStateInput = {
2020 provider: ProviderKind;
......@@ -33,6 +33,16 @@ export type ComposerProviderState = {
3333 modelPickerIconClassName?: string;
3434};
3535
36type TraitsRenderInput = {
37 threadRef?: ScopedThreadRef;
38 draftId?: DraftId;
39 model: string;
40 models: ReadonlyArray<ServerProviderModel>;
41 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
42 prompt: string;
43 onPromptChange: (prompt: string) => void;
44};
45
3646export type ComposerProviderControls = {
3747 showInteractionModeToggle: boolean;
3848};
......@@ -40,24 +50,8 @@ export type ComposerProviderControls = {
4050type 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
6357function hasComposerTraitsTarget(input: {
......@@ -67,6 +61,39 @@ function hasComposerTraitsTarget(input: {
6761 return input.threadRef !== undefined || input.draftId !== undefined;
6862}
6963
64function renderTraitsControl(
65 Component: typeof TraitsMenuContent | typeof TraitsPicker,
66 provider: ProviderKind,
67 input: TraitsRenderInput,
68): ReactNode {
69 const { threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = input;
70 if (
71 !hasComposerTraitsTarget({ threadRef, draftId }) ||
72 !shouldRenderTraitsControls({
73 provider,
74 models,
75 model,
76 modelOptions,
77 prompt,
78 })
79 ) {
80 return null;
81 }
82
83 return (
84 <Component
85 provider={provider}
86 models={models}
87 {...(threadRef ? { threadRef } : {})}
88 {...(draftId ? { draftId } : {})}
89 model={model}
90 modelOptions={modelOptions}
91 prompt={prompt}
92 onPromptChange={onPromptChange}
93 />
94 );
95}
96
7097function getProviderStateFromCapabilities(
7198 input: ComposerProviderStateInput,
7299): ComposerProviderState {
......@@ -78,9 +105,11 @@ function getProviderStateFromCapabilities(
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(
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
170159const 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 {
5656} from "../../store";
5757import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
5858import { cn } from "../../lib/utils";
59import { Badge } from "../ui/badge";
5960import { Button } from "../ui/button";
6061import { Collapsible, CollapsibleContent } from "../ui/collapsible";
6162import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
......@@ -103,6 +104,7 @@ const TIMESTAMP_FORMAT_LABELS = {
103104type 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[] = [
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() {
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() {
545559 >({
546560 codex: "",
547561 claudeAgent: "",
562 cursor: "",
548563 opencode: "",
549564 });
550565 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
......@@ -572,6 +587,11 @@ export function GeneralSettingsPanel() {
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() {
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() {
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() {
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() {
9191}
9292
9393function 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", () => {
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", () => {
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 @@
11import {
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 {
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 {
527531}
528532
529533function 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
533539function normalizeProviderModelOptions(
......@@ -544,15 +550,22 @@ function normalizeProviderModelOptions(
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(
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(
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(
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(
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>()(
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>()(
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>()(
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>()(
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
......@@ -49,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf
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(
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 @@
11import {
22 DEFAULT_MODEL_BY_PROVIDER,
3 type CursorModelOptions,
34 type ModelCapabilities,
45 type ProviderKind,
56 type ServerProvider,
67 type ServerProviderModel,
78} from "@t3tools/contracts";
8import { normalizeModelSlug } from "@t3tools/shared/model";
9import {
10 hasEffortLevel,
11 normalizeModelSlug,
12 resolveContextWindow,
13 trimOrNull,
14} from "@t3tools/shared/model";
915
1016const EMPTY_CAPABILITIES: ModelCapabilities = {
1117 reasoningEffortLevels: [],
......@@ -33,7 +39,10 @@ export function isProviderEnabled(
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
3948export function resolveSelectableProvider(
......@@ -67,3 +76,30 @@ export function getDefaultServerModel(
6776 DEFAULT_MODEL_BY_PROVIDER[provider]
6877 );
6978}
79
80export function normalizeCursorModelOptionsWithCapabilities(
81 caps: ModelCapabilities,
82 modelOptions: CursorModelOptions | null | undefined,
83): CursorModelOptions | undefined {
84 const reasoning = trimOrNull(modelOptions?.reasoning);
85 const reasoningValue =
86 reasoning && hasEffortLevel(caps, reasoning)
87 ? (reasoning as CursorModelOptions["reasoning"])
88 : undefined;
89 const fastMode =
90 caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean"
91 ? modelOptions.fastMode
92 : undefined;
93 const thinking =
94 caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean"
95 ? modelOptions.thinking
96 : undefined;
97 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
98 const nextOptions: CursorModelOptions = {
99 ...(reasoningValue ? { reasoning: reasoningValue } : {}),
100 ...(fastMode !== undefined ? { fastMode } : {}),
101 ...(thinking !== undefined ? { thinking } : {}),
102 ...(contextWindow ? { contextWindow } : {}),
103 };
104 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
105}
apps/web/src/session-logic.test.ts+193-29
......@@ -921,6 +921,199 @@ describe("deriveWorkLogEntries", () => {
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", () => {
13241517 ).toBe("2026-02-27T21:11:00.000Z");
13251518 });
13261519});
1327
1328describe("PROVIDER_OPTIONS", () => {
1329 it("advertises available providers while keeping Cursor as a placeholder", () => {
1330 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent");
1331 const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode");
1332 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");
1333 expect(PROVIDER_OPTIONS).toEqual([
1334 { value: "codex", label: "Codex", available: true },
1335 { value: "claudeAgent", label: "Claude", available: true },
1336 { value: "opencode", label: "OpenCode", available: true },
1337 { value: "cursor", label: "Cursor", available: false },
1338 ]);
1339 expect(claude).toEqual({
1340 value: "claudeAgent",
1341 label: "Claude",
1342 available: true,
1343 });
1344 expect(opencode).toEqual({
1345 value: "opencode",
1346 label: "OpenCode",
1347 available: true,
1348 });
1349 expect(cursor).toEqual({
1350 value: "cursor",
1351 label: "Cursor",
1352 available: false,
1353 });
1354 });
1355});
apps/web/src/session-logic.ts+149-19
......@@ -1,3 +1,5 @@
1import * as Option from "effect/Option";
2import * as Arr from "effect/Array";
13import {
24 ApprovalRequestId,
35 isToolLifecycleItemType,
......@@ -20,7 +22,7 @@ import type {
2022 TurnDiffSummary,
2123} from "./types";
2224
23export type ProviderPickerKind = ProviderKind | "cursor";
25export type ProviderPickerKind = ProviderKind;
2426
2527export const PROVIDER_OPTIONS: Array<{
2628 value: ProviderPickerKind;
......@@ -30,7 +32,7 @@ export const PROVIDER_OPTIONS: Array<{
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
3638export interface WorkLogEntry {
......@@ -50,6 +52,7 @@ export interface WorkLogEntry {
5052interface DerivedWorkLogEntry extends WorkLogEntry {
5153 activityKind: OrchestrationThreadActivity["kind"];
5254 collapseKey?: string;
55 toolCallId?: string;
5356}
5457
5558export interface PendingApproval {
......@@ -352,12 +355,12 @@ export function deriveActivePlanState(
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
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
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
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(
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
599615function mergeDerivedWorkLogEntries(
......@@ -608,6 +624,7 @@ function mergeDerivedWorkLogEntries(
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(
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
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 {
674695 return trimmed.length > 0 ? trimmed : null;
675696}
676697
698function asNumber(value: unknown): number | null {
699 return typeof value === "number" && Number.isFinite(value) ? value : null;
700}
701
677702function trimMatchingOuterQuotes(value: string): string {
678703 const trimmed = value.trim();
679704 if (
......@@ -857,6 +882,111 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
857882 return asTrimmedString(payload?.title);
858883}
859884
885function extractToolCallId(payload: Record<string, unknown> | null): string | null {
886 const data = asRecord(payload?.data);
887 return asTrimmedString(data?.toolCallId);
888}
889
890function normalizeInlinePreview(value: string): string {
891 return value.replace(/\s+/g, " ").trim();
892}
893
894function truncateInlinePreview(value: string, maxLength = 84): string {
895 if (value.length <= maxLength) {
896 return value;
897 }
898 return `${value.slice(0, maxLength - 1).trimEnd()}…`;
899}
900
901function normalizePreviewForComparison(value: string | null | undefined): string | null {
902 const normalized = asTrimmedString(value);
903 if (!normalized) {
904 return null;
905 }
906 return normalizeCompactToolLabel(normalizeInlinePreview(normalized)).toLowerCase();
907}
908
909function summarizeToolTextOutput(value: string): string | null {
910 const lines = value
911 .split(/\r?\n/u)
912 .map((line) => normalizeInlinePreview(line))
913 .filter((line) => line.length > 0);
914 const firstLine = lines.find((line) => line !== "```");
915 if (firstLine) {
916 return truncateInlinePreview(firstLine);
917 }
918 if (lines.length > 1) {
919 return `${lines.length.toLocaleString()} lines`;
920 }
921 return null;
922}
923
924function summarizeToolRawOutput(payload: Record<string, unknown> | null): string | null {
925 const data = asRecord(payload?.data);
926 const rawOutput = asRecord(data?.rawOutput);
927 if (!rawOutput) {
928 return null;
929 }
930
931 const totalFiles = asNumber(rawOutput.totalFiles);
932 if (totalFiles !== null) {
933 const suffix = rawOutput.truncated === true ? "+" : "";
934 return `${totalFiles.toLocaleString()} file${totalFiles === 1 ? "" : "s"}${suffix}`;
935 }
936
937 const content = asTrimmedString(rawOutput.content);
938 if (content) {
939 return summarizeToolTextOutput(content);
940 }
941
942 const stdout = asTrimmedString(rawOutput.stdout);
943 if (stdout) {
944 return summarizeToolTextOutput(stdout);
945 }
946
947 return null;
948}
949
950function isCommandToolDetail(payload: Record<string, unknown> | null, heading: string): boolean {
951 const data = asRecord(payload?.data);
952 const kind = asTrimmedString(data?.kind)?.toLowerCase();
953 const title = asTrimmedString(payload?.title ?? heading)?.toLowerCase();
954 return (
955 extractWorkLogItemType(payload) === "command_execution" ||
956 kind === "execute" ||
957 title === "terminal" ||
958 title === "ran command"
959 );
960}
961
962function extractToolDetail(
963 payload: Record<string, unknown> | null,
964 heading: string,
965): string | null {
966 const rawDetail = asTrimmedString(payload?.detail);
967 const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null;
968 const normalizedHeading = normalizePreviewForComparison(heading);
969 const normalizedDetail = normalizePreviewForComparison(detail);
970
971 if (detail && normalizedHeading !== normalizedDetail) {
972 return detail;
973 }
974
975 if (isCommandToolDetail(payload, heading)) {
976 return null;
977 }
978
979 const rawOutputSummary = summarizeToolRawOutput(payload);
980 if (rawOutputSummary) {
981 const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary);
982 if (normalizedRawOutputSummary !== normalizedHeading) {
983 return rawOutputSummary;
984 }
985 }
986
987 return null;
988}
989
860990function stripTrailingExitCode(value: string): {
861991 output: string | null;
862992 exitCode?: number | undefined;
apps/web/src/store.ts+4-4
......@@ -15,12 +15,12 @@ import type {
1515 OrchestrationThreadShell,
1616 OrchestrationThreadActivity,
1717 ProjectId,
18 ProviderKind,
1918 ScopedProjectRef,
2019 ScopedThreadRef,
21 ThreadId,
22 TurnId,
2320} from "@t3tools/contracts";
21import { ProviderKind } from "@t3tools/contracts";
22import type { ThreadId, TurnId } from "@t3tools/contracts";
23import { Schema } from "effect";
2424import { resolveModelSlugForProvider } from "@t3tools/shared/model";
2525import { create } from "zustand";
2626import {
......@@ -1001,7 +1001,7 @@ function toLegacySessionStatus(
10011001}
10021002
10031003function 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
......@@ -24,6 +24,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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
......@@ -10,6 +10,7 @@
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";
22import { TrimmedNonEmptyString } from "./baseSchemas.ts";
33import type { ProviderKind } from "./orchestration.ts";
44
5export const CodexReasoningEffort = Schema.Literals(["xhigh", "high", "medium", "low"]);
5export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
6export const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS);
67export type CodexReasoningEffort = typeof CodexReasoningEffort.Type;
7export const ClaudeAgentEffort = Schema.Literals([
8export const CLAUDE_AGENT_EFFORT_OPTIONS = [
89 "low",
910 "medium",
1011 "high",
1112 "xhigh",
1213 "max",
1314 "ultrathink",
14]);
15] as const;
16export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS);
1517export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type;
16export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeAgentEffort;
18export type ClaudeCodeEffort = ClaudeAgentEffort;
19export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const;
20export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS);
21export type CursorReasoningOption = typeof CursorReasoningOption.Type;
22
23export type ProviderReasoningEffort =
24 | CodexReasoningEffort
25 | ClaudeAgentEffort
26 | CursorReasoningOption;
1727
1828export const CodexModelOptions = Schema.Struct({
1929 reasoningEffort: Schema.optional(CodexReasoningEffort),
......@@ -29,6 +39,13 @@ export const ClaudeModelOptions = Schema.Struct({
2939});
3040export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
3141
42export const CursorModelOptions = Schema.Struct({
43 reasoning: Schema.optional(CursorReasoningOption),
44 fastMode: Schema.optional(Schema.Boolean),
45 thinking: Schema.optional(Schema.Boolean),
46 contextWindow: Schema.optional(Schema.String),
47});
48export type CursorModelOptions = typeof CursorModelOptions.Type;
3249export const OpenCodeModelOptions = Schema.Struct({
3350 variant: Schema.optional(TrimmedNonEmptyString),
3451 agent: Schema.optional(TrimmedNonEmptyString),
......@@ -38,6 +55,7 @@ export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type;
3855export 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});
4361export type ProviderModelOptions = typeof ProviderModelOptions.Type;
......@@ -70,6 +88,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type;
7088export 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;
7998export 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,
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,
115146export 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 @@
11import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";
2import { ClaudeModelOptions, CodexModelOptions, OpenCodeModelOptions } from "./model.ts";
2import {
3 ClaudeModelOptions,
4 CodexModelOptions,
5 CursorModelOptions,
6 OpenCodeModelOptions,
7} from "./model.ts";
38import { RepositoryIdentity } from "./environment.ts";
49import {
510 ApprovalRequestId,
......@@ -25,7 +30,7 @@ export const ORCHESTRATION_WS_METHODS = {
2530 subscribeThread: "orchestration.subscribeThread",
2631} as const;
2732
28export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]);
33export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]);
2934export type ProviderKind = typeof ProviderKind.Type;
3035export const ProviderApprovalPolicy = Schema.Literals([
3136 "untrusted",
......@@ -57,6 +62,12 @@ export const ClaudeModelSelection = Schema.Struct({
5762});
5863export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
5964
65export const CursorModelSelection = Schema.Struct({
66 provider: Schema.Literal("cursor"),
67 model: TrimmedNonEmptyString,
68 options: Schema.optionalKey(CursorModelOptions),
69});
70export type CursorModelSelection = typeof CursorModelSelection.Type;
6071export const OpenCodeModelSelection = Schema.Struct({
6172 provider: Schema.Literal("opencode"),
6273 model: TrimmedNonEmptyString,
......@@ -67,6 +78,7 @@ export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type;
6778export const ModelSelection = Schema.Union([
6879 CodexModelSelection,
6980 ClaudeModelSelection,
81 CursorModelSelection,
7082 OpenCodeModelSelection,
7183]);
7284export type ModelSelection = typeof ModelSelection.Type;
packages/contracts/src/provider.test.ts+20
......@@ -68,6 +68,26 @@ describe("ProviderSessionStartInput", () => {
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
7393describe("ProviderSendTurnInput", () => {
packages/contracts/src/providerRuntime.ts+10-8
......@@ -17,14 +17,16 @@ import { ProviderKind } from "./orchestration.ts";
1717const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
1818const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown);
1919
20const 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",
20const 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]);
2931export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
3032
packages/contracts/src/settings.ts+29
......@@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
55import {
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({
9091});
9192export type ClaudeSettings = typeof ClaudeSettings.Type;
9293
94export const CursorSettings = Schema.Struct({
95 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
96 binaryPath: makeBinaryPathSetting("agent"),
97 apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
98 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
99});
100export type CursorSettings = typeof CursorSettings.Type;
93101export 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({
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({
167176 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),
168177});
169178
179const CursorModelOptionsPatch = Schema.Struct({
180 reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning),
181 fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode),
182 thinking: Schema.optionalKey(CursorModelOptions.fields.thinking),
183 contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow),
184});
185
170186const 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([
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({
204225 launchArgs: Schema.optionalKey(Schema.String),
205226});
206227
228const CursorSettingsPatch = Schema.Struct({
229 enabled: Schema.optionalKey(Schema.Boolean),
230 binaryPath: Schema.optionalKey(Schema.String),
231 apiEndpoint: Schema.optionalKey(Schema.String),
232 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
233});
234
207235const OpenCodeSettingsPatch = Schema.Struct({
208236 enabled: Schema.optionalKey(Schema.Boolean),
209237 binaryPath: Schema.optionalKey(Schema.String),
......@@ -227,6 +255,7 @@ export const ServerSettingsPatch = Schema.Struct({
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 created+55
......@@ -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 created+289
......@@ -0,0 +1,289 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator";
6import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
7import { Command, Flag } from "effect/unstable/cli";
8import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";
9import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10
11const CURRENT_SCHEMA_RELEASE = "v0.11.3";
12
13interface GenerateCommandError {
14 readonly _tag: "GenerateCommandError";
15 readonly message: string;
16}
17
18interface GeneratedPaths {
19 readonly generatedDir: string;
20 readonly upstreamSchemaPath: string;
21 readonly upstreamMetaPath: string;
22 readonly schemaOutputPath: string;
23 readonly metaOutputPath: string;
24}
25
26const MetaJsonSchema = Schema.Struct({
27 agentMethods: Schema.Record(Schema.String, Schema.String),
28 clientMethods: Schema.Record(Schema.String, Schema.String),
29 version: Schema.Union([Schema.Number, Schema.String]),
30});
31
32const UpstreamJsonSchemaSchema = Schema.Struct({
33 $defs: Schema.Record(Schema.String, Schema.Json),
34});
35
36const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () {
37 const path = yield* Path.Path;
38 const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated");
39 return {
40 generatedDir,
41 upstreamSchemaPath: path.join(generatedDir, "upstream-schema.json"),
42 upstreamMetaPath: path.join(generatedDir, "upstream-meta.json"),
43 schemaOutputPath: path.join(generatedDir, "schema.gen.ts"),
44 metaOutputPath: path.join(generatedDir, "meta.gen.ts"),
45 } satisfies GeneratedPaths;
46});
47
48const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const { generatedDir } = yield* getGeneratedPaths();
51
52 yield* fs.makeDirectory(generatedDir, { recursive: true });
53});
54
55const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) {
56 const fs = yield* FileSystem.FileSystem;
57 const path = yield* Path.Path;
58
59 yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
60
61 const text = yield* HttpClient.get(url).pipe(
62 Effect.flatMap(HttpClientResponse.filterStatusOk),
63 Effect.flatMap((response) => response.text),
64 );
65
66 yield* fs.writeFileString(outputPath, text);
67});
68
69const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) {
70 const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths();
71 const fs = yield* FileSystem.FileSystem;
72 const baseUrl = `https://github.com/agentclientprotocol/agent-client-protocol/releases/download/${tag}`;
73
74 yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath);
75 yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath);
76
77 yield* Effect.addFinalizer(() =>
78 Effect.all([fs.remove(upstreamSchemaPath), fs.remove(upstreamMetaPath)]).pipe(
79 Effect.ignoreCause({ log: true }),
80 ),
81 );
82});
83
84const readJsonFile = Effect.fn("readJsonFile")(function* <
85 S extends Schema.Top & { readonly DecodingServices: never },
86>(schema: S, filePath: string) {
87 const fs = yield* FileSystem.FileSystem;
88 const raw = yield* fs.readFileString(filePath);
89 return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(raw);
90});
91
92const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* (
93 schemaOutput: string,
94 metaOutput: string,
95) {
96 const fs = yield* FileSystem.FileSystem;
97 const { metaOutputPath, schemaOutputPath } = yield* getGeneratedPaths();
98
99 yield* fs.writeFileString(schemaOutputPath, schemaOutput);
100 yield* fs.writeFileString(metaOutputPath, metaOutput);
101});
102
103function collectSchemaEntries(
104 chunk: string,
105): ReadonlyArray<{ readonly name: string; readonly code: string }> {
106 const lines = chunk
107 .split("\n")
108 .map((line) => line.trim())
109 .filter((line) => line.length > 0 && !line.startsWith("//"));
110 const entries: Array<{ name: string; code: string }> = [];
111
112 for (let index = 0; index < lines.length; index += 1) {
113 const typeLine = lines[index];
114 if (!typeLine?.startsWith("export type ")) {
115 continue;
116 }
117
118 const constLine = lines[index + 1];
119 if (!constLine?.startsWith("export const ")) {
120 throw new Error(`Malformed generator output near: ${typeLine}`);
121 }
122
123 const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine);
124 if (!match?.[1]) {
125 throw new Error(`Could not extract schema name from: ${typeLine}`);
126 }
127
128 entries.push({
129 name: match[1],
130 code: `${typeLine}\n${constLine}`,
131 });
132 index += 1;
133 }
134
135 return entries;
136}
137
138function normalizeNullableTypes(value: typeof Schema.Json.Type): typeof Schema.Json.Type {
139 if (Array.isArray(value)) {
140 return value.map(normalizeNullableTypes);
141 }
142 if (value === null || typeof value !== "object") {
143 return value;
144 }
145
146 const normalizedEntries = Object.entries(value).map(([key, child]) => [
147 key,
148 normalizeNullableTypes(child),
149 ]);
150 const normalizedObject = Object.fromEntries(normalizedEntries) as Record<
151 string,
152 typeof Schema.Json.Type
153 >;
154 const typeValue = normalizedObject.type;
155
156 if (!Array.isArray(typeValue)) {
157 return normalizedObject;
158 }
159
160 const normalizedTypes = typeValue.filter((entry): entry is string => typeof entry === "string");
161 if (normalizedTypes.length !== typeValue.length || !normalizedTypes.includes("null")) {
162 return normalizedObject;
163 }
164
165 const nonNullTypes = normalizedTypes.filter((entry) => entry !== "null");
166 if (nonNullTypes.length !== 1) {
167 return normalizedObject;
168 }
169 const nonNullType = nonNullTypes[0]!;
170
171 const nextObject: Record<string, typeof Schema.Json.Type> = {};
172 for (const [key, child] of Object.entries(normalizedObject)) {
173 if (key !== "type") {
174 nextObject[key] = child;
175 }
176 }
177
178 return {
179 anyOf: [
180 {
181 ...nextObject,
182 type: nonNullType,
183 },
184 { type: "null" },
185 ],
186 };
187}
188
189const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: boolean) {
190 const { upstreamMetaPath, upstreamSchemaPath } = yield* getGeneratedPaths();
191
192 yield* ensureGeneratedDir();
193
194 if (!skipDownload) {
195 yield* Effect.log(`Downloading ACP schema assets for ${CURRENT_SCHEMA_RELEASE}`);
196 yield* downloadSchemas(CURRENT_SCHEMA_RELEASE);
197 }
198
199 const upstreamSchema = yield* readJsonFile(UpstreamJsonSchemaSchema, upstreamSchemaPath);
200 const upstreamMeta = yield* readJsonFile(MetaJsonSchema, upstreamMetaPath);
201 const normalizedDefinitions = Object.fromEntries(
202 Object.entries(upstreamSchema.$defs).map(([name, schema]) => [
203 name,
204 normalizeNullableTypes(schema),
205 ]),
206 );
207
208 const sortedEntries = Object.entries(normalizedDefinitions).toSorted(([left], [right]) =>
209 left.localeCompare(right),
210 );
211 const generatedEntries = new Map<string, string>();
212 const generator = makeJsonSchemaGenerator();
213
214 for (const [name, schema] of sortedEntries) {
215 generator.addSchema(name, schema as never);
216 }
217
218 const output = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim();
219 if (output.length > 0) {
220 for (const entry of collectSchemaEntries(output)) {
221 if (!generatedEntries.has(entry.name)) {
222 generatedEntries.set(entry.name, entry.code);
223 }
224 }
225 }
226
227 const prelude = [
228 `// This file is generated by the effect-acp package. Do not edit manually.`,
229 `// Current ACP schema release: ${CURRENT_SCHEMA_RELEASE}`,
230 "",
231 ];
232
233 const schemaOutput = [
234 ...prelude,
235 'import * as Schema from "effect/Schema";',
236 "",
237 [...generatedEntries.values()].join("\n\n"),
238 "",
239 ].join("\n");
240
241 const metaOutput = [
242 ...prelude,
243 `export const AGENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.agentMethods))(upstreamMeta.agentMethods)} as const;`,
244 "",
245 `export const CLIENT_METHODS = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.clientMethods))(upstreamMeta.clientMethods)} as const;`,
246 "",
247 `export const PROTOCOL_VERSION = ${yield* Schema.encodeEffect(Schema.fromJsonString(MetaJsonSchema.fields.version))(upstreamMeta.version)} as const;`,
248 "",
249 ].join("\n");
250
251 yield* writeGeneratedFiles(schemaOutput, metaOutput);
252 yield* Effect.log(
253 `Generated ${generatedEntries.size} ACP schemas from ${CURRENT_SCHEMA_RELEASE}`,
254 );
255
256 const { generatedDir } = yield* getGeneratedPaths();
257 yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe(
258 Effect.flatMap((spawner) => spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))),
259 Effect.flatMap((child) => child.exitCode),
260 Effect.tap((code) =>
261 code === 0
262 ? Effect.void
263 : Effect.fail<GenerateCommandError>({
264 _tag: "GenerateCommandError",
265 message: `oxfmt failed with exit code ${code}`,
266 }),
267 ),
268 );
269});
270
271const generateCommand = Command.make(
272 "generate",
273 {
274 skipDownload: Flag.boolean("skip-download").pipe(Flag.withDefault(false)),
275 },
276 ({ skipDownload }) => generateSchemas(skipDownload),
277).pipe(Command.withDescription("Generate Effect ACP schemas from the pinned ACP release assets."));
278
279const runtimeLayer = Layer.mergeAll(
280 Logger.layer([Logger.consolePretty()]),
281 NodeServices.layer,
282 FetchHttpClient.layer,
283);
284
285Command.run(generateCommand, { version: "0.0.0" }).pipe(
286 Effect.scoped,
287 Effect.provide(runtimeLayer),
288 NodeRuntime.runMain,
289);
packages/effect-acp/src/_generated/meta.gen.ts created+35
......@@ -0,0 +1,35 @@
1// This file is generated by the effect-acp package. Do not edit manually.
2// Current ACP schema release: v0.11.3
3
4export const AGENT_METHODS = {
5 authenticate: "authenticate",
6 initialize: "initialize",
7 logout: "logout",
8 session_cancel: "session/cancel",
9 session_close: "session/close",
10 session_fork: "session/fork",
11 session_list: "session/list",
12 session_load: "session/load",
13 session_new: "session/new",
14 session_prompt: "session/prompt",
15 session_resume: "session/resume",
16 session_set_config_option: "session/set_config_option",
17 session_set_mode: "session/set_mode",
18 session_set_model: "session/set_model",
19} as const;
20
21export const CLIENT_METHODS = {
22 fs_read_text_file: "fs/read_text_file",
23 fs_write_text_file: "fs/write_text_file",
24 session_elicitation: "session/elicitation",
25 session_elicitation_complete: "session/elicitation/complete",
26 session_request_permission: "session/request_permission",
27 session_update: "session/update",
28 terminal_create: "terminal/create",
29 terminal_kill: "terminal/kill",
30 terminal_output: "terminal/output",
31 terminal_release: "terminal/release",
32 terminal_wait_for_exit: "terminal/wait_for_exit",
33} as const;
34
35export const PROTOCOL_VERSION = 1 as const;
packages/effect-acp/src/_generated/schema.gen.ts created+10375
......@@ -0,0 +1,10375 @@
1// This file is generated by the effect-acp package. Do not edit manually.
2// Current ACP schema release: v0.11.3
3
4import * as Schema from "effect/Schema";
5
6export type AuthEnvVar = {
7 readonly _meta?: { readonly [x: string]: unknown } | null;
8 readonly label?: string | null;
9 readonly name: string;
10 readonly optional?: boolean;
11 readonly secret?: boolean;
12};
13export const AuthEnvVar = Schema.Struct({
14 _meta: Schema.optionalKey(
15 Schema.Union([
16 Schema.Record(Schema.String, Schema.Unknown).annotate({
17 description:
18 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
19 }),
20 Schema.Null,
21 ]),
22 ),
23 label: Schema.optionalKey(
24 Schema.Union([
25 Schema.String.annotate({
26 description: "Human-readable label for this variable, displayed in client UI.",
27 }),
28 Schema.Null,
29 ]),
30 ),
31 name: Schema.String.annotate({
32 description: 'The environment variable name (e.g. `"OPENAI_API_KEY"`).',
33 }),
34 optional: Schema.optionalKey(
35 Schema.Boolean.annotate({
36 description: "Whether this variable is optional.\n\nDefaults to `false`.",
37 default: false,
38 }),
39 ),
40 secret: Schema.optionalKey(
41 Schema.Boolean.annotate({
42 description:
43 "Whether this value is a secret (e.g. API key, token).\nClients should use a password-style input for secret vars.\n\nDefaults to `true`.",
44 default: true,
45 }),
46 ),
47}).annotate({
48 description:
49 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nDescribes a single environment variable for an [`AuthMethodEnvVar`] authentication method.",
50});
51
52export type AvailableCommandInput = {
53 readonly _meta?: { readonly [x: string]: unknown } | null;
54 readonly hint: string;
55};
56export const AvailableCommandInput = Schema.Union([
57 Schema.Struct({
58 _meta: Schema.optionalKey(
59 Schema.Union([
60 Schema.Record(Schema.String, Schema.Unknown).annotate({
61 description:
62 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
63 }),
64 Schema.Null,
65 ]),
66 ),
67 hint: Schema.String.annotate({
68 description: "A hint to display when the input hasn't been provided yet",
69 }),
70 }).annotate({
71 title: "unstructured",
72 description: "All text that was typed after the command name is provided as input.",
73 }),
74]).annotate({ description: "The input specification for a command." });
75
76export type Cost = { readonly amount: number; readonly currency: string };
77export const Cost = Schema.Struct({
78 amount: Schema.Number.annotate({
79 description: "Total cumulative cost for session.",
80 format: "double",
81 }).check(Schema.isFinite()),
82 currency: Schema.String.annotate({ description: 'ISO 4217 currency code (e.g., "USD", "EUR").' }),
83}).annotate({
84 description:
85 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCost information for a session.",
86});
87
88export type ElicitationContentValue = string | number | number | boolean | ReadonlyArray<string>;
89export const ElicitationContentValue = Schema.Union([
90 Schema.String.annotate({ title: "String" }),
91 Schema.Number.annotate({ title: "Integer", format: "int64" }).check(Schema.isInt()),
92 Schema.Number.annotate({ title: "Number", format: "double" }).check(Schema.isFinite()),
93 Schema.Boolean.annotate({ title: "Boolean" }),
94 Schema.Array(Schema.String).annotate({ title: "StringArray" }),
95]);
96
97export type ElicitationFormCapabilities = {
98 readonly _meta?: { readonly [x: string]: unknown } | null;
99};
100export const ElicitationFormCapabilities = Schema.Struct({
101 _meta: Schema.optionalKey(
102 Schema.Union([
103 Schema.Record(Schema.String, Schema.Unknown).annotate({
104 description:
105 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
106 }),
107 Schema.Null,
108 ]),
109 ),
110}).annotate({
111 description:
112 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation capabilities.",
113});
114
115export type ElicitationUrlCapabilities = {
116 readonly _meta?: { readonly [x: string]: unknown } | null;
117};
118export const ElicitationUrlCapabilities = Schema.Struct({
119 _meta: Schema.optionalKey(
120 Schema.Union([
121 Schema.Record(Schema.String, Schema.Unknown).annotate({
122 description:
123 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
124 }),
125 Schema.Null,
126 ]),
127 ),
128}).annotate({
129 description:
130 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation capabilities.",
131});
132
133export type EmbeddedResourceResource =
134 | {
135 readonly _meta?: { readonly [x: string]: unknown } | null;
136 readonly mimeType?: string | null;
137 readonly text: string;
138 readonly uri: string;
139 }
140 | {
141 readonly _meta?: { readonly [x: string]: unknown } | null;
142 readonly blob: string;
143 readonly mimeType?: string | null;
144 readonly uri: string;
145 };
146export const EmbeddedResourceResource = Schema.Union([
147 Schema.Struct({
148 _meta: Schema.optionalKey(
149 Schema.Union([
150 Schema.Record(Schema.String, Schema.Unknown).annotate({
151 description:
152 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
153 }),
154 Schema.Null,
155 ]),
156 ),
157 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
158 text: Schema.String,
159 uri: Schema.String,
160 }).annotate({ title: "TextResourceContents", description: "Text-based resource contents." }),
161 Schema.Struct({
162 _meta: Schema.optionalKey(
163 Schema.Union([
164 Schema.Record(Schema.String, Schema.Unknown).annotate({
165 description:
166 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
167 }),
168 Schema.Null,
169 ]),
170 ),
171 blob: Schema.String,
172 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
173 uri: Schema.String,
174 }).annotate({ title: "BlobResourceContents", description: "Binary resource contents." }),
175]).annotate({ description: "Resource content that can be embedded in a message." });
176
177export type EnumOption = { readonly const: string; readonly title: string };
178export const EnumOption = Schema.Struct({
179 const: Schema.String.annotate({ description: "The constant value for this option." }),
180 title: Schema.String.annotate({ description: "Human-readable title for this option." }),
181}).annotate({ description: "A titled enum option with a const value and human-readable title." });
182
183export type EnvVariable = {
184 readonly _meta?: { readonly [x: string]: unknown } | null;
185 readonly name: string;
186 readonly value: string;
187};
188export const EnvVariable = Schema.Struct({
189 _meta: Schema.optionalKey(
190 Schema.Union([
191 Schema.Record(Schema.String, Schema.Unknown).annotate({
192 description:
193 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
194 }),
195 Schema.Null,
196 ]),
197 ),
198 name: Schema.String.annotate({ description: "The name of the environment variable." }),
199 value: Schema.String.annotate({ description: "The value to set for the environment variable." }),
200}).annotate({ description: "An environment variable to set when launching an MCP server." });
201
202export type Error = {
203 readonly code:
204 | -32700
205 | -32600
206 | -32601
207 | -32602
208 | -32603
209 | -32800
210 | -32000
211 | -32002
212 | -32042
213 | number;
214 readonly data?: unknown;
215 readonly message: string;
216};
217export const Error = Schema.Struct({
218 code: Schema.Union([
219 Schema.Literal(-32700).annotate({
220 title: "Parse error",
221 description:
222 "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.",
223 format: "int32",
224 }),
225 Schema.Literal(-32600).annotate({
226 title: "Invalid request",
227 description: "**Invalid request**: The JSON sent is not a valid Request object.",
228 format: "int32",
229 }),
230 Schema.Literal(-32601).annotate({
231 title: "Method not found",
232 description: "**Method not found**: The method does not exist or is not available.",
233 format: "int32",
234 }),
235 Schema.Literal(-32602).annotate({
236 title: "Invalid params",
237 description: "**Invalid params**: Invalid method parameter(s).",
238 format: "int32",
239 }),
240 Schema.Literal(-32603).annotate({
241 title: "Internal error",
242 description:
243 "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.",
244 format: "int32",
245 }),
246 Schema.Literal(-32800).annotate({
247 title: "Request cancelled",
248 description:
249 "**Request cancelled**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nExecution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.",
250 format: "int32",
251 }),
252 Schema.Literal(-32000).annotate({
253 title: "Authentication required",
254 description:
255 "**Authentication required**: Authentication is required before this operation can be performed.",
256 format: "int32",
257 }),
258 Schema.Literal(-32002).annotate({
259 title: "Resource not found",
260 description: "**Resource not found**: A given resource, such as a file, was not found.",
261 format: "int32",
262 }),
263 Schema.Literal(-32042).annotate({
264 title: "URL elicitation required",
265 description:
266 "**URL elicitation required**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe agent requires user input via a URL-based elicitation before it can proceed.",
267 format: "int32",
268 }),
269 Schema.Number.annotate({
270 title: "Other",
271 description: "Other undefined error code.",
272 format: "int32",
273 }).check(Schema.isInt()),
274 ]).annotate({
275 description:
276 "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors.",
277 }),
278 data: Schema.optionalKey(
279 Schema.Unknown.annotate({
280 description:
281 "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details.",
282 }),
283 ),
284 message: Schema.String.annotate({
285 description:
286 "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.",
287 }),
288}).annotate({
289 description:
290 "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)",
291});
292
293export type HttpHeader = {
294 readonly _meta?: { readonly [x: string]: unknown } | null;
295 readonly name: string;
296 readonly value: string;
297};
298export const HttpHeader = Schema.Struct({
299 _meta: Schema.optionalKey(
300 Schema.Union([
301 Schema.Record(Schema.String, Schema.Unknown).annotate({
302 description:
303 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
304 }),
305 Schema.Null,
306 ]),
307 ),
308 name: Schema.String.annotate({ description: "The name of the HTTP header." }),
309 value: Schema.String.annotate({ description: "The value to set for the HTTP header." }),
310}).annotate({ description: "An HTTP header to set when making requests to the MCP server." });
311
312export type Implementation = {
313 readonly _meta?: { readonly [x: string]: unknown } | null;
314 readonly name: string;
315 readonly title?: string | null;
316 readonly version: string;
317};
318export const Implementation = Schema.Struct({
319 _meta: Schema.optionalKey(
320 Schema.Union([
321 Schema.Record(Schema.String, Schema.Unknown).annotate({
322 description:
323 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
324 }),
325 Schema.Null,
326 ]),
327 ),
328 name: Schema.String.annotate({
329 description:
330 "Intended for programmatic or logical use, but can be used as a display\nname fallback if title isn’t present.",
331 }),
332 title: Schema.optionalKey(
333 Schema.Union([
334 Schema.String.annotate({
335 description:
336 "Intended for UI and end-user contexts — optimized to be human-readable\nand easily understood.\n\nIf not provided, the name should be used for display.",
337 }),
338 Schema.Null,
339 ]),
340 ),
341 version: Schema.String.annotate({
342 description:
343 'Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. "1.0.0").',
344 }),
345}).annotate({
346 description:
347 "Metadata about the implementation of the client or agent.\nDescribes the name and version of an MCP implementation, with an optional\ntitle for UI representation.",
348});
349
350export type LogoutCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
351export const LogoutCapabilities = Schema.Struct({
352 _meta: Schema.optionalKey(
353 Schema.Union([
354 Schema.Record(Schema.String, Schema.Unknown).annotate({
355 description:
356 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
357 }),
358 Schema.Null,
359 ]),
360 ),
361}).annotate({
362 description:
363 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nLogout capabilities supported by the agent.\n\nBy supplying `{}` it means that the agent supports the logout method.",
364});
365
366export type ModelInfo = {
367 readonly _meta?: { readonly [x: string]: unknown } | null;
368 readonly description?: string | null;
369 readonly modelId: string;
370 readonly name: string;
371};
372export const ModelInfo = Schema.Struct({
373 _meta: Schema.optionalKey(
374 Schema.Union([
375 Schema.Record(Schema.String, Schema.Unknown).annotate({
376 description:
377 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
378 }),
379 Schema.Null,
380 ]),
381 ),
382 description: Schema.optionalKey(
383 Schema.Union([
384 Schema.String.annotate({ description: "Optional description of the model." }),
385 Schema.Null,
386 ]),
387 ),
388 modelId: Schema.String.annotate({
389 description:
390 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.",
391 }),
392 name: Schema.String.annotate({ description: "Human-readable name of the model." }),
393}).annotate({
394 description:
395 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInformation about a selectable model.",
396});
397
398export type PermissionOption = {
399 readonly _meta?: { readonly [x: string]: unknown } | null;
400 readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
401 readonly name: string;
402 readonly optionId: string;
403};
404export const PermissionOption = Schema.Struct({
405 _meta: Schema.optionalKey(
406 Schema.Union([
407 Schema.Record(Schema.String, Schema.Unknown).annotate({
408 description:
409 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
410 }),
411 Schema.Null,
412 ]),
413 ),
414 kind: Schema.Literals(["allow_once", "allow_always", "reject_once", "reject_always"]).annotate({
415 description:
416 "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.",
417 }),
418 name: Schema.String.annotate({ description: "Human-readable label to display to the user." }),
419 optionId: Schema.String.annotate({ description: "Unique identifier for a permission option." }),
420}).annotate({ description: "An option presented to the user when requesting permission." });
421
422export type PlanEntry = {
423 readonly _meta?: { readonly [x: string]: unknown } | null;
424 readonly content: string;
425 readonly priority: "high" | "medium" | "low";
426 readonly status: "pending" | "in_progress" | "completed";
427};
428export const PlanEntry = Schema.Struct({
429 _meta: Schema.optionalKey(
430 Schema.Union([
431 Schema.Record(Schema.String, Schema.Unknown).annotate({
432 description:
433 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
434 }),
435 Schema.Null,
436 ]),
437 ),
438 content: Schema.String.annotate({
439 description: "Human-readable description of what this task aims to accomplish.",
440 }),
441 priority: Schema.Literals(["high", "medium", "low"]).annotate({
442 description:
443 "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)",
444 }),
445 status: Schema.Literals(["pending", "in_progress", "completed"]).annotate({
446 description:
447 "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)",
448 }),
449}).annotate({
450 description:
451 "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)",
452});
453
454export type RequestId = null | number | string;
455export const RequestId = Schema.Union([
456 Schema.Null.annotate({ title: "Null" }),
457 Schema.Number.annotate({ title: "Number", format: "int64" }).check(Schema.isInt()),
458 Schema.String.annotate({ title: "Str" }),
459]).annotate({
460 description:
461 "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.",
462});
463
464export type Role = "assistant" | "user";
465export const Role = Schema.Literals(["assistant", "user"]).annotate({
466 description: "The sender or recipient of messages and data in a conversation.",
467});
468
469export type SessionCloseCapabilities = {
470 readonly _meta?: { readonly [x: string]: unknown } | null;
471};
472export const SessionCloseCapabilities = Schema.Struct({
473 _meta: Schema.optionalKey(
474 Schema.Union([
475 Schema.Record(Schema.String, Schema.Unknown).annotate({
476 description:
477 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
478 }),
479 Schema.Null,
480 ]),
481 ),
482}).annotate({
483 description:
484 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/close` method.\n\nBy supplying `{}` it means that the agent supports closing of sessions.",
485});
486
487export type SessionConfigOptionCategory = "mode" | "model" | "thought_level" | string;
488export const SessionConfigOptionCategory = Schema.Union([
489 Schema.Literal("mode").annotate({ description: "Session mode selector." }),
490 Schema.Literal("model").annotate({ description: "Model selector." }),
491 Schema.Literal("thought_level").annotate({ description: "Thought/reasoning level selector." }),
492 Schema.String.annotate({ title: "other", description: "Unknown / uncategorized selector." }),
493]).annotate({
494 description:
495 "Semantic category for a session configuration option.\n\nThis is intended to help Clients distinguish broadly common selectors (e.g. model selector vs\nsession mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons,\nplacement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown\ncategories gracefully.\n\nCategory names beginning with `_` are free for custom use, like other ACP extension methods.\nCategory names that do not begin with `_` are reserved for the ACP spec.",
496});
497
498export type SessionConfigSelectOption = {
499 readonly _meta?: { readonly [x: string]: unknown } | null;
500 readonly description?: string | null;
501 readonly name: string;
502 readonly value: string;
503};
504export const SessionConfigSelectOption = Schema.Struct({
505 _meta: Schema.optionalKey(
506 Schema.Union([
507 Schema.Record(Schema.String, Schema.Unknown).annotate({
508 description:
509 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
510 }),
511 Schema.Null,
512 ]),
513 ),
514 description: Schema.optionalKey(
515 Schema.Union([
516 Schema.String.annotate({ description: "Optional description for this option value." }),
517 Schema.Null,
518 ]),
519 ),
520 name: Schema.String.annotate({ description: "Human-readable label for this option value." }),
521 value: Schema.String.annotate({
522 description: "Unique identifier for a session configuration option value.",
523 }),
524}).annotate({ description: "A possible value for a session configuration option." });
525
526export type SessionForkCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
527export const SessionForkCapabilities = Schema.Struct({
528 _meta: Schema.optionalKey(
529 Schema.Union([
530 Schema.Record(Schema.String, Schema.Unknown).annotate({
531 description:
532 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
533 }),
534 Schema.Null,
535 ]),
536 ),
537}).annotate({
538 description:
539 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/fork` method.\n\nBy supplying `{}` it means that the agent supports forking of sessions.",
540});
541
542export type SessionInfo = {
543 readonly _meta?: { readonly [x: string]: unknown } | null;
544 readonly cwd: string;
545 readonly sessionId: string;
546 readonly title?: string | null;
547 readonly updatedAt?: string | null;
548};
549export const SessionInfo = Schema.Struct({
550 _meta: Schema.optionalKey(
551 Schema.Union([
552 Schema.Record(Schema.String, Schema.Unknown).annotate({
553 description:
554 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
555 }),
556 Schema.Null,
557 ]),
558 ),
559 cwd: Schema.String.annotate({
560 description: "The working directory for this session. Must be an absolute path.",
561 }),
562 sessionId: Schema.String.annotate({
563 description:
564 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
565 }),
566 title: Schema.optionalKey(
567 Schema.Union([
568 Schema.String.annotate({ description: "Human-readable title for the session" }),
569 Schema.Null,
570 ]),
571 ),
572 updatedAt: Schema.optionalKey(
573 Schema.Union([
574 Schema.String.annotate({ description: "ISO 8601 timestamp of last activity" }),
575 Schema.Null,
576 ]),
577 ),
578}).annotate({ description: "Information about a session returned by session/list" });
579
580export type SessionListCapabilities = { readonly _meta?: { readonly [x: string]: unknown } | null };
581export const SessionListCapabilities = Schema.Struct({
582 _meta: Schema.optionalKey(
583 Schema.Union([
584 Schema.Record(Schema.String, Schema.Unknown).annotate({
585 description:
586 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
587 }),
588 Schema.Null,
589 ]),
590 ),
591}).annotate({
592 description:
593 "Capabilities for the `session/list` method.\n\nBy supplying `{}` it means that the agent supports listing of sessions.",
594});
595
596export type SessionModeId = string;
597export const SessionModeId = Schema.String.annotate({
598 description: "Unique identifier for a Session Mode.",
599});
600
601export type SessionResumeCapabilities = {
602 readonly _meta?: { readonly [x: string]: unknown } | null;
603};
604export const SessionResumeCapabilities = Schema.Struct({
605 _meta: Schema.optionalKey(
606 Schema.Union([
607 Schema.Record(Schema.String, Schema.Unknown).annotate({
608 description:
609 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
610 }),
611 Schema.Null,
612 ]),
613 ),
614}).annotate({
615 description:
616 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/resume` method.\n\nBy supplying `{}` it means that the agent supports resuming of sessions.",
617});
618
619export type StringFormat = "email" | "uri" | "date" | "date-time";
620export const StringFormat = Schema.Literals(["email", "uri", "date", "date-time"]).annotate({
621 description: "String format types for string properties in elicitation schemas.",
622});
623
624export type TerminalExitStatus = {
625 readonly _meta?: { readonly [x: string]: unknown } | null;
626 readonly exitCode?: number | null;
627 readonly signal?: string | null;
628};
629export const TerminalExitStatus = Schema.Struct({
630 _meta: Schema.optionalKey(
631 Schema.Union([
632 Schema.Record(Schema.String, Schema.Unknown).annotate({
633 description:
634 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
635 }),
636 Schema.Null,
637 ]),
638 ),
639 exitCode: Schema.optionalKey(
640 Schema.Union([
641 Schema.Number.annotate({
642 description: "The process exit code (may be null if terminated by signal).",
643 format: "uint32",
644 })
645 .check(Schema.isInt())
646 .check(Schema.isGreaterThanOrEqualTo(0)),
647 Schema.Null,
648 ]),
649 ),
650 signal: Schema.optionalKey(
651 Schema.Union([
652 Schema.String.annotate({
653 description: "The signal that terminated the process (may be null if exited normally).",
654 }),
655 Schema.Null,
656 ]),
657 ),
658}).annotate({ description: "Exit status of a terminal command." });
659
660export type ToolCallLocation = {
661 readonly _meta?: { readonly [x: string]: unknown } | null;
662 readonly line?: number | null;
663 readonly path: string;
664};
665export const ToolCallLocation = Schema.Struct({
666 _meta: Schema.optionalKey(
667 Schema.Union([
668 Schema.Record(Schema.String, Schema.Unknown).annotate({
669 description:
670 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
671 }),
672 Schema.Null,
673 ]),
674 ),
675 line: Schema.optionalKey(
676 Schema.Union([
677 Schema.Number.annotate({
678 description: "Optional line number within the file.",
679 format: "uint32",
680 })
681 .check(Schema.isInt())
682 .check(Schema.isGreaterThanOrEqualTo(0)),
683 Schema.Null,
684 ]),
685 ),
686 path: Schema.String.annotate({ description: "The file path being accessed or modified." }),
687}).annotate({
688 description:
689 'A file location being accessed or modified by a tool.\n\nEnables clients to implement "follow-along" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)',
690});
691
692export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
693export const ToolCallStatus = Schema.Literals([
694 "pending",
695 "in_progress",
696 "completed",
697 "failed",
698]).annotate({
699 description:
700 "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)",
701});
702
703export type ToolKind =
704 | "read"
705 | "edit"
706 | "delete"
707 | "move"
708 | "search"
709 | "execute"
710 | "think"
711 | "fetch"
712 | "switch_mode"
713 | "other";
714export const ToolKind = Schema.Literals([
715 "read",
716 "edit",
717 "delete",
718 "move",
719 "search",
720 "execute",
721 "think",
722 "fetch",
723 "switch_mode",
724 "other",
725]).annotate({
726 description:
727 "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)",
728});
729
730export type Usage = {
731 readonly cachedReadTokens?: number | null;
732 readonly cachedWriteTokens?: number | null;
733 readonly inputTokens: number;
734 readonly outputTokens: number;
735 readonly thoughtTokens?: number | null;
736 readonly totalTokens: number;
737};
738export const Usage = Schema.Struct({
739 cachedReadTokens: Schema.optionalKey(
740 Schema.Union([
741 Schema.Number.annotate({ description: "Total cache read tokens.", format: "uint64" })
742 .check(Schema.isInt())
743 .check(Schema.isGreaterThanOrEqualTo(0)),
744 Schema.Null,
745 ]),
746 ),
747 cachedWriteTokens: Schema.optionalKey(
748 Schema.Union([
749 Schema.Number.annotate({ description: "Total cache write tokens.", format: "uint64" })
750 .check(Schema.isInt())
751 .check(Schema.isGreaterThanOrEqualTo(0)),
752 Schema.Null,
753 ]),
754 ),
755 inputTokens: Schema.Number.annotate({
756 description: "Total input tokens across all turns.",
757 format: "uint64",
758 })
759 .check(Schema.isInt())
760 .check(Schema.isGreaterThanOrEqualTo(0)),
761 outputTokens: Schema.Number.annotate({
762 description: "Total output tokens across all turns.",
763 format: "uint64",
764 })
765 .check(Schema.isInt())
766 .check(Schema.isGreaterThanOrEqualTo(0)),
767 thoughtTokens: Schema.optionalKey(
768 Schema.Union([
769 Schema.Number.annotate({ description: "Total thought/reasoning tokens", format: "uint64" })
770 .check(Schema.isInt())
771 .check(Schema.isGreaterThanOrEqualTo(0)),
772 Schema.Null,
773 ]),
774 ),
775 totalTokens: Schema.Number.annotate({
776 description: "Sum of all token types across session.",
777 format: "uint64",
778 })
779 .check(Schema.isInt())
780 .check(Schema.isGreaterThanOrEqualTo(0)),
781}).annotate({
782 description:
783 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nToken usage information for a prompt turn.",
784});
785
786export type AuthMethod =
787 | {
788 readonly type: "env_var";
789 readonly _meta?: { readonly [x: string]: unknown } | null;
790 readonly description?: string | null;
791 readonly id: string;
792 readonly link?: string | null;
793 readonly name: string;
794 readonly vars: ReadonlyArray<AuthEnvVar>;
795 }
796 | {
797 readonly type: "terminal";
798 readonly _meta?: { readonly [x: string]: unknown } | null;
799 readonly args?: ReadonlyArray<string>;
800 readonly description?: string | null;
801 readonly env?: { readonly [x: string]: string };
802 readonly id: string;
803 readonly name: string;
804 }
805 | {
806 readonly _meta?: { readonly [x: string]: unknown } | null;
807 readonly description?: string | null;
808 readonly id: string;
809 readonly name: string;
810 };
811export const AuthMethod = Schema.Union([
812 Schema.Struct({
813 type: Schema.Literal("env_var"),
814 _meta: Schema.optionalKey(
815 Schema.Union([
816 Schema.Record(Schema.String, Schema.Unknown).annotate({
817 description:
818 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
819 }),
820 Schema.Null,
821 ]),
822 ),
823 description: Schema.optionalKey(
824 Schema.Union([
825 Schema.String.annotate({
826 description:
827 "Optional description providing more details about this authentication method.",
828 }),
829 Schema.Null,
830 ]),
831 ),
832 id: Schema.String.annotate({
833 description: "Unique identifier for this authentication method.",
834 }),
835 link: Schema.optionalKey(
836 Schema.Union([
837 Schema.String.annotate({
838 description: "Optional link to a page where the user can obtain their credentials.",
839 }),
840 Schema.Null,
841 ]),
842 ),
843 name: Schema.String.annotate({
844 description: "Human-readable name of the authentication method.",
845 }),
846 vars: Schema.Array(AuthEnvVar).annotate({
847 description: "The environment variables the client should set.",
848 }),
849 }).annotate({
850 description:
851 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nEnvironment variable authentication method.\n\nThe user provides credentials that the client passes to the agent as environment variables.",
852 }),
853 Schema.Struct({
854 type: Schema.Literal("terminal"),
855 _meta: Schema.optionalKey(
856 Schema.Union([
857 Schema.Record(Schema.String, Schema.Unknown).annotate({
858 description:
859 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
860 }),
861 Schema.Null,
862 ]),
863 ),
864 args: Schema.optionalKey(
865 Schema.Array(Schema.String).annotate({
866 description:
867 "Additional arguments to pass when running the agent binary for terminal auth.",
868 }),
869 ),
870 description: Schema.optionalKey(
871 Schema.Union([
872 Schema.String.annotate({
873 description:
874 "Optional description providing more details about this authentication method.",
875 }),
876 Schema.Null,
877 ]),
878 ),
879 env: Schema.optionalKey(
880 Schema.Record(Schema.String, Schema.String).annotate({
881 description:
882 "Additional environment variables to set when running the agent binary for terminal auth.",
883 }),
884 ),
885 id: Schema.String.annotate({
886 description: "Unique identifier for this authentication method.",
887 }),
888 name: Schema.String.annotate({
889 description: "Human-readable name of the authentication method.",
890 }),
891 }).annotate({
892 description:
893 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nTerminal-based authentication method.\n\nThe client runs an interactive terminal for the user to authenticate via a TUI.",
894 }),
895 Schema.Struct({
896 _meta: Schema.optionalKey(
897 Schema.Union([
898 Schema.Record(Schema.String, Schema.Unknown).annotate({
899 description:
900 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
901 }),
902 Schema.Null,
903 ]),
904 ),
905 description: Schema.optionalKey(
906 Schema.Union([
907 Schema.String.annotate({
908 description:
909 "Optional description providing more details about this authentication method.",
910 }),
911 Schema.Null,
912 ]),
913 ),
914 id: Schema.String.annotate({
915 description: "Unique identifier for this authentication method.",
916 }),
917 name: Schema.String.annotate({
918 description: "Human-readable name of the authentication method.",
919 }),
920 }).annotate({
921 title: "agent",
922 description:
923 "Agent handles authentication itself.\n\nThis is the default authentication method type.",
924 }),
925]).annotate({
926 description:
927 "Describes an available authentication method.\n\nThe `type` field acts as the discriminator in the serialized JSON form.\nWhen no `type` is present, the method is treated as `agent`.",
928});
929
930export type AvailableCommand = {
931 readonly _meta?: { readonly [x: string]: unknown } | null;
932 readonly description: string;
933 readonly input?: AvailableCommandInput | null;
934 readonly name: string;
935};
936export const AvailableCommand = Schema.Struct({
937 _meta: Schema.optionalKey(
938 Schema.Union([
939 Schema.Record(Schema.String, Schema.Unknown).annotate({
940 description:
941 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
942 }),
943 Schema.Null,
944 ]),
945 ),
946 description: Schema.String.annotate({
947 description: "Human-readable description of what the command does.",
948 }),
949 input: Schema.optionalKey(
950 Schema.Union([AvailableCommandInput, Schema.Null]).annotate({
951 description: "Input for the command if required",
952 }),
953 ),
954 name: Schema.String.annotate({
955 description: "Command name (e.g., `create_plan`, `research_codebase`).",
956 }),
957}).annotate({ description: "Information about a command." });
958
959export type ElicitationCapabilities = {
960 readonly _meta?: { readonly [x: string]: unknown } | null;
961 readonly form?: ElicitationFormCapabilities | null;
962 readonly url?: ElicitationUrlCapabilities | null;
963};
964export const ElicitationCapabilities = Schema.Struct({
965 _meta: Schema.optionalKey(
966 Schema.Union([
967 Schema.Record(Schema.String, Schema.Unknown).annotate({
968 description:
969 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
970 }),
971 Schema.Null,
972 ]),
973 ),
974 form: Schema.optionalKey(
975 Schema.Union([ElicitationFormCapabilities, Schema.Null]).annotate({
976 description: "Whether the client supports form-based elicitation.",
977 }),
978 ),
979 url: Schema.optionalKey(
980 Schema.Union([ElicitationUrlCapabilities, Schema.Null]).annotate({
981 description: "Whether the client supports URL-based elicitation.",
982 }),
983 ),
984}).annotate({
985 description:
986 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.",
987});
988
989export type McpServer =
990 | {
991 readonly type: "http";
992 readonly _meta?: { readonly [x: string]: unknown } | null;
993 readonly headers: ReadonlyArray<HttpHeader>;
994 readonly name: string;
995 readonly url: string;
996 }
997 | {
998 readonly type: "sse";
999 readonly _meta?: { readonly [x: string]: unknown } | null;
1000 readonly headers: ReadonlyArray<HttpHeader>;
1001 readonly name: string;
1002 readonly url: string;
1003 }
1004 | {
1005 readonly _meta?: { readonly [x: string]: unknown } | null;
1006 readonly args: ReadonlyArray<string>;
1007 readonly command: string;
1008 readonly env: ReadonlyArray<EnvVariable>;
1009 readonly name: string;
1010 };
1011export const McpServer = Schema.Union([
1012 Schema.Struct({
1013 type: Schema.Literal("http"),
1014 _meta: Schema.optionalKey(
1015 Schema.Union([
1016 Schema.Record(Schema.String, Schema.Unknown).annotate({
1017 description:
1018 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1019 }),
1020 Schema.Null,
1021 ]),
1022 ),
1023 headers: Schema.Array(HttpHeader).annotate({
1024 description: "HTTP headers to set when making requests to the MCP server.",
1025 }),
1026 name: Schema.String.annotate({
1027 description: "Human-readable name identifying this MCP server.",
1028 }),
1029 url: Schema.String.annotate({ description: "URL to the MCP server." }),
1030 }).annotate({ description: "HTTP transport configuration for MCP." }),
1031 Schema.Struct({
1032 type: Schema.Literal("sse"),
1033 _meta: Schema.optionalKey(
1034 Schema.Union([
1035 Schema.Record(Schema.String, Schema.Unknown).annotate({
1036 description:
1037 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1038 }),
1039 Schema.Null,
1040 ]),
1041 ),
1042 headers: Schema.Array(HttpHeader).annotate({
1043 description: "HTTP headers to set when making requests to the MCP server.",
1044 }),
1045 name: Schema.String.annotate({
1046 description: "Human-readable name identifying this MCP server.",
1047 }),
1048 url: Schema.String.annotate({ description: "URL to the MCP server." }),
1049 }).annotate({ description: "SSE transport configuration for MCP." }),
1050 Schema.Struct({
1051 _meta: Schema.optionalKey(
1052 Schema.Union([
1053 Schema.Record(Schema.String, Schema.Unknown).annotate({
1054 description:
1055 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1056 }),
1057 Schema.Null,
1058 ]),
1059 ),
1060 args: Schema.Array(Schema.String).annotate({
1061 description: "Command-line arguments to pass to the MCP server.",
1062 }),
1063 command: Schema.String.annotate({ description: "Path to the MCP server executable." }),
1064 env: Schema.Array(EnvVariable).annotate({
1065 description: "Environment variables to set when launching the MCP server.",
1066 }),
1067 name: Schema.String.annotate({
1068 description: "Human-readable name identifying this MCP server.",
1069 }),
1070 }).annotate({ title: "stdio", description: "Stdio transport configuration for MCP." }),
1071]).annotate({
1072 description:
1073 "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)",
1074});
1075
1076export type SessionModelState = {
1077 readonly _meta?: { readonly [x: string]: unknown } | null;
1078 readonly availableModels: ReadonlyArray<ModelInfo>;
1079 readonly currentModelId: string;
1080};
1081export const SessionModelState = Schema.Struct({
1082 _meta: Schema.optionalKey(
1083 Schema.Union([
1084 Schema.Record(Schema.String, Schema.Unknown).annotate({
1085 description:
1086 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1087 }),
1088 Schema.Null,
1089 ]),
1090 ),
1091 availableModels: Schema.Array(ModelInfo).annotate({
1092 description: "The set of models that the Agent can use",
1093 }),
1094 currentModelId: Schema.String.annotate({
1095 description:
1096 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.",
1097 }),
1098}).annotate({
1099 description:
1100 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe set of models and the one currently active.",
1101});
1102
1103export type Annotations = {
1104 readonly _meta?: { readonly [x: string]: unknown } | null;
1105 readonly audience?: ReadonlyArray<Role> | null;
1106 readonly lastModified?: string | null;
1107 readonly priority?: number | null;
1108};
1109export const Annotations = Schema.Struct({
1110 _meta: Schema.optionalKey(
1111 Schema.Union([
1112 Schema.Record(Schema.String, Schema.Unknown).annotate({
1113 description:
1114 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1115 }),
1116 Schema.Null,
1117 ]),
1118 ),
1119 audience: Schema.optionalKey(Schema.Union([Schema.Array(Role), Schema.Null])),
1120 lastModified: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1121 priority: Schema.optionalKey(
1122 Schema.Union([
1123 Schema.Number.annotate({ format: "double" }).check(Schema.isFinite()),
1124 Schema.Null,
1125 ]),
1126 ),
1127}).annotate({
1128 description:
1129 "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed",
1130});
1131
1132export type SessionConfigSelectGroup = {
1133 readonly _meta?: { readonly [x: string]: unknown } | null;
1134 readonly group: string;
1135 readonly name: string;
1136 readonly options: ReadonlyArray<SessionConfigSelectOption>;
1137};
1138export const SessionConfigSelectGroup = Schema.Struct({
1139 _meta: Schema.optionalKey(
1140 Schema.Union([
1141 Schema.Record(Schema.String, Schema.Unknown).annotate({
1142 description:
1143 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1144 }),
1145 Schema.Null,
1146 ]),
1147 ),
1148 group: Schema.String.annotate({
1149 description: "Unique identifier for a session configuration option value group.",
1150 }),
1151 name: Schema.String.annotate({ description: "Human-readable label for this group." }),
1152 options: Schema.Array(SessionConfigSelectOption).annotate({
1153 description: "The set of option values in this group.",
1154 }),
1155}).annotate({ description: "A group of possible values for a session configuration option." });
1156
1157export type SessionMode = {
1158 readonly _meta?: { readonly [x: string]: unknown } | null;
1159 readonly description?: string | null;
1160 readonly id: SessionModeId;
1161 readonly name: string;
1162};
1163export const SessionMode = Schema.Struct({
1164 _meta: Schema.optionalKey(
1165 Schema.Union([
1166 Schema.Record(Schema.String, Schema.Unknown).annotate({
1167 description:
1168 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1169 }),
1170 Schema.Null,
1171 ]),
1172 ),
1173 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1174 id: SessionModeId,
1175 name: Schema.String,
1176}).annotate({
1177 description:
1178 "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
1179});
1180
1181export type ElicitationPropertySchema =
1182 | {
1183 readonly type: "string";
1184 readonly default?: string | null;
1185 readonly description?: string | null;
1186 readonly enum?: ReadonlyArray<string> | null;
1187 readonly format?: StringFormat | null;
1188 readonly maxLength?: number | null;
1189 readonly minLength?: number | null;
1190 readonly oneOf?: ReadonlyArray<EnumOption> | null;
1191 readonly pattern?: string | null;
1192 readonly title?: string | null;
1193 }
1194 | {
1195 readonly type: "number";
1196 readonly default?: number | null;
1197 readonly description?: string | null;
1198 readonly maximum?: number | null;
1199 readonly minimum?: number | null;
1200 readonly title?: string | null;
1201 }
1202 | {
1203 readonly type: "integer";
1204 readonly default?: number | null;
1205 readonly description?: string | null;
1206 readonly maximum?: number | null;
1207 readonly minimum?: number | null;
1208 readonly title?: string | null;
1209 }
1210 | {
1211 readonly type: "boolean";
1212 readonly default?: boolean | null;
1213 readonly description?: string | null;
1214 readonly title?: string | null;
1215 }
1216 | {
1217 readonly type: "array";
1218 readonly default?: ReadonlyArray<string> | null;
1219 readonly description?: string | null;
1220 readonly items:
1221 | { readonly enum: ReadonlyArray<string>; readonly type: "string" }
1222 | { readonly anyOf: ReadonlyArray<EnumOption> };
1223 readonly maxItems?: number | null;
1224 readonly minItems?: number | null;
1225 readonly title?: string | null;
1226 };
1227export const ElicitationPropertySchema = Schema.Union(
1228 [
1229 Schema.Struct({
1230 type: Schema.Literal("string"),
1231 default: Schema.optionalKey(
1232 Schema.Union([Schema.String.annotate({ description: "Default value." }), Schema.Null]),
1233 ),
1234 description: Schema.optionalKey(
1235 Schema.Union([
1236 Schema.String.annotate({ description: "Human-readable description." }),
1237 Schema.Null,
1238 ]),
1239 ),
1240 enum: Schema.optionalKey(
1241 Schema.Union([
1242 Schema.Array(Schema.String).annotate({
1243 description: "Enum values for untitled single-select enums.",
1244 }),
1245 Schema.Null,
1246 ]),
1247 ),
1248 format: Schema.optionalKey(
1249 Schema.Union([StringFormat, Schema.Null]).annotate({ description: "String format." }),
1250 ),
1251 maxLength: Schema.optionalKey(
1252 Schema.Union([
1253 Schema.Number.annotate({ description: "Maximum string length.", format: "uint32" })
1254 .check(Schema.isInt())
1255 .check(Schema.isGreaterThanOrEqualTo(0)),
1256 Schema.Null,
1257 ]),
1258 ),
1259 minLength: Schema.optionalKey(
1260 Schema.Union([
1261 Schema.Number.annotate({ description: "Minimum string length.", format: "uint32" })
1262 .check(Schema.isInt())
1263 .check(Schema.isGreaterThanOrEqualTo(0)),
1264 Schema.Null,
1265 ]),
1266 ),
1267 oneOf: Schema.optionalKey(
1268 Schema.Union([
1269 Schema.Array(EnumOption).annotate({
1270 description: "Titled enum options for titled single-select enums.",
1271 }),
1272 Schema.Null,
1273 ]),
1274 ),
1275 pattern: Schema.optionalKey(
1276 Schema.Union([
1277 Schema.String.annotate({ description: "Pattern the string must match." }),
1278 Schema.Null,
1279 ]),
1280 ),
1281 title: Schema.optionalKey(
1282 Schema.Union([
1283 Schema.String.annotate({ description: "Optional title for the property." }),
1284 Schema.Null,
1285 ]),
1286 ),
1287 }).annotate({
1288 description:
1289 'Schema for string properties in an elicitation form.\n\nWhen `enum` or `oneOf` is set, this represents a single-select enum\nwith `"type": "string"`.',
1290 }),
1291 Schema.Struct({
1292 type: Schema.Literal("number"),
1293 default: Schema.optionalKey(
1294 Schema.Union([
1295 Schema.Number.annotate({ description: "Default value.", format: "double" }).check(
1296 Schema.isFinite(),
1297 ),
1298 Schema.Null,
1299 ]),
1300 ),
1301 description: Schema.optionalKey(
1302 Schema.Union([
1303 Schema.String.annotate({ description: "Human-readable description." }),
1304 Schema.Null,
1305 ]),
1306 ),
1307 maximum: Schema.optionalKey(
1308 Schema.Union([
1309 Schema.Number.annotate({
1310 description: "Maximum value (inclusive).",
1311 format: "double",
1312 }).check(Schema.isFinite()),
1313 Schema.Null,
1314 ]),
1315 ),
1316 minimum: Schema.optionalKey(
1317 Schema.Union([
1318 Schema.Number.annotate({
1319 description: "Minimum value (inclusive).",
1320 format: "double",
1321 }).check(Schema.isFinite()),
1322 Schema.Null,
1323 ]),
1324 ),
1325 title: Schema.optionalKey(
1326 Schema.Union([
1327 Schema.String.annotate({ description: "Optional title for the property." }),
1328 Schema.Null,
1329 ]),
1330 ),
1331 }).annotate({
1332 description: "Schema for number (floating-point) properties in an elicitation form.",
1333 }),
1334 Schema.Struct({
1335 type: Schema.Literal("integer"),
1336 default: Schema.optionalKey(
1337 Schema.Union([
1338 Schema.Number.annotate({ description: "Default value.", format: "int64" }).check(
1339 Schema.isInt(),
1340 ),
1341 Schema.Null,
1342 ]),
1343 ),
1344 description: Schema.optionalKey(
1345 Schema.Union([
1346 Schema.String.annotate({ description: "Human-readable description." }),
1347 Schema.Null,
1348 ]),
1349 ),
1350 maximum: Schema.optionalKey(
1351 Schema.Union([
1352 Schema.Number.annotate({
1353 description: "Maximum value (inclusive).",
1354 format: "int64",
1355 }).check(Schema.isInt()),
1356 Schema.Null,
1357 ]),
1358 ),
1359 minimum: Schema.optionalKey(
1360 Schema.Union([
1361 Schema.Number.annotate({
1362 description: "Minimum value (inclusive).",
1363 format: "int64",
1364 }).check(Schema.isInt()),
1365 Schema.Null,
1366 ]),
1367 ),
1368 title: Schema.optionalKey(
1369 Schema.Union([
1370 Schema.String.annotate({ description: "Optional title for the property." }),
1371 Schema.Null,
1372 ]),
1373 ),
1374 }).annotate({ description: "Schema for integer properties in an elicitation form." }),
1375 Schema.Struct({
1376 type: Schema.Literal("boolean"),
1377 default: Schema.optionalKey(
1378 Schema.Union([Schema.Boolean.annotate({ description: "Default value." }), Schema.Null]),
1379 ),
1380 description: Schema.optionalKey(
1381 Schema.Union([
1382 Schema.String.annotate({ description: "Human-readable description." }),
1383 Schema.Null,
1384 ]),
1385 ),
1386 title: Schema.optionalKey(
1387 Schema.Union([
1388 Schema.String.annotate({ description: "Optional title for the property." }),
1389 Schema.Null,
1390 ]),
1391 ),
1392 }).annotate({ description: "Schema for boolean properties in an elicitation form." }),
1393 Schema.Struct({
1394 type: Schema.Literal("array"),
1395 default: Schema.optionalKey(
1396 Schema.Union([
1397 Schema.Array(Schema.String).annotate({ description: "Default selected values." }),
1398 Schema.Null,
1399 ]),
1400 ),
1401 description: Schema.optionalKey(
1402 Schema.Union([
1403 Schema.String.annotate({ description: "Human-readable description." }),
1404 Schema.Null,
1405 ]),
1406 ),
1407 items: Schema.Union([
1408 Schema.Struct({
1409 enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }),
1410 type: Schema.Literal("string").annotate({
1411 description: "Items definition for untitled multi-select enum properties.",
1412 }),
1413 }).annotate({
1414 title: "Untitled",
1415 description: "Items definition for untitled multi-select enum properties.",
1416 }),
1417 Schema.Struct({
1418 anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }),
1419 }).annotate({
1420 title: "Titled",
1421 description: "Items definition for titled multi-select enum properties.",
1422 }),
1423 ]).annotate({ description: "Items for a multi-select (array) property schema." }),
1424 maxItems: Schema.optionalKey(
1425 Schema.Union([
1426 Schema.Number.annotate({
1427 description: "Maximum number of items to select.",
1428 format: "uint64",
1429 })
1430 .check(Schema.isInt())
1431 .check(Schema.isGreaterThanOrEqualTo(0)),
1432 Schema.Null,
1433 ]),
1434 ),
1435 minItems: Schema.optionalKey(
1436 Schema.Union([
1437 Schema.Number.annotate({
1438 description: "Minimum number of items to select.",
1439 format: "uint64",
1440 })
1441 .check(Schema.isInt())
1442 .check(Schema.isGreaterThanOrEqualTo(0)),
1443 Schema.Null,
1444 ]),
1445 ),
1446 title: Schema.optionalKey(
1447 Schema.Union([
1448 Schema.String.annotate({ description: "Optional title for the property." }),
1449 Schema.Null,
1450 ]),
1451 ),
1452 }).annotate({
1453 description: "Schema for multi-select (array) properties in an elicitation form.",
1454 }),
1455 ],
1456 { mode: "oneOf" },
1457).annotate({
1458 description:
1459 'Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `"type"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.',
1460});
1461
1462export type ContentBlock =
1463 | {
1464 readonly type: "text";
1465 readonly _meta?: { readonly [x: string]: unknown } | null;
1466 readonly annotations?: Annotations | null;
1467 readonly text: string;
1468 }
1469 | {
1470 readonly type: "image";
1471 readonly _meta?: { readonly [x: string]: unknown } | null;
1472 readonly annotations?: Annotations | null;
1473 readonly data: string;
1474 readonly mimeType: string;
1475 readonly uri?: string | null;
1476 }
1477 | {
1478 readonly type: "audio";
1479 readonly _meta?: { readonly [x: string]: unknown } | null;
1480 readonly annotations?: Annotations | null;
1481 readonly data: string;
1482 readonly mimeType: string;
1483 }
1484 | {
1485 readonly type: "resource_link";
1486 readonly _meta?: { readonly [x: string]: unknown } | null;
1487 readonly annotations?: Annotations | null;
1488 readonly description?: string | null;
1489 readonly mimeType?: string | null;
1490 readonly name: string;
1491 readonly size?: number | null;
1492 readonly title?: string | null;
1493 readonly uri: string;
1494 }
1495 | {
1496 readonly type: "resource";
1497 readonly _meta?: { readonly [x: string]: unknown } | null;
1498 readonly annotations?: Annotations | null;
1499 readonly resource: EmbeddedResourceResource;
1500 };
1501export const ContentBlock = Schema.Union(
1502 [
1503 Schema.Struct({
1504 type: Schema.Literal("text"),
1505 _meta: Schema.optionalKey(
1506 Schema.Union([
1507 Schema.Record(Schema.String, Schema.Unknown).annotate({
1508 description:
1509 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1510 }),
1511 Schema.Null,
1512 ]),
1513 ),
1514 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1515 text: Schema.String,
1516 }).annotate({ description: "Text provided to or from an LLM." }),
1517 Schema.Struct({
1518 type: Schema.Literal("image"),
1519 _meta: Schema.optionalKey(
1520 Schema.Union([
1521 Schema.Record(Schema.String, Schema.Unknown).annotate({
1522 description:
1523 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1524 }),
1525 Schema.Null,
1526 ]),
1527 ),
1528 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1529 data: Schema.String,
1530 mimeType: Schema.String,
1531 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1532 }).annotate({ description: "An image provided to or from an LLM." }),
1533 Schema.Struct({
1534 type: Schema.Literal("audio"),
1535 _meta: Schema.optionalKey(
1536 Schema.Union([
1537 Schema.Record(Schema.String, Schema.Unknown).annotate({
1538 description:
1539 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1540 }),
1541 Schema.Null,
1542 ]),
1543 ),
1544 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1545 data: Schema.String,
1546 mimeType: Schema.String,
1547 }).annotate({ description: "Audio provided to or from an LLM." }),
1548 Schema.Struct({
1549 type: Schema.Literal("resource_link"),
1550 _meta: Schema.optionalKey(
1551 Schema.Union([
1552 Schema.Record(Schema.String, Schema.Unknown).annotate({
1553 description:
1554 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1555 }),
1556 Schema.Null,
1557 ]),
1558 ),
1559 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1560 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1561 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1562 name: Schema.String,
1563 size: Schema.optionalKey(
1564 Schema.Union([
1565 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
1566 Schema.Null,
1567 ]),
1568 ),
1569 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1570 uri: Schema.String,
1571 }).annotate({
1572 description:
1573 "A resource that the server is capable of reading, included in a prompt or tool call result.",
1574 }),
1575 Schema.Struct({
1576 type: Schema.Literal("resource"),
1577 _meta: Schema.optionalKey(
1578 Schema.Union([
1579 Schema.Record(Schema.String, Schema.Unknown).annotate({
1580 description:
1581 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1582 }),
1583 Schema.Null,
1584 ]),
1585 ),
1586 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1587 resource: EmbeddedResourceResource,
1588 }).annotate({
1589 description: "The contents of a resource, embedded into a prompt or tool call result.",
1590 }),
1591 ],
1592 { mode: "oneOf" },
1593).annotate({
1594 description:
1595 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
1596});
1597
1598export type ToolCallContent =
1599 | {
1600 readonly type: "content";
1601 readonly _meta?: { readonly [x: string]: unknown } | null;
1602 readonly content:
1603 | {
1604 readonly type: "text";
1605 readonly _meta?: { readonly [x: string]: unknown } | null;
1606 readonly annotations?: Annotations | null;
1607 readonly text: string;
1608 }
1609 | {
1610 readonly type: "image";
1611 readonly _meta?: { readonly [x: string]: unknown } | null;
1612 readonly annotations?: Annotations | null;
1613 readonly data: string;
1614 readonly mimeType: string;
1615 readonly uri?: string | null;
1616 }
1617 | {
1618 readonly type: "audio";
1619 readonly _meta?: { readonly [x: string]: unknown } | null;
1620 readonly annotations?: Annotations | null;
1621 readonly data: string;
1622 readonly mimeType: string;
1623 }
1624 | {
1625 readonly type: "resource_link";
1626 readonly _meta?: { readonly [x: string]: unknown } | null;
1627 readonly annotations?: Annotations | null;
1628 readonly description?: string | null;
1629 readonly mimeType?: string | null;
1630 readonly name: string;
1631 readonly size?: number | null;
1632 readonly title?: string | null;
1633 readonly uri: string;
1634 }
1635 | {
1636 readonly type: "resource";
1637 readonly _meta?: { readonly [x: string]: unknown } | null;
1638 readonly annotations?: Annotations | null;
1639 readonly resource: EmbeddedResourceResource;
1640 };
1641 }
1642 | {
1643 readonly type: "diff";
1644 readonly _meta?: { readonly [x: string]: unknown } | null;
1645 readonly newText: string;
1646 readonly oldText?: string | null;
1647 readonly path: string;
1648 }
1649 | {
1650 readonly type: "terminal";
1651 readonly _meta?: { readonly [x: string]: unknown } | null;
1652 readonly terminalId: string;
1653 };
1654export const ToolCallContent = Schema.Union(
1655 [
1656 Schema.Struct({
1657 type: Schema.Literal("content"),
1658 _meta: Schema.optionalKey(
1659 Schema.Union([
1660 Schema.Record(Schema.String, Schema.Unknown).annotate({
1661 description:
1662 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1663 }),
1664 Schema.Null,
1665 ]),
1666 ),
1667 content: Schema.Union(
1668 [
1669 Schema.Struct({
1670 type: Schema.Literal("text"),
1671 _meta: Schema.optionalKey(
1672 Schema.Union([
1673 Schema.Record(Schema.String, Schema.Unknown).annotate({
1674 description:
1675 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1676 }),
1677 Schema.Null,
1678 ]),
1679 ),
1680 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1681 text: Schema.String,
1682 }).annotate({ description: "Text provided to or from an LLM." }),
1683 Schema.Struct({
1684 type: Schema.Literal("image"),
1685 _meta: Schema.optionalKey(
1686 Schema.Union([
1687 Schema.Record(Schema.String, Schema.Unknown).annotate({
1688 description:
1689 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1690 }),
1691 Schema.Null,
1692 ]),
1693 ),
1694 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1695 data: Schema.String,
1696 mimeType: Schema.String,
1697 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1698 }).annotate({ description: "An image provided to or from an LLM." }),
1699 Schema.Struct({
1700 type: Schema.Literal("audio"),
1701 _meta: Schema.optionalKey(
1702 Schema.Union([
1703 Schema.Record(Schema.String, Schema.Unknown).annotate({
1704 description:
1705 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1706 }),
1707 Schema.Null,
1708 ]),
1709 ),
1710 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1711 data: Schema.String,
1712 mimeType: Schema.String,
1713 }).annotate({ description: "Audio provided to or from an LLM." }),
1714 Schema.Struct({
1715 type: Schema.Literal("resource_link"),
1716 _meta: Schema.optionalKey(
1717 Schema.Union([
1718 Schema.Record(Schema.String, Schema.Unknown).annotate({
1719 description:
1720 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1721 }),
1722 Schema.Null,
1723 ]),
1724 ),
1725 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1726 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1727 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1728 name: Schema.String,
1729 size: Schema.optionalKey(
1730 Schema.Union([
1731 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
1732 Schema.Null,
1733 ]),
1734 ),
1735 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
1736 uri: Schema.String,
1737 }).annotate({
1738 description:
1739 "A resource that the server is capable of reading, included in a prompt or tool call result.",
1740 }),
1741 Schema.Struct({
1742 type: Schema.Literal("resource"),
1743 _meta: Schema.optionalKey(
1744 Schema.Union([
1745 Schema.Record(Schema.String, Schema.Unknown).annotate({
1746 description:
1747 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1748 }),
1749 Schema.Null,
1750 ]),
1751 ),
1752 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
1753 resource: EmbeddedResourceResource,
1754 }).annotate({
1755 description: "The contents of a resource, embedded into a prompt or tool call result.",
1756 }),
1757 ],
1758 { mode: "oneOf" },
1759 ).annotate({
1760 description:
1761 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
1762 }),
1763 }).annotate({ description: "Standard content block (text, images, resources)." }),
1764 Schema.Struct({
1765 type: Schema.Literal("diff"),
1766 _meta: Schema.optionalKey(
1767 Schema.Union([
1768 Schema.Record(Schema.String, Schema.Unknown).annotate({
1769 description:
1770 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1771 }),
1772 Schema.Null,
1773 ]),
1774 ),
1775 newText: Schema.String.annotate({ description: "The new content after modification." }),
1776 oldText: Schema.optionalKey(
1777 Schema.Union([
1778 Schema.String.annotate({ description: "The original content (None for new files)." }),
1779 Schema.Null,
1780 ]),
1781 ),
1782 path: Schema.String.annotate({ description: "The file path being modified." }),
1783 }).annotate({
1784 description:
1785 "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)",
1786 }),
1787 Schema.Struct({
1788 type: Schema.Literal("terminal"),
1789 _meta: Schema.optionalKey(
1790 Schema.Union([
1791 Schema.Record(Schema.String, Schema.Unknown).annotate({
1792 description:
1793 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1794 }),
1795 Schema.Null,
1796 ]),
1797 ),
1798 terminalId: Schema.String,
1799 }).annotate({
1800 description:
1801 "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)",
1802 }),
1803 ],
1804 { mode: "oneOf" },
1805).annotate({
1806 description:
1807 "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)",
1808});
1809
1810export type SessionConfigOption =
1811 | {
1812 readonly type: "select";
1813 readonly currentValue: string;
1814 readonly options:
1815 | ReadonlyArray<SessionConfigSelectOption>
1816 | ReadonlyArray<SessionConfigSelectGroup>;
1817 readonly _meta?: { readonly [x: string]: unknown } | null;
1818 readonly category?: SessionConfigOptionCategory | null;
1819 readonly description?: string | null;
1820 readonly id: string;
1821 readonly name: string;
1822 }
1823 | {
1824 readonly type: "boolean";
1825 readonly currentValue: boolean;
1826 readonly _meta?: { readonly [x: string]: unknown } | null;
1827 readonly category?: SessionConfigOptionCategory | null;
1828 readonly description?: string | null;
1829 readonly id: string;
1830 readonly name: string;
1831 };
1832export const SessionConfigOption = Schema.Union([
1833 Schema.Struct({
1834 type: Schema.Literal("select"),
1835 currentValue: Schema.String.annotate({
1836 description: "Unique identifier for a session configuration option value.",
1837 }),
1838 options: Schema.Union([
1839 Schema.Array(SessionConfigSelectOption).annotate({
1840 title: "Ungrouped",
1841 description: "A flat list of options with no grouping.",
1842 }),
1843 Schema.Array(SessionConfigSelectGroup).annotate({
1844 title: "Grouped",
1845 description: "A list of options grouped under headers.",
1846 }),
1847 ]).annotate({ description: "Possible values for a session configuration option." }),
1848 _meta: Schema.optionalKey(
1849 Schema.Union([
1850 Schema.Record(Schema.String, Schema.Unknown).annotate({
1851 description:
1852 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1853 }),
1854 Schema.Null,
1855 ]),
1856 ),
1857 category: Schema.optionalKey(
1858 Schema.Union([SessionConfigOptionCategory, Schema.Null]).annotate({
1859 description: "Optional semantic category for this option (UX only).",
1860 }),
1861 ),
1862 description: Schema.optionalKey(
1863 Schema.Union([
1864 Schema.String.annotate({
1865 description: "Optional description for the Client to display to the user.",
1866 }),
1867 Schema.Null,
1868 ]),
1869 ),
1870 id: Schema.String.annotate({
1871 description: "Unique identifier for a session configuration option.",
1872 }),
1873 name: Schema.String.annotate({ description: "Human-readable label for the option." }),
1874 }).annotate({ description: "A session configuration option selector and its current state." }),
1875 Schema.Struct({
1876 type: Schema.Literal("boolean"),
1877 currentValue: Schema.Boolean.annotate({
1878 description: "The current value of the boolean option.",
1879 }),
1880 _meta: Schema.optionalKey(
1881 Schema.Union([
1882 Schema.Record(Schema.String, Schema.Unknown).annotate({
1883 description:
1884 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1885 }),
1886 Schema.Null,
1887 ]),
1888 ),
1889 category: Schema.optionalKey(
1890 Schema.Union([SessionConfigOptionCategory, Schema.Null]).annotate({
1891 description: "Optional semantic category for this option (UX only).",
1892 }),
1893 ),
1894 description: Schema.optionalKey(
1895 Schema.Union([
1896 Schema.String.annotate({
1897 description: "Optional description for the Client to display to the user.",
1898 }),
1899 Schema.Null,
1900 ]),
1901 ),
1902 id: Schema.String.annotate({
1903 description: "Unique identifier for a session configuration option.",
1904 }),
1905 name: Schema.String.annotate({ description: "Human-readable label for the option." }),
1906 }).annotate({ description: "A session configuration option selector and its current state." }),
1907]);
1908
1909export type SessionModeState = {
1910 readonly _meta?: { readonly [x: string]: unknown } | null;
1911 readonly availableModes: ReadonlyArray<SessionMode>;
1912 readonly currentModeId: string;
1913};
1914export const SessionModeState = Schema.Struct({
1915 _meta: Schema.optionalKey(
1916 Schema.Union([
1917 Schema.Record(Schema.String, Schema.Unknown).annotate({
1918 description:
1919 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1920 }),
1921 Schema.Null,
1922 ]),
1923 ),
1924 availableModes: Schema.Array(SessionMode).annotate({
1925 description: "The set of modes that the Agent can operate in",
1926 }),
1927 currentModeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }),
1928}).annotate({ description: "The set of modes and the one currently active." });
1929
1930export type AgentAuthCapabilities = {
1931 readonly _meta?: { readonly [x: string]: unknown } | null;
1932 readonly logout?: LogoutCapabilities | null;
1933};
1934export const AgentAuthCapabilities = Schema.Struct({
1935 _meta: Schema.optionalKey(
1936 Schema.Union([
1937 Schema.Record(Schema.String, Schema.Unknown).annotate({
1938 description:
1939 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1940 }),
1941 Schema.Null,
1942 ]),
1943 ),
1944 logout: Schema.optionalKey(
1945 Schema.Union([LogoutCapabilities, Schema.Null]).annotate({
1946 description:
1947 "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.",
1948 }),
1949 ),
1950}).annotate({
1951 description:
1952 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.",
1953});
1954
1955export type AgentCapabilities = {
1956 readonly _meta?: { readonly [x: string]: unknown } | null;
1957 readonly auth?: {
1958 readonly _meta?: { readonly [x: string]: unknown } | null;
1959 readonly logout?: LogoutCapabilities | null;
1960 };
1961 readonly loadSession?: boolean;
1962 readonly mcpCapabilities?: {
1963 readonly _meta?: { readonly [x: string]: unknown } | null;
1964 readonly http?: boolean;
1965 readonly sse?: boolean;
1966 };
1967 readonly promptCapabilities?: {
1968 readonly _meta?: { readonly [x: string]: unknown } | null;
1969 readonly audio?: boolean;
1970 readonly embeddedContext?: boolean;
1971 readonly image?: boolean;
1972 };
1973 readonly sessionCapabilities?: {
1974 readonly _meta?: { readonly [x: string]: unknown } | null;
1975 readonly close?: SessionCloseCapabilities | null;
1976 readonly fork?: SessionForkCapabilities | null;
1977 readonly list?: SessionListCapabilities | null;
1978 readonly resume?: SessionResumeCapabilities | null;
1979 };
1980};
1981export const AgentCapabilities = Schema.Struct({
1982 _meta: Schema.optionalKey(
1983 Schema.Union([
1984 Schema.Record(Schema.String, Schema.Unknown).annotate({
1985 description:
1986 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1987 }),
1988 Schema.Null,
1989 ]),
1990 ),
1991 auth: Schema.optionalKey(
1992 Schema.Struct({
1993 _meta: Schema.optionalKey(
1994 Schema.Union([
1995 Schema.Record(Schema.String, Schema.Unknown).annotate({
1996 description:
1997 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
1998 }),
1999 Schema.Null,
2000 ]),
2001 ),
2002 logout: Schema.optionalKey(
2003 Schema.Union([LogoutCapabilities, Schema.Null]).annotate({
2004 description:
2005 "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.",
2006 }),
2007 ),
2008 }).annotate({
2009 description:
2010 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.",
2011 default: {},
2012 }),
2013 ),
2014 loadSession: Schema.optionalKey(
2015 Schema.Boolean.annotate({
2016 description: "Whether the agent supports `session/load`.",
2017 default: false,
2018 }),
2019 ),
2020 mcpCapabilities: Schema.optionalKey(
2021 Schema.Struct({
2022 _meta: Schema.optionalKey(
2023 Schema.Union([
2024 Schema.Record(Schema.String, Schema.Unknown).annotate({
2025 description:
2026 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2027 }),
2028 Schema.Null,
2029 ]),
2030 ),
2031 http: Schema.optionalKey(
2032 Schema.Boolean.annotate({
2033 description: "Agent supports [`McpServer::Http`].",
2034 default: false,
2035 }),
2036 ),
2037 sse: Schema.optionalKey(
2038 Schema.Boolean.annotate({
2039 description: "Agent supports [`McpServer::Sse`].",
2040 default: false,
2041 }),
2042 ),
2043 }).annotate({
2044 description: "MCP capabilities supported by the agent",
2045 default: { http: false, sse: false },
2046 }),
2047 ),
2048 promptCapabilities: Schema.optionalKey(
2049 Schema.Struct({
2050 _meta: Schema.optionalKey(
2051 Schema.Union([
2052 Schema.Record(Schema.String, Schema.Unknown).annotate({
2053 description:
2054 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2055 }),
2056 Schema.Null,
2057 ]),
2058 ),
2059 audio: Schema.optionalKey(
2060 Schema.Boolean.annotate({
2061 description: "Agent supports [`ContentBlock::Audio`].",
2062 default: false,
2063 }),
2064 ),
2065 embeddedContext: Schema.optionalKey(
2066 Schema.Boolean.annotate({
2067 description:
2068 "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.",
2069 default: false,
2070 }),
2071 ),
2072 image: Schema.optionalKey(
2073 Schema.Boolean.annotate({
2074 description: "Agent supports [`ContentBlock::Image`].",
2075 default: false,
2076 }),
2077 ),
2078 }).annotate({
2079 description:
2080 "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)",
2081 default: { audio: false, embeddedContext: false, image: false },
2082 }),
2083 ),
2084 sessionCapabilities: Schema.optionalKey(
2085 Schema.Struct({
2086 _meta: Schema.optionalKey(
2087 Schema.Union([
2088 Schema.Record(Schema.String, Schema.Unknown).annotate({
2089 description:
2090 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2091 }),
2092 Schema.Null,
2093 ]),
2094 ),
2095 close: Schema.optionalKey(
2096 Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({
2097 description:
2098 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.",
2099 }),
2100 ),
2101 fork: Schema.optionalKey(
2102 Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({
2103 description:
2104 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.",
2105 }),
2106 ),
2107 list: Schema.optionalKey(
2108 Schema.Union([SessionListCapabilities, Schema.Null]).annotate({
2109 description: "Whether the agent supports `session/list`.",
2110 }),
2111 ),
2112 resume: Schema.optionalKey(
2113 Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({
2114 description:
2115 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.",
2116 }),
2117 ),
2118 }).annotate({
2119 default: {},
2120 description:
2121 "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)",
2122 }),
2123 ),
2124}).annotate({
2125 description:
2126 "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)",
2127});
2128
2129export type AgentNotification = {
2130 readonly method: string;
2131 readonly params?:
2132 | {
2133 readonly _meta?: { readonly [x: string]: unknown } | null;
2134 readonly sessionId: string;
2135 readonly update:
2136 | {
2137 readonly sessionUpdate: "user_message_chunk";
2138 readonly _meta?: { readonly [x: string]: unknown } | null;
2139 readonly content:
2140 | {
2141 readonly type: "text";
2142 readonly _meta?: { readonly [x: string]: unknown } | null;
2143 readonly annotations?: Annotations | null;
2144 readonly text: string;
2145 }
2146 | {
2147 readonly type: "image";
2148 readonly _meta?: { readonly [x: string]: unknown } | null;
2149 readonly annotations?: Annotations | null;
2150 readonly data: string;
2151 readonly mimeType: string;
2152 readonly uri?: string | null;
2153 }
2154 | {
2155 readonly type: "audio";
2156 readonly _meta?: { readonly [x: string]: unknown } | null;
2157 readonly annotations?: Annotations | null;
2158 readonly data: string;
2159 readonly mimeType: string;
2160 }
2161 | {
2162 readonly type: "resource_link";
2163 readonly _meta?: { readonly [x: string]: unknown } | null;
2164 readonly annotations?: Annotations | null;
2165 readonly description?: string | null;
2166 readonly mimeType?: string | null;
2167 readonly name: string;
2168 readonly size?: number | null;
2169 readonly title?: string | null;
2170 readonly uri: string;
2171 }
2172 | {
2173 readonly type: "resource";
2174 readonly _meta?: { readonly [x: string]: unknown } | null;
2175 readonly annotations?: Annotations | null;
2176 readonly resource: EmbeddedResourceResource;
2177 };
2178 readonly messageId?: string | null;
2179 }
2180 | {
2181 readonly sessionUpdate: "agent_message_chunk";
2182 readonly _meta?: { readonly [x: string]: unknown } | null;
2183 readonly content:
2184 | {
2185 readonly type: "text";
2186 readonly _meta?: { readonly [x: string]: unknown } | null;
2187 readonly annotations?: Annotations | null;
2188 readonly text: string;
2189 }
2190 | {
2191 readonly type: "image";
2192 readonly _meta?: { readonly [x: string]: unknown } | null;
2193 readonly annotations?: Annotations | null;
2194 readonly data: string;
2195 readonly mimeType: string;
2196 readonly uri?: string | null;
2197 }
2198 | {
2199 readonly type: "audio";
2200 readonly _meta?: { readonly [x: string]: unknown } | null;
2201 readonly annotations?: Annotations | null;
2202 readonly data: string;
2203 readonly mimeType: string;
2204 }
2205 | {
2206 readonly type: "resource_link";
2207 readonly _meta?: { readonly [x: string]: unknown } | null;
2208 readonly annotations?: Annotations | null;
2209 readonly description?: string | null;
2210 readonly mimeType?: string | null;
2211 readonly name: string;
2212 readonly size?: number | null;
2213 readonly title?: string | null;
2214 readonly uri: string;
2215 }
2216 | {
2217 readonly type: "resource";
2218 readonly _meta?: { readonly [x: string]: unknown } | null;
2219 readonly annotations?: Annotations | null;
2220 readonly resource: EmbeddedResourceResource;
2221 };
2222 readonly messageId?: string | null;
2223 }
2224 | {
2225 readonly sessionUpdate: "agent_thought_chunk";
2226 readonly _meta?: { readonly [x: string]: unknown } | null;
2227 readonly content:
2228 | {
2229 readonly type: "text";
2230 readonly _meta?: { readonly [x: string]: unknown } | null;
2231 readonly annotations?: Annotations | null;
2232 readonly text: string;
2233 }
2234 | {
2235 readonly type: "image";
2236 readonly _meta?: { readonly [x: string]: unknown } | null;
2237 readonly annotations?: Annotations | null;
2238 readonly data: string;
2239 readonly mimeType: string;
2240 readonly uri?: string | null;
2241 }
2242 | {
2243 readonly type: "audio";
2244 readonly _meta?: { readonly [x: string]: unknown } | null;
2245 readonly annotations?: Annotations | null;
2246 readonly data: string;
2247 readonly mimeType: string;
2248 }
2249 | {
2250 readonly type: "resource_link";
2251 readonly _meta?: { readonly [x: string]: unknown } | null;
2252 readonly annotations?: Annotations | null;
2253 readonly description?: string | null;
2254 readonly mimeType?: string | null;
2255 readonly name: string;
2256 readonly size?: number | null;
2257 readonly title?: string | null;
2258 readonly uri: string;
2259 }
2260 | {
2261 readonly type: "resource";
2262 readonly _meta?: { readonly [x: string]: unknown } | null;
2263 readonly annotations?: Annotations | null;
2264 readonly resource: EmbeddedResourceResource;
2265 };
2266 readonly messageId?: string | null;
2267 }
2268 | {
2269 readonly sessionUpdate: "tool_call";
2270 readonly _meta?: { readonly [x: string]: unknown } | null;
2271 readonly content?: ReadonlyArray<ToolCallContent>;
2272 readonly kind?:
2273 | "read"
2274 | "edit"
2275 | "delete"
2276 | "move"
2277 | "search"
2278 | "execute"
2279 | "think"
2280 | "fetch"
2281 | "switch_mode"
2282 | "other";
2283 readonly locations?: ReadonlyArray<ToolCallLocation>;
2284 readonly rawInput?: unknown;
2285 readonly rawOutput?: unknown;
2286 readonly status?: "pending" | "in_progress" | "completed" | "failed";
2287 readonly title: string;
2288 readonly toolCallId: string;
2289 }
2290 | {
2291 readonly sessionUpdate: "tool_call_update";
2292 readonly _meta?: { readonly [x: string]: unknown } | null;
2293 readonly content?: ReadonlyArray<ToolCallContent> | null;
2294 readonly kind?: ToolKind | null;
2295 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
2296 readonly rawInput?: unknown;
2297 readonly rawOutput?: unknown;
2298 readonly status?: ToolCallStatus | null;
2299 readonly title?: string | null;
2300 readonly toolCallId: string;
2301 }
2302 | {
2303 readonly sessionUpdate: "plan";
2304 readonly _meta?: { readonly [x: string]: unknown } | null;
2305 readonly entries: ReadonlyArray<PlanEntry>;
2306 }
2307 | {
2308 readonly sessionUpdate: "available_commands_update";
2309 readonly _meta?: { readonly [x: string]: unknown } | null;
2310 readonly availableCommands: ReadonlyArray<AvailableCommand>;
2311 }
2312 | {
2313 readonly sessionUpdate: "current_mode_update";
2314 readonly _meta?: { readonly [x: string]: unknown } | null;
2315 readonly currentModeId: string;
2316 }
2317 | {
2318 readonly sessionUpdate: "config_option_update";
2319 readonly _meta?: { readonly [x: string]: unknown } | null;
2320 readonly configOptions: ReadonlyArray<SessionConfigOption>;
2321 }
2322 | {
2323 readonly sessionUpdate: "session_info_update";
2324 readonly _meta?: { readonly [x: string]: unknown } | null;
2325 readonly title?: string | null;
2326 readonly updatedAt?: string | null;
2327 }
2328 | {
2329 readonly sessionUpdate: "usage_update";
2330 readonly _meta?: { readonly [x: string]: unknown } | null;
2331 readonly cost?: Cost | null;
2332 readonly size: number;
2333 readonly used: number;
2334 };
2335 }
2336 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly elicitationId: string }
2337 | unknown
2338 | null;
2339};
2340export const AgentNotification = Schema.Struct({
2341 method: Schema.String,
2342 params: Schema.optionalKey(
2343 Schema.Union([
2344 Schema.Union([
2345 Schema.Struct({
2346 _meta: Schema.optionalKey(
2347 Schema.Union([
2348 Schema.Record(Schema.String, Schema.Unknown).annotate({
2349 description:
2350 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2351 }),
2352 Schema.Null,
2353 ]),
2354 ),
2355 sessionId: Schema.String.annotate({
2356 description:
2357 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
2358 }),
2359 update: Schema.Union(
2360 [
2361 Schema.Struct({
2362 sessionUpdate: Schema.Literal("user_message_chunk"),
2363 _meta: Schema.optionalKey(
2364 Schema.Union([
2365 Schema.Record(Schema.String, Schema.Unknown).annotate({
2366 description:
2367 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2368 }),
2369 Schema.Null,
2370 ]),
2371 ),
2372 content: Schema.Union(
2373 [
2374 Schema.Struct({
2375 type: Schema.Literal("text"),
2376 _meta: Schema.optionalKey(
2377 Schema.Union([
2378 Schema.Record(Schema.String, Schema.Unknown).annotate({
2379 description:
2380 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2381 }),
2382 Schema.Null,
2383 ]),
2384 ),
2385 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2386 text: Schema.String,
2387 }).annotate({ description: "Text provided to or from an LLM." }),
2388 Schema.Struct({
2389 type: Schema.Literal("image"),
2390 _meta: Schema.optionalKey(
2391 Schema.Union([
2392 Schema.Record(Schema.String, Schema.Unknown).annotate({
2393 description:
2394 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2395 }),
2396 Schema.Null,
2397 ]),
2398 ),
2399 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2400 data: Schema.String,
2401 mimeType: Schema.String,
2402 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2403 }).annotate({ description: "An image provided to or from an LLM." }),
2404 Schema.Struct({
2405 type: Schema.Literal("audio"),
2406 _meta: Schema.optionalKey(
2407 Schema.Union([
2408 Schema.Record(Schema.String, Schema.Unknown).annotate({
2409 description:
2410 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2411 }),
2412 Schema.Null,
2413 ]),
2414 ),
2415 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2416 data: Schema.String,
2417 mimeType: Schema.String,
2418 }).annotate({ description: "Audio provided to or from an LLM." }),
2419 Schema.Struct({
2420 type: Schema.Literal("resource_link"),
2421 _meta: Schema.optionalKey(
2422 Schema.Union([
2423 Schema.Record(Schema.String, Schema.Unknown).annotate({
2424 description:
2425 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2426 }),
2427 Schema.Null,
2428 ]),
2429 ),
2430 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2431 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2432 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2433 name: Schema.String,
2434 size: Schema.optionalKey(
2435 Schema.Union([
2436 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
2437 Schema.Null,
2438 ]),
2439 ),
2440 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2441 uri: Schema.String,
2442 }).annotate({
2443 description:
2444 "A resource that the server is capable of reading, included in a prompt or tool call result.",
2445 }),
2446 Schema.Struct({
2447 type: Schema.Literal("resource"),
2448 _meta: Schema.optionalKey(
2449 Schema.Union([
2450 Schema.Record(Schema.String, Schema.Unknown).annotate({
2451 description:
2452 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2453 }),
2454 Schema.Null,
2455 ]),
2456 ),
2457 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2458 resource: EmbeddedResourceResource,
2459 }).annotate({
2460 description:
2461 "The contents of a resource, embedded into a prompt or tool call result.",
2462 }),
2463 ],
2464 { mode: "oneOf" },
2465 ).annotate({
2466 description:
2467 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
2468 }),
2469 messageId: Schema.optionalKey(
2470 Schema.Union([
2471 Schema.String.annotate({
2472 description:
2473 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.",
2474 }),
2475 Schema.Null,
2476 ]),
2477 ),
2478 }).annotate({ description: "A streamed item of content" }),
2479 Schema.Struct({
2480 sessionUpdate: Schema.Literal("agent_message_chunk"),
2481 _meta: Schema.optionalKey(
2482 Schema.Union([
2483 Schema.Record(Schema.String, Schema.Unknown).annotate({
2484 description:
2485 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2486 }),
2487 Schema.Null,
2488 ]),
2489 ),
2490 content: Schema.Union(
2491 [
2492 Schema.Struct({
2493 type: Schema.Literal("text"),
2494 _meta: Schema.optionalKey(
2495 Schema.Union([
2496 Schema.Record(Schema.String, Schema.Unknown).annotate({
2497 description:
2498 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2499 }),
2500 Schema.Null,
2501 ]),
2502 ),
2503 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2504 text: Schema.String,
2505 }).annotate({ description: "Text provided to or from an LLM." }),
2506 Schema.Struct({
2507 type: Schema.Literal("image"),
2508 _meta: Schema.optionalKey(
2509 Schema.Union([
2510 Schema.Record(Schema.String, Schema.Unknown).annotate({
2511 description:
2512 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2513 }),
2514 Schema.Null,
2515 ]),
2516 ),
2517 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2518 data: Schema.String,
2519 mimeType: Schema.String,
2520 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2521 }).annotate({ description: "An image provided to or from an LLM." }),
2522 Schema.Struct({
2523 type: Schema.Literal("audio"),
2524 _meta: Schema.optionalKey(
2525 Schema.Union([
2526 Schema.Record(Schema.String, Schema.Unknown).annotate({
2527 description:
2528 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2529 }),
2530 Schema.Null,
2531 ]),
2532 ),
2533 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2534 data: Schema.String,
2535 mimeType: Schema.String,
2536 }).annotate({ description: "Audio provided to or from an LLM." }),
2537 Schema.Struct({
2538 type: Schema.Literal("resource_link"),
2539 _meta: Schema.optionalKey(
2540 Schema.Union([
2541 Schema.Record(Schema.String, Schema.Unknown).annotate({
2542 description:
2543 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2544 }),
2545 Schema.Null,
2546 ]),
2547 ),
2548 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2549 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2550 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2551 name: Schema.String,
2552 size: Schema.optionalKey(
2553 Schema.Union([
2554 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
2555 Schema.Null,
2556 ]),
2557 ),
2558 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2559 uri: Schema.String,
2560 }).annotate({
2561 description:
2562 "A resource that the server is capable of reading, included in a prompt or tool call result.",
2563 }),
2564 Schema.Struct({
2565 type: Schema.Literal("resource"),
2566 _meta: Schema.optionalKey(
2567 Schema.Union([
2568 Schema.Record(Schema.String, Schema.Unknown).annotate({
2569 description:
2570 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2571 }),
2572 Schema.Null,
2573 ]),
2574 ),
2575 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2576 resource: EmbeddedResourceResource,
2577 }).annotate({
2578 description:
2579 "The contents of a resource, embedded into a prompt or tool call result.",
2580 }),
2581 ],
2582 { mode: "oneOf" },
2583 ).annotate({
2584 description:
2585 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
2586 }),
2587 messageId: Schema.optionalKey(
2588 Schema.Union([
2589 Schema.String.annotate({
2590 description:
2591 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.",
2592 }),
2593 Schema.Null,
2594 ]),
2595 ),
2596 }).annotate({ description: "A streamed item of content" }),
2597 Schema.Struct({
2598 sessionUpdate: Schema.Literal("agent_thought_chunk"),
2599 _meta: Schema.optionalKey(
2600 Schema.Union([
2601 Schema.Record(Schema.String, Schema.Unknown).annotate({
2602 description:
2603 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2604 }),
2605 Schema.Null,
2606 ]),
2607 ),
2608 content: Schema.Union(
2609 [
2610 Schema.Struct({
2611 type: Schema.Literal("text"),
2612 _meta: Schema.optionalKey(
2613 Schema.Union([
2614 Schema.Record(Schema.String, Schema.Unknown).annotate({
2615 description:
2616 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2617 }),
2618 Schema.Null,
2619 ]),
2620 ),
2621 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2622 text: Schema.String,
2623 }).annotate({ description: "Text provided to or from an LLM." }),
2624 Schema.Struct({
2625 type: Schema.Literal("image"),
2626 _meta: Schema.optionalKey(
2627 Schema.Union([
2628 Schema.Record(Schema.String, Schema.Unknown).annotate({
2629 description:
2630 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2631 }),
2632 Schema.Null,
2633 ]),
2634 ),
2635 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2636 data: Schema.String,
2637 mimeType: Schema.String,
2638 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2639 }).annotate({ description: "An image provided to or from an LLM." }),
2640 Schema.Struct({
2641 type: Schema.Literal("audio"),
2642 _meta: Schema.optionalKey(
2643 Schema.Union([
2644 Schema.Record(Schema.String, Schema.Unknown).annotate({
2645 description:
2646 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2647 }),
2648 Schema.Null,
2649 ]),
2650 ),
2651 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2652 data: Schema.String,
2653 mimeType: Schema.String,
2654 }).annotate({ description: "Audio provided to or from an LLM." }),
2655 Schema.Struct({
2656 type: Schema.Literal("resource_link"),
2657 _meta: Schema.optionalKey(
2658 Schema.Union([
2659 Schema.Record(Schema.String, Schema.Unknown).annotate({
2660 description:
2661 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2662 }),
2663 Schema.Null,
2664 ]),
2665 ),
2666 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2667 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2668 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2669 name: Schema.String,
2670 size: Schema.optionalKey(
2671 Schema.Union([
2672 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
2673 Schema.Null,
2674 ]),
2675 ),
2676 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
2677 uri: Schema.String,
2678 }).annotate({
2679 description:
2680 "A resource that the server is capable of reading, included in a prompt or tool call result.",
2681 }),
2682 Schema.Struct({
2683 type: Schema.Literal("resource"),
2684 _meta: Schema.optionalKey(
2685 Schema.Union([
2686 Schema.Record(Schema.String, Schema.Unknown).annotate({
2687 description:
2688 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2689 }),
2690 Schema.Null,
2691 ]),
2692 ),
2693 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
2694 resource: EmbeddedResourceResource,
2695 }).annotate({
2696 description:
2697 "The contents of a resource, embedded into a prompt or tool call result.",
2698 }),
2699 ],
2700 { mode: "oneOf" },
2701 ).annotate({
2702 description:
2703 "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
2704 }),
2705 messageId: Schema.optionalKey(
2706 Schema.Union([
2707 Schema.String.annotate({
2708 description:
2709 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.\nBoth clients and agents MUST use UUID format for message IDs.",
2710 }),
2711 Schema.Null,
2712 ]),
2713 ),
2714 }).annotate({ description: "A streamed item of content" }),
2715 Schema.Struct({
2716 sessionUpdate: Schema.Literal("tool_call"),
2717 _meta: Schema.optionalKey(
2718 Schema.Union([
2719 Schema.Record(Schema.String, Schema.Unknown).annotate({
2720 description:
2721 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2722 }),
2723 Schema.Null,
2724 ]),
2725 ),
2726 content: Schema.optionalKey(
2727 Schema.Array(ToolCallContent).annotate({
2728 description: "Content produced by the tool call.",
2729 }),
2730 ),
2731 kind: Schema.optionalKey(
2732 Schema.Literals([
2733 "read",
2734 "edit",
2735 "delete",
2736 "move",
2737 "search",
2738 "execute",
2739 "think",
2740 "fetch",
2741 "switch_mode",
2742 "other",
2743 ]).annotate({
2744 description:
2745 "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)",
2746 }),
2747 ),
2748 locations: Schema.optionalKey(
2749 Schema.Array(ToolCallLocation).annotate({
2750 description:
2751 'File locations affected by this tool call.\nEnables "follow-along" features in clients.',
2752 }),
2753 ),
2754 rawInput: Schema.optionalKey(
2755 Schema.Unknown.annotate({
2756 description: "Raw input parameters sent to the tool.",
2757 }),
2758 ),
2759 rawOutput: Schema.optionalKey(
2760 Schema.Unknown.annotate({ description: "Raw output returned by the tool." }),
2761 ),
2762 status: Schema.optionalKey(
2763 Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({
2764 description:
2765 "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)",
2766 }),
2767 ),
2768 title: Schema.String.annotate({
2769 description: "Human-readable title describing what the tool is doing.",
2770 }),
2771 toolCallId: Schema.String.annotate({
2772 description: "Unique identifier for a tool call within a session.",
2773 }),
2774 }).annotate({
2775 description:
2776 "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)",
2777 }),
2778 Schema.Struct({
2779 sessionUpdate: Schema.Literal("tool_call_update"),
2780 _meta: Schema.optionalKey(
2781 Schema.Union([
2782 Schema.Record(Schema.String, Schema.Unknown).annotate({
2783 description:
2784 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2785 }),
2786 Schema.Null,
2787 ]),
2788 ),
2789 content: Schema.optionalKey(
2790 Schema.Union([
2791 Schema.Array(ToolCallContent).annotate({
2792 description: "Replace the content collection.",
2793 }),
2794 Schema.Null,
2795 ]),
2796 ),
2797 kind: Schema.optionalKey(
2798 Schema.Union([ToolKind, Schema.Null]).annotate({
2799 description: "Update the tool kind.",
2800 }),
2801 ),
2802 locations: Schema.optionalKey(
2803 Schema.Union([
2804 Schema.Array(ToolCallLocation).annotate({
2805 description: "Replace the locations collection.",
2806 }),
2807 Schema.Null,
2808 ]),
2809 ),
2810 rawInput: Schema.optionalKey(
2811 Schema.Unknown.annotate({ description: "Update the raw input." }),
2812 ),
2813 rawOutput: Schema.optionalKey(
2814 Schema.Unknown.annotate({ description: "Update the raw output." }),
2815 ),
2816 status: Schema.optionalKey(
2817 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
2818 description: "Update the execution status.",
2819 }),
2820 ),
2821 title: Schema.optionalKey(
2822 Schema.Union([
2823 Schema.String.annotate({ description: "Update the human-readable title." }),
2824 Schema.Null,
2825 ]),
2826 ),
2827 toolCallId: Schema.String.annotate({
2828 description: "Unique identifier for a tool call within a session.",
2829 }),
2830 }).annotate({
2831 description:
2832 "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)",
2833 }),
2834 Schema.Struct({
2835 sessionUpdate: Schema.Literal("plan"),
2836 _meta: Schema.optionalKey(
2837 Schema.Union([
2838 Schema.Record(Schema.String, Schema.Unknown).annotate({
2839 description:
2840 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2841 }),
2842 Schema.Null,
2843 ]),
2844 ),
2845 entries: Schema.Array(PlanEntry).annotate({
2846 description:
2847 "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.",
2848 }),
2849 }).annotate({
2850 description:
2851 "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)",
2852 }),
2853 Schema.Struct({
2854 sessionUpdate: Schema.Literal("available_commands_update"),
2855 _meta: Schema.optionalKey(
2856 Schema.Union([
2857 Schema.Record(Schema.String, Schema.Unknown).annotate({
2858 description:
2859 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2860 }),
2861 Schema.Null,
2862 ]),
2863 ),
2864 availableCommands: Schema.Array(AvailableCommand).annotate({
2865 description: "Commands the agent can execute",
2866 }),
2867 }).annotate({ description: "Available commands are ready or have changed" }),
2868 Schema.Struct({
2869 sessionUpdate: Schema.Literal("current_mode_update"),
2870 _meta: Schema.optionalKey(
2871 Schema.Union([
2872 Schema.Record(Schema.String, Schema.Unknown).annotate({
2873 description:
2874 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2875 }),
2876 Schema.Null,
2877 ]),
2878 ),
2879 currentModeId: Schema.String.annotate({
2880 description: "Unique identifier for a Session Mode.",
2881 }),
2882 }).annotate({
2883 description:
2884 "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
2885 }),
2886 Schema.Struct({
2887 sessionUpdate: Schema.Literal("config_option_update"),
2888 _meta: Schema.optionalKey(
2889 Schema.Union([
2890 Schema.Record(Schema.String, Schema.Unknown).annotate({
2891 description:
2892 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2893 }),
2894 Schema.Null,
2895 ]),
2896 ),
2897 configOptions: Schema.Array(SessionConfigOption).annotate({
2898 description: "The full set of configuration options and their current values.",
2899 }),
2900 }).annotate({ description: "Session configuration options have been updated." }),
2901 Schema.Struct({
2902 sessionUpdate: Schema.Literal("session_info_update"),
2903 _meta: Schema.optionalKey(
2904 Schema.Union([
2905 Schema.Record(Schema.String, Schema.Unknown).annotate({
2906 description:
2907 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2908 }),
2909 Schema.Null,
2910 ]),
2911 ),
2912 title: Schema.optionalKey(
2913 Schema.Union([
2914 Schema.String.annotate({
2915 description: "Human-readable title for the session. Set to null to clear.",
2916 }),
2917 Schema.Null,
2918 ]),
2919 ),
2920 updatedAt: Schema.optionalKey(
2921 Schema.Union([
2922 Schema.String.annotate({
2923 description: "ISO 8601 timestamp of last activity. Set to null to clear.",
2924 }),
2925 Schema.Null,
2926 ]),
2927 ),
2928 }).annotate({
2929 description:
2930 "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.",
2931 }),
2932 Schema.Struct({
2933 sessionUpdate: Schema.Literal("usage_update"),
2934 _meta: Schema.optionalKey(
2935 Schema.Union([
2936 Schema.Record(Schema.String, Schema.Unknown).annotate({
2937 description:
2938 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2939 }),
2940 Schema.Null,
2941 ]),
2942 ),
2943 cost: Schema.optionalKey(
2944 Schema.Union([Cost, Schema.Null]).annotate({
2945 description: "Cumulative session cost (optional).",
2946 }),
2947 ),
2948 size: Schema.Number.annotate({
2949 description: "Total context window size in tokens.",
2950 format: "uint64",
2951 })
2952 .check(Schema.isInt())
2953 .check(Schema.isGreaterThanOrEqualTo(0)),
2954 used: Schema.Number.annotate({
2955 description: "Tokens currently in context.",
2956 format: "uint64",
2957 })
2958 .check(Schema.isInt())
2959 .check(Schema.isGreaterThanOrEqualTo(0)),
2960 }).annotate({
2961 description:
2962 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nContext window and cost update for a session.",
2963 }),
2964 ],
2965 { mode: "oneOf" },
2966 ).annotate({
2967 description:
2968 "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)",
2969 }),
2970 }).annotate({
2971 title: "SessionNotification",
2972 description:
2973 "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)",
2974 }),
2975 Schema.Struct({
2976 _meta: Schema.optionalKey(
2977 Schema.Union([
2978 Schema.Record(Schema.String, Schema.Unknown).annotate({
2979 description:
2980 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2981 }),
2982 Schema.Null,
2983 ]),
2984 ),
2985 elicitationId: Schema.String.annotate({
2986 description:
2987 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.",
2988 }),
2989 }).annotate({
2990 title: "ElicitationCompleteNotification",
2991 description:
2992 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification sent by the agent when a URL-based elicitation is complete.",
2993 }),
2994 Schema.Unknown.annotate({
2995 title: "ExtNotification",
2996 description:
2997 "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
2998 }),
2999 ]).annotate({
3000 description:
3001 "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response.",
3002 }),
3003 Schema.Null,
3004 ]),
3005 ),
3006});
3007
3008export type AgentRequest = {
3009 readonly id: RequestId;
3010 readonly method: string;
3011 readonly params?:
3012 | {
3013 readonly _meta?: { readonly [x: string]: unknown } | null;
3014 readonly content: string;
3015 readonly path: string;
3016 readonly sessionId: string;
3017 }
3018 | {
3019 readonly _meta?: { readonly [x: string]: unknown } | null;
3020 readonly limit?: number | null;
3021 readonly line?: number | null;
3022 readonly path: string;
3023 readonly sessionId: string;
3024 }
3025 | {
3026 readonly _meta?: { readonly [x: string]: unknown } | null;
3027 readonly options: ReadonlyArray<PermissionOption>;
3028 readonly sessionId: string;
3029 readonly toolCall: {
3030 readonly _meta?: { readonly [x: string]: unknown } | null;
3031 readonly content?: ReadonlyArray<ToolCallContent> | null;
3032 readonly kind?: ToolKind | null;
3033 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
3034 readonly rawInput?: unknown;
3035 readonly rawOutput?: unknown;
3036 readonly status?: ToolCallStatus | null;
3037 readonly title?: string | null;
3038 readonly toolCallId: string;
3039 };
3040 }
3041 | {
3042 readonly _meta?: { readonly [x: string]: unknown } | null;
3043 readonly args?: ReadonlyArray<string>;
3044 readonly command: string;
3045 readonly cwd?: string | null;
3046 readonly env?: ReadonlyArray<EnvVariable>;
3047 readonly outputByteLimit?: number | null;
3048 readonly sessionId: string;
3049 }
3050 | {
3051 readonly _meta?: { readonly [x: string]: unknown } | null;
3052 readonly sessionId: string;
3053 readonly terminalId: string;
3054 }
3055 | {
3056 readonly _meta?: { readonly [x: string]: unknown } | null;
3057 readonly sessionId: string;
3058 readonly terminalId: string;
3059 }
3060 | {
3061 readonly _meta?: { readonly [x: string]: unknown } | null;
3062 readonly sessionId: string;
3063 readonly terminalId: string;
3064 }
3065 | {
3066 readonly _meta?: { readonly [x: string]: unknown } | null;
3067 readonly sessionId: string;
3068 readonly terminalId: string;
3069 }
3070 | {
3071 readonly mode: "form";
3072 readonly requestedSchema: {
3073 readonly description?: string | null;
3074 readonly properties?: { readonly [x: string]: ElicitationPropertySchema };
3075 readonly required?: ReadonlyArray<string> | null;
3076 readonly title?: string | null;
3077 readonly type?: "object";
3078 };
3079 readonly _meta?: { readonly [x: string]: unknown } | null;
3080 readonly message: string;
3081 readonly sessionId: string;
3082 }
3083 | {
3084 readonly mode: "url";
3085 readonly elicitationId: string;
3086 readonly url: string;
3087 readonly _meta?: { readonly [x: string]: unknown } | null;
3088 readonly message: string;
3089 readonly sessionId: string;
3090 }
3091 | unknown
3092 | null;
3093};
3094export const AgentRequest = Schema.Struct({
3095 id: RequestId,
3096 method: Schema.String,
3097 params: Schema.optionalKey(
3098 Schema.Union([
3099 Schema.Union([
3100 Schema.Struct({
3101 _meta: Schema.optionalKey(
3102 Schema.Union([
3103 Schema.Record(Schema.String, Schema.Unknown).annotate({
3104 description:
3105 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3106 }),
3107 Schema.Null,
3108 ]),
3109 ),
3110 content: Schema.String.annotate({
3111 description: "The text content to write to the file.",
3112 }),
3113 path: Schema.String.annotate({ description: "Absolute path to the file to write." }),
3114 sessionId: Schema.String.annotate({
3115 description:
3116 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3117 }),
3118 }).annotate({
3119 title: "WriteTextFileRequest",
3120 description:
3121 "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.",
3122 }),
3123 Schema.Struct({
3124 _meta: Schema.optionalKey(
3125 Schema.Union([
3126 Schema.Record(Schema.String, Schema.Unknown).annotate({
3127 description:
3128 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3129 }),
3130 Schema.Null,
3131 ]),
3132 ),
3133 limit: Schema.optionalKey(
3134 Schema.Union([
3135 Schema.Number.annotate({
3136 description: "Maximum number of lines to read.",
3137 format: "uint32",
3138 })
3139 .check(Schema.isInt())
3140 .check(Schema.isGreaterThanOrEqualTo(0)),
3141 Schema.Null,
3142 ]),
3143 ),
3144 line: Schema.optionalKey(
3145 Schema.Union([
3146 Schema.Number.annotate({
3147 description: "Line number to start reading from (1-based).",
3148 format: "uint32",
3149 })
3150 .check(Schema.isInt())
3151 .check(Schema.isGreaterThanOrEqualTo(0)),
3152 Schema.Null,
3153 ]),
3154 ),
3155 path: Schema.String.annotate({ description: "Absolute path to the file to read." }),
3156 sessionId: Schema.String.annotate({
3157 description:
3158 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3159 }),
3160 }).annotate({
3161 title: "ReadTextFileRequest",
3162 description:
3163 "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.",
3164 }),
3165 Schema.Struct({
3166 _meta: Schema.optionalKey(
3167 Schema.Union([
3168 Schema.Record(Schema.String, Schema.Unknown).annotate({
3169 description:
3170 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3171 }),
3172 Schema.Null,
3173 ]),
3174 ),
3175 options: Schema.Array(PermissionOption).annotate({
3176 description: "Available permission options for the user to choose from.",
3177 }),
3178 sessionId: Schema.String.annotate({
3179 description:
3180 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3181 }),
3182 toolCall: Schema.Struct({
3183 _meta: Schema.optionalKey(
3184 Schema.Union([
3185 Schema.Record(Schema.String, Schema.Unknown).annotate({
3186 description:
3187 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3188 }),
3189 Schema.Null,
3190 ]),
3191 ),
3192 content: Schema.optionalKey(
3193 Schema.Union([
3194 Schema.Array(ToolCallContent).annotate({
3195 description: "Replace the content collection.",
3196 }),
3197 Schema.Null,
3198 ]),
3199 ),
3200 kind: Schema.optionalKey(
3201 Schema.Union([ToolKind, Schema.Null]).annotate({
3202 description: "Update the tool kind.",
3203 }),
3204 ),
3205 locations: Schema.optionalKey(
3206 Schema.Union([
3207 Schema.Array(ToolCallLocation).annotate({
3208 description: "Replace the locations collection.",
3209 }),
3210 Schema.Null,
3211 ]),
3212 ),
3213 rawInput: Schema.optionalKey(
3214 Schema.Unknown.annotate({ description: "Update the raw input." }),
3215 ),
3216 rawOutput: Schema.optionalKey(
3217 Schema.Unknown.annotate({ description: "Update the raw output." }),
3218 ),
3219 status: Schema.optionalKey(
3220 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
3221 description: "Update the execution status.",
3222 }),
3223 ),
3224 title: Schema.optionalKey(
3225 Schema.Union([
3226 Schema.String.annotate({ description: "Update the human-readable title." }),
3227 Schema.Null,
3228 ]),
3229 ),
3230 toolCallId: Schema.String.annotate({
3231 description: "Unique identifier for a tool call within a session.",
3232 }),
3233 }).annotate({
3234 description:
3235 "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)",
3236 }),
3237 }).annotate({
3238 title: "RequestPermissionRequest",
3239 description:
3240 "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)",
3241 }),
3242 Schema.Struct({
3243 _meta: Schema.optionalKey(
3244 Schema.Union([
3245 Schema.Record(Schema.String, Schema.Unknown).annotate({
3246 description:
3247 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3248 }),
3249 Schema.Null,
3250 ]),
3251 ),
3252 args: Schema.optionalKey(
3253 Schema.Array(Schema.String).annotate({ description: "Array of command arguments." }),
3254 ),
3255 command: Schema.String.annotate({ description: "The command to execute." }),
3256 cwd: Schema.optionalKey(
3257 Schema.Union([
3258 Schema.String.annotate({
3259 description: "Working directory for the command (absolute path).",
3260 }),
3261 Schema.Null,
3262 ]),
3263 ),
3264 env: Schema.optionalKey(
3265 Schema.Array(EnvVariable).annotate({
3266 description: "Environment variables for the command.",
3267 }),
3268 ),
3269 outputByteLimit: Schema.optionalKey(
3270 Schema.Union([
3271 Schema.Number.annotate({
3272 description:
3273 "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.",
3274 format: "uint64",
3275 })
3276 .check(Schema.isInt())
3277 .check(Schema.isGreaterThanOrEqualTo(0)),
3278 Schema.Null,
3279 ]),
3280 ),
3281 sessionId: Schema.String.annotate({
3282 description:
3283 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3284 }),
3285 }).annotate({
3286 title: "CreateTerminalRequest",
3287 description: "Request to create a new terminal and execute a command.",
3288 }),
3289 Schema.Struct({
3290 _meta: Schema.optionalKey(
3291 Schema.Union([
3292 Schema.Record(Schema.String, Schema.Unknown).annotate({
3293 description:
3294 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3295 }),
3296 Schema.Null,
3297 ]),
3298 ),
3299 sessionId: Schema.String.annotate({
3300 description:
3301 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3302 }),
3303 terminalId: Schema.String.annotate({
3304 description: "The ID of the terminal to get output from.",
3305 }),
3306 }).annotate({
3307 title: "TerminalOutputRequest",
3308 description: "Request to get the current output and status of a terminal.",
3309 }),
3310 Schema.Struct({
3311 _meta: Schema.optionalKey(
3312 Schema.Union([
3313 Schema.Record(Schema.String, Schema.Unknown).annotate({
3314 description:
3315 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3316 }),
3317 Schema.Null,
3318 ]),
3319 ),
3320 sessionId: Schema.String.annotate({
3321 description:
3322 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3323 }),
3324 terminalId: Schema.String.annotate({ description: "The ID of the terminal to release." }),
3325 }).annotate({
3326 title: "ReleaseTerminalRequest",
3327 description: "Request to release a terminal and free its resources.",
3328 }),
3329 Schema.Struct({
3330 _meta: Schema.optionalKey(
3331 Schema.Union([
3332 Schema.Record(Schema.String, Schema.Unknown).annotate({
3333 description:
3334 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3335 }),
3336 Schema.Null,
3337 ]),
3338 ),
3339 sessionId: Schema.String.annotate({
3340 description:
3341 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3342 }),
3343 terminalId: Schema.String.annotate({
3344 description: "The ID of the terminal to wait for.",
3345 }),
3346 }).annotate({
3347 title: "WaitForTerminalExitRequest",
3348 description: "Request to wait for a terminal command to exit.",
3349 }),
3350 Schema.Struct({
3351 _meta: Schema.optionalKey(
3352 Schema.Union([
3353 Schema.Record(Schema.String, Schema.Unknown).annotate({
3354 description:
3355 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3356 }),
3357 Schema.Null,
3358 ]),
3359 ),
3360 sessionId: Schema.String.annotate({
3361 description:
3362 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3363 }),
3364 terminalId: Schema.String.annotate({ description: "The ID of the terminal to kill." }),
3365 }).annotate({
3366 title: "KillTerminalRequest",
3367 description: "Request to kill a terminal without releasing it.",
3368 }),
3369 Schema.Union([
3370 Schema.Struct({
3371 mode: Schema.Literal("form"),
3372 requestedSchema: Schema.Struct({
3373 description: Schema.optionalKey(
3374 Schema.Union([
3375 Schema.String.annotate({
3376 description: "Optional description of what this schema represents.",
3377 }),
3378 Schema.Null,
3379 ]),
3380 ),
3381 properties: Schema.optionalKey(
3382 Schema.Record(Schema.String, ElicitationPropertySchema).annotate({
3383 description: "Property definitions (must be primitive types).",
3384 default: {},
3385 }),
3386 ),
3387 required: Schema.optionalKey(
3388 Schema.Union([
3389 Schema.Array(Schema.String).annotate({
3390 description: "List of required property names.",
3391 }),
3392 Schema.Null,
3393 ]),
3394 ),
3395 title: Schema.optionalKey(
3396 Schema.Union([
3397 Schema.String.annotate({ description: "Optional title for the schema." }),
3398 Schema.Null,
3399 ]),
3400 ),
3401 type: Schema.optionalKey(
3402 Schema.Literal("object").annotate({
3403 description: "Type discriminator for elicitation schemas.",
3404 default: "object",
3405 }),
3406 ),
3407 }).annotate({
3408 description:
3409 "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.",
3410 }),
3411 _meta: Schema.optionalKey(
3412 Schema.Union([
3413 Schema.Record(Schema.String, Schema.Unknown).annotate({
3414 description:
3415 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3416 }),
3417 Schema.Null,
3418 ]),
3419 ),
3420 message: Schema.String.annotate({
3421 description: "A human-readable message describing what input is needed.",
3422 }),
3423 sessionId: Schema.String.annotate({
3424 description:
3425 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3426 }),
3427 }).annotate({
3428 description:
3429 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.",
3430 }),
3431 Schema.Struct({
3432 mode: Schema.Literal("url"),
3433 elicitationId: Schema.String.annotate({
3434 description:
3435 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for an elicitation.",
3436 }),
3437 url: Schema.String.annotate({
3438 description: "The URL to direct the user to.",
3439 format: "uri",
3440 }),
3441 _meta: Schema.optionalKey(
3442 Schema.Union([
3443 Schema.Record(Schema.String, Schema.Unknown).annotate({
3444 description:
3445 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3446 }),
3447 Schema.Null,
3448 ]),
3449 ),
3450 message: Schema.String.annotate({
3451 description: "A human-readable message describing what input is needed.",
3452 }),
3453 sessionId: Schema.String.annotate({
3454 description:
3455 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3456 }),
3457 }).annotate({
3458 description:
3459 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.",
3460 }),
3461 ]).annotate({
3462 title: "ElicitationRequest",
3463 description:
3464 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequests structured user input via a form or URL.",
3465 }),
3466 Schema.Unknown.annotate({
3467 title: "ExtMethodRequest",
3468 description:
3469 "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3470 }),
3471 ]).annotate({
3472 description:
3473 "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Client`] trait.\n\nThis enum encompasses all method calls from agent to client.",
3474 }),
3475 Schema.Null,
3476 ]),
3477 ),
3478});
3479
3480export type AgentResponse =
3481 | {
3482 readonly id: RequestId;
3483 readonly result:
3484 | {
3485 readonly _meta?: { readonly [x: string]: unknown } | null;
3486 readonly agentCapabilities?: {
3487 readonly _meta?: { readonly [x: string]: unknown } | null;
3488 readonly auth?: {
3489 readonly _meta?: { readonly [x: string]: unknown } | null;
3490 readonly logout?: LogoutCapabilities | null;
3491 };
3492 readonly loadSession?: boolean;
3493 readonly mcpCapabilities?: {
3494 readonly _meta?: { readonly [x: string]: unknown } | null;
3495 readonly http?: boolean;
3496 readonly sse?: boolean;
3497 };
3498 readonly promptCapabilities?: {
3499 readonly _meta?: { readonly [x: string]: unknown } | null;
3500 readonly audio?: boolean;
3501 readonly embeddedContext?: boolean;
3502 readonly image?: boolean;
3503 };
3504 readonly sessionCapabilities?: {
3505 readonly _meta?: { readonly [x: string]: unknown } | null;
3506 readonly close?: SessionCloseCapabilities | null;
3507 readonly fork?: SessionForkCapabilities | null;
3508 readonly list?: SessionListCapabilities | null;
3509 readonly resume?: SessionResumeCapabilities | null;
3510 };
3511 };
3512 readonly agentInfo?: Implementation | null;
3513 readonly authMethods?: ReadonlyArray<AuthMethod>;
3514 readonly protocolVersion: number;
3515 }
3516 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3517 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3518 | {
3519 readonly _meta?: { readonly [x: string]: unknown } | null;
3520 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3521 readonly models?: SessionModelState | null;
3522 readonly modes?: SessionModeState | null;
3523 readonly sessionId: string;
3524 }
3525 | {
3526 readonly _meta?: { readonly [x: string]: unknown } | null;
3527 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3528 readonly models?: SessionModelState | null;
3529 readonly modes?: SessionModeState | null;
3530 }
3531 | {
3532 readonly _meta?: { readonly [x: string]: unknown } | null;
3533 readonly nextCursor?: string | null;
3534 readonly sessions: ReadonlyArray<SessionInfo>;
3535 }
3536 | {
3537 readonly _meta?: { readonly [x: string]: unknown } | null;
3538 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3539 readonly models?: SessionModelState | null;
3540 readonly modes?: SessionModeState | null;
3541 readonly sessionId: string;
3542 }
3543 | {
3544 readonly _meta?: { readonly [x: string]: unknown } | null;
3545 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
3546 readonly models?: SessionModelState | null;
3547 readonly modes?: SessionModeState | null;
3548 }
3549 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3550 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3551 | {
3552 readonly _meta?: { readonly [x: string]: unknown } | null;
3553 readonly configOptions: ReadonlyArray<SessionConfigOption>;
3554 }
3555 | {
3556 readonly _meta?: { readonly [x: string]: unknown } | null;
3557 readonly stopReason:
3558 | "end_turn"
3559 | "max_tokens"
3560 | "max_turn_requests"
3561 | "refusal"
3562 | "cancelled";
3563 readonly usage?: Usage | null;
3564 readonly userMessageId?: string | null;
3565 }
3566 | { readonly _meta?: { readonly [x: string]: unknown } | null }
3567 | unknown;
3568 }
3569 | { readonly error: Error; readonly id: RequestId };
3570export const AgentResponse = Schema.Union([
3571 Schema.Struct({
3572 id: RequestId,
3573 result: Schema.Union([
3574 Schema.Struct({
3575 _meta: Schema.optionalKey(
3576 Schema.Union([
3577 Schema.Record(Schema.String, Schema.Unknown).annotate({
3578 description:
3579 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3580 }),
3581 Schema.Null,
3582 ]),
3583 ),
3584 agentCapabilities: Schema.optionalKey(
3585 Schema.Struct({
3586 _meta: Schema.optionalKey(
3587 Schema.Union([
3588 Schema.Record(Schema.String, Schema.Unknown).annotate({
3589 description:
3590 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3591 }),
3592 Schema.Null,
3593 ]),
3594 ),
3595 auth: Schema.optionalKey(
3596 Schema.Struct({
3597 _meta: Schema.optionalKey(
3598 Schema.Union([
3599 Schema.Record(Schema.String, Schema.Unknown).annotate({
3600 description:
3601 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3602 }),
3603 Schema.Null,
3604 ]),
3605 ),
3606 logout: Schema.optionalKey(
3607 Schema.Union([LogoutCapabilities, Schema.Null]).annotate({
3608 description:
3609 "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.",
3610 }),
3611 ),
3612 }).annotate({
3613 description:
3614 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication-related capabilities supported by the agent.",
3615 default: {},
3616 }),
3617 ),
3618 loadSession: Schema.optionalKey(
3619 Schema.Boolean.annotate({
3620 description: "Whether the agent supports `session/load`.",
3621 default: false,
3622 }),
3623 ),
3624 mcpCapabilities: Schema.optionalKey(
3625 Schema.Struct({
3626 _meta: Schema.optionalKey(
3627 Schema.Union([
3628 Schema.Record(Schema.String, Schema.Unknown).annotate({
3629 description:
3630 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3631 }),
3632 Schema.Null,
3633 ]),
3634 ),
3635 http: Schema.optionalKey(
3636 Schema.Boolean.annotate({
3637 description: "Agent supports [`McpServer::Http`].",
3638 default: false,
3639 }),
3640 ),
3641 sse: Schema.optionalKey(
3642 Schema.Boolean.annotate({
3643 description: "Agent supports [`McpServer::Sse`].",
3644 default: false,
3645 }),
3646 ),
3647 }).annotate({
3648 description: "MCP capabilities supported by the agent",
3649 default: { http: false, sse: false },
3650 }),
3651 ),
3652 promptCapabilities: Schema.optionalKey(
3653 Schema.Struct({
3654 _meta: Schema.optionalKey(
3655 Schema.Union([
3656 Schema.Record(Schema.String, Schema.Unknown).annotate({
3657 description:
3658 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3659 }),
3660 Schema.Null,
3661 ]),
3662 ),
3663 audio: Schema.optionalKey(
3664 Schema.Boolean.annotate({
3665 description: "Agent supports [`ContentBlock::Audio`].",
3666 default: false,
3667 }),
3668 ),
3669 embeddedContext: Schema.optionalKey(
3670 Schema.Boolean.annotate({
3671 description:
3672 "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.",
3673 default: false,
3674 }),
3675 ),
3676 image: Schema.optionalKey(
3677 Schema.Boolean.annotate({
3678 description: "Agent supports [`ContentBlock::Image`].",
3679 default: false,
3680 }),
3681 ),
3682 }).annotate({
3683 description:
3684 "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)",
3685 default: { audio: false, embeddedContext: false, image: false },
3686 }),
3687 ),
3688 sessionCapabilities: Schema.optionalKey(
3689 Schema.Struct({
3690 _meta: Schema.optionalKey(
3691 Schema.Union([
3692 Schema.Record(Schema.String, Schema.Unknown).annotate({
3693 description:
3694 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3695 }),
3696 Schema.Null,
3697 ]),
3698 ),
3699 close: Schema.optionalKey(
3700 Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({
3701 description:
3702 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/close`.",
3703 }),
3704 ),
3705 fork: Schema.optionalKey(
3706 Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({
3707 description:
3708 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.",
3709 }),
3710 ),
3711 list: Schema.optionalKey(
3712 Schema.Union([SessionListCapabilities, Schema.Null]).annotate({
3713 description: "Whether the agent supports `session/list`.",
3714 }),
3715 ),
3716 resume: Schema.optionalKey(
3717 Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({
3718 description:
3719 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/resume`.",
3720 }),
3721 ),
3722 }).annotate({
3723 default: {},
3724 description:
3725 "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)",
3726 }),
3727 ),
3728 }).annotate({
3729 description:
3730 "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)",
3731 default: {
3732 auth: {},
3733 loadSession: false,
3734 mcpCapabilities: { http: false, sse: false },
3735 promptCapabilities: { audio: false, embeddedContext: false, image: false },
3736 sessionCapabilities: {},
3737 },
3738 }),
3739 ),
3740 agentInfo: Schema.optionalKey(
3741 Schema.Union([Implementation, Schema.Null]).annotate({
3742 description:
3743 "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required.",
3744 }),
3745 ),
3746 authMethods: Schema.optionalKey(
3747 Schema.Array(AuthMethod).annotate({
3748 description: "Authentication methods supported by the agent.",
3749 default: [],
3750 }),
3751 ),
3752 protocolVersion: Schema.Number.annotate({
3753 description:
3754 "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.",
3755 format: "uint16",
3756 })
3757 .check(Schema.isInt())
3758 .check(Schema.isGreaterThanOrEqualTo(0))
3759 .check(Schema.isLessThanOrEqualTo(65535)),
3760 }).annotate({
3761 title: "InitializeResponse",
3762 description:
3763 "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)",
3764 }),
3765 Schema.Struct({
3766 _meta: Schema.optionalKey(
3767 Schema.Union([
3768 Schema.Record(Schema.String, Schema.Unknown).annotate({
3769 description:
3770 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3771 }),
3772 Schema.Null,
3773 ]),
3774 ),
3775 }).annotate({
3776 title: "AuthenticateResponse",
3777 description: "Response to the `authenticate` method.",
3778 }),
3779 Schema.Struct({
3780 _meta: Schema.optionalKey(
3781 Schema.Union([
3782 Schema.Record(Schema.String, Schema.Unknown).annotate({
3783 description:
3784 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3785 }),
3786 Schema.Null,
3787 ]),
3788 ),
3789 }).annotate({
3790 title: "LogoutResponse",
3791 description:
3792 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to the `logout` method.",
3793 }),
3794 Schema.Struct({
3795 _meta: Schema.optionalKey(
3796 Schema.Union([
3797 Schema.Record(Schema.String, Schema.Unknown).annotate({
3798 description:
3799 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3800 }),
3801 Schema.Null,
3802 ]),
3803 ),
3804 configOptions: Schema.optionalKey(
3805 Schema.Union([
3806 Schema.Array(SessionConfigOption).annotate({
3807 description: "Initial session configuration options if supported by the Agent.",
3808 }),
3809 Schema.Null,
3810 ]),
3811 ),
3812 models: Schema.optionalKey(
3813 Schema.Union([SessionModelState, Schema.Null]).annotate({
3814 description:
3815 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3816 }),
3817 ),
3818 modes: Schema.optionalKey(
3819 Schema.Union([SessionModeState, Schema.Null]).annotate({
3820 description:
3821 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3822 }),
3823 ),
3824 sessionId: Schema.String.annotate({
3825 description:
3826 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3827 }),
3828 }).annotate({
3829 title: "NewSessionResponse",
3830 description:
3831 "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)",
3832 }),
3833 Schema.Struct({
3834 _meta: Schema.optionalKey(
3835 Schema.Union([
3836 Schema.Record(Schema.String, Schema.Unknown).annotate({
3837 description:
3838 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3839 }),
3840 Schema.Null,
3841 ]),
3842 ),
3843 configOptions: Schema.optionalKey(
3844 Schema.Union([
3845 Schema.Array(SessionConfigOption).annotate({
3846 description: "Initial session configuration options if supported by the Agent.",
3847 }),
3848 Schema.Null,
3849 ]),
3850 ),
3851 models: Schema.optionalKey(
3852 Schema.Union([SessionModelState, Schema.Null]).annotate({
3853 description:
3854 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3855 }),
3856 ),
3857 modes: Schema.optionalKey(
3858 Schema.Union([SessionModeState, Schema.Null]).annotate({
3859 description:
3860 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3861 }),
3862 ),
3863 }).annotate({
3864 title: "LoadSessionResponse",
3865 description: "Response from loading an existing session.",
3866 }),
3867 Schema.Struct({
3868 _meta: Schema.optionalKey(
3869 Schema.Union([
3870 Schema.Record(Schema.String, Schema.Unknown).annotate({
3871 description:
3872 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3873 }),
3874 Schema.Null,
3875 ]),
3876 ),
3877 nextCursor: Schema.optionalKey(
3878 Schema.Union([
3879 Schema.String.annotate({
3880 description:
3881 "Opaque cursor token. If present, pass this in the next request's cursor parameter\nto fetch the next page. If absent, there are no more results.",
3882 }),
3883 Schema.Null,
3884 ]),
3885 ),
3886 sessions: Schema.Array(SessionInfo).annotate({
3887 description: "Array of session information objects",
3888 }),
3889 }).annotate({
3890 title: "ListSessionsResponse",
3891 description: "Response from listing sessions.",
3892 }),
3893 Schema.Struct({
3894 _meta: Schema.optionalKey(
3895 Schema.Union([
3896 Schema.Record(Schema.String, Schema.Unknown).annotate({
3897 description:
3898 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3899 }),
3900 Schema.Null,
3901 ]),
3902 ),
3903 configOptions: Schema.optionalKey(
3904 Schema.Union([
3905 Schema.Array(SessionConfigOption).annotate({
3906 description: "Initial session configuration options if supported by the Agent.",
3907 }),
3908 Schema.Null,
3909 ]),
3910 ),
3911 models: Schema.optionalKey(
3912 Schema.Union([SessionModelState, Schema.Null]).annotate({
3913 description:
3914 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3915 }),
3916 ),
3917 modes: Schema.optionalKey(
3918 Schema.Union([SessionModeState, Schema.Null]).annotate({
3919 description:
3920 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3921 }),
3922 ),
3923 sessionId: Schema.String.annotate({
3924 description:
3925 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
3926 }),
3927 }).annotate({
3928 title: "ForkSessionResponse",
3929 description:
3930 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from forking an existing session.",
3931 }),
3932 Schema.Struct({
3933 _meta: Schema.optionalKey(
3934 Schema.Union([
3935 Schema.Record(Schema.String, Schema.Unknown).annotate({
3936 description:
3937 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3938 }),
3939 Schema.Null,
3940 ]),
3941 ),
3942 configOptions: Schema.optionalKey(
3943 Schema.Union([
3944 Schema.Array(SessionConfigOption).annotate({
3945 description: "Initial session configuration options if supported by the Agent.",
3946 }),
3947 Schema.Null,
3948 ]),
3949 ),
3950 models: Schema.optionalKey(
3951 Schema.Union([SessionModelState, Schema.Null]).annotate({
3952 description:
3953 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInitial model state if supported by the Agent",
3954 }),
3955 ),
3956 modes: Schema.optionalKey(
3957 Schema.Union([SessionModeState, Schema.Null]).annotate({
3958 description:
3959 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
3960 }),
3961 ),
3962 }).annotate({
3963 title: "ResumeSessionResponse",
3964 description:
3965 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from resuming an existing session.",
3966 }),
3967 Schema.Struct({
3968 _meta: Schema.optionalKey(
3969 Schema.Union([
3970 Schema.Record(Schema.String, Schema.Unknown).annotate({
3971 description:
3972 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3973 }),
3974 Schema.Null,
3975 ]),
3976 ),
3977 }).annotate({
3978 title: "CloseSessionResponse",
3979 description:
3980 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from closing a session.",
3981 }),
3982 Schema.Struct({
3983 _meta: Schema.optionalKey(
3984 Schema.Union([
3985 Schema.Record(Schema.String, Schema.Unknown).annotate({
3986 description:
3987 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
3988 }),
3989 Schema.Null,
3990 ]),
3991 ),
3992 }).annotate({
3993 title: "SetSessionModeResponse",
3994 description: "Response to `session/set_mode` method.",
3995 }),
3996 Schema.Struct({
3997 _meta: Schema.optionalKey(
3998 Schema.Union([
3999 Schema.Record(Schema.String, Schema.Unknown).annotate({
4000 description:
4001 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4002 }),
4003 Schema.Null,
4004 ]),
4005 ),
4006 configOptions: Schema.Array(SessionConfigOption).annotate({
4007 description: "The full set of configuration options and their current values.",
4008 }),
4009 }).annotate({
4010 title: "SetSessionConfigOptionResponse",
4011 description: "Response to `session/set_config_option` method.",
4012 }),
4013 Schema.Struct({
4014 _meta: Schema.optionalKey(
4015 Schema.Union([
4016 Schema.Record(Schema.String, Schema.Unknown).annotate({
4017 description:
4018 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4019 }),
4020 Schema.Null,
4021 ]),
4022 ),
4023 stopReason: Schema.Literals([
4024 "end_turn",
4025 "max_tokens",
4026 "max_turn_requests",
4027 "refusal",
4028 "cancelled",
4029 ]).annotate({
4030 description:
4031 "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)",
4032 }),
4033 usage: Schema.optionalKey(
4034 Schema.Union([Usage, Schema.Null]).annotate({
4035 description:
4036 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nToken usage for this turn (optional).",
4037 }),
4038 ),
4039 userMessageId: Schema.optionalKey(
4040 Schema.Union([
4041 Schema.String.annotate({
4042 description:
4043 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe acknowledged user message ID.\n\nIf the client provided a `messageId` in the [`PromptRequest`], the agent echoes it here\nto confirm it was recorded. If the client did not provide one, the agent MAY assign one\nand return it here. Absence of this field indicates the agent did not record a message ID.",
4044 }),
4045 Schema.Null,
4046 ]),
4047 ),
4048 }).annotate({
4049 title: "PromptResponse",
4050 description:
4051 "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)",
4052 }),
4053 Schema.Struct({
4054 _meta: Schema.optionalKey(
4055 Schema.Union([
4056 Schema.Record(Schema.String, Schema.Unknown).annotate({
4057 description:
4058 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4059 }),
4060 Schema.Null,
4061 ]),
4062 ),
4063 }).annotate({
4064 title: "SetSessionModelResponse",
4065 description:
4066 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse to `session/set_model` method.",
4067 }),
4068 Schema.Unknown.annotate({
4069 title: "ExtMethodResponse",
4070 description:
4071 "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4072 }),
4073 ]).annotate({
4074 description:
4075 "All possible responses that an agent can send to a client.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `ClientRequest` variants.",
4076 }),
4077 }).annotate({ title: "Result" }),
4078 Schema.Struct({ error: Error, id: RequestId }).annotate({ title: "Error" }),
4079]);
4080
4081export type AudioContent = {
4082 readonly _meta?: { readonly [x: string]: unknown } | null;
4083 readonly annotations?: Annotations | null;
4084 readonly data: string;
4085 readonly mimeType: string;
4086};
4087export const AudioContent = Schema.Struct({
4088 _meta: Schema.optionalKey(
4089 Schema.Union([
4090 Schema.Record(Schema.String, Schema.Unknown).annotate({
4091 description:
4092 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4093 }),
4094 Schema.Null,
4095 ]),
4096 ),
4097 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
4098 data: Schema.String,
4099 mimeType: Schema.String,
4100}).annotate({ description: "Audio provided to or from an LLM." });
4101
4102export type AuthCapabilities = {
4103 readonly _meta?: { readonly [x: string]: unknown } | null;
4104 readonly terminal?: boolean;
4105};
4106export const AuthCapabilities = Schema.Struct({
4107 _meta: Schema.optionalKey(
4108 Schema.Union([
4109 Schema.Record(Schema.String, Schema.Unknown).annotate({
4110 description:
4111 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4112 }),
4113 Schema.Null,
4114 ]),
4115 ),
4116 terminal: Schema.optionalKey(
4117 Schema.Boolean.annotate({
4118 description:
4119 "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.",
4120 default: false,
4121 }),
4122 ),
4123}).annotate({
4124 description:
4125 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.",
4126});
4127
4128export type AuthenticateRequest = {
4129 readonly _meta?: { readonly [x: string]: unknown } | null;
4130 readonly methodId: string;
4131};
4132export const AuthenticateRequest = Schema.Struct({
4133 _meta: Schema.optionalKey(
4134 Schema.Union([
4135 Schema.Record(Schema.String, Schema.Unknown).annotate({
4136 description:
4137 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4138 }),
4139 Schema.Null,
4140 ]),
4141 ),
4142 methodId: Schema.String.annotate({
4143 description:
4144 "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.",
4145 }),
4146}).annotate({
4147 description:
4148 "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.",
4149});
4150
4151export type AuthenticateResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
4152export const AuthenticateResponse = Schema.Struct({
4153 _meta: Schema.optionalKey(
4154 Schema.Union([
4155 Schema.Record(Schema.String, Schema.Unknown).annotate({
4156 description:
4157 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4158 }),
4159 Schema.Null,
4160 ]),
4161 ),
4162}).annotate({ description: "Response to the `authenticate` method." });
4163
4164export type AuthMethodAgent = {
4165 readonly _meta?: { readonly [x: string]: unknown } | null;
4166 readonly description?: string | null;
4167 readonly id: string;
4168 readonly name: string;
4169};
4170export const AuthMethodAgent = Schema.Struct({
4171 _meta: Schema.optionalKey(
4172 Schema.Union([
4173 Schema.Record(Schema.String, Schema.Unknown).annotate({
4174 description:
4175 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4176 }),
4177 Schema.Null,
4178 ]),
4179 ),
4180 description: Schema.optionalKey(
4181 Schema.Union([
4182 Schema.String.annotate({
4183 description:
4184 "Optional description providing more details about this authentication method.",
4185 }),
4186 Schema.Null,
4187 ]),
4188 ),
4189 id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }),
4190 name: Schema.String.annotate({
4191 description: "Human-readable name of the authentication method.",
4192 }),
4193}).annotate({
4194 description:
4195 "Agent handles authentication itself.\n\nThis is the default authentication method type.",
4196});
4197
4198export type AuthMethodEnvVar = {
4199 readonly _meta?: { readonly [x: string]: unknown } | null;
4200 readonly description?: string | null;
4201 readonly id: string;
4202 readonly link?: string | null;
4203 readonly name: string;
4204 readonly vars: ReadonlyArray<AuthEnvVar>;
4205};
4206export const AuthMethodEnvVar = Schema.Struct({
4207 _meta: Schema.optionalKey(
4208 Schema.Union([
4209 Schema.Record(Schema.String, Schema.Unknown).annotate({
4210 description:
4211 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4212 }),
4213 Schema.Null,
4214 ]),
4215 ),
4216 description: Schema.optionalKey(
4217 Schema.Union([
4218 Schema.String.annotate({
4219 description:
4220 "Optional description providing more details about this authentication method.",
4221 }),
4222 Schema.Null,
4223 ]),
4224 ),
4225 id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }),
4226 link: Schema.optionalKey(
4227 Schema.Union([
4228 Schema.String.annotate({
4229 description: "Optional link to a page where the user can obtain their credentials.",
4230 }),
4231 Schema.Null,
4232 ]),
4233 ),
4234 name: Schema.String.annotate({
4235 description: "Human-readable name of the authentication method.",
4236 }),
4237 vars: Schema.Array(AuthEnvVar).annotate({
4238 description: "The environment variables the client should set.",
4239 }),
4240}).annotate({
4241 description:
4242 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nEnvironment variable authentication method.\n\nThe user provides credentials that the client passes to the agent as environment variables.",
4243});
4244
4245export type AuthMethodTerminal = {
4246 readonly _meta?: { readonly [x: string]: unknown } | null;
4247 readonly args?: ReadonlyArray<string>;
4248 readonly description?: string | null;
4249 readonly env?: { readonly [x: string]: string };
4250 readonly id: string;
4251 readonly name: string;
4252};
4253export const AuthMethodTerminal = Schema.Struct({
4254 _meta: Schema.optionalKey(
4255 Schema.Union([
4256 Schema.Record(Schema.String, Schema.Unknown).annotate({
4257 description:
4258 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4259 }),
4260 Schema.Null,
4261 ]),
4262 ),
4263 args: Schema.optionalKey(
4264 Schema.Array(Schema.String).annotate({
4265 description: "Additional arguments to pass when running the agent binary for terminal auth.",
4266 }),
4267 ),
4268 description: Schema.optionalKey(
4269 Schema.Union([
4270 Schema.String.annotate({
4271 description:
4272 "Optional description providing more details about this authentication method.",
4273 }),
4274 Schema.Null,
4275 ]),
4276 ),
4277 env: Schema.optionalKey(
4278 Schema.Record(Schema.String, Schema.String).annotate({
4279 description:
4280 "Additional environment variables to set when running the agent binary for terminal auth.",
4281 }),
4282 ),
4283 id: Schema.String.annotate({ description: "Unique identifier for this authentication method." }),
4284 name: Schema.String.annotate({
4285 description: "Human-readable name of the authentication method.",
4286 }),
4287}).annotate({
4288 description:
4289 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nTerminal-based authentication method.\n\nThe client runs an interactive terminal for the user to authenticate via a TUI.",
4290});
4291
4292export type AvailableCommandsUpdate = {
4293 readonly _meta?: { readonly [x: string]: unknown } | null;
4294 readonly availableCommands: ReadonlyArray<AvailableCommand>;
4295};
4296export const AvailableCommandsUpdate = Schema.Struct({
4297 _meta: Schema.optionalKey(
4298 Schema.Union([
4299 Schema.Record(Schema.String, Schema.Unknown).annotate({
4300 description:
4301 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4302 }),
4303 Schema.Null,
4304 ]),
4305 ),
4306 availableCommands: Schema.Array(AvailableCommand).annotate({
4307 description: "Commands the agent can execute",
4308 }),
4309}).annotate({ description: "Available commands are ready or have changed" });
4310
4311export type BlobResourceContents = {
4312 readonly _meta?: { readonly [x: string]: unknown } | null;
4313 readonly blob: string;
4314 readonly mimeType?: string | null;
4315 readonly uri: string;
4316};
4317export const BlobResourceContents = Schema.Struct({
4318 _meta: Schema.optionalKey(
4319 Schema.Union([
4320 Schema.Record(Schema.String, Schema.Unknown).annotate({
4321 description:
4322 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4323 }),
4324 Schema.Null,
4325 ]),
4326 ),
4327 blob: Schema.String,
4328 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
4329 uri: Schema.String,
4330}).annotate({ description: "Binary resource contents." });
4331
4332export type BooleanPropertySchema = {
4333 readonly default?: boolean | null;
4334 readonly description?: string | null;
4335 readonly title?: string | null;
4336};
4337export const BooleanPropertySchema = Schema.Struct({
4338 default: Schema.optionalKey(
4339 Schema.Union([Schema.Boolean.annotate({ description: "Default value." }), Schema.Null]),
4340 ),
4341 description: Schema.optionalKey(
4342 Schema.Union([
4343 Schema.String.annotate({ description: "Human-readable description." }),
4344 Schema.Null,
4345 ]),
4346 ),
4347 title: Schema.optionalKey(
4348 Schema.Union([
4349 Schema.String.annotate({ description: "Optional title for the property." }),
4350 Schema.Null,
4351 ]),
4352 ),
4353}).annotate({ description: "Schema for boolean properties in an elicitation form." });
4354
4355export type CancelNotification = {
4356 readonly _meta?: { readonly [x: string]: unknown } | null;
4357 readonly sessionId: string;
4358};
4359export const CancelNotification = Schema.Struct({
4360 _meta: Schema.optionalKey(
4361 Schema.Union([
4362 Schema.Record(Schema.String, Schema.Unknown).annotate({
4363 description:
4364 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4365 }),
4366 Schema.Null,
4367 ]),
4368 ),
4369 sessionId: Schema.String.annotate({
4370 description:
4371 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4372 }),
4373}).annotate({
4374 description:
4375 "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)",
4376});
4377
4378export type CancelRequestNotification = {
4379 readonly _meta?: { readonly [x: string]: unknown } | null;
4380 readonly requestId: null | number | string;
4381};
4382export const CancelRequestNotification = Schema.Struct({
4383 _meta: Schema.optionalKey(
4384 Schema.Union([
4385 Schema.Record(Schema.String, Schema.Unknown).annotate({
4386 description:
4387 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4388 }),
4389 Schema.Null,
4390 ]),
4391 ),
4392 requestId: Schema.Union([
4393 Schema.Null.annotate({ title: "Null" }),
4394 Schema.Number.annotate({ title: "Number", format: "int64" }).check(Schema.isInt()),
4395 Schema.String.annotate({ title: "Str" }),
4396 ]).annotate({
4397 description:
4398 "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.",
4399 }),
4400}).annotate({
4401 description:
4402 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification to cancel an ongoing request.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)",
4403});
4404
4405export type ClientCapabilities = {
4406 readonly _meta?: { readonly [x: string]: unknown } | null;
4407 readonly auth?: {
4408 readonly _meta?: { readonly [x: string]: unknown } | null;
4409 readonly terminal?: boolean;
4410 };
4411 readonly elicitation?: ElicitationCapabilities | null;
4412 readonly fs?: {
4413 readonly _meta?: { readonly [x: string]: unknown } | null;
4414 readonly readTextFile?: boolean;
4415 readonly writeTextFile?: boolean;
4416 };
4417 readonly terminal?: boolean;
4418};
4419export const ClientCapabilities = Schema.Struct({
4420 _meta: Schema.optionalKey(
4421 Schema.Union([
4422 Schema.Record(Schema.String, Schema.Unknown).annotate({
4423 description:
4424 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4425 }),
4426 Schema.Null,
4427 ]),
4428 ),
4429 auth: Schema.optionalKey(
4430 Schema.Struct({
4431 _meta: Schema.optionalKey(
4432 Schema.Union([
4433 Schema.Record(Schema.String, Schema.Unknown).annotate({
4434 description:
4435 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4436 }),
4437 Schema.Null,
4438 ]),
4439 ),
4440 terminal: Schema.optionalKey(
4441 Schema.Boolean.annotate({
4442 description:
4443 "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.",
4444 default: false,
4445 }),
4446 ),
4447 }).annotate({
4448 description:
4449 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.",
4450 default: { terminal: false },
4451 }),
4452 ),
4453 elicitation: Schema.optionalKey(
4454 Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({
4455 description:
4456 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.",
4457 }),
4458 ),
4459 fs: Schema.optionalKey(
4460 Schema.Struct({
4461 _meta: Schema.optionalKey(
4462 Schema.Union([
4463 Schema.Record(Schema.String, Schema.Unknown).annotate({
4464 description:
4465 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4466 }),
4467 Schema.Null,
4468 ]),
4469 ),
4470 readTextFile: Schema.optionalKey(
4471 Schema.Boolean.annotate({
4472 description: "Whether the Client supports `fs/read_text_file` requests.",
4473 default: false,
4474 }),
4475 ),
4476 writeTextFile: Schema.optionalKey(
4477 Schema.Boolean.annotate({
4478 description: "Whether the Client supports `fs/write_text_file` requests.",
4479 default: false,
4480 }),
4481 ),
4482 }).annotate({
4483 description:
4484 "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)",
4485 default: { readTextFile: false, writeTextFile: false },
4486 }),
4487 ),
4488 terminal: Schema.optionalKey(
4489 Schema.Boolean.annotate({
4490 description: "Whether the Client support all `terminal/*` methods.",
4491 default: false,
4492 }),
4493 ),
4494}).annotate({
4495 description:
4496 "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)",
4497});
4498
4499export type ClientNotification = {
4500 readonly method: string;
4501 readonly params?:
4502 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly sessionId: string }
4503 | unknown
4504 | null;
4505};
4506export const ClientNotification = Schema.Struct({
4507 method: Schema.String,
4508 params: Schema.optionalKey(
4509 Schema.Union([
4510 Schema.Union([
4511 Schema.Struct({
4512 _meta: Schema.optionalKey(
4513 Schema.Union([
4514 Schema.Record(Schema.String, Schema.Unknown).annotate({
4515 description:
4516 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4517 }),
4518 Schema.Null,
4519 ]),
4520 ),
4521 sessionId: Schema.String.annotate({
4522 description:
4523 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4524 }),
4525 }).annotate({
4526 title: "CancelNotification",
4527 description:
4528 "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)",
4529 }),
4530 Schema.Unknown.annotate({
4531 title: "ExtNotification",
4532 description:
4533 "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4534 }),
4535 ]).annotate({
4536 description:
4537 "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response.",
4538 }),
4539 Schema.Null,
4540 ]),
4541 ),
4542});
4543
4544export type ClientRequest = {
4545 readonly id: RequestId;
4546 readonly method: string;
4547 readonly params?:
4548 | {
4549 readonly _meta?: { readonly [x: string]: unknown } | null;
4550 readonly clientCapabilities?: {
4551 readonly _meta?: { readonly [x: string]: unknown } | null;
4552 readonly auth?: {
4553 readonly _meta?: { readonly [x: string]: unknown } | null;
4554 readonly terminal?: boolean;
4555 };
4556 readonly elicitation?: ElicitationCapabilities | null;
4557 readonly fs?: {
4558 readonly _meta?: { readonly [x: string]: unknown } | null;
4559 readonly readTextFile?: boolean;
4560 readonly writeTextFile?: boolean;
4561 };
4562 readonly terminal?: boolean;
4563 };
4564 readonly clientInfo?: Implementation | null;
4565 readonly protocolVersion: number;
4566 }
4567 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly methodId: string }
4568 | { readonly _meta?: { readonly [x: string]: unknown } | null }
4569 | {
4570 readonly _meta?: { readonly [x: string]: unknown } | null;
4571 readonly cwd: string;
4572 readonly mcpServers: ReadonlyArray<McpServer>;
4573 }
4574 | {
4575 readonly _meta?: { readonly [x: string]: unknown } | null;
4576 readonly cwd: string;
4577 readonly mcpServers: ReadonlyArray<McpServer>;
4578 readonly sessionId: string;
4579 }
4580 | {
4581 readonly _meta?: { readonly [x: string]: unknown } | null;
4582 readonly cursor?: string | null;
4583 readonly cwd?: string | null;
4584 }
4585 | {
4586 readonly _meta?: { readonly [x: string]: unknown } | null;
4587 readonly cwd: string;
4588 readonly mcpServers?: ReadonlyArray<McpServer>;
4589 readonly sessionId: string;
4590 }
4591 | {
4592 readonly _meta?: { readonly [x: string]: unknown } | null;
4593 readonly cwd: string;
4594 readonly mcpServers?: ReadonlyArray<McpServer>;
4595 readonly sessionId: string;
4596 }
4597 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly sessionId: string }
4598 | {
4599 readonly _meta?: { readonly [x: string]: unknown } | null;
4600 readonly modeId: string;
4601 readonly sessionId: string;
4602 }
4603 | {
4604 readonly type: "boolean";
4605 readonly value: boolean;
4606 readonly _meta?: { readonly [x: string]: unknown } | null;
4607 readonly configId: string;
4608 readonly sessionId: string;
4609 }
4610 | {
4611 readonly value: string;
4612 readonly _meta?: { readonly [x: string]: unknown } | null;
4613 readonly configId: string;
4614 readonly sessionId: string;
4615 }
4616 | {
4617 readonly _meta?: { readonly [x: string]: unknown } | null;
4618 readonly messageId?: string | null;
4619 readonly prompt: ReadonlyArray<ContentBlock>;
4620 readonly sessionId: string;
4621 }
4622 | {
4623 readonly _meta?: { readonly [x: string]: unknown } | null;
4624 readonly modelId: string;
4625 readonly sessionId: string;
4626 }
4627 | unknown
4628 | null;
4629};
4630export const ClientRequest = Schema.Struct({
4631 id: RequestId,
4632 method: Schema.String,
4633 params: Schema.optionalKey(
4634 Schema.Union([
4635 Schema.Union([
4636 Schema.Struct({
4637 _meta: Schema.optionalKey(
4638 Schema.Union([
4639 Schema.Record(Schema.String, Schema.Unknown).annotate({
4640 description:
4641 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4642 }),
4643 Schema.Null,
4644 ]),
4645 ),
4646 clientCapabilities: Schema.optionalKey(
4647 Schema.Struct({
4648 _meta: Schema.optionalKey(
4649 Schema.Union([
4650 Schema.Record(Schema.String, Schema.Unknown).annotate({
4651 description:
4652 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4653 }),
4654 Schema.Null,
4655 ]),
4656 ),
4657 auth: Schema.optionalKey(
4658 Schema.Struct({
4659 _meta: Schema.optionalKey(
4660 Schema.Union([
4661 Schema.Record(Schema.String, Schema.Unknown).annotate({
4662 description:
4663 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4664 }),
4665 Schema.Null,
4666 ]),
4667 ),
4668 terminal: Schema.optionalKey(
4669 Schema.Boolean.annotate({
4670 description:
4671 "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.",
4672 default: false,
4673 }),
4674 ),
4675 }).annotate({
4676 description:
4677 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.",
4678 default: { terminal: false },
4679 }),
4680 ),
4681 elicitation: Schema.optionalKey(
4682 Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({
4683 description:
4684 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.",
4685 }),
4686 ),
4687 fs: Schema.optionalKey(
4688 Schema.Struct({
4689 _meta: Schema.optionalKey(
4690 Schema.Union([
4691 Schema.Record(Schema.String, Schema.Unknown).annotate({
4692 description:
4693 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4694 }),
4695 Schema.Null,
4696 ]),
4697 ),
4698 readTextFile: Schema.optionalKey(
4699 Schema.Boolean.annotate({
4700 description: "Whether the Client supports `fs/read_text_file` requests.",
4701 default: false,
4702 }),
4703 ),
4704 writeTextFile: Schema.optionalKey(
4705 Schema.Boolean.annotate({
4706 description: "Whether the Client supports `fs/write_text_file` requests.",
4707 default: false,
4708 }),
4709 ),
4710 }).annotate({
4711 description:
4712 "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)",
4713 default: { readTextFile: false, writeTextFile: false },
4714 }),
4715 ),
4716 terminal: Schema.optionalKey(
4717 Schema.Boolean.annotate({
4718 description: "Whether the Client support all `terminal/*` methods.",
4719 default: false,
4720 }),
4721 ),
4722 }).annotate({
4723 description:
4724 "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)",
4725 default: {
4726 auth: { terminal: false },
4727 fs: { readTextFile: false, writeTextFile: false },
4728 terminal: false,
4729 },
4730 }),
4731 ),
4732 clientInfo: Schema.optionalKey(
4733 Schema.Union([Implementation, Schema.Null]).annotate({
4734 description:
4735 "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required.",
4736 }),
4737 ),
4738 protocolVersion: Schema.Number.annotate({
4739 description:
4740 "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.",
4741 format: "uint16",
4742 })
4743 .check(Schema.isInt())
4744 .check(Schema.isGreaterThanOrEqualTo(0))
4745 .check(Schema.isLessThanOrEqualTo(65535)),
4746 }).annotate({
4747 title: "InitializeRequest",
4748 description:
4749 "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)",
4750 }),
4751 Schema.Struct({
4752 _meta: Schema.optionalKey(
4753 Schema.Union([
4754 Schema.Record(Schema.String, Schema.Unknown).annotate({
4755 description:
4756 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4757 }),
4758 Schema.Null,
4759 ]),
4760 ),
4761 methodId: Schema.String.annotate({
4762 description:
4763 "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.",
4764 }),
4765 }).annotate({
4766 title: "AuthenticateRequest",
4767 description:
4768 "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.",
4769 }),
4770 Schema.Struct({
4771 _meta: Schema.optionalKey(
4772 Schema.Union([
4773 Schema.Record(Schema.String, Schema.Unknown).annotate({
4774 description:
4775 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4776 }),
4777 Schema.Null,
4778 ]),
4779 ),
4780 }).annotate({
4781 title: "LogoutRequest",
4782 description:
4783 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for the logout method.\n\nTerminates the current authenticated session.",
4784 }),
4785 Schema.Struct({
4786 _meta: Schema.optionalKey(
4787 Schema.Union([
4788 Schema.Record(Schema.String, Schema.Unknown).annotate({
4789 description:
4790 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4791 }),
4792 Schema.Null,
4793 ]),
4794 ),
4795 cwd: Schema.String.annotate({
4796 description: "The working directory for this session. Must be an absolute path.",
4797 }),
4798 mcpServers: Schema.Array(McpServer).annotate({
4799 description:
4800 "List of MCP (Model Context Protocol) servers the agent should connect to.",
4801 }),
4802 }).annotate({
4803 title: "NewSessionRequest",
4804 description:
4805 "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)",
4806 }),
4807 Schema.Struct({
4808 _meta: Schema.optionalKey(
4809 Schema.Union([
4810 Schema.Record(Schema.String, Schema.Unknown).annotate({
4811 description:
4812 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4813 }),
4814 Schema.Null,
4815 ]),
4816 ),
4817 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
4818 mcpServers: Schema.Array(McpServer).annotate({
4819 description: "List of MCP servers to connect to for this session.",
4820 }),
4821 sessionId: Schema.String.annotate({
4822 description:
4823 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4824 }),
4825 }).annotate({
4826 title: "LoadSessionRequest",
4827 description:
4828 "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)",
4829 }),
4830 Schema.Struct({
4831 _meta: Schema.optionalKey(
4832 Schema.Union([
4833 Schema.Record(Schema.String, Schema.Unknown).annotate({
4834 description:
4835 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4836 }),
4837 Schema.Null,
4838 ]),
4839 ),
4840 cursor: Schema.optionalKey(
4841 Schema.Union([
4842 Schema.String.annotate({
4843 description:
4844 "Opaque cursor token from a previous response's nextCursor field for cursor-based pagination",
4845 }),
4846 Schema.Null,
4847 ]),
4848 ),
4849 cwd: Schema.optionalKey(
4850 Schema.Union([
4851 Schema.String.annotate({
4852 description: "Filter sessions by working directory. Must be an absolute path.",
4853 }),
4854 Schema.Null,
4855 ]),
4856 ),
4857 }).annotate({
4858 title: "ListSessionsRequest",
4859 description:
4860 "Request parameters for listing existing sessions.\n\nOnly available if the Agent supports the `sessionCapabilities.list` capability.",
4861 }),
4862 Schema.Struct({
4863 _meta: Schema.optionalKey(
4864 Schema.Union([
4865 Schema.Record(Schema.String, Schema.Unknown).annotate({
4866 description:
4867 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4868 }),
4869 Schema.Null,
4870 ]),
4871 ),
4872 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
4873 mcpServers: Schema.optionalKey(
4874 Schema.Array(McpServer).annotate({
4875 description: "List of MCP servers to connect to for this session.",
4876 }),
4877 ),
4878 sessionId: Schema.String.annotate({
4879 description:
4880 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4881 }),
4882 }).annotate({
4883 title: "ForkSessionRequest",
4884 description:
4885 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for forking an existing session.\n\nCreates a new session based on the context of an existing one, allowing\noperations like generating summaries without affecting the original session's history.\n\nOnly available if the Agent supports the `session.fork` capability.",
4886 }),
4887 Schema.Struct({
4888 _meta: Schema.optionalKey(
4889 Schema.Union([
4890 Schema.Record(Schema.String, Schema.Unknown).annotate({
4891 description:
4892 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4893 }),
4894 Schema.Null,
4895 ]),
4896 ),
4897 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
4898 mcpServers: Schema.optionalKey(
4899 Schema.Array(McpServer).annotate({
4900 description: "List of MCP servers to connect to for this session.",
4901 }),
4902 ),
4903 sessionId: Schema.String.annotate({
4904 description:
4905 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4906 }),
4907 }).annotate({
4908 title: "ResumeSessionRequest",
4909 description:
4910 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for resuming an existing session.\n\nResumes an existing session without returning previous messages (unlike `session/load`).\nThis is useful for agents that can resume sessions but don't implement full session loading.\n\nOnly available if the Agent supports the `session.resume` capability.",
4911 }),
4912 Schema.Struct({
4913 _meta: Schema.optionalKey(
4914 Schema.Union([
4915 Schema.Record(Schema.String, Schema.Unknown).annotate({
4916 description:
4917 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4918 }),
4919 Schema.Null,
4920 ]),
4921 ),
4922 sessionId: Schema.String.annotate({
4923 description:
4924 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4925 }),
4926 }).annotate({
4927 title: "CloseSessionRequest",
4928 description:
4929 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for closing an active session.\n\nIf supported, the agent **must** cancel any ongoing work related to the session\n(treat it as if `session/cancel` was called) and then free up any resources\nassociated with the session.\n\nOnly available if the Agent supports the `session.close` capability.",
4930 }),
4931 Schema.Struct({
4932 _meta: Schema.optionalKey(
4933 Schema.Union([
4934 Schema.Record(Schema.String, Schema.Unknown).annotate({
4935 description:
4936 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4937 }),
4938 Schema.Null,
4939 ]),
4940 ),
4941 modeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }),
4942 sessionId: Schema.String.annotate({
4943 description:
4944 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4945 }),
4946 }).annotate({
4947 title: "SetSessionModeRequest",
4948 description: "Request parameters for setting a session mode.",
4949 }),
4950 Schema.Union([
4951 Schema.Struct({
4952 type: Schema.Literal("boolean"),
4953 value: Schema.Boolean.annotate({ description: "The boolean value." }),
4954 _meta: Schema.optionalKey(
4955 Schema.Union([
4956 Schema.Record(Schema.String, Schema.Unknown).annotate({
4957 description:
4958 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4959 }),
4960 Schema.Null,
4961 ]),
4962 ),
4963 configId: Schema.String.annotate({
4964 description: "Unique identifier for a session configuration option.",
4965 }),
4966 sessionId: Schema.String.annotate({
4967 description:
4968 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4969 }),
4970 }).annotate({
4971 description: "Request parameters for setting a session configuration option.",
4972 }),
4973 Schema.Struct({
4974 value: Schema.String.annotate({
4975 description: "Unique identifier for a session configuration option value.",
4976 }),
4977 _meta: Schema.optionalKey(
4978 Schema.Union([
4979 Schema.Record(Schema.String, Schema.Unknown).annotate({
4980 description:
4981 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
4982 }),
4983 Schema.Null,
4984 ]),
4985 ),
4986 configId: Schema.String.annotate({
4987 description: "Unique identifier for a session configuration option.",
4988 }),
4989 sessionId: Schema.String.annotate({
4990 description:
4991 "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)",
4992 }),
4993 }).annotate({
4994 title: "value_id",
4995 description: "Request parameters for setting a session configuration option.",
4996 }),
4997 ]).annotate({
4998 title: "SetSessionConfigOptionRequest",
4999 description: "Sets the current value for a session configuration option.",
5000 }),
5001 Schema.Struct({
5002 _meta: Schema.optionalKey(
5003 Schema.Union([
5004 Schema.Record(Schema.String, Schema.Unknown).annotate({
5005 description:
5006 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5007 }),
5008 Schema.Null,
5009 ]),
5010 ),
5011 messageId: Schema.optionalKey(
5012 Schema.Union([
5013 Schema.String.annotate({
5014 description:
5015 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA client-generated unique identifier for this user message.\n\nIf provided, the Agent SHOULD echo this value as `userMessageId` in the\n[`PromptResponse`] to confirm it was recorded.\nBoth clients and agents MUST use UUID format for message IDs.",
5016 }),
5017 Schema.Null,
5018 ]),
5019 ),
5020 prompt: Schema.Array(ContentBlock).annotate({
5021 description:
5022 "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.",
5023 }),
5024 sessionId: Schema.String.annotate({
5025 description:
5026 "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)",
5027 }),
5028 }).annotate({
5029 title: "PromptRequest",
5030 description:
5031 "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)",
5032 }),
5033 Schema.Struct({
5034 _meta: Schema.optionalKey(
5035 Schema.Union([
5036 Schema.Record(Schema.String, Schema.Unknown).annotate({
5037 description:
5038 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5039 }),
5040 Schema.Null,
5041 ]),
5042 ),
5043 modelId: Schema.String.annotate({
5044 description:
5045 "**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.",
5046 }),
5047 sessionId: Schema.String.annotate({
5048 description:
5049 "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)",
5050 }),
5051 }).annotate({
5052 title: "SetSessionModelRequest",
5053 description:
5054 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for setting a session model.",
5055 }),
5056 Schema.Unknown.annotate({
5057 title: "ExtMethodRequest",
5058 description:
5059 "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)",
5060 }),
5061 ]).annotate({
5062 description:
5063 "All possible requests that a client can send to an agent.\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 [`Agent`] trait.\n\nThis enum encompasses all method calls from client to agent.",
5064 }),
5065 Schema.Null,
5066 ]),
5067 ),
5068});
5069
5070export type ClientResponse =
5071 | {
5072 readonly id: RequestId;
5073 readonly result:
5074 | { readonly _meta?: { readonly [x: string]: unknown } | null }
5075 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly content: string }
5076 | {
5077 readonly _meta?: { readonly [x: string]: unknown } | null;
5078 readonly outcome:
5079 | { readonly outcome: "cancelled" }
5080 | {
5081 readonly outcome: "selected";
5082 readonly _meta?: { readonly [x: string]: unknown } | null;
5083 readonly optionId: string;
5084 };
5085 }
5086 | { readonly _meta?: { readonly [x: string]: unknown } | null; readonly terminalId: string }
5087 | {
5088 readonly _meta?: { readonly [x: string]: unknown } | null;
5089 readonly exitStatus?: TerminalExitStatus | null;
5090 readonly output: string;
5091 readonly truncated: boolean;
5092 }
5093 | { readonly _meta?: { readonly [x: string]: unknown } | null }
5094 | {
5095 readonly _meta?: { readonly [x: string]: unknown } | null;
5096 readonly exitCode?: number | null;
5097 readonly signal?: string | null;
5098 }
5099 | { readonly _meta?: { readonly [x: string]: unknown } | null }
5100 | {
5101 readonly _meta?: { readonly [x: string]: unknown } | null;
5102 readonly action:
5103 | {
5104 readonly action: "accept";
5105 readonly content?: { readonly [x: string]: ElicitationContentValue } | null;
5106 }
5107 | { readonly action: "decline" }
5108 | { readonly action: "cancel" };
5109 }
5110 | unknown;
5111 }
5112 | { readonly error: Error; readonly id: RequestId };
5113export const ClientResponse = Schema.Union([
5114 Schema.Struct({
5115 id: RequestId,
5116 result: Schema.Union([
5117 Schema.Struct({
5118 _meta: Schema.optionalKey(
5119 Schema.Union([
5120 Schema.Record(Schema.String, Schema.Unknown).annotate({
5121 description:
5122 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5123 }),
5124 Schema.Null,
5125 ]),
5126 ),
5127 }).annotate({
5128 title: "WriteTextFileResponse",
5129 description: "Response to `fs/write_text_file`",
5130 }),
5131 Schema.Struct({
5132 _meta: Schema.optionalKey(
5133 Schema.Union([
5134 Schema.Record(Schema.String, Schema.Unknown).annotate({
5135 description:
5136 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5137 }),
5138 Schema.Null,
5139 ]),
5140 ),
5141 content: Schema.String,
5142 }).annotate({
5143 title: "ReadTextFileResponse",
5144 description: "Response containing the contents of a text file.",
5145 }),
5146 Schema.Struct({
5147 _meta: Schema.optionalKey(
5148 Schema.Union([
5149 Schema.Record(Schema.String, Schema.Unknown).annotate({
5150 description:
5151 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5152 }),
5153 Schema.Null,
5154 ]),
5155 ),
5156 outcome: Schema.Union(
5157 [
5158 Schema.Struct({ outcome: Schema.Literal("cancelled") }).annotate({
5159 description:
5160 "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)",
5161 }),
5162 Schema.Struct({
5163 outcome: Schema.Literal("selected"),
5164 _meta: Schema.optionalKey(
5165 Schema.Union([
5166 Schema.Record(Schema.String, Schema.Unknown).annotate({
5167 description:
5168 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5169 }),
5170 Schema.Null,
5171 ]),
5172 ),
5173 optionId: Schema.String.annotate({
5174 description: "Unique identifier for a permission option.",
5175 }),
5176 }).annotate({ description: "The user selected one of the provided options." }),
5177 ],
5178 { mode: "oneOf" },
5179 ).annotate({ description: "The outcome of a permission request." }),
5180 }).annotate({
5181 title: "RequestPermissionResponse",
5182 description: "Response to a permission request.",
5183 }),
5184 Schema.Struct({
5185 _meta: Schema.optionalKey(
5186 Schema.Union([
5187 Schema.Record(Schema.String, Schema.Unknown).annotate({
5188 description:
5189 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5190 }),
5191 Schema.Null,
5192 ]),
5193 ),
5194 terminalId: Schema.String.annotate({
5195 description: "The unique identifier for the created terminal.",
5196 }),
5197 }).annotate({
5198 title: "CreateTerminalResponse",
5199 description: "Response containing the ID of the created terminal.",
5200 }),
5201 Schema.Struct({
5202 _meta: Schema.optionalKey(
5203 Schema.Union([
5204 Schema.Record(Schema.String, Schema.Unknown).annotate({
5205 description:
5206 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5207 }),
5208 Schema.Null,
5209 ]),
5210 ),
5211 exitStatus: Schema.optionalKey(
5212 Schema.Union([TerminalExitStatus, Schema.Null]).annotate({
5213 description: "Exit status if the command has completed.",
5214 }),
5215 ),
5216 output: Schema.String.annotate({ description: "The terminal output captured so far." }),
5217 truncated: Schema.Boolean.annotate({
5218 description: "Whether the output was truncated due to byte limits.",
5219 }),
5220 }).annotate({
5221 title: "TerminalOutputResponse",
5222 description: "Response containing the terminal output and exit status.",
5223 }),
5224 Schema.Struct({
5225 _meta: Schema.optionalKey(
5226 Schema.Union([
5227 Schema.Record(Schema.String, Schema.Unknown).annotate({
5228 description:
5229 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5230 }),
5231 Schema.Null,
5232 ]),
5233 ),
5234 }).annotate({
5235 title: "ReleaseTerminalResponse",
5236 description: "Response to terminal/release method",
5237 }),
5238 Schema.Struct({
5239 _meta: Schema.optionalKey(
5240 Schema.Union([
5241 Schema.Record(Schema.String, Schema.Unknown).annotate({
5242 description:
5243 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5244 }),
5245 Schema.Null,
5246 ]),
5247 ),
5248 exitCode: Schema.optionalKey(
5249 Schema.Union([
5250 Schema.Number.annotate({
5251 description: "The process exit code (may be null if terminated by signal).",
5252 format: "uint32",
5253 })
5254 .check(Schema.isInt())
5255 .check(Schema.isGreaterThanOrEqualTo(0)),
5256 Schema.Null,
5257 ]),
5258 ),
5259 signal: Schema.optionalKey(
5260 Schema.Union([
5261 Schema.String.annotate({
5262 description:
5263 "The signal that terminated the process (may be null if exited normally).",
5264 }),
5265 Schema.Null,
5266 ]),
5267 ),
5268 }).annotate({
5269 title: "WaitForTerminalExitResponse",
5270 description: "Response containing the exit status of a terminal command.",
5271 }),
5272 Schema.Struct({
5273 _meta: Schema.optionalKey(
5274 Schema.Union([
5275 Schema.Record(Schema.String, Schema.Unknown).annotate({
5276 description:
5277 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5278 }),
5279 Schema.Null,
5280 ]),
5281 ),
5282 }).annotate({
5283 title: "KillTerminalResponse",
5284 description: "Response to `terminal/kill` method",
5285 }),
5286 Schema.Struct({
5287 _meta: Schema.optionalKey(
5288 Schema.Union([
5289 Schema.Record(Schema.String, Schema.Unknown).annotate({
5290 description:
5291 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5292 }),
5293 Schema.Null,
5294 ]),
5295 ),
5296 action: Schema.Union(
5297 [
5298 Schema.Struct({
5299 action: Schema.Literal("accept"),
5300 content: Schema.optionalKey(
5301 Schema.Union([
5302 Schema.Record(Schema.String, ElicitationContentValue).annotate({
5303 description:
5304 "The user-provided content, if any, as an object matching the requested schema.",
5305 }),
5306 Schema.Null,
5307 ]),
5308 ),
5309 }).annotate({
5310 description:
5311 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.",
5312 }),
5313 Schema.Struct({ action: Schema.Literal("decline") }).annotate({
5314 description: "The user declined the elicitation.",
5315 }),
5316 Schema.Struct({ action: Schema.Literal("cancel") }).annotate({
5317 description: "The elicitation was cancelled.",
5318 }),
5319 ],
5320 { mode: "oneOf" },
5321 ).annotate({
5322 description:
5323 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user's action in response to an elicitation.",
5324 }),
5325 }).annotate({
5326 title: "ElicitationResponse",
5327 description:
5328 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from the client to an elicitation request.",
5329 }),
5330 Schema.Unknown.annotate({
5331 title: "ExtMethodResponse",
5332 description:
5333 "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)",
5334 }),
5335 ]).annotate({
5336 description:
5337 "All possible responses that a client can send to an agent.\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 `AgentRequest` variants.",
5338 }),
5339 }).annotate({ title: "Result" }),
5340 Schema.Struct({ error: Error, id: RequestId }).annotate({ title: "Error" }),
5341]);
5342
5343export type CloseSessionRequest = {
5344 readonly _meta?: { readonly [x: string]: unknown } | null;
5345 readonly sessionId: string;
5346};
5347export const CloseSessionRequest = Schema.Struct({
5348 _meta: Schema.optionalKey(
5349 Schema.Union([
5350 Schema.Record(Schema.String, Schema.Unknown).annotate({
5351 description:
5352 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5353 }),
5354 Schema.Null,
5355 ]),
5356 ),
5357 sessionId: Schema.String.annotate({
5358 description:
5359 "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)",
5360 }),
5361}).annotate({
5362 description:
5363 "**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.",
5364});
5365
5366export type CloseSessionResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
5367export const CloseSessionResponse = Schema.Struct({
5368 _meta: Schema.optionalKey(
5369 Schema.Union([
5370 Schema.Record(Schema.String, Schema.Unknown).annotate({
5371 description:
5372 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5373 }),
5374 Schema.Null,
5375 ]),
5376 ),
5377}).annotate({
5378 description:
5379 "**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.",
5380});
5381
5382export type ConfigOptionUpdate = {
5383 readonly _meta?: { readonly [x: string]: unknown } | null;
5384 readonly configOptions: ReadonlyArray<SessionConfigOption>;
5385};
5386export const ConfigOptionUpdate = Schema.Struct({
5387 _meta: Schema.optionalKey(
5388 Schema.Union([
5389 Schema.Record(Schema.String, Schema.Unknown).annotate({
5390 description:
5391 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5392 }),
5393 Schema.Null,
5394 ]),
5395 ),
5396 configOptions: Schema.Array(SessionConfigOption).annotate({
5397 description: "The full set of configuration options and their current values.",
5398 }),
5399}).annotate({ description: "Session configuration options have been updated." });
5400
5401export type Content = {
5402 readonly _meta?: { readonly [x: string]: unknown } | null;
5403 readonly content:
5404 | {
5405 readonly type: "text";
5406 readonly _meta?: { readonly [x: string]: unknown } | null;
5407 readonly annotations?: Annotations | null;
5408 readonly text: string;
5409 }
5410 | {
5411 readonly type: "image";
5412 readonly _meta?: { readonly [x: string]: unknown } | null;
5413 readonly annotations?: Annotations | null;
5414 readonly data: string;
5415 readonly mimeType: string;
5416 readonly uri?: string | null;
5417 }
5418 | {
5419 readonly type: "audio";
5420 readonly _meta?: { readonly [x: string]: unknown } | null;
5421 readonly annotations?: Annotations | null;
5422 readonly data: string;
5423 readonly mimeType: string;
5424 }
5425 | {
5426 readonly type: "resource_link";
5427 readonly _meta?: { readonly [x: string]: unknown } | null;
5428 readonly annotations?: Annotations | null;
5429 readonly description?: string | null;
5430 readonly mimeType?: string | null;
5431 readonly name: string;
5432 readonly size?: number | null;
5433 readonly title?: string | null;
5434 readonly uri: string;
5435 }
5436 | {
5437 readonly type: "resource";
5438 readonly _meta?: { readonly [x: string]: unknown } | null;
5439 readonly annotations?: Annotations | null;
5440 readonly resource: EmbeddedResourceResource;
5441 };
5442};
5443export const Content = Schema.Struct({
5444 _meta: Schema.optionalKey(
5445 Schema.Union([
5446 Schema.Record(Schema.String, Schema.Unknown).annotate({
5447 description:
5448 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5449 }),
5450 Schema.Null,
5451 ]),
5452 ),
5453 content: Schema.Union(
5454 [
5455 Schema.Struct({
5456 type: Schema.Literal("text"),
5457 _meta: Schema.optionalKey(
5458 Schema.Union([
5459 Schema.Record(Schema.String, Schema.Unknown).annotate({
5460 description:
5461 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5462 }),
5463 Schema.Null,
5464 ]),
5465 ),
5466 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5467 text: Schema.String,
5468 }).annotate({ description: "Text provided to or from an LLM." }),
5469 Schema.Struct({
5470 type: Schema.Literal("image"),
5471 _meta: Schema.optionalKey(
5472 Schema.Union([
5473 Schema.Record(Schema.String, Schema.Unknown).annotate({
5474 description:
5475 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5476 }),
5477 Schema.Null,
5478 ]),
5479 ),
5480 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5481 data: Schema.String,
5482 mimeType: Schema.String,
5483 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5484 }).annotate({ description: "An image provided to or from an LLM." }),
5485 Schema.Struct({
5486 type: Schema.Literal("audio"),
5487 _meta: Schema.optionalKey(
5488 Schema.Union([
5489 Schema.Record(Schema.String, Schema.Unknown).annotate({
5490 description:
5491 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5492 }),
5493 Schema.Null,
5494 ]),
5495 ),
5496 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5497 data: Schema.String,
5498 mimeType: Schema.String,
5499 }).annotate({ description: "Audio provided to or from an LLM." }),
5500 Schema.Struct({
5501 type: Schema.Literal("resource_link"),
5502 _meta: Schema.optionalKey(
5503 Schema.Union([
5504 Schema.Record(Schema.String, Schema.Unknown).annotate({
5505 description:
5506 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5507 }),
5508 Schema.Null,
5509 ]),
5510 ),
5511 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5512 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5513 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5514 name: Schema.String,
5515 size: Schema.optionalKey(
5516 Schema.Union([
5517 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
5518 Schema.Null,
5519 ]),
5520 ),
5521 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5522 uri: Schema.String,
5523 }).annotate({
5524 description:
5525 "A resource that the server is capable of reading, included in a prompt or tool call result.",
5526 }),
5527 Schema.Struct({
5528 type: Schema.Literal("resource"),
5529 _meta: Schema.optionalKey(
5530 Schema.Union([
5531 Schema.Record(Schema.String, Schema.Unknown).annotate({
5532 description:
5533 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5534 }),
5535 Schema.Null,
5536 ]),
5537 ),
5538 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5539 resource: EmbeddedResourceResource,
5540 }).annotate({
5541 description: "The contents of a resource, embedded into a prompt or tool call result.",
5542 }),
5543 ],
5544 { mode: "oneOf" },
5545 ).annotate({
5546 description:
5547 "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)",
5548 }),
5549}).annotate({ description: "Standard content block (text, images, resources)." });
5550
5551export type ContentChunk = {
5552 readonly _meta?: { readonly [x: string]: unknown } | null;
5553 readonly content:
5554 | {
5555 readonly type: "text";
5556 readonly _meta?: { readonly [x: string]: unknown } | null;
5557 readonly annotations?: Annotations | null;
5558 readonly text: string;
5559 }
5560 | {
5561 readonly type: "image";
5562 readonly _meta?: { readonly [x: string]: unknown } | null;
5563 readonly annotations?: Annotations | null;
5564 readonly data: string;
5565 readonly mimeType: string;
5566 readonly uri?: string | null;
5567 }
5568 | {
5569 readonly type: "audio";
5570 readonly _meta?: { readonly [x: string]: unknown } | null;
5571 readonly annotations?: Annotations | null;
5572 readonly data: string;
5573 readonly mimeType: string;
5574 }
5575 | {
5576 readonly type: "resource_link";
5577 readonly _meta?: { readonly [x: string]: unknown } | null;
5578 readonly annotations?: Annotations | null;
5579 readonly description?: string | null;
5580 readonly mimeType?: string | null;
5581 readonly name: string;
5582 readonly size?: number | null;
5583 readonly title?: string | null;
5584 readonly uri: string;
5585 }
5586 | {
5587 readonly type: "resource";
5588 readonly _meta?: { readonly [x: string]: unknown } | null;
5589 readonly annotations?: Annotations | null;
5590 readonly resource: EmbeddedResourceResource;
5591 };
5592 readonly messageId?: string | null;
5593};
5594export const ContentChunk = Schema.Struct({
5595 _meta: Schema.optionalKey(
5596 Schema.Union([
5597 Schema.Record(Schema.String, Schema.Unknown).annotate({
5598 description:
5599 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5600 }),
5601 Schema.Null,
5602 ]),
5603 ),
5604 content: Schema.Union(
5605 [
5606 Schema.Struct({
5607 type: Schema.Literal("text"),
5608 _meta: Schema.optionalKey(
5609 Schema.Union([
5610 Schema.Record(Schema.String, Schema.Unknown).annotate({
5611 description:
5612 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5613 }),
5614 Schema.Null,
5615 ]),
5616 ),
5617 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5618 text: Schema.String,
5619 }).annotate({ description: "Text provided to or from an LLM." }),
5620 Schema.Struct({
5621 type: Schema.Literal("image"),
5622 _meta: Schema.optionalKey(
5623 Schema.Union([
5624 Schema.Record(Schema.String, Schema.Unknown).annotate({
5625 description:
5626 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5627 }),
5628 Schema.Null,
5629 ]),
5630 ),
5631 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5632 data: Schema.String,
5633 mimeType: Schema.String,
5634 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5635 }).annotate({ description: "An image provided to or from an LLM." }),
5636 Schema.Struct({
5637 type: Schema.Literal("audio"),
5638 _meta: Schema.optionalKey(
5639 Schema.Union([
5640 Schema.Record(Schema.String, Schema.Unknown).annotate({
5641 description:
5642 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5643 }),
5644 Schema.Null,
5645 ]),
5646 ),
5647 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5648 data: Schema.String,
5649 mimeType: Schema.String,
5650 }).annotate({ description: "Audio provided to or from an LLM." }),
5651 Schema.Struct({
5652 type: Schema.Literal("resource_link"),
5653 _meta: Schema.optionalKey(
5654 Schema.Union([
5655 Schema.Record(Schema.String, Schema.Unknown).annotate({
5656 description:
5657 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5658 }),
5659 Schema.Null,
5660 ]),
5661 ),
5662 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5663 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5664 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5665 name: Schema.String,
5666 size: Schema.optionalKey(
5667 Schema.Union([
5668 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
5669 Schema.Null,
5670 ]),
5671 ),
5672 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
5673 uri: Schema.String,
5674 }).annotate({
5675 description:
5676 "A resource that the server is capable of reading, included in a prompt or tool call result.",
5677 }),
5678 Schema.Struct({
5679 type: Schema.Literal("resource"),
5680 _meta: Schema.optionalKey(
5681 Schema.Union([
5682 Schema.Record(Schema.String, Schema.Unknown).annotate({
5683 description:
5684 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5685 }),
5686 Schema.Null,
5687 ]),
5688 ),
5689 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
5690 resource: EmbeddedResourceResource,
5691 }).annotate({
5692 description: "The contents of a resource, embedded into a prompt or tool call result.",
5693 }),
5694 ],
5695 { mode: "oneOf" },
5696 ).annotate({
5697 description:
5698 "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)",
5699 }),
5700 messageId: Schema.optionalKey(
5701 Schema.Union([
5702 Schema.String.annotate({
5703 description:
5704 "**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.",
5705 }),
5706 Schema.Null,
5707 ]),
5708 ),
5709}).annotate({ description: "A streamed item of content" });
5710
5711export type CreateTerminalRequest = {
5712 readonly _meta?: { readonly [x: string]: unknown } | null;
5713 readonly args?: ReadonlyArray<string>;
5714 readonly command: string;
5715 readonly cwd?: string | null;
5716 readonly env?: ReadonlyArray<EnvVariable>;
5717 readonly outputByteLimit?: number | null;
5718 readonly sessionId: string;
5719};
5720export const CreateTerminalRequest = Schema.Struct({
5721 _meta: Schema.optionalKey(
5722 Schema.Union([
5723 Schema.Record(Schema.String, Schema.Unknown).annotate({
5724 description:
5725 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5726 }),
5727 Schema.Null,
5728 ]),
5729 ),
5730 args: Schema.optionalKey(
5731 Schema.Array(Schema.String).annotate({ description: "Array of command arguments." }),
5732 ),
5733 command: Schema.String.annotate({ description: "The command to execute." }),
5734 cwd: Schema.optionalKey(
5735 Schema.Union([
5736 Schema.String.annotate({ description: "Working directory for the command (absolute path)." }),
5737 Schema.Null,
5738 ]),
5739 ),
5740 env: Schema.optionalKey(
5741 Schema.Array(EnvVariable).annotate({ description: "Environment variables for the command." }),
5742 ),
5743 outputByteLimit: Schema.optionalKey(
5744 Schema.Union([
5745 Schema.Number.annotate({
5746 description:
5747 "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.",
5748 format: "uint64",
5749 })
5750 .check(Schema.isInt())
5751 .check(Schema.isGreaterThanOrEqualTo(0)),
5752 Schema.Null,
5753 ]),
5754 ),
5755 sessionId: Schema.String.annotate({
5756 description:
5757 "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)",
5758 }),
5759}).annotate({ description: "Request to create a new terminal and execute a command." });
5760
5761export type CreateTerminalResponse = {
5762 readonly _meta?: { readonly [x: string]: unknown } | null;
5763 readonly terminalId: string;
5764};
5765export const CreateTerminalResponse = Schema.Struct({
5766 _meta: Schema.optionalKey(
5767 Schema.Union([
5768 Schema.Record(Schema.String, Schema.Unknown).annotate({
5769 description:
5770 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5771 }),
5772 Schema.Null,
5773 ]),
5774 ),
5775 terminalId: Schema.String.annotate({
5776 description: "The unique identifier for the created terminal.",
5777 }),
5778}).annotate({ description: "Response containing the ID of the created terminal." });
5779
5780export type CurrentModeUpdate = {
5781 readonly _meta?: { readonly [x: string]: unknown } | null;
5782 readonly currentModeId: string;
5783};
5784export const CurrentModeUpdate = Schema.Struct({
5785 _meta: Schema.optionalKey(
5786 Schema.Union([
5787 Schema.Record(Schema.String, Schema.Unknown).annotate({
5788 description:
5789 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5790 }),
5791 Schema.Null,
5792 ]),
5793 ),
5794 currentModeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }),
5795}).annotate({
5796 description:
5797 "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
5798});
5799
5800export type Diff = {
5801 readonly _meta?: { readonly [x: string]: unknown } | null;
5802 readonly newText: string;
5803 readonly oldText?: string | null;
5804 readonly path: string;
5805};
5806export const Diff = Schema.Struct({
5807 _meta: Schema.optionalKey(
5808 Schema.Union([
5809 Schema.Record(Schema.String, Schema.Unknown).annotate({
5810 description:
5811 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5812 }),
5813 Schema.Null,
5814 ]),
5815 ),
5816 newText: Schema.String.annotate({ description: "The new content after modification." }),
5817 oldText: Schema.optionalKey(
5818 Schema.Union([
5819 Schema.String.annotate({ description: "The original content (None for new files)." }),
5820 Schema.Null,
5821 ]),
5822 ),
5823 path: Schema.String.annotate({ description: "The file path being modified." }),
5824}).annotate({
5825 description:
5826 "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)",
5827});
5828
5829export type ElicitationAcceptAction = {
5830 readonly content?: { readonly [x: string]: ElicitationContentValue } | null;
5831};
5832export const ElicitationAcceptAction = Schema.Struct({
5833 content: Schema.optionalKey(
5834 Schema.Union([
5835 Schema.Record(Schema.String, ElicitationContentValue).annotate({
5836 description:
5837 "The user-provided content, if any, as an object matching the requested schema.",
5838 }),
5839 Schema.Null,
5840 ]),
5841 ),
5842}).annotate({
5843 description:
5844 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.",
5845});
5846
5847export type ElicitationAction =
5848 | {
5849 readonly action: "accept";
5850 readonly content?: { readonly [x: string]: ElicitationContentValue } | null;
5851 }
5852 | { readonly action: "decline" }
5853 | { readonly action: "cancel" };
5854export const ElicitationAction = Schema.Union(
5855 [
5856 Schema.Struct({
5857 action: Schema.Literal("accept"),
5858 content: Schema.optionalKey(
5859 Schema.Union([
5860 Schema.Record(Schema.String, ElicitationContentValue).annotate({
5861 description:
5862 "The user-provided content, if any, as an object matching the requested schema.",
5863 }),
5864 Schema.Null,
5865 ]),
5866 ),
5867 }).annotate({
5868 description:
5869 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.",
5870 }),
5871 Schema.Struct({ action: Schema.Literal("decline") }).annotate({
5872 description: "The user declined the elicitation.",
5873 }),
5874 Schema.Struct({ action: Schema.Literal("cancel") }).annotate({
5875 description: "The elicitation was cancelled.",
5876 }),
5877 ],
5878 { mode: "oneOf" },
5879).annotate({
5880 description:
5881 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user's action in response to an elicitation.",
5882});
5883
5884export type ElicitationCompleteNotification = {
5885 readonly _meta?: { readonly [x: string]: unknown } | null;
5886 readonly elicitationId: string;
5887};
5888export const ElicitationCompleteNotification = Schema.Struct({
5889 _meta: Schema.optionalKey(
5890 Schema.Union([
5891 Schema.Record(Schema.String, Schema.Unknown).annotate({
5892 description:
5893 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
5894 }),
5895 Schema.Null,
5896 ]),
5897 ),
5898 elicitationId: Schema.String.annotate({
5899 description:
5900 "**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.",
5901 }),
5902}).annotate({
5903 description:
5904 "**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.",
5905});
5906
5907export type ElicitationFormMode = {
5908 readonly requestedSchema: {
5909 readonly description?: string | null;
5910 readonly properties?: { readonly [x: string]: ElicitationPropertySchema };
5911 readonly required?: ReadonlyArray<string> | null;
5912 readonly title?: string | null;
5913 readonly type?: "object";
5914 };
5915};
5916export const ElicitationFormMode = Schema.Struct({
5917 requestedSchema: Schema.Struct({
5918 description: Schema.optionalKey(
5919 Schema.Union([
5920 Schema.String.annotate({
5921 description: "Optional description of what this schema represents.",
5922 }),
5923 Schema.Null,
5924 ]),
5925 ),
5926 properties: Schema.optionalKey(
5927 Schema.Record(Schema.String, ElicitationPropertySchema).annotate({
5928 description: "Property definitions (must be primitive types).",
5929 default: {},
5930 }),
5931 ),
5932 required: Schema.optionalKey(
5933 Schema.Union([
5934 Schema.Array(Schema.String).annotate({ description: "List of required property names." }),
5935 Schema.Null,
5936 ]),
5937 ),
5938 title: Schema.optionalKey(
5939 Schema.Union([
5940 Schema.String.annotate({ description: "Optional title for the schema." }),
5941 Schema.Null,
5942 ]),
5943 ),
5944 type: Schema.optionalKey(
5945 Schema.Literal("object").annotate({
5946 description: "Type discriminator for elicitation schemas.",
5947 default: "object",
5948 }),
5949 ),
5950 }).annotate({
5951 description:
5952 "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.",
5953 }),
5954}).annotate({
5955 description:
5956 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema.",
5957});
5958
5959export type ElicitationId = string;
5960export const ElicitationId = Schema.String.annotate({
5961 description:
5962 "**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.",
5963});
5964
5965export type ElicitationRequest =
5966 | {
5967 readonly mode: "form";
5968 readonly requestedSchema: {
5969 readonly description?: string | null;
5970 readonly properties?: { readonly [x: string]: ElicitationPropertySchema };
5971 readonly required?: ReadonlyArray<string> | null;
5972 readonly title?: string | null;
5973 readonly type?: "object";
5974 };
5975 readonly _meta?: { readonly [x: string]: unknown } | null;
5976 readonly message: string;
5977 readonly sessionId: string;
5978 }
5979 | {
5980 readonly mode: "url";
5981 readonly elicitationId: string;
5982 readonly url: string;
5983 readonly _meta?: { readonly [x: string]: unknown } | null;
5984 readonly message: string;
5985 readonly sessionId: string;
5986 };
5987export const ElicitationRequest = Schema.Union([
5988 Schema.Struct({
5989 mode: Schema.Literal("form"),
5990 requestedSchema: Schema.Struct({
5991 description: Schema.optionalKey(
5992 Schema.Union([
5993 Schema.String.annotate({
5994 description: "Optional description of what this schema represents.",
5995 }),
5996 Schema.Null,
5997 ]),
5998 ),
5999 properties: Schema.optionalKey(
6000 Schema.Record(Schema.String, ElicitationPropertySchema).annotate({
6001 description: "Property definitions (must be primitive types).",
6002 default: {},
6003 }),
6004 ),
6005 required: Schema.optionalKey(
6006 Schema.Union([
6007 Schema.Array(Schema.String).annotate({ description: "List of required property names." }),
6008 Schema.Null,
6009 ]),
6010 ),
6011 title: Schema.optionalKey(
6012 Schema.Union([
6013 Schema.String.annotate({ description: "Optional title for the schema." }),
6014 Schema.Null,
6015 ]),
6016 ),
6017 type: Schema.optionalKey(
6018 Schema.Literal("object").annotate({
6019 description: "Type discriminator for elicitation schemas.",
6020 default: "object",
6021 }),
6022 ),
6023 }).annotate({
6024 description:
6025 "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.",
6026 }),
6027 _meta: Schema.optionalKey(
6028 Schema.Union([
6029 Schema.Record(Schema.String, Schema.Unknown).annotate({
6030 description:
6031 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6032 }),
6033 Schema.Null,
6034 ]),
6035 ),
6036 message: Schema.String.annotate({
6037 description: "A human-readable message describing what input is needed.",
6038 }),
6039 sessionId: Schema.String.annotate({
6040 description:
6041 "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)",
6042 }),
6043 }).annotate({
6044 description:
6045 "**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.",
6046 }),
6047 Schema.Struct({
6048 mode: Schema.Literal("url"),
6049 elicitationId: Schema.String.annotate({
6050 description:
6051 "**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.",
6052 }),
6053 url: Schema.String.annotate({ description: "The URL to direct the user to.", format: "uri" }),
6054 _meta: Schema.optionalKey(
6055 Schema.Union([
6056 Schema.Record(Schema.String, Schema.Unknown).annotate({
6057 description:
6058 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6059 }),
6060 Schema.Null,
6061 ]),
6062 ),
6063 message: Schema.String.annotate({
6064 description: "A human-readable message describing what input is needed.",
6065 }),
6066 sessionId: Schema.String.annotate({
6067 description:
6068 "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)",
6069 }),
6070 }).annotate({
6071 description:
6072 "**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.",
6073 }),
6074]);
6075
6076export type ElicitationResponse = {
6077 readonly _meta?: { readonly [x: string]: unknown } | null;
6078 readonly action:
6079 | {
6080 readonly action: "accept";
6081 readonly content?: { readonly [x: string]: ElicitationContentValue } | null;
6082 }
6083 | { readonly action: "decline" }
6084 | { readonly action: "cancel" };
6085};
6086export const ElicitationResponse = Schema.Struct({
6087 _meta: Schema.optionalKey(
6088 Schema.Union([
6089 Schema.Record(Schema.String, Schema.Unknown).annotate({
6090 description:
6091 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6092 }),
6093 Schema.Null,
6094 ]),
6095 ),
6096 action: Schema.Union(
6097 [
6098 Schema.Struct({
6099 action: Schema.Literal("accept"),
6100 content: Schema.optionalKey(
6101 Schema.Union([
6102 Schema.Record(Schema.String, ElicitationContentValue).annotate({
6103 description:
6104 "The user-provided content, if any, as an object matching the requested schema.",
6105 }),
6106 Schema.Null,
6107 ]),
6108 ),
6109 }).annotate({
6110 description:
6111 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user accepted the elicitation and provided content.",
6112 }),
6113 Schema.Struct({ action: Schema.Literal("decline") }).annotate({
6114 description: "The user declined the elicitation.",
6115 }),
6116 Schema.Struct({ action: Schema.Literal("cancel") }).annotate({
6117 description: "The elicitation was cancelled.",
6118 }),
6119 ],
6120 { mode: "oneOf" },
6121 ).annotate({
6122 description:
6123 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe user's action in response to an elicitation.",
6124 }),
6125}).annotate({
6126 description:
6127 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nResponse from the client to an elicitation request.",
6128});
6129
6130export type ElicitationSchema = {
6131 readonly description?: string | null;
6132 readonly properties?: { readonly [x: string]: ElicitationPropertySchema };
6133 readonly required?: ReadonlyArray<string> | null;
6134 readonly title?: string | null;
6135 readonly type?: "object";
6136};
6137export const ElicitationSchema = Schema.Struct({
6138 description: Schema.optionalKey(
6139 Schema.Union([
6140 Schema.String.annotate({
6141 description: "Optional description of what this schema represents.",
6142 }),
6143 Schema.Null,
6144 ]),
6145 ),
6146 properties: Schema.optionalKey(
6147 Schema.Record(Schema.String, ElicitationPropertySchema).annotate({
6148 description: "Property definitions (must be primitive types).",
6149 default: {},
6150 }),
6151 ),
6152 required: Schema.optionalKey(
6153 Schema.Union([
6154 Schema.Array(Schema.String).annotate({ description: "List of required property names." }),
6155 Schema.Null,
6156 ]),
6157 ),
6158 title: Schema.optionalKey(
6159 Schema.Union([
6160 Schema.String.annotate({ description: "Optional title for the schema." }),
6161 Schema.Null,
6162 ]),
6163 ),
6164 type: Schema.optionalKey(
6165 Schema.Literal("object").annotate({
6166 description: "Type discriminator for elicitation schemas.",
6167 default: "object",
6168 }),
6169 ),
6170}).annotate({
6171 description:
6172 "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.",
6173});
6174
6175export type ElicitationSchemaType = "object";
6176export const ElicitationSchemaType = Schema.Literal("object").annotate({
6177 description: "Type discriminator for elicitation schemas.",
6178});
6179
6180export type ElicitationStringType = "string";
6181export const ElicitationStringType = Schema.Literal("string").annotate({
6182 description: "Items definition for untitled multi-select enum properties.",
6183});
6184
6185export type ElicitationUrlMode = { readonly elicitationId: string; readonly url: string };
6186export const ElicitationUrlMode = Schema.Struct({
6187 elicitationId: Schema.String.annotate({
6188 description:
6189 "**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.",
6190 }),
6191 url: Schema.String.annotate({ description: "The URL to direct the user to.", format: "uri" }),
6192}).annotate({
6193 description:
6194 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation mode where the client directs the user to a URL.",
6195});
6196
6197export type EmbeddedResource = {
6198 readonly _meta?: { readonly [x: string]: unknown } | null;
6199 readonly annotations?: Annotations | null;
6200 readonly resource: EmbeddedResourceResource;
6201};
6202export const EmbeddedResource = Schema.Struct({
6203 _meta: Schema.optionalKey(
6204 Schema.Union([
6205 Schema.Record(Schema.String, Schema.Unknown).annotate({
6206 description:
6207 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6208 }),
6209 Schema.Null,
6210 ]),
6211 ),
6212 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
6213 resource: EmbeddedResourceResource,
6214}).annotate({
6215 description: "The contents of a resource, embedded into a prompt or tool call result.",
6216});
6217
6218export type ErrorCode =
6219 | -32700
6220 | -32600
6221 | -32601
6222 | -32602
6223 | -32603
6224 | -32800
6225 | -32000
6226 | -32002
6227 | -32042
6228 | number;
6229export const ErrorCode = Schema.Union([
6230 Schema.Literal(-32700).annotate({
6231 title: "Parse error",
6232 description:
6233 "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.",
6234 format: "int32",
6235 }),
6236 Schema.Literal(-32600).annotate({
6237 title: "Invalid request",
6238 description: "**Invalid request**: The JSON sent is not a valid Request object.",
6239 format: "int32",
6240 }),
6241 Schema.Literal(-32601).annotate({
6242 title: "Method not found",
6243 description: "**Method not found**: The method does not exist or is not available.",
6244 format: "int32",
6245 }),
6246 Schema.Literal(-32602).annotate({
6247 title: "Invalid params",
6248 description: "**Invalid params**: Invalid method parameter(s).",
6249 format: "int32",
6250 }),
6251 Schema.Literal(-32603).annotate({
6252 title: "Internal error",
6253 description:
6254 "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.",
6255 format: "int32",
6256 }),
6257 Schema.Literal(-32800).annotate({
6258 title: "Request cancelled",
6259 description:
6260 "**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.",
6261 format: "int32",
6262 }),
6263 Schema.Literal(-32000).annotate({
6264 title: "Authentication required",
6265 description:
6266 "**Authentication required**: Authentication is required before this operation can be performed.",
6267 format: "int32",
6268 }),
6269 Schema.Literal(-32002).annotate({
6270 title: "Resource not found",
6271 description: "**Resource not found**: A given resource, such as a file, was not found.",
6272 format: "int32",
6273 }),
6274 Schema.Literal(-32042).annotate({
6275 title: "URL elicitation required",
6276 description:
6277 "**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.",
6278 format: "int32",
6279 }),
6280 Schema.Number.annotate({
6281 title: "Other",
6282 description: "Other undefined error code.",
6283 format: "int32",
6284 }).check(Schema.isInt()),
6285]).annotate({
6286 description:
6287 "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.",
6288});
6289
6290export type ExtNotification = unknown;
6291export const ExtNotification = Schema.Unknown.annotate({
6292 description:
6293 "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)",
6294});
6295
6296export type ExtRequest = unknown;
6297export const ExtRequest = Schema.Unknown.annotate({
6298 description:
6299 "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)",
6300});
6301
6302export type ExtResponse = unknown;
6303export const ExtResponse = Schema.Unknown.annotate({
6304 description:
6305 "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)",
6306});
6307
6308export type FileSystemCapabilities = {
6309 readonly _meta?: { readonly [x: string]: unknown } | null;
6310 readonly readTextFile?: boolean;
6311 readonly writeTextFile?: boolean;
6312};
6313export const FileSystemCapabilities = Schema.Struct({
6314 _meta: Schema.optionalKey(
6315 Schema.Union([
6316 Schema.Record(Schema.String, Schema.Unknown).annotate({
6317 description:
6318 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6319 }),
6320 Schema.Null,
6321 ]),
6322 ),
6323 readTextFile: Schema.optionalKey(
6324 Schema.Boolean.annotate({
6325 description: "Whether the Client supports `fs/read_text_file` requests.",
6326 default: false,
6327 }),
6328 ),
6329 writeTextFile: Schema.optionalKey(
6330 Schema.Boolean.annotate({
6331 description: "Whether the Client supports `fs/write_text_file` requests.",
6332 default: false,
6333 }),
6334 ),
6335}).annotate({
6336 description:
6337 "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)",
6338});
6339
6340export type ForkSessionRequest = {
6341 readonly _meta?: { readonly [x: string]: unknown } | null;
6342 readonly cwd: string;
6343 readonly mcpServers?: ReadonlyArray<McpServer>;
6344 readonly sessionId: string;
6345};
6346export const ForkSessionRequest = Schema.Struct({
6347 _meta: Schema.optionalKey(
6348 Schema.Union([
6349 Schema.Record(Schema.String, Schema.Unknown).annotate({
6350 description:
6351 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6352 }),
6353 Schema.Null,
6354 ]),
6355 ),
6356 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
6357 mcpServers: Schema.optionalKey(
6358 Schema.Array(McpServer).annotate({
6359 description: "List of MCP servers to connect to for this session.",
6360 }),
6361 ),
6362 sessionId: Schema.String.annotate({
6363 description:
6364 "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)",
6365 }),
6366}).annotate({
6367 description:
6368 "**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.",
6369});
6370
6371export type ForkSessionResponse = {
6372 readonly _meta?: { readonly [x: string]: unknown } | null;
6373 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
6374 readonly models?: SessionModelState | null;
6375 readonly modes?: SessionModeState | null;
6376 readonly sessionId: string;
6377};
6378export const ForkSessionResponse = Schema.Struct({
6379 _meta: Schema.optionalKey(
6380 Schema.Union([
6381 Schema.Record(Schema.String, Schema.Unknown).annotate({
6382 description:
6383 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6384 }),
6385 Schema.Null,
6386 ]),
6387 ),
6388 configOptions: Schema.optionalKey(
6389 Schema.Union([
6390 Schema.Array(SessionConfigOption).annotate({
6391 description: "Initial session configuration options if supported by the Agent.",
6392 }),
6393 Schema.Null,
6394 ]),
6395 ),
6396 models: Schema.optionalKey(
6397 Schema.Union([SessionModelState, Schema.Null]).annotate({
6398 description:
6399 "**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",
6400 }),
6401 ),
6402 modes: Schema.optionalKey(
6403 Schema.Union([SessionModeState, Schema.Null]).annotate({
6404 description:
6405 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
6406 }),
6407 ),
6408 sessionId: Schema.String.annotate({
6409 description:
6410 "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)",
6411 }),
6412}).annotate({
6413 description:
6414 "**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.",
6415});
6416
6417export type ImageContent = {
6418 readonly _meta?: { readonly [x: string]: unknown } | null;
6419 readonly annotations?: Annotations | null;
6420 readonly data: string;
6421 readonly mimeType: string;
6422 readonly uri?: string | null;
6423};
6424export const ImageContent = Schema.Struct({
6425 _meta: Schema.optionalKey(
6426 Schema.Union([
6427 Schema.Record(Schema.String, Schema.Unknown).annotate({
6428 description:
6429 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6430 }),
6431 Schema.Null,
6432 ]),
6433 ),
6434 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
6435 data: Schema.String,
6436 mimeType: Schema.String,
6437 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
6438}).annotate({ description: "An image provided to or from an LLM." });
6439
6440export type InitializeRequest = {
6441 readonly _meta?: { readonly [x: string]: unknown } | null;
6442 readonly clientCapabilities?: {
6443 readonly _meta?: { readonly [x: string]: unknown } | null;
6444 readonly auth?: {
6445 readonly _meta?: { readonly [x: string]: unknown } | null;
6446 readonly terminal?: boolean;
6447 };
6448 readonly elicitation?: ElicitationCapabilities | null;
6449 readonly fs?: {
6450 readonly _meta?: { readonly [x: string]: unknown } | null;
6451 readonly readTextFile?: boolean;
6452 readonly writeTextFile?: boolean;
6453 };
6454 readonly terminal?: boolean;
6455 };
6456 readonly clientInfo?: Implementation | null;
6457 readonly protocolVersion: number;
6458};
6459export const InitializeRequest = Schema.Struct({
6460 _meta: Schema.optionalKey(
6461 Schema.Union([
6462 Schema.Record(Schema.String, Schema.Unknown).annotate({
6463 description:
6464 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6465 }),
6466 Schema.Null,
6467 ]),
6468 ),
6469 clientCapabilities: Schema.optionalKey(
6470 Schema.Struct({
6471 _meta: Schema.optionalKey(
6472 Schema.Union([
6473 Schema.Record(Schema.String, Schema.Unknown).annotate({
6474 description:
6475 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6476 }),
6477 Schema.Null,
6478 ]),
6479 ),
6480 auth: Schema.optionalKey(
6481 Schema.Struct({
6482 _meta: Schema.optionalKey(
6483 Schema.Union([
6484 Schema.Record(Schema.String, Schema.Unknown).annotate({
6485 description:
6486 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6487 }),
6488 Schema.Null,
6489 ]),
6490 ),
6491 terminal: Schema.optionalKey(
6492 Schema.Boolean.annotate({
6493 description:
6494 "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.",
6495 default: false,
6496 }),
6497 ),
6498 }).annotate({
6499 description:
6500 "**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.",
6501 default: { terminal: false },
6502 }),
6503 ),
6504 elicitation: Schema.optionalKey(
6505 Schema.Union([ElicitationCapabilities, Schema.Null]).annotate({
6506 description:
6507 "**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.",
6508 }),
6509 ),
6510 fs: Schema.optionalKey(
6511 Schema.Struct({
6512 _meta: Schema.optionalKey(
6513 Schema.Union([
6514 Schema.Record(Schema.String, Schema.Unknown).annotate({
6515 description:
6516 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6517 }),
6518 Schema.Null,
6519 ]),
6520 ),
6521 readTextFile: Schema.optionalKey(
6522 Schema.Boolean.annotate({
6523 description: "Whether the Client supports `fs/read_text_file` requests.",
6524 default: false,
6525 }),
6526 ),
6527 writeTextFile: Schema.optionalKey(
6528 Schema.Boolean.annotate({
6529 description: "Whether the Client supports `fs/write_text_file` requests.",
6530 default: false,
6531 }),
6532 ),
6533 }).annotate({
6534 description:
6535 "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)",
6536 default: { readTextFile: false, writeTextFile: false },
6537 }),
6538 ),
6539 terminal: Schema.optionalKey(
6540 Schema.Boolean.annotate({
6541 description: "Whether the Client support all `terminal/*` methods.",
6542 default: false,
6543 }),
6544 ),
6545 }).annotate({
6546 description:
6547 "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)",
6548 default: {
6549 auth: { terminal: false },
6550 fs: { readTextFile: false, writeTextFile: false },
6551 terminal: false,
6552 },
6553 }),
6554 ),
6555 clientInfo: Schema.optionalKey(
6556 Schema.Union([Implementation, Schema.Null]).annotate({
6557 description:
6558 "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required.",
6559 }),
6560 ),
6561 protocolVersion: Schema.Number.annotate({
6562 description:
6563 "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.",
6564 format: "uint16",
6565 })
6566 .check(Schema.isInt())
6567 .check(Schema.isGreaterThanOrEqualTo(0))
6568 .check(Schema.isLessThanOrEqualTo(65535)),
6569}).annotate({
6570 description:
6571 "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)",
6572});
6573
6574export type InitializeResponse = {
6575 readonly _meta?: { readonly [x: string]: unknown } | null;
6576 readonly agentCapabilities?: {
6577 readonly _meta?: { readonly [x: string]: unknown } | null;
6578 readonly auth?: {
6579 readonly _meta?: { readonly [x: string]: unknown } | null;
6580 readonly logout?: LogoutCapabilities | null;
6581 };
6582 readonly loadSession?: boolean;
6583 readonly mcpCapabilities?: {
6584 readonly _meta?: { readonly [x: string]: unknown } | null;
6585 readonly http?: boolean;
6586 readonly sse?: boolean;
6587 };
6588 readonly promptCapabilities?: {
6589 readonly _meta?: { readonly [x: string]: unknown } | null;
6590 readonly audio?: boolean;
6591 readonly embeddedContext?: boolean;
6592 readonly image?: boolean;
6593 };
6594 readonly sessionCapabilities?: {
6595 readonly _meta?: { readonly [x: string]: unknown } | null;
6596 readonly close?: SessionCloseCapabilities | null;
6597 readonly fork?: SessionForkCapabilities | null;
6598 readonly list?: SessionListCapabilities | null;
6599 readonly resume?: SessionResumeCapabilities | null;
6600 };
6601 };
6602 readonly agentInfo?: Implementation | null;
6603 readonly authMethods?: ReadonlyArray<AuthMethod>;
6604 readonly protocolVersion: number;
6605};
6606export const InitializeResponse = Schema.Struct({
6607 _meta: Schema.optionalKey(
6608 Schema.Union([
6609 Schema.Record(Schema.String, Schema.Unknown).annotate({
6610 description:
6611 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6612 }),
6613 Schema.Null,
6614 ]),
6615 ),
6616 agentCapabilities: Schema.optionalKey(
6617 Schema.Struct({
6618 _meta: Schema.optionalKey(
6619 Schema.Union([
6620 Schema.Record(Schema.String, Schema.Unknown).annotate({
6621 description:
6622 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6623 }),
6624 Schema.Null,
6625 ]),
6626 ),
6627 auth: Schema.optionalKey(
6628 Schema.Struct({
6629 _meta: Schema.optionalKey(
6630 Schema.Union([
6631 Schema.Record(Schema.String, Schema.Unknown).annotate({
6632 description:
6633 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6634 }),
6635 Schema.Null,
6636 ]),
6637 ),
6638 logout: Schema.optionalKey(
6639 Schema.Union([LogoutCapabilities, Schema.Null]).annotate({
6640 description:
6641 "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.",
6642 }),
6643 ),
6644 }).annotate({
6645 description:
6646 "**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.",
6647 default: {},
6648 }),
6649 ),
6650 loadSession: Schema.optionalKey(
6651 Schema.Boolean.annotate({
6652 description: "Whether the agent supports `session/load`.",
6653 default: false,
6654 }),
6655 ),
6656 mcpCapabilities: Schema.optionalKey(
6657 Schema.Struct({
6658 _meta: Schema.optionalKey(
6659 Schema.Union([
6660 Schema.Record(Schema.String, Schema.Unknown).annotate({
6661 description:
6662 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6663 }),
6664 Schema.Null,
6665 ]),
6666 ),
6667 http: Schema.optionalKey(
6668 Schema.Boolean.annotate({
6669 description: "Agent supports [`McpServer::Http`].",
6670 default: false,
6671 }),
6672 ),
6673 sse: Schema.optionalKey(
6674 Schema.Boolean.annotate({
6675 description: "Agent supports [`McpServer::Sse`].",
6676 default: false,
6677 }),
6678 ),
6679 }).annotate({
6680 description: "MCP capabilities supported by the agent",
6681 default: { http: false, sse: false },
6682 }),
6683 ),
6684 promptCapabilities: Schema.optionalKey(
6685 Schema.Struct({
6686 _meta: Schema.optionalKey(
6687 Schema.Union([
6688 Schema.Record(Schema.String, Schema.Unknown).annotate({
6689 description:
6690 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6691 }),
6692 Schema.Null,
6693 ]),
6694 ),
6695 audio: Schema.optionalKey(
6696 Schema.Boolean.annotate({
6697 description: "Agent supports [`ContentBlock::Audio`].",
6698 default: false,
6699 }),
6700 ),
6701 embeddedContext: Schema.optionalKey(
6702 Schema.Boolean.annotate({
6703 description:
6704 "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.",
6705 default: false,
6706 }),
6707 ),
6708 image: Schema.optionalKey(
6709 Schema.Boolean.annotate({
6710 description: "Agent supports [`ContentBlock::Image`].",
6711 default: false,
6712 }),
6713 ),
6714 }).annotate({
6715 description:
6716 "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)",
6717 default: { audio: false, embeddedContext: false, image: false },
6718 }),
6719 ),
6720 sessionCapabilities: Schema.optionalKey(
6721 Schema.Struct({
6722 _meta: Schema.optionalKey(
6723 Schema.Union([
6724 Schema.Record(Schema.String, Schema.Unknown).annotate({
6725 description:
6726 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6727 }),
6728 Schema.Null,
6729 ]),
6730 ),
6731 close: Schema.optionalKey(
6732 Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({
6733 description:
6734 "**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`.",
6735 }),
6736 ),
6737 fork: Schema.optionalKey(
6738 Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({
6739 description:
6740 "**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`.",
6741 }),
6742 ),
6743 list: Schema.optionalKey(
6744 Schema.Union([SessionListCapabilities, Schema.Null]).annotate({
6745 description: "Whether the agent supports `session/list`.",
6746 }),
6747 ),
6748 resume: Schema.optionalKey(
6749 Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({
6750 description:
6751 "**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`.",
6752 }),
6753 ),
6754 }).annotate({
6755 default: {},
6756 description:
6757 "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)",
6758 }),
6759 ),
6760 }).annotate({
6761 description:
6762 "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)",
6763 default: {
6764 auth: {},
6765 loadSession: false,
6766 mcpCapabilities: { http: false, sse: false },
6767 promptCapabilities: { audio: false, embeddedContext: false, image: false },
6768 sessionCapabilities: {},
6769 },
6770 }),
6771 ),
6772 agentInfo: Schema.optionalKey(
6773 Schema.Union([Implementation, Schema.Null]).annotate({
6774 description:
6775 "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required.",
6776 }),
6777 ),
6778 authMethods: Schema.optionalKey(
6779 Schema.Array(AuthMethod).annotate({
6780 description: "Authentication methods supported by the agent.",
6781 default: [],
6782 }),
6783 ),
6784 protocolVersion: Schema.Number.annotate({
6785 description:
6786 "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.",
6787 format: "uint16",
6788 })
6789 .check(Schema.isInt())
6790 .check(Schema.isGreaterThanOrEqualTo(0))
6791 .check(Schema.isLessThanOrEqualTo(65535)),
6792}).annotate({
6793 description:
6794 "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)",
6795});
6796
6797export type IntegerPropertySchema = {
6798 readonly default?: number | null;
6799 readonly description?: string | null;
6800 readonly maximum?: number | null;
6801 readonly minimum?: number | null;
6802 readonly title?: string | null;
6803};
6804export const IntegerPropertySchema = Schema.Struct({
6805 default: Schema.optionalKey(
6806 Schema.Union([
6807 Schema.Number.annotate({ description: "Default value.", format: "int64" }).check(
6808 Schema.isInt(),
6809 ),
6810 Schema.Null,
6811 ]),
6812 ),
6813 description: Schema.optionalKey(
6814 Schema.Union([
6815 Schema.String.annotate({ description: "Human-readable description." }),
6816 Schema.Null,
6817 ]),
6818 ),
6819 maximum: Schema.optionalKey(
6820 Schema.Union([
6821 Schema.Number.annotate({ description: "Maximum value (inclusive).", format: "int64" }).check(
6822 Schema.isInt(),
6823 ),
6824 Schema.Null,
6825 ]),
6826 ),
6827 minimum: Schema.optionalKey(
6828 Schema.Union([
6829 Schema.Number.annotate({ description: "Minimum value (inclusive).", format: "int64" }).check(
6830 Schema.isInt(),
6831 ),
6832 Schema.Null,
6833 ]),
6834 ),
6835 title: Schema.optionalKey(
6836 Schema.Union([
6837 Schema.String.annotate({ description: "Optional title for the property." }),
6838 Schema.Null,
6839 ]),
6840 ),
6841}).annotate({ description: "Schema for integer properties in an elicitation form." });
6842
6843export type KillTerminalRequest = {
6844 readonly _meta?: { readonly [x: string]: unknown } | null;
6845 readonly sessionId: string;
6846 readonly terminalId: string;
6847};
6848export const KillTerminalRequest = Schema.Struct({
6849 _meta: Schema.optionalKey(
6850 Schema.Union([
6851 Schema.Record(Schema.String, Schema.Unknown).annotate({
6852 description:
6853 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6854 }),
6855 Schema.Null,
6856 ]),
6857 ),
6858 sessionId: Schema.String.annotate({
6859 description:
6860 "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)",
6861 }),
6862 terminalId: Schema.String.annotate({ description: "The ID of the terminal to kill." }),
6863}).annotate({ description: "Request to kill a terminal without releasing it." });
6864
6865export type KillTerminalResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
6866export const KillTerminalResponse = Schema.Struct({
6867 _meta: Schema.optionalKey(
6868 Schema.Union([
6869 Schema.Record(Schema.String, Schema.Unknown).annotate({
6870 description:
6871 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6872 }),
6873 Schema.Null,
6874 ]),
6875 ),
6876}).annotate({ description: "Response to `terminal/kill` method" });
6877
6878export type ListSessionsRequest = {
6879 readonly _meta?: { readonly [x: string]: unknown } | null;
6880 readonly cursor?: string | null;
6881 readonly cwd?: string | null;
6882};
6883export const ListSessionsRequest = Schema.Struct({
6884 _meta: Schema.optionalKey(
6885 Schema.Union([
6886 Schema.Record(Schema.String, Schema.Unknown).annotate({
6887 description:
6888 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6889 }),
6890 Schema.Null,
6891 ]),
6892 ),
6893 cursor: Schema.optionalKey(
6894 Schema.Union([
6895 Schema.String.annotate({
6896 description:
6897 "Opaque cursor token from a previous response's nextCursor field for cursor-based pagination",
6898 }),
6899 Schema.Null,
6900 ]),
6901 ),
6902 cwd: Schema.optionalKey(
6903 Schema.Union([
6904 Schema.String.annotate({
6905 description: "Filter sessions by working directory. Must be an absolute path.",
6906 }),
6907 Schema.Null,
6908 ]),
6909 ),
6910}).annotate({
6911 description:
6912 "Request parameters for listing existing sessions.\n\nOnly available if the Agent supports the `sessionCapabilities.list` capability.",
6913});
6914
6915export type ListSessionsResponse = {
6916 readonly _meta?: { readonly [x: string]: unknown } | null;
6917 readonly nextCursor?: string | null;
6918 readonly sessions: ReadonlyArray<SessionInfo>;
6919};
6920export const ListSessionsResponse = Schema.Struct({
6921 _meta: Schema.optionalKey(
6922 Schema.Union([
6923 Schema.Record(Schema.String, Schema.Unknown).annotate({
6924 description:
6925 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6926 }),
6927 Schema.Null,
6928 ]),
6929 ),
6930 nextCursor: Schema.optionalKey(
6931 Schema.Union([
6932 Schema.String.annotate({
6933 description:
6934 "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.",
6935 }),
6936 Schema.Null,
6937 ]),
6938 ),
6939 sessions: Schema.Array(SessionInfo).annotate({
6940 description: "Array of session information objects",
6941 }),
6942}).annotate({ description: "Response from listing sessions." });
6943
6944export type LoadSessionRequest = {
6945 readonly _meta?: { readonly [x: string]: unknown } | null;
6946 readonly cwd: string;
6947 readonly mcpServers: ReadonlyArray<McpServer>;
6948 readonly sessionId: string;
6949};
6950export const LoadSessionRequest = Schema.Struct({
6951 _meta: Schema.optionalKey(
6952 Schema.Union([
6953 Schema.Record(Schema.String, Schema.Unknown).annotate({
6954 description:
6955 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6956 }),
6957 Schema.Null,
6958 ]),
6959 ),
6960 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
6961 mcpServers: Schema.Array(McpServer).annotate({
6962 description: "List of MCP servers to connect to for this session.",
6963 }),
6964 sessionId: Schema.String.annotate({
6965 description:
6966 "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)",
6967 }),
6968}).annotate({
6969 description:
6970 "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)",
6971});
6972
6973export type LoadSessionResponse = {
6974 readonly _meta?: { readonly [x: string]: unknown } | null;
6975 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
6976 readonly models?: SessionModelState | null;
6977 readonly modes?: SessionModeState | null;
6978};
6979export const LoadSessionResponse = Schema.Struct({
6980 _meta: Schema.optionalKey(
6981 Schema.Union([
6982 Schema.Record(Schema.String, Schema.Unknown).annotate({
6983 description:
6984 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
6985 }),
6986 Schema.Null,
6987 ]),
6988 ),
6989 configOptions: Schema.optionalKey(
6990 Schema.Union([
6991 Schema.Array(SessionConfigOption).annotate({
6992 description: "Initial session configuration options if supported by the Agent.",
6993 }),
6994 Schema.Null,
6995 ]),
6996 ),
6997 models: Schema.optionalKey(
6998 Schema.Union([SessionModelState, Schema.Null]).annotate({
6999 description:
7000 "**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",
7001 }),
7002 ),
7003 modes: Schema.optionalKey(
7004 Schema.Union([SessionModeState, Schema.Null]).annotate({
7005 description:
7006 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
7007 }),
7008 ),
7009}).annotate({ description: "Response from loading an existing session." });
7010
7011export type LogoutRequest = { readonly _meta?: { readonly [x: string]: unknown } | null };
7012export const LogoutRequest = Schema.Struct({
7013 _meta: Schema.optionalKey(
7014 Schema.Union([
7015 Schema.Record(Schema.String, Schema.Unknown).annotate({
7016 description:
7017 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7018 }),
7019 Schema.Null,
7020 ]),
7021 ),
7022}).annotate({
7023 description:
7024 "**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.",
7025});
7026
7027export type LogoutResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
7028export const LogoutResponse = Schema.Struct({
7029 _meta: Schema.optionalKey(
7030 Schema.Union([
7031 Schema.Record(Schema.String, Schema.Unknown).annotate({
7032 description:
7033 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7034 }),
7035 Schema.Null,
7036 ]),
7037 ),
7038}).annotate({
7039 description:
7040 "**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.",
7041});
7042
7043export type McpCapabilities = {
7044 readonly _meta?: { readonly [x: string]: unknown } | null;
7045 readonly http?: boolean;
7046 readonly sse?: boolean;
7047};
7048export const McpCapabilities = Schema.Struct({
7049 _meta: Schema.optionalKey(
7050 Schema.Union([
7051 Schema.Record(Schema.String, Schema.Unknown).annotate({
7052 description:
7053 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7054 }),
7055 Schema.Null,
7056 ]),
7057 ),
7058 http: Schema.optionalKey(
7059 Schema.Boolean.annotate({ description: "Agent supports [`McpServer::Http`].", default: false }),
7060 ),
7061 sse: Schema.optionalKey(
7062 Schema.Boolean.annotate({ description: "Agent supports [`McpServer::Sse`].", default: false }),
7063 ),
7064}).annotate({ description: "MCP capabilities supported by the agent" });
7065
7066export type McpServerHttp = {
7067 readonly _meta?: { readonly [x: string]: unknown } | null;
7068 readonly headers: ReadonlyArray<HttpHeader>;
7069 readonly name: string;
7070 readonly url: string;
7071};
7072export const McpServerHttp = Schema.Struct({
7073 _meta: Schema.optionalKey(
7074 Schema.Union([
7075 Schema.Record(Schema.String, Schema.Unknown).annotate({
7076 description:
7077 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7078 }),
7079 Schema.Null,
7080 ]),
7081 ),
7082 headers: Schema.Array(HttpHeader).annotate({
7083 description: "HTTP headers to set when making requests to the MCP server.",
7084 }),
7085 name: Schema.String.annotate({ description: "Human-readable name identifying this MCP server." }),
7086 url: Schema.String.annotate({ description: "URL to the MCP server." }),
7087}).annotate({ description: "HTTP transport configuration for MCP." });
7088
7089export type McpServerSse = {
7090 readonly _meta?: { readonly [x: string]: unknown } | null;
7091 readonly headers: ReadonlyArray<HttpHeader>;
7092 readonly name: string;
7093 readonly url: string;
7094};
7095export const McpServerSse = Schema.Struct({
7096 _meta: Schema.optionalKey(
7097 Schema.Union([
7098 Schema.Record(Schema.String, Schema.Unknown).annotate({
7099 description:
7100 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7101 }),
7102 Schema.Null,
7103 ]),
7104 ),
7105 headers: Schema.Array(HttpHeader).annotate({
7106 description: "HTTP headers to set when making requests to the MCP server.",
7107 }),
7108 name: Schema.String.annotate({ description: "Human-readable name identifying this MCP server." }),
7109 url: Schema.String.annotate({ description: "URL to the MCP server." }),
7110}).annotate({ description: "SSE transport configuration for MCP." });
7111
7112export type McpServerStdio = {
7113 readonly _meta?: { readonly [x: string]: unknown } | null;
7114 readonly args: ReadonlyArray<string>;
7115 readonly command: string;
7116 readonly env: ReadonlyArray<EnvVariable>;
7117 readonly name: string;
7118};
7119export const McpServerStdio = Schema.Struct({
7120 _meta: Schema.optionalKey(
7121 Schema.Union([
7122 Schema.Record(Schema.String, Schema.Unknown).annotate({
7123 description:
7124 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7125 }),
7126 Schema.Null,
7127 ]),
7128 ),
7129 args: Schema.Array(Schema.String).annotate({
7130 description: "Command-line arguments to pass to the MCP server.",
7131 }),
7132 command: Schema.String.annotate({ description: "Path to the MCP server executable." }),
7133 env: Schema.Array(EnvVariable).annotate({
7134 description: "Environment variables to set when launching the MCP server.",
7135 }),
7136 name: Schema.String.annotate({ description: "Human-readable name identifying this MCP server." }),
7137}).annotate({ description: "Stdio transport configuration for MCP." });
7138
7139export type ModelId = string;
7140export const ModelId = Schema.String.annotate({
7141 description:
7142 "**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.",
7143});
7144
7145export type MultiSelectItems =
7146 | { readonly enum: ReadonlyArray<string>; readonly type: "string" }
7147 | { readonly anyOf: ReadonlyArray<EnumOption> };
7148export const MultiSelectItems = Schema.Union([
7149 Schema.Struct({
7150 enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }),
7151 type: Schema.Literal("string").annotate({
7152 description: "Items definition for untitled multi-select enum properties.",
7153 }),
7154 }).annotate({
7155 title: "Untitled",
7156 description: "Items definition for untitled multi-select enum properties.",
7157 }),
7158 Schema.Struct({
7159 anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }),
7160 }).annotate({
7161 title: "Titled",
7162 description: "Items definition for titled multi-select enum properties.",
7163 }),
7164]).annotate({ description: "Items for a multi-select (array) property schema." });
7165
7166export type MultiSelectPropertySchema = {
7167 readonly default?: ReadonlyArray<string> | null;
7168 readonly description?: string | null;
7169 readonly items:
7170 | { readonly enum: ReadonlyArray<string>; readonly type: "string" }
7171 | { readonly anyOf: ReadonlyArray<EnumOption> };
7172 readonly maxItems?: number | null;
7173 readonly minItems?: number | null;
7174 readonly title?: string | null;
7175};
7176export const MultiSelectPropertySchema = Schema.Struct({
7177 default: Schema.optionalKey(
7178 Schema.Union([
7179 Schema.Array(Schema.String).annotate({ description: "Default selected values." }),
7180 Schema.Null,
7181 ]),
7182 ),
7183 description: Schema.optionalKey(
7184 Schema.Union([
7185 Schema.String.annotate({ description: "Human-readable description." }),
7186 Schema.Null,
7187 ]),
7188 ),
7189 items: Schema.Union([
7190 Schema.Struct({
7191 enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }),
7192 type: Schema.Literal("string").annotate({
7193 description: "Items definition for untitled multi-select enum properties.",
7194 }),
7195 }).annotate({
7196 title: "Untitled",
7197 description: "Items definition for untitled multi-select enum properties.",
7198 }),
7199 Schema.Struct({
7200 anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }),
7201 }).annotate({
7202 title: "Titled",
7203 description: "Items definition for titled multi-select enum properties.",
7204 }),
7205 ]).annotate({ description: "Items for a multi-select (array) property schema." }),
7206 maxItems: Schema.optionalKey(
7207 Schema.Union([
7208 Schema.Number.annotate({
7209 description: "Maximum number of items to select.",
7210 format: "uint64",
7211 })
7212 .check(Schema.isInt())
7213 .check(Schema.isGreaterThanOrEqualTo(0)),
7214 Schema.Null,
7215 ]),
7216 ),
7217 minItems: Schema.optionalKey(
7218 Schema.Union([
7219 Schema.Number.annotate({
7220 description: "Minimum number of items to select.",
7221 format: "uint64",
7222 })
7223 .check(Schema.isInt())
7224 .check(Schema.isGreaterThanOrEqualTo(0)),
7225 Schema.Null,
7226 ]),
7227 ),
7228 title: Schema.optionalKey(
7229 Schema.Union([
7230 Schema.String.annotate({ description: "Optional title for the property." }),
7231 Schema.Null,
7232 ]),
7233 ),
7234}).annotate({ description: "Schema for multi-select (array) properties in an elicitation form." });
7235
7236export type NewSessionRequest = {
7237 readonly _meta?: { readonly [x: string]: unknown } | null;
7238 readonly cwd: string;
7239 readonly mcpServers: ReadonlyArray<McpServer>;
7240};
7241export const NewSessionRequest = Schema.Struct({
7242 _meta: Schema.optionalKey(
7243 Schema.Union([
7244 Schema.Record(Schema.String, Schema.Unknown).annotate({
7245 description:
7246 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7247 }),
7248 Schema.Null,
7249 ]),
7250 ),
7251 cwd: Schema.String.annotate({
7252 description: "The working directory for this session. Must be an absolute path.",
7253 }),
7254 mcpServers: Schema.Array(McpServer).annotate({
7255 description: "List of MCP (Model Context Protocol) servers the agent should connect to.",
7256 }),
7257}).annotate({
7258 description:
7259 "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)",
7260});
7261
7262export type NewSessionResponse = {
7263 readonly _meta?: { readonly [x: string]: unknown } | null;
7264 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
7265 readonly models?: SessionModelState | null;
7266 readonly modes?: SessionModeState | null;
7267 readonly sessionId: string;
7268};
7269export const NewSessionResponse = Schema.Struct({
7270 _meta: Schema.optionalKey(
7271 Schema.Union([
7272 Schema.Record(Schema.String, Schema.Unknown).annotate({
7273 description:
7274 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7275 }),
7276 Schema.Null,
7277 ]),
7278 ),
7279 configOptions: Schema.optionalKey(
7280 Schema.Union([
7281 Schema.Array(SessionConfigOption).annotate({
7282 description: "Initial session configuration options if supported by the Agent.",
7283 }),
7284 Schema.Null,
7285 ]),
7286 ),
7287 models: Schema.optionalKey(
7288 Schema.Union([SessionModelState, Schema.Null]).annotate({
7289 description:
7290 "**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",
7291 }),
7292 ),
7293 modes: Schema.optionalKey(
7294 Schema.Union([SessionModeState, Schema.Null]).annotate({
7295 description:
7296 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
7297 }),
7298 ),
7299 sessionId: Schema.String.annotate({
7300 description:
7301 "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)",
7302 }),
7303}).annotate({
7304 description:
7305 "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)",
7306});
7307
7308export type NumberPropertySchema = {
7309 readonly default?: number | null;
7310 readonly description?: string | null;
7311 readonly maximum?: number | null;
7312 readonly minimum?: number | null;
7313 readonly title?: string | null;
7314};
7315export const NumberPropertySchema = Schema.Struct({
7316 default: Schema.optionalKey(
7317 Schema.Union([
7318 Schema.Number.annotate({ description: "Default value.", format: "double" }).check(
7319 Schema.isFinite(),
7320 ),
7321 Schema.Null,
7322 ]),
7323 ),
7324 description: Schema.optionalKey(
7325 Schema.Union([
7326 Schema.String.annotate({ description: "Human-readable description." }),
7327 Schema.Null,
7328 ]),
7329 ),
7330 maximum: Schema.optionalKey(
7331 Schema.Union([
7332 Schema.Number.annotate({ description: "Maximum value (inclusive).", format: "double" }).check(
7333 Schema.isFinite(),
7334 ),
7335 Schema.Null,
7336 ]),
7337 ),
7338 minimum: Schema.optionalKey(
7339 Schema.Union([
7340 Schema.Number.annotate({ description: "Minimum value (inclusive).", format: "double" }).check(
7341 Schema.isFinite(),
7342 ),
7343 Schema.Null,
7344 ]),
7345 ),
7346 title: Schema.optionalKey(
7347 Schema.Union([
7348 Schema.String.annotate({ description: "Optional title for the property." }),
7349 Schema.Null,
7350 ]),
7351 ),
7352}).annotate({
7353 description: "Schema for number (floating-point) properties in an elicitation form.",
7354});
7355
7356export type PermissionOptionId = string;
7357export const PermissionOptionId = Schema.String.annotate({
7358 description: "Unique identifier for a permission option.",
7359});
7360
7361export type PermissionOptionKind = "allow_once" | "allow_always" | "reject_once" | "reject_always";
7362export const PermissionOptionKind = Schema.Literals([
7363 "allow_once",
7364 "allow_always",
7365 "reject_once",
7366 "reject_always",
7367]).annotate({
7368 description:
7369 "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.",
7370});
7371
7372export type Plan = {
7373 readonly _meta?: { readonly [x: string]: unknown } | null;
7374 readonly entries: ReadonlyArray<PlanEntry>;
7375};
7376export const Plan = Schema.Struct({
7377 _meta: Schema.optionalKey(
7378 Schema.Union([
7379 Schema.Record(Schema.String, Schema.Unknown).annotate({
7380 description:
7381 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7382 }),
7383 Schema.Null,
7384 ]),
7385 ),
7386 entries: Schema.Array(PlanEntry).annotate({
7387 description:
7388 "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.",
7389 }),
7390}).annotate({
7391 description:
7392 "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)",
7393});
7394
7395export type PlanEntryPriority = "high" | "medium" | "low";
7396export const PlanEntryPriority = Schema.Literals(["high", "medium", "low"]).annotate({
7397 description:
7398 "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)",
7399});
7400
7401export type PlanEntryStatus = "pending" | "in_progress" | "completed";
7402export const PlanEntryStatus = Schema.Literals(["pending", "in_progress", "completed"]).annotate({
7403 description:
7404 "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)",
7405});
7406
7407export type PromptCapabilities = {
7408 readonly _meta?: { readonly [x: string]: unknown } | null;
7409 readonly audio?: boolean;
7410 readonly embeddedContext?: boolean;
7411 readonly image?: boolean;
7412};
7413export const PromptCapabilities = Schema.Struct({
7414 _meta: Schema.optionalKey(
7415 Schema.Union([
7416 Schema.Record(Schema.String, Schema.Unknown).annotate({
7417 description:
7418 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7419 }),
7420 Schema.Null,
7421 ]),
7422 ),
7423 audio: Schema.optionalKey(
7424 Schema.Boolean.annotate({
7425 description: "Agent supports [`ContentBlock::Audio`].",
7426 default: false,
7427 }),
7428 ),
7429 embeddedContext: Schema.optionalKey(
7430 Schema.Boolean.annotate({
7431 description:
7432 "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.",
7433 default: false,
7434 }),
7435 ),
7436 image: Schema.optionalKey(
7437 Schema.Boolean.annotate({
7438 description: "Agent supports [`ContentBlock::Image`].",
7439 default: false,
7440 }),
7441 ),
7442}).annotate({
7443 description:
7444 "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)",
7445});
7446
7447export type PromptRequest = {
7448 readonly _meta?: { readonly [x: string]: unknown } | null;
7449 readonly messageId?: string | null;
7450 readonly prompt: ReadonlyArray<ContentBlock>;
7451 readonly sessionId: string;
7452};
7453export const PromptRequest = Schema.Struct({
7454 _meta: Schema.optionalKey(
7455 Schema.Union([
7456 Schema.Record(Schema.String, Schema.Unknown).annotate({
7457 description:
7458 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7459 }),
7460 Schema.Null,
7461 ]),
7462 ),
7463 messageId: Schema.optionalKey(
7464 Schema.Union([
7465 Schema.String.annotate({
7466 description:
7467 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA client-generated unique identifier for this user message.\n\nIf provided, the Agent SHOULD echo this value as `userMessageId` in the\n[`PromptResponse`] to confirm it was recorded.\nBoth clients and agents MUST use UUID format for message IDs.",
7468 }),
7469 Schema.Null,
7470 ]),
7471 ),
7472 prompt: Schema.Array(ContentBlock).annotate({
7473 description:
7474 "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.",
7475 }),
7476 sessionId: Schema.String.annotate({
7477 description:
7478 "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)",
7479 }),
7480}).annotate({
7481 description:
7482 "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)",
7483});
7484
7485export type PromptResponse = {
7486 readonly _meta?: { readonly [x: string]: unknown } | null;
7487 readonly stopReason: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
7488 readonly usage?: Usage | null;
7489 readonly userMessageId?: string | null;
7490};
7491export const PromptResponse = Schema.Struct({
7492 _meta: Schema.optionalKey(
7493 Schema.Union([
7494 Schema.Record(Schema.String, Schema.Unknown).annotate({
7495 description:
7496 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7497 }),
7498 Schema.Null,
7499 ]),
7500 ),
7501 stopReason: Schema.Literals([
7502 "end_turn",
7503 "max_tokens",
7504 "max_turn_requests",
7505 "refusal",
7506 "cancelled",
7507 ]).annotate({
7508 description:
7509 "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)",
7510 }),
7511 usage: Schema.optionalKey(
7512 Schema.Union([Usage, Schema.Null]).annotate({
7513 description:
7514 "**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).",
7515 }),
7516 ),
7517 userMessageId: Schema.optionalKey(
7518 Schema.Union([
7519 Schema.String.annotate({
7520 description:
7521 "**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.",
7522 }),
7523 Schema.Null,
7524 ]),
7525 ),
7526}).annotate({
7527 description:
7528 "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)",
7529});
7530
7531export type ProtocolVersion = number;
7532export const ProtocolVersion = Schema.Number.annotate({
7533 description:
7534 "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.",
7535 format: "uint16",
7536})
7537 .check(Schema.isInt())
7538 .check(Schema.isGreaterThanOrEqualTo(0))
7539 .check(Schema.isLessThanOrEqualTo(65535));
7540
7541export type ReadTextFileRequest = {
7542 readonly _meta?: { readonly [x: string]: unknown } | null;
7543 readonly limit?: number | null;
7544 readonly line?: number | null;
7545 readonly path: string;
7546 readonly sessionId: string;
7547};
7548export const ReadTextFileRequest = Schema.Struct({
7549 _meta: Schema.optionalKey(
7550 Schema.Union([
7551 Schema.Record(Schema.String, Schema.Unknown).annotate({
7552 description:
7553 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7554 }),
7555 Schema.Null,
7556 ]),
7557 ),
7558 limit: Schema.optionalKey(
7559 Schema.Union([
7560 Schema.Number.annotate({ description: "Maximum number of lines to read.", format: "uint32" })
7561 .check(Schema.isInt())
7562 .check(Schema.isGreaterThanOrEqualTo(0)),
7563 Schema.Null,
7564 ]),
7565 ),
7566 line: Schema.optionalKey(
7567 Schema.Union([
7568 Schema.Number.annotate({
7569 description: "Line number to start reading from (1-based).",
7570 format: "uint32",
7571 })
7572 .check(Schema.isInt())
7573 .check(Schema.isGreaterThanOrEqualTo(0)),
7574 Schema.Null,
7575 ]),
7576 ),
7577 path: Schema.String.annotate({ description: "Absolute path to the file to read." }),
7578 sessionId: Schema.String.annotate({
7579 description:
7580 "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)",
7581 }),
7582}).annotate({
7583 description:
7584 "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.",
7585});
7586
7587export type ReadTextFileResponse = {
7588 readonly _meta?: { readonly [x: string]: unknown } | null;
7589 readonly content: string;
7590};
7591export const ReadTextFileResponse = Schema.Struct({
7592 _meta: Schema.optionalKey(
7593 Schema.Union([
7594 Schema.Record(Schema.String, Schema.Unknown).annotate({
7595 description:
7596 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7597 }),
7598 Schema.Null,
7599 ]),
7600 ),
7601 content: Schema.String,
7602}).annotate({ description: "Response containing the contents of a text file." });
7603
7604export type ReleaseTerminalRequest = {
7605 readonly _meta?: { readonly [x: string]: unknown } | null;
7606 readonly sessionId: string;
7607 readonly terminalId: string;
7608};
7609export const ReleaseTerminalRequest = Schema.Struct({
7610 _meta: Schema.optionalKey(
7611 Schema.Union([
7612 Schema.Record(Schema.String, Schema.Unknown).annotate({
7613 description:
7614 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7615 }),
7616 Schema.Null,
7617 ]),
7618 ),
7619 sessionId: Schema.String.annotate({
7620 description:
7621 "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)",
7622 }),
7623 terminalId: Schema.String.annotate({ description: "The ID of the terminal to release." }),
7624}).annotate({ description: "Request to release a terminal and free its resources." });
7625
7626export type ReleaseTerminalResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
7627export const ReleaseTerminalResponse = Schema.Struct({
7628 _meta: Schema.optionalKey(
7629 Schema.Union([
7630 Schema.Record(Schema.String, Schema.Unknown).annotate({
7631 description:
7632 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7633 }),
7634 Schema.Null,
7635 ]),
7636 ),
7637}).annotate({ description: "Response to terminal/release method" });
7638
7639export type RequestPermissionOutcome =
7640 | { readonly outcome: "cancelled" }
7641 | {
7642 readonly outcome: "selected";
7643 readonly _meta?: { readonly [x: string]: unknown } | null;
7644 readonly optionId: string;
7645 };
7646export const RequestPermissionOutcome = Schema.Union(
7647 [
7648 Schema.Struct({ outcome: Schema.Literal("cancelled") }).annotate({
7649 description:
7650 "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)",
7651 }),
7652 Schema.Struct({
7653 outcome: Schema.Literal("selected"),
7654 _meta: Schema.optionalKey(
7655 Schema.Union([
7656 Schema.Record(Schema.String, Schema.Unknown).annotate({
7657 description:
7658 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7659 }),
7660 Schema.Null,
7661 ]),
7662 ),
7663 optionId: Schema.String.annotate({
7664 description: "Unique identifier for a permission option.",
7665 }),
7666 }).annotate({ description: "The user selected one of the provided options." }),
7667 ],
7668 { mode: "oneOf" },
7669).annotate({ description: "The outcome of a permission request." });
7670
7671export type RequestPermissionRequest = {
7672 readonly _meta?: { readonly [x: string]: unknown } | null;
7673 readonly options: ReadonlyArray<PermissionOption>;
7674 readonly sessionId: string;
7675 readonly toolCall: {
7676 readonly _meta?: { readonly [x: string]: unknown } | null;
7677 readonly content?: ReadonlyArray<ToolCallContent> | null;
7678 readonly kind?: ToolKind | null;
7679 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
7680 readonly rawInput?: unknown;
7681 readonly rawOutput?: unknown;
7682 readonly status?: ToolCallStatus | null;
7683 readonly title?: string | null;
7684 readonly toolCallId: string;
7685 };
7686};
7687export const RequestPermissionRequest = Schema.Struct({
7688 _meta: Schema.optionalKey(
7689 Schema.Union([
7690 Schema.Record(Schema.String, Schema.Unknown).annotate({
7691 description:
7692 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7693 }),
7694 Schema.Null,
7695 ]),
7696 ),
7697 options: Schema.Array(PermissionOption).annotate({
7698 description: "Available permission options for the user to choose from.",
7699 }),
7700 sessionId: Schema.String.annotate({
7701 description:
7702 "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)",
7703 }),
7704 toolCall: Schema.Struct({
7705 _meta: Schema.optionalKey(
7706 Schema.Union([
7707 Schema.Record(Schema.String, Schema.Unknown).annotate({
7708 description:
7709 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7710 }),
7711 Schema.Null,
7712 ]),
7713 ),
7714 content: Schema.optionalKey(
7715 Schema.Union([
7716 Schema.Array(ToolCallContent).annotate({ description: "Replace the content collection." }),
7717 Schema.Null,
7718 ]),
7719 ),
7720 kind: Schema.optionalKey(
7721 Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }),
7722 ),
7723 locations: Schema.optionalKey(
7724 Schema.Union([
7725 Schema.Array(ToolCallLocation).annotate({
7726 description: "Replace the locations collection.",
7727 }),
7728 Schema.Null,
7729 ]),
7730 ),
7731 rawInput: Schema.optionalKey(Schema.Unknown.annotate({ description: "Update the raw input." })),
7732 rawOutput: Schema.optionalKey(
7733 Schema.Unknown.annotate({ description: "Update the raw output." }),
7734 ),
7735 status: Schema.optionalKey(
7736 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
7737 description: "Update the execution status.",
7738 }),
7739 ),
7740 title: Schema.optionalKey(
7741 Schema.Union([
7742 Schema.String.annotate({ description: "Update the human-readable title." }),
7743 Schema.Null,
7744 ]),
7745 ),
7746 toolCallId: Schema.String.annotate({
7747 description: "Unique identifier for a tool call within a session.",
7748 }),
7749 }).annotate({
7750 description:
7751 "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)",
7752 }),
7753}).annotate({
7754 description:
7755 "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)",
7756});
7757
7758export type RequestPermissionResponse = {
7759 readonly _meta?: { readonly [x: string]: unknown } | null;
7760 readonly outcome:
7761 | { readonly outcome: "cancelled" }
7762 | {
7763 readonly outcome: "selected";
7764 readonly _meta?: { readonly [x: string]: unknown } | null;
7765 readonly optionId: string;
7766 };
7767};
7768export const RequestPermissionResponse = Schema.Struct({
7769 _meta: Schema.optionalKey(
7770 Schema.Union([
7771 Schema.Record(Schema.String, Schema.Unknown).annotate({
7772 description:
7773 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7774 }),
7775 Schema.Null,
7776 ]),
7777 ),
7778 outcome: Schema.Union(
7779 [
7780 Schema.Struct({ outcome: Schema.Literal("cancelled") }).annotate({
7781 description:
7782 "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)",
7783 }),
7784 Schema.Struct({
7785 outcome: Schema.Literal("selected"),
7786 _meta: Schema.optionalKey(
7787 Schema.Union([
7788 Schema.Record(Schema.String, Schema.Unknown).annotate({
7789 description:
7790 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7791 }),
7792 Schema.Null,
7793 ]),
7794 ),
7795 optionId: Schema.String.annotate({
7796 description: "Unique identifier for a permission option.",
7797 }),
7798 }).annotate({ description: "The user selected one of the provided options." }),
7799 ],
7800 { mode: "oneOf" },
7801 ).annotate({ description: "The outcome of a permission request." }),
7802}).annotate({ description: "Response to a permission request." });
7803
7804export type ResourceLink = {
7805 readonly _meta?: { readonly [x: string]: unknown } | null;
7806 readonly annotations?: Annotations | null;
7807 readonly description?: string | null;
7808 readonly mimeType?: string | null;
7809 readonly name: string;
7810 readonly size?: number | null;
7811 readonly title?: string | null;
7812 readonly uri: string;
7813};
7814export const ResourceLink = Schema.Struct({
7815 _meta: Schema.optionalKey(
7816 Schema.Union([
7817 Schema.Record(Schema.String, Schema.Unknown).annotate({
7818 description:
7819 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7820 }),
7821 Schema.Null,
7822 ]),
7823 ),
7824 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
7825 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
7826 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
7827 name: Schema.String,
7828 size: Schema.optionalKey(
7829 Schema.Union([Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()), Schema.Null]),
7830 ),
7831 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
7832 uri: Schema.String,
7833}).annotate({
7834 description:
7835 "A resource that the server is capable of reading, included in a prompt or tool call result.",
7836});
7837
7838export type ResumeSessionRequest = {
7839 readonly _meta?: { readonly [x: string]: unknown } | null;
7840 readonly cwd: string;
7841 readonly mcpServers?: ReadonlyArray<McpServer>;
7842 readonly sessionId: string;
7843};
7844export const ResumeSessionRequest = Schema.Struct({
7845 _meta: Schema.optionalKey(
7846 Schema.Union([
7847 Schema.Record(Schema.String, Schema.Unknown).annotate({
7848 description:
7849 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7850 }),
7851 Schema.Null,
7852 ]),
7853 ),
7854 cwd: Schema.String.annotate({ description: "The working directory for this session." }),
7855 mcpServers: Schema.optionalKey(
7856 Schema.Array(McpServer).annotate({
7857 description: "List of MCP servers to connect to for this session.",
7858 }),
7859 ),
7860 sessionId: Schema.String.annotate({
7861 description:
7862 "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)",
7863 }),
7864}).annotate({
7865 description:
7866 "**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.",
7867});
7868
7869export type ResumeSessionResponse = {
7870 readonly _meta?: { readonly [x: string]: unknown } | null;
7871 readonly configOptions?: ReadonlyArray<SessionConfigOption> | null;
7872 readonly models?: SessionModelState | null;
7873 readonly modes?: SessionModeState | null;
7874};
7875export const ResumeSessionResponse = Schema.Struct({
7876 _meta: Schema.optionalKey(
7877 Schema.Union([
7878 Schema.Record(Schema.String, Schema.Unknown).annotate({
7879 description:
7880 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7881 }),
7882 Schema.Null,
7883 ]),
7884 ),
7885 configOptions: Schema.optionalKey(
7886 Schema.Union([
7887 Schema.Array(SessionConfigOption).annotate({
7888 description: "Initial session configuration options if supported by the Agent.",
7889 }),
7890 Schema.Null,
7891 ]),
7892 ),
7893 models: Schema.optionalKey(
7894 Schema.Union([SessionModelState, Schema.Null]).annotate({
7895 description:
7896 "**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",
7897 }),
7898 ),
7899 modes: Schema.optionalKey(
7900 Schema.Union([SessionModeState, Schema.Null]).annotate({
7901 description:
7902 "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
7903 }),
7904 ),
7905}).annotate({
7906 description:
7907 "**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.",
7908});
7909
7910export type SelectedPermissionOutcome = {
7911 readonly _meta?: { readonly [x: string]: unknown } | null;
7912 readonly optionId: string;
7913};
7914export const SelectedPermissionOutcome = Schema.Struct({
7915 _meta: Schema.optionalKey(
7916 Schema.Union([
7917 Schema.Record(Schema.String, Schema.Unknown).annotate({
7918 description:
7919 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7920 }),
7921 Schema.Null,
7922 ]),
7923 ),
7924 optionId: Schema.String.annotate({ description: "Unique identifier for a permission option." }),
7925}).annotate({ description: "The user selected one of the provided options." });
7926
7927export type SessionCapabilities = {
7928 readonly _meta?: { readonly [x: string]: unknown } | null;
7929 readonly close?: SessionCloseCapabilities | null;
7930 readonly fork?: SessionForkCapabilities | null;
7931 readonly list?: SessionListCapabilities | null;
7932 readonly resume?: SessionResumeCapabilities | null;
7933};
7934export const SessionCapabilities = Schema.Struct({
7935 _meta: Schema.optionalKey(
7936 Schema.Union([
7937 Schema.Record(Schema.String, Schema.Unknown).annotate({
7938 description:
7939 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
7940 }),
7941 Schema.Null,
7942 ]),
7943 ),
7944 close: Schema.optionalKey(
7945 Schema.Union([SessionCloseCapabilities, Schema.Null]).annotate({
7946 description:
7947 "**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`.",
7948 }),
7949 ),
7950 fork: Schema.optionalKey(
7951 Schema.Union([SessionForkCapabilities, Schema.Null]).annotate({
7952 description:
7953 "**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`.",
7954 }),
7955 ),
7956 list: Schema.optionalKey(
7957 Schema.Union([SessionListCapabilities, Schema.Null]).annotate({
7958 description: "Whether the agent supports `session/list`.",
7959 }),
7960 ),
7961 resume: Schema.optionalKey(
7962 Schema.Union([SessionResumeCapabilities, Schema.Null]).annotate({
7963 description:
7964 "**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`.",
7965 }),
7966 ),
7967}).annotate({
7968 description:
7969 "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)",
7970});
7971
7972export type SessionConfigBoolean = { readonly currentValue: boolean };
7973export const SessionConfigBoolean = Schema.Struct({
7974 currentValue: Schema.Boolean.annotate({
7975 description: "The current value of the boolean option.",
7976 }),
7977}).annotate({
7978 description:
7979 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA boolean on/off toggle session configuration option payload.",
7980});
7981
7982export type SessionConfigGroupId = string;
7983export const SessionConfigGroupId = Schema.String.annotate({
7984 description: "Unique identifier for a session configuration option value group.",
7985});
7986
7987export type SessionConfigId = string;
7988export const SessionConfigId = Schema.String.annotate({
7989 description: "Unique identifier for a session configuration option.",
7990});
7991
7992export type SessionConfigSelect = {
7993 readonly currentValue: string;
7994 readonly options:
7995 | ReadonlyArray<SessionConfigSelectOption>
7996 | ReadonlyArray<SessionConfigSelectGroup>;
7997};
7998export const SessionConfigSelect = Schema.Struct({
7999 currentValue: Schema.String.annotate({
8000 description: "Unique identifier for a session configuration option value.",
8001 }),
8002 options: Schema.Union([
8003 Schema.Array(SessionConfigSelectOption).annotate({
8004 title: "Ungrouped",
8005 description: "A flat list of options with no grouping.",
8006 }),
8007 Schema.Array(SessionConfigSelectGroup).annotate({
8008 title: "Grouped",
8009 description: "A list of options grouped under headers.",
8010 }),
8011 ]).annotate({ description: "Possible values for a session configuration option." }),
8012}).annotate({
8013 description: "A single-value selector (dropdown) session configuration option payload.",
8014});
8015
8016export type SessionConfigSelectOptions =
8017 | ReadonlyArray<SessionConfigSelectOption>
8018 | ReadonlyArray<SessionConfigSelectGroup>;
8019export const SessionConfigSelectOptions = Schema.Union([
8020 Schema.Array(SessionConfigSelectOption).annotate({
8021 title: "Ungrouped",
8022 description: "A flat list of options with no grouping.",
8023 }),
8024 Schema.Array(SessionConfigSelectGroup).annotate({
8025 title: "Grouped",
8026 description: "A list of options grouped under headers.",
8027 }),
8028]).annotate({ description: "Possible values for a session configuration option." });
8029
8030export type SessionConfigValueId = string;
8031export const SessionConfigValueId = Schema.String.annotate({
8032 description: "Unique identifier for a session configuration option value.",
8033});
8034
8035export type SessionId = string;
8036export const SessionId = Schema.String.annotate({
8037 description:
8038 "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)",
8039});
8040
8041export type SessionInfoUpdate = {
8042 readonly _meta?: { readonly [x: string]: unknown } | null;
8043 readonly title?: string | null;
8044 readonly updatedAt?: string | null;
8045};
8046export const SessionInfoUpdate = Schema.Struct({
8047 _meta: Schema.optionalKey(
8048 Schema.Union([
8049 Schema.Record(Schema.String, Schema.Unknown).annotate({
8050 description:
8051 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8052 }),
8053 Schema.Null,
8054 ]),
8055 ),
8056 title: Schema.optionalKey(
8057 Schema.Union([
8058 Schema.String.annotate({
8059 description: "Human-readable title for the session. Set to null to clear.",
8060 }),
8061 Schema.Null,
8062 ]),
8063 ),
8064 updatedAt: Schema.optionalKey(
8065 Schema.Union([
8066 Schema.String.annotate({
8067 description: "ISO 8601 timestamp of last activity. Set to null to clear.",
8068 }),
8069 Schema.Null,
8070 ]),
8071 ),
8072}).annotate({
8073 description:
8074 "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.",
8075});
8076
8077export type SessionNotification = {
8078 readonly _meta?: { readonly [x: string]: unknown } | null;
8079 readonly sessionId: string;
8080 readonly update:
8081 | {
8082 readonly sessionUpdate: "user_message_chunk";
8083 readonly _meta?: { readonly [x: string]: unknown } | null;
8084 readonly content:
8085 | {
8086 readonly type: "text";
8087 readonly _meta?: { readonly [x: string]: unknown } | null;
8088 readonly annotations?: Annotations | null;
8089 readonly text: string;
8090 }
8091 | {
8092 readonly type: "image";
8093 readonly _meta?: { readonly [x: string]: unknown } | null;
8094 readonly annotations?: Annotations | null;
8095 readonly data: string;
8096 readonly mimeType: string;
8097 readonly uri?: string | null;
8098 }
8099 | {
8100 readonly type: "audio";
8101 readonly _meta?: { readonly [x: string]: unknown } | null;
8102 readonly annotations?: Annotations | null;
8103 readonly data: string;
8104 readonly mimeType: string;
8105 }
8106 | {
8107 readonly type: "resource_link";
8108 readonly _meta?: { readonly [x: string]: unknown } | null;
8109 readonly annotations?: Annotations | null;
8110 readonly description?: string | null;
8111 readonly mimeType?: string | null;
8112 readonly name: string;
8113 readonly size?: number | null;
8114 readonly title?: string | null;
8115 readonly uri: string;
8116 }
8117 | {
8118 readonly type: "resource";
8119 readonly _meta?: { readonly [x: string]: unknown } | null;
8120 readonly annotations?: Annotations | null;
8121 readonly resource: EmbeddedResourceResource;
8122 };
8123 readonly messageId?: string | null;
8124 }
8125 | {
8126 readonly sessionUpdate: "agent_message_chunk";
8127 readonly _meta?: { readonly [x: string]: unknown } | null;
8128 readonly content:
8129 | {
8130 readonly type: "text";
8131 readonly _meta?: { readonly [x: string]: unknown } | null;
8132 readonly annotations?: Annotations | null;
8133 readonly text: string;
8134 }
8135 | {
8136 readonly type: "image";
8137 readonly _meta?: { readonly [x: string]: unknown } | null;
8138 readonly annotations?: Annotations | null;
8139 readonly data: string;
8140 readonly mimeType: string;
8141 readonly uri?: string | null;
8142 }
8143 | {
8144 readonly type: "audio";
8145 readonly _meta?: { readonly [x: string]: unknown } | null;
8146 readonly annotations?: Annotations | null;
8147 readonly data: string;
8148 readonly mimeType: string;
8149 }
8150 | {
8151 readonly type: "resource_link";
8152 readonly _meta?: { readonly [x: string]: unknown } | null;
8153 readonly annotations?: Annotations | null;
8154 readonly description?: string | null;
8155 readonly mimeType?: string | null;
8156 readonly name: string;
8157 readonly size?: number | null;
8158 readonly title?: string | null;
8159 readonly uri: string;
8160 }
8161 | {
8162 readonly type: "resource";
8163 readonly _meta?: { readonly [x: string]: unknown } | null;
8164 readonly annotations?: Annotations | null;
8165 readonly resource: EmbeddedResourceResource;
8166 };
8167 readonly messageId?: string | null;
8168 }
8169 | {
8170 readonly sessionUpdate: "agent_thought_chunk";
8171 readonly _meta?: { readonly [x: string]: unknown } | null;
8172 readonly content:
8173 | {
8174 readonly type: "text";
8175 readonly _meta?: { readonly [x: string]: unknown } | null;
8176 readonly annotations?: Annotations | null;
8177 readonly text: string;
8178 }
8179 | {
8180 readonly type: "image";
8181 readonly _meta?: { readonly [x: string]: unknown } | null;
8182 readonly annotations?: Annotations | null;
8183 readonly data: string;
8184 readonly mimeType: string;
8185 readonly uri?: string | null;
8186 }
8187 | {
8188 readonly type: "audio";
8189 readonly _meta?: { readonly [x: string]: unknown } | null;
8190 readonly annotations?: Annotations | null;
8191 readonly data: string;
8192 readonly mimeType: string;
8193 }
8194 | {
8195 readonly type: "resource_link";
8196 readonly _meta?: { readonly [x: string]: unknown } | null;
8197 readonly annotations?: Annotations | null;
8198 readonly description?: string | null;
8199 readonly mimeType?: string | null;
8200 readonly name: string;
8201 readonly size?: number | null;
8202 readonly title?: string | null;
8203 readonly uri: string;
8204 }
8205 | {
8206 readonly type: "resource";
8207 readonly _meta?: { readonly [x: string]: unknown } | null;
8208 readonly annotations?: Annotations | null;
8209 readonly resource: EmbeddedResourceResource;
8210 };
8211 readonly messageId?: string | null;
8212 }
8213 | {
8214 readonly sessionUpdate: "tool_call";
8215 readonly _meta?: { readonly [x: string]: unknown } | null;
8216 readonly content?: ReadonlyArray<ToolCallContent>;
8217 readonly kind?:
8218 | "read"
8219 | "edit"
8220 | "delete"
8221 | "move"
8222 | "search"
8223 | "execute"
8224 | "think"
8225 | "fetch"
8226 | "switch_mode"
8227 | "other";
8228 readonly locations?: ReadonlyArray<ToolCallLocation>;
8229 readonly rawInput?: unknown;
8230 readonly rawOutput?: unknown;
8231 readonly status?: "pending" | "in_progress" | "completed" | "failed";
8232 readonly title: string;
8233 readonly toolCallId: string;
8234 }
8235 | {
8236 readonly sessionUpdate: "tool_call_update";
8237 readonly _meta?: { readonly [x: string]: unknown } | null;
8238 readonly content?: ReadonlyArray<ToolCallContent> | null;
8239 readonly kind?: ToolKind | null;
8240 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
8241 readonly rawInput?: unknown;
8242 readonly rawOutput?: unknown;
8243 readonly status?: ToolCallStatus | null;
8244 readonly title?: string | null;
8245 readonly toolCallId: string;
8246 }
8247 | {
8248 readonly sessionUpdate: "plan";
8249 readonly _meta?: { readonly [x: string]: unknown } | null;
8250 readonly entries: ReadonlyArray<PlanEntry>;
8251 }
8252 | {
8253 readonly sessionUpdate: "available_commands_update";
8254 readonly _meta?: { readonly [x: string]: unknown } | null;
8255 readonly availableCommands: ReadonlyArray<AvailableCommand>;
8256 }
8257 | {
8258 readonly sessionUpdate: "current_mode_update";
8259 readonly _meta?: { readonly [x: string]: unknown } | null;
8260 readonly currentModeId: string;
8261 }
8262 | {
8263 readonly sessionUpdate: "config_option_update";
8264 readonly _meta?: { readonly [x: string]: unknown } | null;
8265 readonly configOptions: ReadonlyArray<SessionConfigOption>;
8266 }
8267 | {
8268 readonly sessionUpdate: "session_info_update";
8269 readonly _meta?: { readonly [x: string]: unknown } | null;
8270 readonly title?: string | null;
8271 readonly updatedAt?: string | null;
8272 }
8273 | {
8274 readonly sessionUpdate: "usage_update";
8275 readonly _meta?: { readonly [x: string]: unknown } | null;
8276 readonly cost?: Cost | null;
8277 readonly size: number;
8278 readonly used: number;
8279 };
8280};
8281export const SessionNotification = Schema.Struct({
8282 _meta: Schema.optionalKey(
8283 Schema.Union([
8284 Schema.Record(Schema.String, Schema.Unknown).annotate({
8285 description:
8286 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8287 }),
8288 Schema.Null,
8289 ]),
8290 ),
8291 sessionId: Schema.String.annotate({
8292 description:
8293 "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)",
8294 }),
8295 update: Schema.Union(
8296 [
8297 Schema.Struct({
8298 sessionUpdate: Schema.Literal("user_message_chunk"),
8299 _meta: Schema.optionalKey(
8300 Schema.Union([
8301 Schema.Record(Schema.String, Schema.Unknown).annotate({
8302 description:
8303 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8304 }),
8305 Schema.Null,
8306 ]),
8307 ),
8308 content: Schema.Union(
8309 [
8310 Schema.Struct({
8311 type: Schema.Literal("text"),
8312 _meta: Schema.optionalKey(
8313 Schema.Union([
8314 Schema.Record(Schema.String, Schema.Unknown).annotate({
8315 description:
8316 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8317 }),
8318 Schema.Null,
8319 ]),
8320 ),
8321 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8322 text: Schema.String,
8323 }).annotate({ description: "Text provided to or from an LLM." }),
8324 Schema.Struct({
8325 type: Schema.Literal("image"),
8326 _meta: Schema.optionalKey(
8327 Schema.Union([
8328 Schema.Record(Schema.String, Schema.Unknown).annotate({
8329 description:
8330 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8331 }),
8332 Schema.Null,
8333 ]),
8334 ),
8335 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8336 data: Schema.String,
8337 mimeType: Schema.String,
8338 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8339 }).annotate({ description: "An image provided to or from an LLM." }),
8340 Schema.Struct({
8341 type: Schema.Literal("audio"),
8342 _meta: Schema.optionalKey(
8343 Schema.Union([
8344 Schema.Record(Schema.String, Schema.Unknown).annotate({
8345 description:
8346 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8347 }),
8348 Schema.Null,
8349 ]),
8350 ),
8351 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8352 data: Schema.String,
8353 mimeType: Schema.String,
8354 }).annotate({ description: "Audio provided to or from an LLM." }),
8355 Schema.Struct({
8356 type: Schema.Literal("resource_link"),
8357 _meta: Schema.optionalKey(
8358 Schema.Union([
8359 Schema.Record(Schema.String, Schema.Unknown).annotate({
8360 description:
8361 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8362 }),
8363 Schema.Null,
8364 ]),
8365 ),
8366 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8367 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8368 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8369 name: Schema.String,
8370 size: Schema.optionalKey(
8371 Schema.Union([
8372 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
8373 Schema.Null,
8374 ]),
8375 ),
8376 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8377 uri: Schema.String,
8378 }).annotate({
8379 description:
8380 "A resource that the server is capable of reading, included in a prompt or tool call result.",
8381 }),
8382 Schema.Struct({
8383 type: Schema.Literal("resource"),
8384 _meta: Schema.optionalKey(
8385 Schema.Union([
8386 Schema.Record(Schema.String, Schema.Unknown).annotate({
8387 description:
8388 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8389 }),
8390 Schema.Null,
8391 ]),
8392 ),
8393 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8394 resource: EmbeddedResourceResource,
8395 }).annotate({
8396 description:
8397 "The contents of a resource, embedded into a prompt or tool call result.",
8398 }),
8399 ],
8400 { mode: "oneOf" },
8401 ).annotate({
8402 description:
8403 "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)",
8404 }),
8405 messageId: Schema.optionalKey(
8406 Schema.Union([
8407 Schema.String.annotate({
8408 description:
8409 "**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.",
8410 }),
8411 Schema.Null,
8412 ]),
8413 ),
8414 }).annotate({ description: "A streamed item of content" }),
8415 Schema.Struct({
8416 sessionUpdate: Schema.Literal("agent_message_chunk"),
8417 _meta: Schema.optionalKey(
8418 Schema.Union([
8419 Schema.Record(Schema.String, Schema.Unknown).annotate({
8420 description:
8421 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8422 }),
8423 Schema.Null,
8424 ]),
8425 ),
8426 content: Schema.Union(
8427 [
8428 Schema.Struct({
8429 type: Schema.Literal("text"),
8430 _meta: Schema.optionalKey(
8431 Schema.Union([
8432 Schema.Record(Schema.String, Schema.Unknown).annotate({
8433 description:
8434 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8435 }),
8436 Schema.Null,
8437 ]),
8438 ),
8439 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8440 text: Schema.String,
8441 }).annotate({ description: "Text provided to or from an LLM." }),
8442 Schema.Struct({
8443 type: Schema.Literal("image"),
8444 _meta: Schema.optionalKey(
8445 Schema.Union([
8446 Schema.Record(Schema.String, Schema.Unknown).annotate({
8447 description:
8448 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8449 }),
8450 Schema.Null,
8451 ]),
8452 ),
8453 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8454 data: Schema.String,
8455 mimeType: Schema.String,
8456 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8457 }).annotate({ description: "An image provided to or from an LLM." }),
8458 Schema.Struct({
8459 type: Schema.Literal("audio"),
8460 _meta: Schema.optionalKey(
8461 Schema.Union([
8462 Schema.Record(Schema.String, Schema.Unknown).annotate({
8463 description:
8464 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8465 }),
8466 Schema.Null,
8467 ]),
8468 ),
8469 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8470 data: Schema.String,
8471 mimeType: Schema.String,
8472 }).annotate({ description: "Audio provided to or from an LLM." }),
8473 Schema.Struct({
8474 type: Schema.Literal("resource_link"),
8475 _meta: Schema.optionalKey(
8476 Schema.Union([
8477 Schema.Record(Schema.String, Schema.Unknown).annotate({
8478 description:
8479 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8480 }),
8481 Schema.Null,
8482 ]),
8483 ),
8484 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8485 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8486 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8487 name: Schema.String,
8488 size: Schema.optionalKey(
8489 Schema.Union([
8490 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
8491 Schema.Null,
8492 ]),
8493 ),
8494 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8495 uri: Schema.String,
8496 }).annotate({
8497 description:
8498 "A resource that the server is capable of reading, included in a prompt or tool call result.",
8499 }),
8500 Schema.Struct({
8501 type: Schema.Literal("resource"),
8502 _meta: Schema.optionalKey(
8503 Schema.Union([
8504 Schema.Record(Schema.String, Schema.Unknown).annotate({
8505 description:
8506 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8507 }),
8508 Schema.Null,
8509 ]),
8510 ),
8511 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8512 resource: EmbeddedResourceResource,
8513 }).annotate({
8514 description:
8515 "The contents of a resource, embedded into a prompt or tool call result.",
8516 }),
8517 ],
8518 { mode: "oneOf" },
8519 ).annotate({
8520 description:
8521 "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)",
8522 }),
8523 messageId: Schema.optionalKey(
8524 Schema.Union([
8525 Schema.String.annotate({
8526 description:
8527 "**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.",
8528 }),
8529 Schema.Null,
8530 ]),
8531 ),
8532 }).annotate({ description: "A streamed item of content" }),
8533 Schema.Struct({
8534 sessionUpdate: Schema.Literal("agent_thought_chunk"),
8535 _meta: Schema.optionalKey(
8536 Schema.Union([
8537 Schema.Record(Schema.String, Schema.Unknown).annotate({
8538 description:
8539 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8540 }),
8541 Schema.Null,
8542 ]),
8543 ),
8544 content: Schema.Union(
8545 [
8546 Schema.Struct({
8547 type: Schema.Literal("text"),
8548 _meta: Schema.optionalKey(
8549 Schema.Union([
8550 Schema.Record(Schema.String, Schema.Unknown).annotate({
8551 description:
8552 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8553 }),
8554 Schema.Null,
8555 ]),
8556 ),
8557 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8558 text: Schema.String,
8559 }).annotate({ description: "Text provided to or from an LLM." }),
8560 Schema.Struct({
8561 type: Schema.Literal("image"),
8562 _meta: Schema.optionalKey(
8563 Schema.Union([
8564 Schema.Record(Schema.String, Schema.Unknown).annotate({
8565 description:
8566 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8567 }),
8568 Schema.Null,
8569 ]),
8570 ),
8571 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8572 data: Schema.String,
8573 mimeType: Schema.String,
8574 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8575 }).annotate({ description: "An image provided to or from an LLM." }),
8576 Schema.Struct({
8577 type: Schema.Literal("audio"),
8578 _meta: Schema.optionalKey(
8579 Schema.Union([
8580 Schema.Record(Schema.String, Schema.Unknown).annotate({
8581 description:
8582 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8583 }),
8584 Schema.Null,
8585 ]),
8586 ),
8587 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8588 data: Schema.String,
8589 mimeType: Schema.String,
8590 }).annotate({ description: "Audio provided to or from an LLM." }),
8591 Schema.Struct({
8592 type: Schema.Literal("resource_link"),
8593 _meta: Schema.optionalKey(
8594 Schema.Union([
8595 Schema.Record(Schema.String, Schema.Unknown).annotate({
8596 description:
8597 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8598 }),
8599 Schema.Null,
8600 ]),
8601 ),
8602 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8603 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8604 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8605 name: Schema.String,
8606 size: Schema.optionalKey(
8607 Schema.Union([
8608 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
8609 Schema.Null,
8610 ]),
8611 ),
8612 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
8613 uri: Schema.String,
8614 }).annotate({
8615 description:
8616 "A resource that the server is capable of reading, included in a prompt or tool call result.",
8617 }),
8618 Schema.Struct({
8619 type: Schema.Literal("resource"),
8620 _meta: Schema.optionalKey(
8621 Schema.Union([
8622 Schema.Record(Schema.String, Schema.Unknown).annotate({
8623 description:
8624 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8625 }),
8626 Schema.Null,
8627 ]),
8628 ),
8629 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
8630 resource: EmbeddedResourceResource,
8631 }).annotate({
8632 description:
8633 "The contents of a resource, embedded into a prompt or tool call result.",
8634 }),
8635 ],
8636 { mode: "oneOf" },
8637 ).annotate({
8638 description:
8639 "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)",
8640 }),
8641 messageId: Schema.optionalKey(
8642 Schema.Union([
8643 Schema.String.annotate({
8644 description:
8645 "**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.",
8646 }),
8647 Schema.Null,
8648 ]),
8649 ),
8650 }).annotate({ description: "A streamed item of content" }),
8651 Schema.Struct({
8652 sessionUpdate: Schema.Literal("tool_call"),
8653 _meta: Schema.optionalKey(
8654 Schema.Union([
8655 Schema.Record(Schema.String, Schema.Unknown).annotate({
8656 description:
8657 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8658 }),
8659 Schema.Null,
8660 ]),
8661 ),
8662 content: Schema.optionalKey(
8663 Schema.Array(ToolCallContent).annotate({
8664 description: "Content produced by the tool call.",
8665 }),
8666 ),
8667 kind: Schema.optionalKey(
8668 Schema.Literals([
8669 "read",
8670 "edit",
8671 "delete",
8672 "move",
8673 "search",
8674 "execute",
8675 "think",
8676 "fetch",
8677 "switch_mode",
8678 "other",
8679 ]).annotate({
8680 description:
8681 "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)",
8682 }),
8683 ),
8684 locations: Schema.optionalKey(
8685 Schema.Array(ToolCallLocation).annotate({
8686 description:
8687 'File locations affected by this tool call.\nEnables "follow-along" features in clients.',
8688 }),
8689 ),
8690 rawInput: Schema.optionalKey(
8691 Schema.Unknown.annotate({ description: "Raw input parameters sent to the tool." }),
8692 ),
8693 rawOutput: Schema.optionalKey(
8694 Schema.Unknown.annotate({ description: "Raw output returned by the tool." }),
8695 ),
8696 status: Schema.optionalKey(
8697 Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({
8698 description:
8699 "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)",
8700 }),
8701 ),
8702 title: Schema.String.annotate({
8703 description: "Human-readable title describing what the tool is doing.",
8704 }),
8705 toolCallId: Schema.String.annotate({
8706 description: "Unique identifier for a tool call within a session.",
8707 }),
8708 }).annotate({
8709 description:
8710 "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)",
8711 }),
8712 Schema.Struct({
8713 sessionUpdate: Schema.Literal("tool_call_update"),
8714 _meta: Schema.optionalKey(
8715 Schema.Union([
8716 Schema.Record(Schema.String, Schema.Unknown).annotate({
8717 description:
8718 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8719 }),
8720 Schema.Null,
8721 ]),
8722 ),
8723 content: Schema.optionalKey(
8724 Schema.Union([
8725 Schema.Array(ToolCallContent).annotate({
8726 description: "Replace the content collection.",
8727 }),
8728 Schema.Null,
8729 ]),
8730 ),
8731 kind: Schema.optionalKey(
8732 Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }),
8733 ),
8734 locations: Schema.optionalKey(
8735 Schema.Union([
8736 Schema.Array(ToolCallLocation).annotate({
8737 description: "Replace the locations collection.",
8738 }),
8739 Schema.Null,
8740 ]),
8741 ),
8742 rawInput: Schema.optionalKey(
8743 Schema.Unknown.annotate({ description: "Update the raw input." }),
8744 ),
8745 rawOutput: Schema.optionalKey(
8746 Schema.Unknown.annotate({ description: "Update the raw output." }),
8747 ),
8748 status: Schema.optionalKey(
8749 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
8750 description: "Update the execution status.",
8751 }),
8752 ),
8753 title: Schema.optionalKey(
8754 Schema.Union([
8755 Schema.String.annotate({ description: "Update the human-readable title." }),
8756 Schema.Null,
8757 ]),
8758 ),
8759 toolCallId: Schema.String.annotate({
8760 description: "Unique identifier for a tool call within a session.",
8761 }),
8762 }).annotate({
8763 description:
8764 "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)",
8765 }),
8766 Schema.Struct({
8767 sessionUpdate: Schema.Literal("plan"),
8768 _meta: Schema.optionalKey(
8769 Schema.Union([
8770 Schema.Record(Schema.String, Schema.Unknown).annotate({
8771 description:
8772 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8773 }),
8774 Schema.Null,
8775 ]),
8776 ),
8777 entries: Schema.Array(PlanEntry).annotate({
8778 description:
8779 "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.",
8780 }),
8781 }).annotate({
8782 description:
8783 "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)",
8784 }),
8785 Schema.Struct({
8786 sessionUpdate: Schema.Literal("available_commands_update"),
8787 _meta: Schema.optionalKey(
8788 Schema.Union([
8789 Schema.Record(Schema.String, Schema.Unknown).annotate({
8790 description:
8791 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8792 }),
8793 Schema.Null,
8794 ]),
8795 ),
8796 availableCommands: Schema.Array(AvailableCommand).annotate({
8797 description: "Commands the agent can execute",
8798 }),
8799 }).annotate({ description: "Available commands are ready or have changed" }),
8800 Schema.Struct({
8801 sessionUpdate: Schema.Literal("current_mode_update"),
8802 _meta: Schema.optionalKey(
8803 Schema.Union([
8804 Schema.Record(Schema.String, Schema.Unknown).annotate({
8805 description:
8806 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8807 }),
8808 Schema.Null,
8809 ]),
8810 ),
8811 currentModeId: Schema.String.annotate({
8812 description: "Unique identifier for a Session Mode.",
8813 }),
8814 }).annotate({
8815 description:
8816 "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
8817 }),
8818 Schema.Struct({
8819 sessionUpdate: Schema.Literal("config_option_update"),
8820 _meta: Schema.optionalKey(
8821 Schema.Union([
8822 Schema.Record(Schema.String, Schema.Unknown).annotate({
8823 description:
8824 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8825 }),
8826 Schema.Null,
8827 ]),
8828 ),
8829 configOptions: Schema.Array(SessionConfigOption).annotate({
8830 description: "The full set of configuration options and their current values.",
8831 }),
8832 }).annotate({ description: "Session configuration options have been updated." }),
8833 Schema.Struct({
8834 sessionUpdate: Schema.Literal("session_info_update"),
8835 _meta: Schema.optionalKey(
8836 Schema.Union([
8837 Schema.Record(Schema.String, Schema.Unknown).annotate({
8838 description:
8839 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8840 }),
8841 Schema.Null,
8842 ]),
8843 ),
8844 title: Schema.optionalKey(
8845 Schema.Union([
8846 Schema.String.annotate({
8847 description: "Human-readable title for the session. Set to null to clear.",
8848 }),
8849 Schema.Null,
8850 ]),
8851 ),
8852 updatedAt: Schema.optionalKey(
8853 Schema.Union([
8854 Schema.String.annotate({
8855 description: "ISO 8601 timestamp of last activity. Set to null to clear.",
8856 }),
8857 Schema.Null,
8858 ]),
8859 ),
8860 }).annotate({
8861 description:
8862 "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.",
8863 }),
8864 Schema.Struct({
8865 sessionUpdate: Schema.Literal("usage_update"),
8866 _meta: Schema.optionalKey(
8867 Schema.Union([
8868 Schema.Record(Schema.String, Schema.Unknown).annotate({
8869 description:
8870 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
8871 }),
8872 Schema.Null,
8873 ]),
8874 ),
8875 cost: Schema.optionalKey(
8876 Schema.Union([Cost, Schema.Null]).annotate({
8877 description: "Cumulative session cost (optional).",
8878 }),
8879 ),
8880 size: Schema.Number.annotate({
8881 description: "Total context window size in tokens.",
8882 format: "uint64",
8883 })
8884 .check(Schema.isInt())
8885 .check(Schema.isGreaterThanOrEqualTo(0)),
8886 used: Schema.Number.annotate({
8887 description: "Tokens currently in context.",
8888 format: "uint64",
8889 })
8890 .check(Schema.isInt())
8891 .check(Schema.isGreaterThanOrEqualTo(0)),
8892 }).annotate({
8893 description:
8894 "**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.",
8895 }),
8896 ],
8897 { mode: "oneOf" },
8898 ).annotate({
8899 description:
8900 "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)",
8901 }),
8902}).annotate({
8903 description:
8904 "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)",
8905});
8906
8907export type SessionUpdate =
8908 | {
8909 readonly sessionUpdate: "user_message_chunk";
8910 readonly _meta?: { readonly [x: string]: unknown } | null;
8911 readonly content:
8912 | {
8913 readonly type: "text";
8914 readonly _meta?: { readonly [x: string]: unknown } | null;
8915 readonly annotations?: Annotations | null;
8916 readonly text: string;
8917 }
8918 | {
8919 readonly type: "image";
8920 readonly _meta?: { readonly [x: string]: unknown } | null;
8921 readonly annotations?: Annotations | null;
8922 readonly data: string;
8923 readonly mimeType: string;
8924 readonly uri?: string | null;
8925 }
8926 | {
8927 readonly type: "audio";
8928 readonly _meta?: { readonly [x: string]: unknown } | null;
8929 readonly annotations?: Annotations | null;
8930 readonly data: string;
8931 readonly mimeType: string;
8932 }
8933 | {
8934 readonly type: "resource_link";
8935 readonly _meta?: { readonly [x: string]: unknown } | null;
8936 readonly annotations?: Annotations | null;
8937 readonly description?: string | null;
8938 readonly mimeType?: string | null;
8939 readonly name: string;
8940 readonly size?: number | null;
8941 readonly title?: string | null;
8942 readonly uri: string;
8943 }
8944 | {
8945 readonly type: "resource";
8946 readonly _meta?: { readonly [x: string]: unknown } | null;
8947 readonly annotations?: Annotations | null;
8948 readonly resource: EmbeddedResourceResource;
8949 };
8950 readonly messageId?: string | null;
8951 }
8952 | {
8953 readonly sessionUpdate: "agent_message_chunk";
8954 readonly _meta?: { readonly [x: string]: unknown } | null;
8955 readonly content:
8956 | {
8957 readonly type: "text";
8958 readonly _meta?: { readonly [x: string]: unknown } | null;
8959 readonly annotations?: Annotations | null;
8960 readonly text: string;
8961 }
8962 | {
8963 readonly type: "image";
8964 readonly _meta?: { readonly [x: string]: unknown } | null;
8965 readonly annotations?: Annotations | null;
8966 readonly data: string;
8967 readonly mimeType: string;
8968 readonly uri?: string | null;
8969 }
8970 | {
8971 readonly type: "audio";
8972 readonly _meta?: { readonly [x: string]: unknown } | null;
8973 readonly annotations?: Annotations | null;
8974 readonly data: string;
8975 readonly mimeType: string;
8976 }
8977 | {
8978 readonly type: "resource_link";
8979 readonly _meta?: { readonly [x: string]: unknown } | null;
8980 readonly annotations?: Annotations | null;
8981 readonly description?: string | null;
8982 readonly mimeType?: string | null;
8983 readonly name: string;
8984 readonly size?: number | null;
8985 readonly title?: string | null;
8986 readonly uri: string;
8987 }
8988 | {
8989 readonly type: "resource";
8990 readonly _meta?: { readonly [x: string]: unknown } | null;
8991 readonly annotations?: Annotations | null;
8992 readonly resource: EmbeddedResourceResource;
8993 };
8994 readonly messageId?: string | null;
8995 }
8996 | {
8997 readonly sessionUpdate: "agent_thought_chunk";
8998 readonly _meta?: { readonly [x: string]: unknown } | null;
8999 readonly content:
9000 | {
9001 readonly type: "text";
9002 readonly _meta?: { readonly [x: string]: unknown } | null;
9003 readonly annotations?: Annotations | null;
9004 readonly text: string;
9005 }
9006 | {
9007 readonly type: "image";
9008 readonly _meta?: { readonly [x: string]: unknown } | null;
9009 readonly annotations?: Annotations | null;
9010 readonly data: string;
9011 readonly mimeType: string;
9012 readonly uri?: string | null;
9013 }
9014 | {
9015 readonly type: "audio";
9016 readonly _meta?: { readonly [x: string]: unknown } | null;
9017 readonly annotations?: Annotations | null;
9018 readonly data: string;
9019 readonly mimeType: string;
9020 }
9021 | {
9022 readonly type: "resource_link";
9023 readonly _meta?: { readonly [x: string]: unknown } | null;
9024 readonly annotations?: Annotations | null;
9025 readonly description?: string | null;
9026 readonly mimeType?: string | null;
9027 readonly name: string;
9028 readonly size?: number | null;
9029 readonly title?: string | null;
9030 readonly uri: string;
9031 }
9032 | {
9033 readonly type: "resource";
9034 readonly _meta?: { readonly [x: string]: unknown } | null;
9035 readonly annotations?: Annotations | null;
9036 readonly resource: EmbeddedResourceResource;
9037 };
9038 readonly messageId?: string | null;
9039 }
9040 | {
9041 readonly sessionUpdate: "tool_call";
9042 readonly _meta?: { readonly [x: string]: unknown } | null;
9043 readonly content?: ReadonlyArray<ToolCallContent>;
9044 readonly kind?:
9045 | "read"
9046 | "edit"
9047 | "delete"
9048 | "move"
9049 | "search"
9050 | "execute"
9051 | "think"
9052 | "fetch"
9053 | "switch_mode"
9054 | "other";
9055 readonly locations?: ReadonlyArray<ToolCallLocation>;
9056 readonly rawInput?: unknown;
9057 readonly rawOutput?: unknown;
9058 readonly status?: "pending" | "in_progress" | "completed" | "failed";
9059 readonly title: string;
9060 readonly toolCallId: string;
9061 }
9062 | {
9063 readonly sessionUpdate: "tool_call_update";
9064 readonly _meta?: { readonly [x: string]: unknown } | null;
9065 readonly content?: ReadonlyArray<ToolCallContent> | null;
9066 readonly kind?: ToolKind | null;
9067 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
9068 readonly rawInput?: unknown;
9069 readonly rawOutput?: unknown;
9070 readonly status?: ToolCallStatus | null;
9071 readonly title?: string | null;
9072 readonly toolCallId: string;
9073 }
9074 | {
9075 readonly sessionUpdate: "plan";
9076 readonly _meta?: { readonly [x: string]: unknown } | null;
9077 readonly entries: ReadonlyArray<PlanEntry>;
9078 }
9079 | {
9080 readonly sessionUpdate: "available_commands_update";
9081 readonly _meta?: { readonly [x: string]: unknown } | null;
9082 readonly availableCommands: ReadonlyArray<AvailableCommand>;
9083 }
9084 | {
9085 readonly sessionUpdate: "current_mode_update";
9086 readonly _meta?: { readonly [x: string]: unknown } | null;
9087 readonly currentModeId: string;
9088 }
9089 | {
9090 readonly sessionUpdate: "config_option_update";
9091 readonly _meta?: { readonly [x: string]: unknown } | null;
9092 readonly configOptions: ReadonlyArray<SessionConfigOption>;
9093 }
9094 | {
9095 readonly sessionUpdate: "session_info_update";
9096 readonly _meta?: { readonly [x: string]: unknown } | null;
9097 readonly title?: string | null;
9098 readonly updatedAt?: string | null;
9099 }
9100 | {
9101 readonly sessionUpdate: "usage_update";
9102 readonly _meta?: { readonly [x: string]: unknown } | null;
9103 readonly cost?: Cost | null;
9104 readonly size: number;
9105 readonly used: number;
9106 };
9107export const SessionUpdate = Schema.Union(
9108 [
9109 Schema.Struct({
9110 sessionUpdate: Schema.Literal("user_message_chunk"),
9111 _meta: Schema.optionalKey(
9112 Schema.Union([
9113 Schema.Record(Schema.String, Schema.Unknown).annotate({
9114 description:
9115 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9116 }),
9117 Schema.Null,
9118 ]),
9119 ),
9120 content: Schema.Union(
9121 [
9122 Schema.Struct({
9123 type: Schema.Literal("text"),
9124 _meta: Schema.optionalKey(
9125 Schema.Union([
9126 Schema.Record(Schema.String, Schema.Unknown).annotate({
9127 description:
9128 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9129 }),
9130 Schema.Null,
9131 ]),
9132 ),
9133 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9134 text: Schema.String,
9135 }).annotate({ description: "Text provided to or from an LLM." }),
9136 Schema.Struct({
9137 type: Schema.Literal("image"),
9138 _meta: Schema.optionalKey(
9139 Schema.Union([
9140 Schema.Record(Schema.String, Schema.Unknown).annotate({
9141 description:
9142 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9143 }),
9144 Schema.Null,
9145 ]),
9146 ),
9147 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9148 data: Schema.String,
9149 mimeType: Schema.String,
9150 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9151 }).annotate({ description: "An image provided to or from an LLM." }),
9152 Schema.Struct({
9153 type: Schema.Literal("audio"),
9154 _meta: Schema.optionalKey(
9155 Schema.Union([
9156 Schema.Record(Schema.String, Schema.Unknown).annotate({
9157 description:
9158 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9159 }),
9160 Schema.Null,
9161 ]),
9162 ),
9163 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9164 data: Schema.String,
9165 mimeType: Schema.String,
9166 }).annotate({ description: "Audio provided to or from an LLM." }),
9167 Schema.Struct({
9168 type: Schema.Literal("resource_link"),
9169 _meta: Schema.optionalKey(
9170 Schema.Union([
9171 Schema.Record(Schema.String, Schema.Unknown).annotate({
9172 description:
9173 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9174 }),
9175 Schema.Null,
9176 ]),
9177 ),
9178 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9179 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9180 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9181 name: Schema.String,
9182 size: Schema.optionalKey(
9183 Schema.Union([
9184 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
9185 Schema.Null,
9186 ]),
9187 ),
9188 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9189 uri: Schema.String,
9190 }).annotate({
9191 description:
9192 "A resource that the server is capable of reading, included in a prompt or tool call result.",
9193 }),
9194 Schema.Struct({
9195 type: Schema.Literal("resource"),
9196 _meta: Schema.optionalKey(
9197 Schema.Union([
9198 Schema.Record(Schema.String, Schema.Unknown).annotate({
9199 description:
9200 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9201 }),
9202 Schema.Null,
9203 ]),
9204 ),
9205 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9206 resource: EmbeddedResourceResource,
9207 }).annotate({
9208 description: "The contents of a resource, embedded into a prompt or tool call result.",
9209 }),
9210 ],
9211 { mode: "oneOf" },
9212 ).annotate({
9213 description:
9214 "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)",
9215 }),
9216 messageId: Schema.optionalKey(
9217 Schema.Union([
9218 Schema.String.annotate({
9219 description:
9220 "**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.",
9221 }),
9222 Schema.Null,
9223 ]),
9224 ),
9225 }).annotate({ description: "A streamed item of content" }),
9226 Schema.Struct({
9227 sessionUpdate: Schema.Literal("agent_message_chunk"),
9228 _meta: Schema.optionalKey(
9229 Schema.Union([
9230 Schema.Record(Schema.String, Schema.Unknown).annotate({
9231 description:
9232 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9233 }),
9234 Schema.Null,
9235 ]),
9236 ),
9237 content: Schema.Union(
9238 [
9239 Schema.Struct({
9240 type: Schema.Literal("text"),
9241 _meta: Schema.optionalKey(
9242 Schema.Union([
9243 Schema.Record(Schema.String, Schema.Unknown).annotate({
9244 description:
9245 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9246 }),
9247 Schema.Null,
9248 ]),
9249 ),
9250 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9251 text: Schema.String,
9252 }).annotate({ description: "Text provided to or from an LLM." }),
9253 Schema.Struct({
9254 type: Schema.Literal("image"),
9255 _meta: Schema.optionalKey(
9256 Schema.Union([
9257 Schema.Record(Schema.String, Schema.Unknown).annotate({
9258 description:
9259 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9260 }),
9261 Schema.Null,
9262 ]),
9263 ),
9264 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9265 data: Schema.String,
9266 mimeType: Schema.String,
9267 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9268 }).annotate({ description: "An image provided to or from an LLM." }),
9269 Schema.Struct({
9270 type: Schema.Literal("audio"),
9271 _meta: Schema.optionalKey(
9272 Schema.Union([
9273 Schema.Record(Schema.String, Schema.Unknown).annotate({
9274 description:
9275 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9276 }),
9277 Schema.Null,
9278 ]),
9279 ),
9280 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9281 data: Schema.String,
9282 mimeType: Schema.String,
9283 }).annotate({ description: "Audio provided to or from an LLM." }),
9284 Schema.Struct({
9285 type: Schema.Literal("resource_link"),
9286 _meta: Schema.optionalKey(
9287 Schema.Union([
9288 Schema.Record(Schema.String, Schema.Unknown).annotate({
9289 description:
9290 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9291 }),
9292 Schema.Null,
9293 ]),
9294 ),
9295 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9296 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9297 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9298 name: Schema.String,
9299 size: Schema.optionalKey(
9300 Schema.Union([
9301 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
9302 Schema.Null,
9303 ]),
9304 ),
9305 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9306 uri: Schema.String,
9307 }).annotate({
9308 description:
9309 "A resource that the server is capable of reading, included in a prompt or tool call result.",
9310 }),
9311 Schema.Struct({
9312 type: Schema.Literal("resource"),
9313 _meta: Schema.optionalKey(
9314 Schema.Union([
9315 Schema.Record(Schema.String, Schema.Unknown).annotate({
9316 description:
9317 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9318 }),
9319 Schema.Null,
9320 ]),
9321 ),
9322 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9323 resource: EmbeddedResourceResource,
9324 }).annotate({
9325 description: "The contents of a resource, embedded into a prompt or tool call result.",
9326 }),
9327 ],
9328 { mode: "oneOf" },
9329 ).annotate({
9330 description:
9331 "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)",
9332 }),
9333 messageId: Schema.optionalKey(
9334 Schema.Union([
9335 Schema.String.annotate({
9336 description:
9337 "**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.",
9338 }),
9339 Schema.Null,
9340 ]),
9341 ),
9342 }).annotate({ description: "A streamed item of content" }),
9343 Schema.Struct({
9344 sessionUpdate: Schema.Literal("agent_thought_chunk"),
9345 _meta: Schema.optionalKey(
9346 Schema.Union([
9347 Schema.Record(Schema.String, Schema.Unknown).annotate({
9348 description:
9349 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9350 }),
9351 Schema.Null,
9352 ]),
9353 ),
9354 content: Schema.Union(
9355 [
9356 Schema.Struct({
9357 type: Schema.Literal("text"),
9358 _meta: Schema.optionalKey(
9359 Schema.Union([
9360 Schema.Record(Schema.String, Schema.Unknown).annotate({
9361 description:
9362 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9363 }),
9364 Schema.Null,
9365 ]),
9366 ),
9367 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9368 text: Schema.String,
9369 }).annotate({ description: "Text provided to or from an LLM." }),
9370 Schema.Struct({
9371 type: Schema.Literal("image"),
9372 _meta: Schema.optionalKey(
9373 Schema.Union([
9374 Schema.Record(Schema.String, Schema.Unknown).annotate({
9375 description:
9376 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9377 }),
9378 Schema.Null,
9379 ]),
9380 ),
9381 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9382 data: Schema.String,
9383 mimeType: Schema.String,
9384 uri: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9385 }).annotate({ description: "An image provided to or from an LLM." }),
9386 Schema.Struct({
9387 type: Schema.Literal("audio"),
9388 _meta: Schema.optionalKey(
9389 Schema.Union([
9390 Schema.Record(Schema.String, Schema.Unknown).annotate({
9391 description:
9392 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9393 }),
9394 Schema.Null,
9395 ]),
9396 ),
9397 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9398 data: Schema.String,
9399 mimeType: Schema.String,
9400 }).annotate({ description: "Audio provided to or from an LLM." }),
9401 Schema.Struct({
9402 type: Schema.Literal("resource_link"),
9403 _meta: Schema.optionalKey(
9404 Schema.Union([
9405 Schema.Record(Schema.String, Schema.Unknown).annotate({
9406 description:
9407 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9408 }),
9409 Schema.Null,
9410 ]),
9411 ),
9412 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9413 description: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9414 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9415 name: Schema.String,
9416 size: Schema.optionalKey(
9417 Schema.Union([
9418 Schema.Number.annotate({ format: "int64" }).check(Schema.isInt()),
9419 Schema.Null,
9420 ]),
9421 ),
9422 title: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
9423 uri: Schema.String,
9424 }).annotate({
9425 description:
9426 "A resource that the server is capable of reading, included in a prompt or tool call result.",
9427 }),
9428 Schema.Struct({
9429 type: Schema.Literal("resource"),
9430 _meta: Schema.optionalKey(
9431 Schema.Union([
9432 Schema.Record(Schema.String, Schema.Unknown).annotate({
9433 description:
9434 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9435 }),
9436 Schema.Null,
9437 ]),
9438 ),
9439 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
9440 resource: EmbeddedResourceResource,
9441 }).annotate({
9442 description: "The contents of a resource, embedded into a prompt or tool call result.",
9443 }),
9444 ],
9445 { mode: "oneOf" },
9446 ).annotate({
9447 description:
9448 "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)",
9449 }),
9450 messageId: Schema.optionalKey(
9451 Schema.Union([
9452 Schema.String.annotate({
9453 description:
9454 "**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.",
9455 }),
9456 Schema.Null,
9457 ]),
9458 ),
9459 }).annotate({ description: "A streamed item of content" }),
9460 Schema.Struct({
9461 sessionUpdate: Schema.Literal("tool_call"),
9462 _meta: Schema.optionalKey(
9463 Schema.Union([
9464 Schema.Record(Schema.String, Schema.Unknown).annotate({
9465 description:
9466 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9467 }),
9468 Schema.Null,
9469 ]),
9470 ),
9471 content: Schema.optionalKey(
9472 Schema.Array(ToolCallContent).annotate({
9473 description: "Content produced by the tool call.",
9474 }),
9475 ),
9476 kind: Schema.optionalKey(
9477 Schema.Literals([
9478 "read",
9479 "edit",
9480 "delete",
9481 "move",
9482 "search",
9483 "execute",
9484 "think",
9485 "fetch",
9486 "switch_mode",
9487 "other",
9488 ]).annotate({
9489 description:
9490 "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)",
9491 }),
9492 ),
9493 locations: Schema.optionalKey(
9494 Schema.Array(ToolCallLocation).annotate({
9495 description:
9496 'File locations affected by this tool call.\nEnables "follow-along" features in clients.',
9497 }),
9498 ),
9499 rawInput: Schema.optionalKey(
9500 Schema.Unknown.annotate({ description: "Raw input parameters sent to the tool." }),
9501 ),
9502 rawOutput: Schema.optionalKey(
9503 Schema.Unknown.annotate({ description: "Raw output returned by the tool." }),
9504 ),
9505 status: Schema.optionalKey(
9506 Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({
9507 description:
9508 "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)",
9509 }),
9510 ),
9511 title: Schema.String.annotate({
9512 description: "Human-readable title describing what the tool is doing.",
9513 }),
9514 toolCallId: Schema.String.annotate({
9515 description: "Unique identifier for a tool call within a session.",
9516 }),
9517 }).annotate({
9518 description:
9519 "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)",
9520 }),
9521 Schema.Struct({
9522 sessionUpdate: Schema.Literal("tool_call_update"),
9523 _meta: Schema.optionalKey(
9524 Schema.Union([
9525 Schema.Record(Schema.String, Schema.Unknown).annotate({
9526 description:
9527 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9528 }),
9529 Schema.Null,
9530 ]),
9531 ),
9532 content: Schema.optionalKey(
9533 Schema.Union([
9534 Schema.Array(ToolCallContent).annotate({
9535 description: "Replace the content collection.",
9536 }),
9537 Schema.Null,
9538 ]),
9539 ),
9540 kind: Schema.optionalKey(
9541 Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }),
9542 ),
9543 locations: Schema.optionalKey(
9544 Schema.Union([
9545 Schema.Array(ToolCallLocation).annotate({
9546 description: "Replace the locations collection.",
9547 }),
9548 Schema.Null,
9549 ]),
9550 ),
9551 rawInput: Schema.optionalKey(
9552 Schema.Unknown.annotate({ description: "Update the raw input." }),
9553 ),
9554 rawOutput: Schema.optionalKey(
9555 Schema.Unknown.annotate({ description: "Update the raw output." }),
9556 ),
9557 status: Schema.optionalKey(
9558 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
9559 description: "Update the execution status.",
9560 }),
9561 ),
9562 title: Schema.optionalKey(
9563 Schema.Union([
9564 Schema.String.annotate({ description: "Update the human-readable title." }),
9565 Schema.Null,
9566 ]),
9567 ),
9568 toolCallId: Schema.String.annotate({
9569 description: "Unique identifier for a tool call within a session.",
9570 }),
9571 }).annotate({
9572 description:
9573 "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)",
9574 }),
9575 Schema.Struct({
9576 sessionUpdate: Schema.Literal("plan"),
9577 _meta: Schema.optionalKey(
9578 Schema.Union([
9579 Schema.Record(Schema.String, Schema.Unknown).annotate({
9580 description:
9581 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9582 }),
9583 Schema.Null,
9584 ]),
9585 ),
9586 entries: Schema.Array(PlanEntry).annotate({
9587 description:
9588 "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.",
9589 }),
9590 }).annotate({
9591 description:
9592 "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)",
9593 }),
9594 Schema.Struct({
9595 sessionUpdate: Schema.Literal("available_commands_update"),
9596 _meta: Schema.optionalKey(
9597 Schema.Union([
9598 Schema.Record(Schema.String, Schema.Unknown).annotate({
9599 description:
9600 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9601 }),
9602 Schema.Null,
9603 ]),
9604 ),
9605 availableCommands: Schema.Array(AvailableCommand).annotate({
9606 description: "Commands the agent can execute",
9607 }),
9608 }).annotate({ description: "Available commands are ready or have changed" }),
9609 Schema.Struct({
9610 sessionUpdate: Schema.Literal("current_mode_update"),
9611 _meta: Schema.optionalKey(
9612 Schema.Union([
9613 Schema.Record(Schema.String, Schema.Unknown).annotate({
9614 description:
9615 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9616 }),
9617 Schema.Null,
9618 ]),
9619 ),
9620 currentModeId: Schema.String.annotate({
9621 description: "Unique identifier for a Session Mode.",
9622 }),
9623 }).annotate({
9624 description:
9625 "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)",
9626 }),
9627 Schema.Struct({
9628 sessionUpdate: Schema.Literal("config_option_update"),
9629 _meta: Schema.optionalKey(
9630 Schema.Union([
9631 Schema.Record(Schema.String, Schema.Unknown).annotate({
9632 description:
9633 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9634 }),
9635 Schema.Null,
9636 ]),
9637 ),
9638 configOptions: Schema.Array(SessionConfigOption).annotate({
9639 description: "The full set of configuration options and their current values.",
9640 }),
9641 }).annotate({ description: "Session configuration options have been updated." }),
9642 Schema.Struct({
9643 sessionUpdate: Schema.Literal("session_info_update"),
9644 _meta: Schema.optionalKey(
9645 Schema.Union([
9646 Schema.Record(Schema.String, Schema.Unknown).annotate({
9647 description:
9648 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9649 }),
9650 Schema.Null,
9651 ]),
9652 ),
9653 title: Schema.optionalKey(
9654 Schema.Union([
9655 Schema.String.annotate({
9656 description: "Human-readable title for the session. Set to null to clear.",
9657 }),
9658 Schema.Null,
9659 ]),
9660 ),
9661 updatedAt: Schema.optionalKey(
9662 Schema.Union([
9663 Schema.String.annotate({
9664 description: "ISO 8601 timestamp of last activity. Set to null to clear.",
9665 }),
9666 Schema.Null,
9667 ]),
9668 ),
9669 }).annotate({
9670 description:
9671 "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.",
9672 }),
9673 Schema.Struct({
9674 sessionUpdate: Schema.Literal("usage_update"),
9675 _meta: Schema.optionalKey(
9676 Schema.Union([
9677 Schema.Record(Schema.String, Schema.Unknown).annotate({
9678 description:
9679 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9680 }),
9681 Schema.Null,
9682 ]),
9683 ),
9684 cost: Schema.optionalKey(
9685 Schema.Union([Cost, Schema.Null]).annotate({
9686 description: "Cumulative session cost (optional).",
9687 }),
9688 ),
9689 size: Schema.Number.annotate({
9690 description: "Total context window size in tokens.",
9691 format: "uint64",
9692 })
9693 .check(Schema.isInt())
9694 .check(Schema.isGreaterThanOrEqualTo(0)),
9695 used: Schema.Number.annotate({
9696 description: "Tokens currently in context.",
9697 format: "uint64",
9698 })
9699 .check(Schema.isInt())
9700 .check(Schema.isGreaterThanOrEqualTo(0)),
9701 }).annotate({
9702 description:
9703 "**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.",
9704 }),
9705 ],
9706 { mode: "oneOf" },
9707).annotate({
9708 description:
9709 "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)",
9710});
9711
9712export type SetSessionConfigOptionRequest =
9713 | {
9714 readonly type: "boolean";
9715 readonly value: boolean;
9716 readonly _meta?: { readonly [x: string]: unknown } | null;
9717 readonly configId: string;
9718 readonly sessionId: string;
9719 }
9720 | {
9721 readonly value: string;
9722 readonly _meta?: { readonly [x: string]: unknown } | null;
9723 readonly configId: string;
9724 readonly sessionId: string;
9725 };
9726export const SetSessionConfigOptionRequest = Schema.Union([
9727 Schema.Struct({
9728 type: Schema.Literal("boolean"),
9729 value: Schema.Boolean.annotate({ description: "The boolean value." }),
9730 _meta: Schema.optionalKey(
9731 Schema.Union([
9732 Schema.Record(Schema.String, Schema.Unknown).annotate({
9733 description:
9734 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9735 }),
9736 Schema.Null,
9737 ]),
9738 ),
9739 configId: Schema.String.annotate({
9740 description: "Unique identifier for a session configuration option.",
9741 }),
9742 sessionId: Schema.String.annotate({
9743 description:
9744 "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)",
9745 }),
9746 }).annotate({ description: "Request parameters for setting a session configuration option." }),
9747 Schema.Struct({
9748 value: Schema.String.annotate({
9749 description: "Unique identifier for a session configuration option value.",
9750 }),
9751 _meta: Schema.optionalKey(
9752 Schema.Union([
9753 Schema.Record(Schema.String, Schema.Unknown).annotate({
9754 description:
9755 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9756 }),
9757 Schema.Null,
9758 ]),
9759 ),
9760 configId: Schema.String.annotate({
9761 description: "Unique identifier for a session configuration option.",
9762 }),
9763 sessionId: Schema.String.annotate({
9764 description:
9765 "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)",
9766 }),
9767 }).annotate({
9768 title: "value_id",
9769 description: "Request parameters for setting a session configuration option.",
9770 }),
9771]);
9772
9773export type SetSessionConfigOptionResponse = {
9774 readonly _meta?: { readonly [x: string]: unknown } | null;
9775 readonly configOptions: ReadonlyArray<SessionConfigOption>;
9776};
9777export const SetSessionConfigOptionResponse = Schema.Struct({
9778 _meta: Schema.optionalKey(
9779 Schema.Union([
9780 Schema.Record(Schema.String, Schema.Unknown).annotate({
9781 description:
9782 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9783 }),
9784 Schema.Null,
9785 ]),
9786 ),
9787 configOptions: Schema.Array(SessionConfigOption).annotate({
9788 description: "The full set of configuration options and their current values.",
9789 }),
9790}).annotate({ description: "Response to `session/set_config_option` method." });
9791
9792export type SetSessionModelRequest = {
9793 readonly _meta?: { readonly [x: string]: unknown } | null;
9794 readonly modelId: string;
9795 readonly sessionId: string;
9796};
9797export const SetSessionModelRequest = Schema.Struct({
9798 _meta: Schema.optionalKey(
9799 Schema.Union([
9800 Schema.Record(Schema.String, Schema.Unknown).annotate({
9801 description:
9802 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9803 }),
9804 Schema.Null,
9805 ]),
9806 ),
9807 modelId: Schema.String.annotate({
9808 description:
9809 "**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.",
9810 }),
9811 sessionId: Schema.String.annotate({
9812 description:
9813 "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)",
9814 }),
9815}).annotate({
9816 description:
9817 "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for setting a session model.",
9818});
9819
9820export type SetSessionModelResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
9821export const SetSessionModelResponse = Schema.Struct({
9822 _meta: Schema.optionalKey(
9823 Schema.Union([
9824 Schema.Record(Schema.String, Schema.Unknown).annotate({
9825 description:
9826 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9827 }),
9828 Schema.Null,
9829 ]),
9830 ),
9831}).annotate({
9832 description:
9833 "**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.",
9834});
9835
9836export type SetSessionModeRequest = {
9837 readonly _meta?: { readonly [x: string]: unknown } | null;
9838 readonly modeId: string;
9839 readonly sessionId: string;
9840};
9841export const SetSessionModeRequest = Schema.Struct({
9842 _meta: Schema.optionalKey(
9843 Schema.Union([
9844 Schema.Record(Schema.String, Schema.Unknown).annotate({
9845 description:
9846 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9847 }),
9848 Schema.Null,
9849 ]),
9850 ),
9851 modeId: Schema.String.annotate({ description: "Unique identifier for a Session Mode." }),
9852 sessionId: Schema.String.annotate({
9853 description:
9854 "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)",
9855 }),
9856}).annotate({ description: "Request parameters for setting a session mode." });
9857
9858export type SetSessionModeResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
9859export const SetSessionModeResponse = Schema.Struct({
9860 _meta: Schema.optionalKey(
9861 Schema.Union([
9862 Schema.Record(Schema.String, Schema.Unknown).annotate({
9863 description:
9864 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9865 }),
9866 Schema.Null,
9867 ]),
9868 ),
9869}).annotate({ description: "Response to `session/set_mode` method." });
9870
9871export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
9872export const StopReason = Schema.Literals([
9873 "end_turn",
9874 "max_tokens",
9875 "max_turn_requests",
9876 "refusal",
9877 "cancelled",
9878]).annotate({
9879 description:
9880 "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)",
9881});
9882
9883export type StringPropertySchema = {
9884 readonly default?: string | null;
9885 readonly description?: string | null;
9886 readonly enum?: ReadonlyArray<string> | null;
9887 readonly format?: StringFormat | null;
9888 readonly maxLength?: number | null;
9889 readonly minLength?: number | null;
9890 readonly oneOf?: ReadonlyArray<EnumOption> | null;
9891 readonly pattern?: string | null;
9892 readonly title?: string | null;
9893};
9894export const StringPropertySchema = Schema.Struct({
9895 default: Schema.optionalKey(
9896 Schema.Union([Schema.String.annotate({ description: "Default value." }), Schema.Null]),
9897 ),
9898 description: Schema.optionalKey(
9899 Schema.Union([
9900 Schema.String.annotate({ description: "Human-readable description." }),
9901 Schema.Null,
9902 ]),
9903 ),
9904 enum: Schema.optionalKey(
9905 Schema.Union([
9906 Schema.Array(Schema.String).annotate({
9907 description: "Enum values for untitled single-select enums.",
9908 }),
9909 Schema.Null,
9910 ]),
9911 ),
9912 format: Schema.optionalKey(
9913 Schema.Union([StringFormat, Schema.Null]).annotate({ description: "String format." }),
9914 ),
9915 maxLength: Schema.optionalKey(
9916 Schema.Union([
9917 Schema.Number.annotate({ description: "Maximum string length.", format: "uint32" })
9918 .check(Schema.isInt())
9919 .check(Schema.isGreaterThanOrEqualTo(0)),
9920 Schema.Null,
9921 ]),
9922 ),
9923 minLength: Schema.optionalKey(
9924 Schema.Union([
9925 Schema.Number.annotate({ description: "Minimum string length.", format: "uint32" })
9926 .check(Schema.isInt())
9927 .check(Schema.isGreaterThanOrEqualTo(0)),
9928 Schema.Null,
9929 ]),
9930 ),
9931 oneOf: Schema.optionalKey(
9932 Schema.Union([
9933 Schema.Array(EnumOption).annotate({
9934 description: "Titled enum options for titled single-select enums.",
9935 }),
9936 Schema.Null,
9937 ]),
9938 ),
9939 pattern: Schema.optionalKey(
9940 Schema.Union([
9941 Schema.String.annotate({ description: "Pattern the string must match." }),
9942 Schema.Null,
9943 ]),
9944 ),
9945 title: Schema.optionalKey(
9946 Schema.Union([
9947 Schema.String.annotate({ description: "Optional title for the property." }),
9948 Schema.Null,
9949 ]),
9950 ),
9951}).annotate({
9952 description:
9953 'Schema for string properties in an elicitation form.\n\nWhen `enum` or `oneOf` is set, this represents a single-select enum\nwith `"type": "string"`.',
9954});
9955
9956export type Terminal = {
9957 readonly _meta?: { readonly [x: string]: unknown } | null;
9958 readonly terminalId: string;
9959};
9960export const Terminal = Schema.Struct({
9961 _meta: Schema.optionalKey(
9962 Schema.Union([
9963 Schema.Record(Schema.String, Schema.Unknown).annotate({
9964 description:
9965 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9966 }),
9967 Schema.Null,
9968 ]),
9969 ),
9970 terminalId: Schema.String,
9971}).annotate({
9972 description:
9973 "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)",
9974});
9975
9976export type TerminalOutputRequest = {
9977 readonly _meta?: { readonly [x: string]: unknown } | null;
9978 readonly sessionId: string;
9979 readonly terminalId: string;
9980};
9981export const TerminalOutputRequest = Schema.Struct({
9982 _meta: Schema.optionalKey(
9983 Schema.Union([
9984 Schema.Record(Schema.String, Schema.Unknown).annotate({
9985 description:
9986 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
9987 }),
9988 Schema.Null,
9989 ]),
9990 ),
9991 sessionId: Schema.String.annotate({
9992 description:
9993 "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)",
9994 }),
9995 terminalId: Schema.String.annotate({ description: "The ID of the terminal to get output from." }),
9996}).annotate({ description: "Request to get the current output and status of a terminal." });
9997
9998export type TerminalOutputResponse = {
9999 readonly _meta?: { readonly [x: string]: unknown } | null;
10000 readonly exitStatus?: TerminalExitStatus | null;
10001 readonly output: string;
10002 readonly truncated: boolean;
10003};
10004export const TerminalOutputResponse = Schema.Struct({
10005 _meta: Schema.optionalKey(
10006 Schema.Union([
10007 Schema.Record(Schema.String, Schema.Unknown).annotate({
10008 description:
10009 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10010 }),
10011 Schema.Null,
10012 ]),
10013 ),
10014 exitStatus: Schema.optionalKey(
10015 Schema.Union([TerminalExitStatus, Schema.Null]).annotate({
10016 description: "Exit status if the command has completed.",
10017 }),
10018 ),
10019 output: Schema.String.annotate({ description: "The terminal output captured so far." }),
10020 truncated: Schema.Boolean.annotate({
10021 description: "Whether the output was truncated due to byte limits.",
10022 }),
10023}).annotate({ description: "Response containing the terminal output and exit status." });
10024
10025export type TextContent = {
10026 readonly _meta?: { readonly [x: string]: unknown } | null;
10027 readonly annotations?: Annotations | null;
10028 readonly text: string;
10029};
10030export const TextContent = Schema.Struct({
10031 _meta: Schema.optionalKey(
10032 Schema.Union([
10033 Schema.Record(Schema.String, Schema.Unknown).annotate({
10034 description:
10035 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10036 }),
10037 Schema.Null,
10038 ]),
10039 ),
10040 annotations: Schema.optionalKey(Schema.Union([Annotations, Schema.Null])),
10041 text: Schema.String,
10042}).annotate({ description: "Text provided to or from an LLM." });
10043
10044export type TextResourceContents = {
10045 readonly _meta?: { readonly [x: string]: unknown } | null;
10046 readonly mimeType?: string | null;
10047 readonly text: string;
10048 readonly uri: string;
10049};
10050export const TextResourceContents = Schema.Struct({
10051 _meta: Schema.optionalKey(
10052 Schema.Union([
10053 Schema.Record(Schema.String, Schema.Unknown).annotate({
10054 description:
10055 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10056 }),
10057 Schema.Null,
10058 ]),
10059 ),
10060 mimeType: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])),
10061 text: Schema.String,
10062 uri: Schema.String,
10063}).annotate({ description: "Text-based resource contents." });
10064
10065export type TitledMultiSelectItems = { readonly anyOf: ReadonlyArray<EnumOption> };
10066export const TitledMultiSelectItems = Schema.Struct({
10067 anyOf: Schema.Array(EnumOption).annotate({ description: "Titled enum options." }),
10068}).annotate({ description: "Items definition for titled multi-select enum properties." });
10069
10070export type ToolCall = {
10071 readonly _meta?: { readonly [x: string]: unknown } | null;
10072 readonly content?: ReadonlyArray<ToolCallContent>;
10073 readonly kind?:
10074 | "read"
10075 | "edit"
10076 | "delete"
10077 | "move"
10078 | "search"
10079 | "execute"
10080 | "think"
10081 | "fetch"
10082 | "switch_mode"
10083 | "other";
10084 readonly locations?: ReadonlyArray<ToolCallLocation>;
10085 readonly rawInput?: unknown;
10086 readonly rawOutput?: unknown;
10087 readonly status?: "pending" | "in_progress" | "completed" | "failed";
10088 readonly title: string;
10089 readonly toolCallId: string;
10090};
10091export const ToolCall = Schema.Struct({
10092 _meta: Schema.optionalKey(
10093 Schema.Union([
10094 Schema.Record(Schema.String, Schema.Unknown).annotate({
10095 description:
10096 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10097 }),
10098 Schema.Null,
10099 ]),
10100 ),
10101 content: Schema.optionalKey(
10102 Schema.Array(ToolCallContent).annotate({ description: "Content produced by the tool call." }),
10103 ),
10104 kind: Schema.optionalKey(
10105 Schema.Literals([
10106 "read",
10107 "edit",
10108 "delete",
10109 "move",
10110 "search",
10111 "execute",
10112 "think",
10113 "fetch",
10114 "switch_mode",
10115 "other",
10116 ]).annotate({
10117 description:
10118 "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)",
10119 }),
10120 ),
10121 locations: Schema.optionalKey(
10122 Schema.Array(ToolCallLocation).annotate({
10123 description:
10124 'File locations affected by this tool call.\nEnables "follow-along" features in clients.',
10125 }),
10126 ),
10127 rawInput: Schema.optionalKey(
10128 Schema.Unknown.annotate({ description: "Raw input parameters sent to the tool." }),
10129 ),
10130 rawOutput: Schema.optionalKey(
10131 Schema.Unknown.annotate({ description: "Raw output returned by the tool." }),
10132 ),
10133 status: Schema.optionalKey(
10134 Schema.Literals(["pending", "in_progress", "completed", "failed"]).annotate({
10135 description:
10136 "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)",
10137 }),
10138 ),
10139 title: Schema.String.annotate({
10140 description: "Human-readable title describing what the tool is doing.",
10141 }),
10142 toolCallId: Schema.String.annotate({
10143 description: "Unique identifier for a tool call within a session.",
10144 }),
10145}).annotate({
10146 description:
10147 "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)",
10148});
10149
10150export type ToolCallId = string;
10151export const ToolCallId = Schema.String.annotate({
10152 description: "Unique identifier for a tool call within a session.",
10153});
10154
10155export type ToolCallUpdate = {
10156 readonly _meta?: { readonly [x: string]: unknown } | null;
10157 readonly content?: ReadonlyArray<ToolCallContent> | null;
10158 readonly kind?: ToolKind | null;
10159 readonly locations?: ReadonlyArray<ToolCallLocation> | null;
10160 readonly rawInput?: unknown;
10161 readonly rawOutput?: unknown;
10162 readonly status?: ToolCallStatus | null;
10163 readonly title?: string | null;
10164 readonly toolCallId: string;
10165};
10166export const ToolCallUpdate = Schema.Struct({
10167 _meta: Schema.optionalKey(
10168 Schema.Union([
10169 Schema.Record(Schema.String, Schema.Unknown).annotate({
10170 description:
10171 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10172 }),
10173 Schema.Null,
10174 ]),
10175 ),
10176 content: Schema.optionalKey(
10177 Schema.Union([
10178 Schema.Array(ToolCallContent).annotate({ description: "Replace the content collection." }),
10179 Schema.Null,
10180 ]),
10181 ),
10182 kind: Schema.optionalKey(
10183 Schema.Union([ToolKind, Schema.Null]).annotate({ description: "Update the tool kind." }),
10184 ),
10185 locations: Schema.optionalKey(
10186 Schema.Union([
10187 Schema.Array(ToolCallLocation).annotate({ description: "Replace the locations collection." }),
10188 Schema.Null,
10189 ]),
10190 ),
10191 rawInput: Schema.optionalKey(Schema.Unknown.annotate({ description: "Update the raw input." })),
10192 rawOutput: Schema.optionalKey(Schema.Unknown.annotate({ description: "Update the raw output." })),
10193 status: Schema.optionalKey(
10194 Schema.Union([ToolCallStatus, Schema.Null]).annotate({
10195 description: "Update the execution status.",
10196 }),
10197 ),
10198 title: Schema.optionalKey(
10199 Schema.Union([
10200 Schema.String.annotate({ description: "Update the human-readable title." }),
10201 Schema.Null,
10202 ]),
10203 ),
10204 toolCallId: Schema.String.annotate({
10205 description: "Unique identifier for a tool call within a session.",
10206 }),
10207}).annotate({
10208 description:
10209 "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)",
10210});
10211
10212export type UnstructuredCommandInput = {
10213 readonly _meta?: { readonly [x: string]: unknown } | null;
10214 readonly hint: string;
10215};
10216export const UnstructuredCommandInput = Schema.Struct({
10217 _meta: Schema.optionalKey(
10218 Schema.Union([
10219 Schema.Record(Schema.String, Schema.Unknown).annotate({
10220 description:
10221 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10222 }),
10223 Schema.Null,
10224 ]),
10225 ),
10226 hint: Schema.String.annotate({
10227 description: "A hint to display when the input hasn't been provided yet",
10228 }),
10229}).annotate({
10230 description: "All text that was typed after the command name is provided as input.",
10231});
10232
10233export type UntitledMultiSelectItems = {
10234 readonly enum: ReadonlyArray<string>;
10235 readonly type: "string";
10236};
10237export const UntitledMultiSelectItems = Schema.Struct({
10238 enum: Schema.Array(Schema.String).annotate({ description: "Allowed enum values." }),
10239 type: Schema.Literal("string").annotate({
10240 description: "Items definition for untitled multi-select enum properties.",
10241 }),
10242}).annotate({ description: "Items definition for untitled multi-select enum properties." });
10243
10244export type UsageUpdate = {
10245 readonly _meta?: { readonly [x: string]: unknown } | null;
10246 readonly cost?: Cost | null;
10247 readonly size: number;
10248 readonly used: number;
10249};
10250export const UsageUpdate = Schema.Struct({
10251 _meta: Schema.optionalKey(
10252 Schema.Union([
10253 Schema.Record(Schema.String, Schema.Unknown).annotate({
10254 description:
10255 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10256 }),
10257 Schema.Null,
10258 ]),
10259 ),
10260 cost: Schema.optionalKey(
10261 Schema.Union([Cost, Schema.Null]).annotate({
10262 description: "Cumulative session cost (optional).",
10263 }),
10264 ),
10265 size: Schema.Number.annotate({
10266 description: "Total context window size in tokens.",
10267 format: "uint64",
10268 })
10269 .check(Schema.isInt())
10270 .check(Schema.isGreaterThanOrEqualTo(0)),
10271 used: Schema.Number.annotate({ description: "Tokens currently in context.", format: "uint64" })
10272 .check(Schema.isInt())
10273 .check(Schema.isGreaterThanOrEqualTo(0)),
10274}).annotate({
10275 description:
10276 "**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.",
10277});
10278
10279export type WaitForTerminalExitRequest = {
10280 readonly _meta?: { readonly [x: string]: unknown } | null;
10281 readonly sessionId: string;
10282 readonly terminalId: string;
10283};
10284export const WaitForTerminalExitRequest = Schema.Struct({
10285 _meta: Schema.optionalKey(
10286 Schema.Union([
10287 Schema.Record(Schema.String, Schema.Unknown).annotate({
10288 description:
10289 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10290 }),
10291 Schema.Null,
10292 ]),
10293 ),
10294 sessionId: Schema.String.annotate({
10295 description:
10296 "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)",
10297 }),
10298 terminalId: Schema.String.annotate({ description: "The ID of the terminal to wait for." }),
10299}).annotate({ description: "Request to wait for a terminal command to exit." });
10300
10301export type WaitForTerminalExitResponse = {
10302 readonly _meta?: { readonly [x: string]: unknown } | null;
10303 readonly exitCode?: number | null;
10304 readonly signal?: string | null;
10305};
10306export const WaitForTerminalExitResponse = Schema.Struct({
10307 _meta: Schema.optionalKey(
10308 Schema.Union([
10309 Schema.Record(Schema.String, Schema.Unknown).annotate({
10310 description:
10311 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10312 }),
10313 Schema.Null,
10314 ]),
10315 ),
10316 exitCode: Schema.optionalKey(
10317 Schema.Union([
10318 Schema.Number.annotate({
10319 description: "The process exit code (may be null if terminated by signal).",
10320 format: "uint32",
10321 })
10322 .check(Schema.isInt())
10323 .check(Schema.isGreaterThanOrEqualTo(0)),
10324 Schema.Null,
10325 ]),
10326 ),
10327 signal: Schema.optionalKey(
10328 Schema.Union([
10329 Schema.String.annotate({
10330 description: "The signal that terminated the process (may be null if exited normally).",
10331 }),
10332 Schema.Null,
10333 ]),
10334 ),
10335}).annotate({ description: "Response containing the exit status of a terminal command." });
10336
10337export type WriteTextFileRequest = {
10338 readonly _meta?: { readonly [x: string]: unknown } | null;
10339 readonly content: string;
10340 readonly path: string;
10341 readonly sessionId: string;
10342};
10343export const WriteTextFileRequest = Schema.Struct({
10344 _meta: Schema.optionalKey(
10345 Schema.Union([
10346 Schema.Record(Schema.String, Schema.Unknown).annotate({
10347 description:
10348 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10349 }),
10350 Schema.Null,
10351 ]),
10352 ),
10353 content: Schema.String.annotate({ description: "The text content to write to the file." }),
10354 path: Schema.String.annotate({ description: "Absolute path to the file to write." }),
10355 sessionId: Schema.String.annotate({
10356 description:
10357 "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)",
10358 }),
10359}).annotate({
10360 description:
10361 "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.",
10362});
10363
10364export type WriteTextFileResponse = { readonly _meta?: { readonly [x: string]: unknown } | null };
10365export const WriteTextFileResponse = Schema.Struct({
10366 _meta: Schema.optionalKey(
10367 Schema.Union([
10368 Schema.Record(Schema.String, Schema.Unknown).annotate({
10369 description:
10370 "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)",
10371 }),
10372 Schema.Null,
10373 ]),
10374 ),
10375}).annotate({ description: "Response to `fs/write_text_file`" });
packages/effect-acp/src/_internal/shared.ts created+111
......@@ -0,0 +1,111 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as SchemaIssue from "effect/SchemaIssue";
4import { RpcClientError } from "effect/unstable/rpc";
5
6import * as AcpSchema from "../_generated/schema.gen.ts";
7import * as AcpError from "../errors.ts";
8
9const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
10
11export const callRpc = <A>(
12 effect: Effect.Effect<A, RpcClientError.RpcClientError | AcpSchema.Error>,
13): Effect.Effect<A, AcpError.AcpError> =>
14 effect.pipe(
15 Effect.catchTag("RpcClientError", (error) =>
16 Effect.fail(
17 new AcpError.AcpTransportError({
18 detail: error.message,
19 cause: error,
20 }),
21 ),
22 ),
23 Effect.catchIf(Schema.is(AcpSchema.Error), (error) =>
24 Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)),
25 ),
26 );
27
28export const runHandler = Effect.fnUntraced(function* <A, B>(
29 handler: ((payload: A) => Effect.Effect<B, AcpError.AcpError>) | undefined,
30 payload: A,
31 method: string,
32) {
33 if (!handler) {
34 return yield* Effect.fail(AcpError.AcpRequestError.methodNotFound(method).toProtocolError());
35 }
36 return yield* handler(payload).pipe(
37 Effect.mapError((error) =>
38 Schema.is(AcpError.AcpRequestError)(error)
39 ? error.toProtocolError()
40 : AcpError.AcpRequestError.internalError(error.message).toProtocolError(),
41 ),
42 );
43});
44
45export function decodeExtRequestRegistration<A, I>(
46 method: string,
47 payload: Schema.Codec<A, I>,
48 handler: (payload: A) => Effect.Effect<unknown, AcpError.AcpError>,
49) {
50 return (params: unknown): Effect.Effect<unknown, AcpError.AcpError> =>
51 Schema.decodeUnknownEffect(payload)(params).pipe(
52 Effect.mapError((error) =>
53 AcpError.AcpRequestError.invalidParams(
54 `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`,
55 { issue: error.issue },
56 ),
57 ),
58 Effect.flatMap((decoded) => handler(decoded)),
59 );
60}
61
62export function decodeExtNotificationRegistration<A, I>(
63 method: string,
64 payload: Schema.Codec<A, I>,
65 handler: (payload: A) => Effect.Effect<void, AcpError.AcpError>,
66) {
67 return (params: unknown): Effect.Effect<void, AcpError.AcpError> =>
68 Schema.decodeUnknownEffect(payload)(params).pipe(
69 Effect.mapError(
70 (error) =>
71 new AcpError.AcpProtocolParseError({
72 detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`,
73 cause: error,
74 }),
75 ),
76 Effect.flatMap((decoded) => handler(decoded)),
77 );
78}
79
80const encoder = new TextEncoder();
81
82const JsonRpcId = Schema.Union([Schema.Number, Schema.String]);
83const JsonRpcHeaders = Schema.Array(Schema.Unknown);
84
85export const jsonRpcRequest = <A, I>(method: string, params: Schema.Codec<A, I>) =>
86 Schema.Struct({
87 jsonrpc: Schema.Literal("2.0"),
88 id: JsonRpcId,
89 method: Schema.Literal(method),
90 params,
91 headers: JsonRpcHeaders,
92 });
93
94export const jsonRpcNotification = <A, I>(method: string, params: Schema.Codec<A, I>) =>
95 Schema.Struct({
96 jsonrpc: Schema.Literal("2.0"),
97 method: Schema.Literal(method),
98 params,
99 });
100
101export const jsonRpcResponse = <A, I>(result: Schema.Codec<A, I>) =>
102 Schema.Struct({
103 jsonrpc: Schema.Literal("2.0"),
104 id: JsonRpcId,
105 result,
106 });
107
108export const encodeJsonl = <A, I>(schema: Schema.Codec<A, I>, value: A) =>
109 Effect.map(Schema.encodeEffect(Schema.fromJsonString(schema))(value), (encoded) =>
110 encoder.encode(`${encoded}\n`),
111 );
packages/effect-acp/src/_internal/stdio.ts created+54
......@@ -0,0 +1,54 @@
1import * as Queue from "effect/Queue";
2import * as Stream from "effect/Stream";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Sink from "effect/Sink";
6import * as Stdio from "effect/Stdio";
7import { ChildProcessSpawner } from "effect/unstable/process";
8
9import * as AcpError from "../errors.ts";
10
11const encoder = new TextEncoder();
12const decoder = new TextDecoder();
13
14export const makeChildStdio = (handle: ChildProcessSpawner.ChildProcessHandle) =>
15 Stdio.make({
16 args: Effect.succeed([]),
17 stdin: handle.stdout,
18 stdout: () =>
19 Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) =>
20 typeof chunk === "string" ? encoder.encode(chunk) : chunk,
21 ),
22 stderr: () => Sink.drain,
23 });
24
25export const makeInMemoryStdio = Effect.fn("makeInMemoryStdio")(function* () {
26 const input = yield* Queue.unbounded<Uint8Array, Cause.Done<void>>();
27 const output = yield* Queue.unbounded<string>();
28
29 return {
30 stdio: Stdio.make({
31 args: Effect.succeed([]),
32 stdin: Stream.fromQueue(input),
33 stdout: () =>
34 Sink.forEach((chunk: string | Uint8Array) =>
35 Queue.offer(output, typeof chunk === "string" ? chunk : decoder.decode(chunk)),
36 ),
37 stderr: () => Sink.drain,
38 }),
39 input,
40 output,
41 };
42});
43
44export const makeTerminationError = (
45 handle: ChildProcessSpawner.ChildProcessHandle,
46): Effect.Effect<AcpError.AcpError> =>
47 Effect.match(handle.exitCode, {
48 onFailure: (cause) =>
49 new AcpError.AcpTransportError({
50 detail: "Failed to determine ACP process exit status",
51 cause,
52 }),
53 onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }),
54 });
packages/effect-acp/src/agent.test.ts created+255
......@@ -0,0 +1,255 @@
1import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";
6import * as Queue from "effect/Queue";
7import * as Ref from "effect/Ref";
8import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";
10
11import { assert, it } from "@effect/vitest";
12
13import * as AcpAgent from "./agent.ts";
14import * as AcpSchema from "./_generated/schema.gen.ts";
15import {
16 encodeJsonl,
17 jsonRpcNotification,
18 jsonRpcRequest,
19 jsonRpcResponse,
20} from "./_internal/shared.ts";
21import { makeInMemoryStdio } from "./_internal/stdio.ts";
22
23const RequestPermissionRequest = jsonRpcRequest(
24 "session/request_permission",
25 AcpSchema.RequestPermissionRequest,
26);
27const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest);
28const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
29const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
30const SessionCancelNotification = jsonRpcNotification(
31 "session/cancel",
32 AcpSchema.CancelNotification,
33);
34const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number }));
35const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
36const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
37
38it.effect("effect-acp agent handles core agent requests and outbound client requests", () =>
39 Effect.gen(function* () {
40 const { stdio, input, output } = yield* makeInMemoryStdio();
41 const cancelNotifications = yield* Ref.make<Array<string>>([]);
42 const extNotifications = yield* Ref.make<Array<number>>([]);
43 const cancelReceived = yield* Deferred.make<void>();
44 const extReceived = yield* Deferred.make<void>();
45 const scope = yield* Scope.make();
46 const context = yield* Layer.buildWithScope(AcpAgent.layer(stdio), scope);
47
48 yield* Effect.gen(function* () {
49 const agent = yield* AcpAgent.AcpAgent;
50
51 yield* agent.handleInitialize(() =>
52 Effect.succeed({
53 protocolVersion: 1,
54 agentCapabilities: {},
55 agentInfo: {
56 name: "mock-agent",
57 version: "0.0.0",
58 },
59 }),
60 );
61 yield* agent.handleCancel((notification) =>
62 Ref.update(cancelNotifications, (current) => [...current, notification.sessionId]).pipe(
63 Effect.andThen(Deferred.succeed(cancelReceived, undefined)),
64 ),
65 );
66 yield* agent.handleExtNotification(
67 "x/ping",
68 Schema.Struct({ count: Schema.Number }),
69 (payload) =>
70 Ref.update(extNotifications, (current) => [...current, payload.count]).pipe(
71 Effect.andThen(Deferred.succeed(extReceived, undefined)),
72 ),
73 );
74
75 const permissionFiber = yield* agent.client
76 .requestPermission({
77 sessionId: "session-1",
78 toolCall: {
79 toolCallId: "tool-1",
80 title: "Allow mock action",
81 },
82 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
83 })
84 .pipe(Effect.forkScoped);
85
86 const permissionRequest = yield* Schema.decodeEffect(
87 Schema.fromJsonString(RequestPermissionRequest),
88 )(yield* Queue.take(output));
89 assert.equal(permissionRequest.jsonrpc, "2.0");
90 assert.equal(permissionRequest.method, "session/request_permission");
91 assert.deepEqual(permissionRequest.params, {
92 sessionId: "session-1",
93 toolCall: {
94 toolCallId: "tool-1",
95 title: "Allow mock action",
96 },
97 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
98 });
99 assert.deepEqual(permissionRequest.headers, []);
100
101 yield* Queue.offer(
102 input,
103 yield* encodeJsonl(RequestPermissionResponse, {
104 jsonrpc: "2.0",
105 id: permissionRequest.id,
106 result: {
107 outcome: {
108 outcome: "selected",
109 optionId: "allow",
110 },
111 },
112 }),
113 );
114
115 const permission = yield* Fiber.join(permissionFiber);
116 assert.equal(permission.outcome.outcome, "selected");
117
118 yield* Queue.offer(
119 input,
120 yield* encodeJsonl(InitializeRequest, {
121 jsonrpc: "2.0",
122 id: 2,
123 method: "initialize",
124 params: {
125 protocolVersion: 1,
126 clientCapabilities: {
127 fs: { readTextFile: false, writeTextFile: false },
128 terminal: false,
129 },
130 clientInfo: {
131 name: "effect-acp-test",
132 version: "0.0.0",
133 },
134 },
135 headers: [],
136 }),
137 );
138
139 const initResponse = yield* Schema.decodeEffect(Schema.fromJsonString(InitializeResponse))(
140 yield* Queue.take(output),
141 );
142 assert.deepEqual(initResponse, {
143 jsonrpc: "2.0",
144 id: 2,
145 result: {
146 protocolVersion: 1,
147 agentCapabilities: {},
148 agentInfo: {
149 name: "mock-agent",
150 version: "0.0.0",
151 },
152 },
153 });
154
155 yield* Queue.offer(
156 input,
157 yield* encodeJsonl(SessionCancelNotification, {
158 jsonrpc: "2.0",
159 method: "session/cancel",
160 params: {
161 sessionId: "session-1",
162 },
163 }),
164 );
165 yield* Queue.offer(
166 input,
167 yield* encodeJsonl(ExtPingNotification, {
168 jsonrpc: "2.0",
169 method: "x/ping",
170 params: { count: 2 },
171 }),
172 );
173
174 yield* Deferred.await(cancelReceived);
175 yield* Deferred.await(extReceived);
176 assert.deepEqual(yield* Ref.get(cancelNotifications), ["session-1"]);
177 assert.deepEqual(yield* Ref.get(extNotifications), [2]);
178 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
179 }),
180);
181
182it.effect("effect-acp agent uses distinct ids for RPC calls and extension requests", () =>
183 Effect.gen(function* () {
184 const { stdio, input, output } = yield* makeInMemoryStdio();
185 const scope = yield* Scope.make();
186 const context = yield* Layer.buildWithScope(AcpAgent.layer(stdio), scope);
187
188 yield* Effect.gen(function* () {
189 const agent = yield* AcpAgent.AcpAgent;
190
191 const permissionFiber = yield* agent.client
192 .requestPermission({
193 sessionId: "session-1",
194 toolCall: {
195 toolCallId: "tool-1",
196 title: "Allow mock action",
197 },
198 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
199 })
200 .pipe(Effect.forkScoped);
201 const extFiber = yield* agent.client
202 .extRequest("x/test", { hello: "world" })
203 .pipe(Effect.forkScoped);
204
205 const firstOutbound = yield* Queue.take(output);
206 const secondOutbound = yield* Queue.take(output);
207
208 const decodedPermission = Schema.decodeEffect(
209 Schema.fromJsonString(RequestPermissionRequest),
210 );
211 const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest));
212 const firstIsPermission = yield* decodedPermission(firstOutbound).pipe(
213 Effect.match({
214 onFailure: () => false,
215 onSuccess: () => true,
216 }),
217 );
218
219 const permissionRequest = firstIsPermission
220 ? yield* decodedPermission(firstOutbound)
221 : yield* decodedPermission(secondOutbound);
222 const extRequest = firstIsPermission
223 ? yield* decodedExt(secondOutbound)
224 : yield* decodedExt(firstOutbound);
225
226 assert.notEqual(permissionRequest.id, extRequest.id);
227
228 yield* Queue.offer(
229 input,
230 yield* encodeJsonl(RequestPermissionResponse, {
231 jsonrpc: "2.0",
232 id: permissionRequest.id,
233 result: {
234 outcome: {
235 outcome: "selected",
236 optionId: "allow",
237 },
238 },
239 }),
240 );
241 yield* Queue.offer(
242 input,
243 yield* encodeJsonl(ExtResponse, {
244 jsonrpc: "2.0",
245 id: extRequest.id,
246 result: { ok: true },
247 }),
248 );
249
250 const permission = yield* Fiber.join(permissionFiber);
251 assert.equal(permission.outcome.outcome, "selected");
252 assert.deepEqual(yield* Fiber.join(extFiber), { ok: true });
253 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
254 }),
255);
packages/effect-acp/src/agent.ts created+519
......@@ -0,0 +1,519 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as Scope from "effect/Scope";
6import * as Stream from "effect/Stream";
7import * as Stdio from "effect/Stdio";
8import * as RpcClient from "effect/unstable/rpc/RpcClient";
9import * as RpcServer from "effect/unstable/rpc/RpcServer";
10
11import * as AcpSchema from "./_generated/schema.gen.ts";
12import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";
13import * as AcpError from "./errors.ts";
14import * as AcpProtocol from "./protocol.ts";
15import * as AcpRpcs from "./rpc.ts";
16import {
17 callRpc,
18 decodeExtNotificationRegistration,
19 decodeExtRequestRegistration,
20 runHandler,
21} from "./_internal/shared.ts";
22import * as AcpTerminal from "./terminal.ts";
23
24export interface AcpAgentOptions {
25 readonly logIncoming?: boolean;
26 readonly logOutgoing?: boolean;
27 readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
28}
29
30export interface AcpAgentShape {
31 readonly raw: {
32 /**
33 * Stream of inbound ACP notifications observed on the connection.
34 */
35 readonly notifications: Stream.Stream<AcpProtocol.AcpIncomingNotification>;
36 /**
37 * Sends a generic ACP extension request.
38 * @see https://agentclientprotocol.com/protocol/extensibility
39 */
40 readonly request: (
41 method: string,
42 payload: unknown,
43 ) => Effect.Effect<unknown, AcpError.AcpError>;
44 /**
45 * Sends a generic ACP extension notification.
46 * @see https://agentclientprotocol.com/protocol/extensibility
47 */
48 readonly notify: (method: string, payload: unknown) => Effect.Effect<void, AcpError.AcpError>;
49 };
50 readonly client: {
51 /**
52 * Requests client permission for an operation.
53 * @see https://agentclientprotocol.com/protocol/schema#session/request_permission
54 */
55 readonly requestPermission: (
56 payload: AcpSchema.RequestPermissionRequest,
57 ) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpError.AcpError>;
58 /**
59 * Requests structured user input from the client.
60 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation
61 */
62 readonly elicit: (
63 payload: AcpSchema.ElicitationRequest,
64 ) => Effect.Effect<AcpSchema.ElicitationResponse, AcpError.AcpError>;
65 /**
66 * Requests file contents from the client.
67 * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file
68 */
69 readonly readTextFile: (
70 payload: AcpSchema.ReadTextFileRequest,
71 ) => Effect.Effect<AcpSchema.ReadTextFileResponse, AcpError.AcpError>;
72 /**
73 * Writes a text file through the client.
74 * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file
75 */
76 readonly writeTextFile: (
77 payload: AcpSchema.WriteTextFileRequest,
78 ) => Effect.Effect<AcpSchema.WriteTextFileResponse, AcpError.AcpError>;
79 /**
80 * Creates a terminal on the client side.
81 * @see https://agentclientprotocol.com/protocol/schema#terminal/create
82 */
83 readonly createTerminal: (
84 payload: AcpSchema.CreateTerminalRequest,
85 ) => Effect.Effect<AcpTerminal.AcpTerminal, AcpError.AcpError>;
86 /**
87 * Sends a `session/update` notification to the client.
88 * @see https://agentclientprotocol.com/protocol/schema#session/update
89 */
90 readonly sessionUpdate: (
91 payload: AcpSchema.SessionNotification,
92 ) => Effect.Effect<void, AcpError.AcpError>;
93 /**
94 * Sends a `session/elicitation/complete` notification to the client.
95 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete
96 */
97 readonly elicitationComplete: (
98 payload: AcpSchema.ElicitationCompleteNotification,
99 ) => Effect.Effect<void, AcpError.AcpError>;
100 /**
101 * Sends an ACP extension request to the client.
102 * @see https://agentclientprotocol.com/protocol/extensibility
103 */
104 readonly extRequest: (
105 method: string,
106 payload: unknown,
107 ) => Effect.Effect<unknown, AcpError.AcpError>;
108 /**
109 * Sends an ACP extension notification to the client.
110 * @see https://agentclientprotocol.com/protocol/extensibility
111 */
112 readonly extNotification: (
113 method: string,
114 payload: unknown,
115 ) => Effect.Effect<void, AcpError.AcpError>;
116 };
117 /**
118 * Registers a handler for `initialize`.
119 * @see https://agentclientprotocol.com/protocol/schema#initialize
120 */
121 readonly handleInitialize: (
122 handler: (
123 request: AcpSchema.InitializeRequest,
124 ) => Effect.Effect<AcpSchema.InitializeResponse, AcpError.AcpError>,
125 ) => Effect.Effect<void>;
126 /**
127 * Registers a handler for `authenticate`.
128 * @see https://agentclientprotocol.com/protocol/schema#authenticate
129 */
130 readonly handleAuthenticate: (
131 handler: (
132 request: AcpSchema.AuthenticateRequest,
133 ) => Effect.Effect<AcpSchema.AuthenticateResponse, AcpError.AcpError>,
134 ) => Effect.Effect<void>;
135 readonly handleLogout: (
136 handler: (
137 request: AcpSchema.LogoutRequest,
138 ) => Effect.Effect<AcpSchema.LogoutResponse, AcpError.AcpError>,
139 ) => Effect.Effect<void>;
140 readonly handleCreateSession: (
141 handler: (
142 request: AcpSchema.NewSessionRequest,
143 ) => Effect.Effect<AcpSchema.NewSessionResponse, AcpError.AcpError>,
144 ) => Effect.Effect<void>;
145 readonly handleLoadSession: (
146 handler: (
147 request: AcpSchema.LoadSessionRequest,
148 ) => Effect.Effect<AcpSchema.LoadSessionResponse, AcpError.AcpError>,
149 ) => Effect.Effect<void>;
150 readonly handleListSessions: (
151 handler: (
152 request: AcpSchema.ListSessionsRequest,
153 ) => Effect.Effect<AcpSchema.ListSessionsResponse, AcpError.AcpError>,
154 ) => Effect.Effect<void>;
155 readonly handleForkSession: (
156 handler: (
157 request: AcpSchema.ForkSessionRequest,
158 ) => Effect.Effect<AcpSchema.ForkSessionResponse, AcpError.AcpError>,
159 ) => Effect.Effect<void>;
160 readonly handleResumeSession: (
161 handler: (
162 request: AcpSchema.ResumeSessionRequest,
163 ) => Effect.Effect<AcpSchema.ResumeSessionResponse, AcpError.AcpError>,
164 ) => Effect.Effect<void>;
165 readonly handleCloseSession: (
166 handler: (
167 request: AcpSchema.CloseSessionRequest,
168 ) => Effect.Effect<AcpSchema.CloseSessionResponse, AcpError.AcpError>,
169 ) => Effect.Effect<void>;
170 readonly handleSetSessionModel: (
171 handler: (
172 request: AcpSchema.SetSessionModelRequest,
173 ) => Effect.Effect<AcpSchema.SetSessionModelResponse, AcpError.AcpError>,
174 ) => Effect.Effect<void>;
175 readonly handleSetSessionConfigOption: (
176 handler: (
177 request: AcpSchema.SetSessionConfigOptionRequest,
178 ) => Effect.Effect<AcpSchema.SetSessionConfigOptionResponse, AcpError.AcpError>,
179 ) => Effect.Effect<void>;
180 readonly handlePrompt: (
181 handler: (
182 request: AcpSchema.PromptRequest,
183 ) => Effect.Effect<AcpSchema.PromptResponse, AcpError.AcpError>,
184 ) => Effect.Effect<void>;
185 /**
186 * Registers a handler for `session/cancel`.
187 * @see https://agentclientprotocol.com/protocol/schema#session/cancel
188 */
189 readonly handleCancel: (
190 handler: (notification: AcpSchema.CancelNotification) => Effect.Effect<void, AcpError.AcpError>,
191 ) => Effect.Effect<void>;
192 readonly handleUnknownExtRequest: (
193 handler: (method: string, params: unknown) => Effect.Effect<unknown, AcpError.AcpError>,
194 ) => Effect.Effect<void>;
195 readonly handleUnknownExtNotification: (
196 handler: (method: string, params: unknown) => Effect.Effect<void, AcpError.AcpError>,
197 ) => Effect.Effect<void>;
198 readonly handleExtRequest: <A, I>(
199 method: string,
200 payload: Schema.Codec<A, I>,
201 handler: (payload: A) => Effect.Effect<unknown, AcpError.AcpError>,
202 ) => Effect.Effect<void>;
203 readonly handleExtNotification: <A, I>(
204 method: string,
205 payload: Schema.Codec<A, I>,
206 handler: (payload: A) => Effect.Effect<void, AcpError.AcpError>,
207 ) => Effect.Effect<void>;
208}
209
210export class AcpAgent extends Context.Service<AcpAgent, AcpAgentShape>()("effect-acp/AcpAgent") {}
211
212interface AcpCoreAgentRequestHandlers {
213 initialize?: (
214 request: AcpSchema.InitializeRequest,
215 ) => Effect.Effect<AcpSchema.InitializeResponse, AcpError.AcpError>;
216 authenticate?: (
217 request: AcpSchema.AuthenticateRequest,
218 ) => Effect.Effect<AcpSchema.AuthenticateResponse, AcpError.AcpError>;
219 logout?: (
220 request: AcpSchema.LogoutRequest,
221 ) => Effect.Effect<AcpSchema.LogoutResponse, AcpError.AcpError>;
222 createSession?: (
223 request: AcpSchema.NewSessionRequest,
224 ) => Effect.Effect<AcpSchema.NewSessionResponse, AcpError.AcpError>;
225 loadSession?: (
226 request: AcpSchema.LoadSessionRequest,
227 ) => Effect.Effect<AcpSchema.LoadSessionResponse, AcpError.AcpError>;
228 listSessions?: (
229 request: AcpSchema.ListSessionsRequest,
230 ) => Effect.Effect<AcpSchema.ListSessionsResponse, AcpError.AcpError>;
231 forkSession?: (
232 request: AcpSchema.ForkSessionRequest,
233 ) => Effect.Effect<AcpSchema.ForkSessionResponse, AcpError.AcpError>;
234 resumeSession?: (
235 request: AcpSchema.ResumeSessionRequest,
236 ) => Effect.Effect<AcpSchema.ResumeSessionResponse, AcpError.AcpError>;
237 closeSession?: (
238 request: AcpSchema.CloseSessionRequest,
239 ) => Effect.Effect<AcpSchema.CloseSessionResponse, AcpError.AcpError>;
240 setSessionModel?: (
241 request: AcpSchema.SetSessionModelRequest,
242 ) => Effect.Effect<AcpSchema.SetSessionModelResponse, AcpError.AcpError>;
243 setSessionConfigOption?: (
244 request: AcpSchema.SetSessionConfigOptionRequest,
245 ) => Effect.Effect<AcpSchema.SetSessionConfigOptionResponse, AcpError.AcpError>;
246 prompt?: (
247 request: AcpSchema.PromptRequest,
248 ) => Effect.Effect<AcpSchema.PromptResponse, AcpError.AcpError>;
249}
250
251const decodeCancelNotification = Schema.decodeUnknownEffect(AcpSchema.CancelNotification);
252
253export const make = Effect.fn("effect-acp/AcpAgent.make")(function* (
254 stdio: Stdio.Stdio,
255 options: AcpAgentOptions = {},
256): Effect.fn.Return<AcpAgentShape, never, Scope.Scope> {
257 const coreHandlers: AcpCoreAgentRequestHandlers = {};
258 const cancelHandlers: Array<
259 (notification: AcpSchema.CancelNotification) => Effect.Effect<void, AcpError.AcpError>
260 > = [];
261 const extRequestHandlers = new Map<
262 string,
263 (params: unknown) => Effect.Effect<unknown, AcpError.AcpError>
264 >();
265 const extNotificationHandlers = new Map<
266 string,
267 (params: unknown) => Effect.Effect<void, AcpError.AcpError>
268 >();
269 let unknownExtRequestHandler:
270 | ((method: string, params: unknown) => Effect.Effect<unknown, AcpError.AcpError>)
271 | undefined;
272 let unknownExtNotificationHandler:
273 | ((method: string, params: unknown) => Effect.Effect<void, AcpError.AcpError>)
274 | undefined;
275
276 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
277 stdio,
278 serverRequestMethods: new Set(AcpRpcs.AgentRpcs.requests.keys()),
279 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),
280 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),
281 ...(options.logger ? { logger: options.logger } : {}),
282 onNotification: (notification) => {
283 if (
284 notification._tag === "ExtNotification" &&
285 notification.method === AGENT_METHODS.session_cancel
286 ) {
287 return decodeCancelNotification(notification.params).pipe(
288 Effect.mapError(
289 (error) =>
290 new AcpError.AcpProtocolParseError({
291 detail: `Invalid ${AGENT_METHODS.session_cancel} notification payload`,
292 cause: error,
293 }),
294 ),
295 Effect.flatMap((decoded) =>
296 Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }),
297 ),
298 );
299 }
300
301 if (notification._tag !== "ExtNotification") {
302 return Effect.void;
303 }
304
305 const handler = extNotificationHandlers.get(notification.method);
306 if (handler) {
307 return handler(notification.params);
308 }
309 return unknownExtNotificationHandler
310 ? unknownExtNotificationHandler(notification.method, notification.params)
311 : Effect.void;
312 },
313 onExtRequest: (method, params) => {
314 const handler = extRequestHandlers.get(method);
315 if (handler) {
316 return handler(params);
317 }
318 return unknownExtRequestHandler
319 ? unknownExtRequestHandler(method, params)
320 : Effect.fail(AcpError.AcpRequestError.methodNotFound(method));
321 },
322 });
323
324 const agentHandlerLayer = AcpRpcs.AgentRpcs.toLayer(
325 AcpRpcs.AgentRpcs.of({
326 [AGENT_METHODS.initialize]: (payload) =>
327 runHandler(coreHandlers.initialize, payload, AGENT_METHODS.initialize),
328 [AGENT_METHODS.authenticate]: (payload) =>
329 runHandler(coreHandlers.authenticate, payload, AGENT_METHODS.authenticate),
330 [AGENT_METHODS.logout]: (payload) =>
331 runHandler(coreHandlers.logout, payload, AGENT_METHODS.logout),
332 [AGENT_METHODS.session_new]: (payload) =>
333 runHandler(coreHandlers.createSession, payload, AGENT_METHODS.session_new),
334 [AGENT_METHODS.session_load]: (payload) =>
335 runHandler(coreHandlers.loadSession, payload, AGENT_METHODS.session_load),
336 [AGENT_METHODS.session_list]: (payload) =>
337 runHandler(coreHandlers.listSessions, payload, AGENT_METHODS.session_list),
338 [AGENT_METHODS.session_fork]: (payload) =>
339 runHandler(coreHandlers.forkSession, payload, AGENT_METHODS.session_fork),
340 [AGENT_METHODS.session_resume]: (payload) =>
341 runHandler(coreHandlers.resumeSession, payload, AGENT_METHODS.session_resume),
342 [AGENT_METHODS.session_close]: (payload) =>
343 runHandler(coreHandlers.closeSession, payload, AGENT_METHODS.session_close),
344 [AGENT_METHODS.session_set_model]: (payload) =>
345 runHandler(coreHandlers.setSessionModel, payload, AGENT_METHODS.session_set_model),
346 [AGENT_METHODS.session_set_config_option]: (payload) =>
347 runHandler(
348 coreHandlers.setSessionConfigOption,
349 payload,
350 AGENT_METHODS.session_set_config_option,
351 ),
352 [AGENT_METHODS.session_prompt]: (payload) =>
353 runHandler(coreHandlers.prompt, payload, AGENT_METHODS.session_prompt),
354 }),
355 );
356
357 yield* RpcServer.make(AcpRpcs.AgentRpcs).pipe(
358 Effect.provideService(RpcServer.Protocol, transport.serverProtocol),
359 Effect.provide(agentHandlerLayer),
360 Effect.forkScoped,
361 );
362
363 let nextRpcRequestId = 1n << 32n;
364 const rpc = yield* RpcClient.make(AcpRpcs.ClientRpcs, {
365 generateRequestId: () => nextRpcRequestId++ as never,
366 }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol));
367
368 return AcpAgent.of({
369 raw: {
370 notifications: transport.incoming,
371 request: transport.request,
372 notify: transport.notify,
373 },
374 client: {
375 requestPermission: (payload) =>
376 callRpc(rpc[CLIENT_METHODS.session_request_permission](payload)),
377 elicit: (payload) => callRpc(rpc[CLIENT_METHODS.session_elicitation](payload)),
378 readTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_read_text_file](payload)),
379 writeTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_write_text_file](payload)),
380 createTerminal: (payload) =>
381 callRpc(rpc[CLIENT_METHODS.terminal_create](payload)).pipe(
382 Effect.map((response) =>
383 AcpTerminal.makeTerminal({
384 sessionId: payload.sessionId,
385 terminalId: response.terminalId,
386 output: callRpc(
387 rpc[CLIENT_METHODS.terminal_output]({
388 sessionId: payload.sessionId,
389 terminalId: response.terminalId,
390 }),
391 ),
392 waitForExit: callRpc(
393 rpc[CLIENT_METHODS.terminal_wait_for_exit]({
394 sessionId: payload.sessionId,
395 terminalId: response.terminalId,
396 }),
397 ),
398 kill: callRpc(
399 rpc[CLIENT_METHODS.terminal_kill]({
400 sessionId: payload.sessionId,
401 terminalId: response.terminalId,
402 }),
403 ),
404 release: callRpc(
405 rpc[CLIENT_METHODS.terminal_release]({
406 sessionId: payload.sessionId,
407 terminalId: response.terminalId,
408 }),
409 ),
410 }),
411 ),
412 ),
413 sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload),
414 elicitationComplete: (payload) =>
415 transport.notify(CLIENT_METHODS.session_elicitation_complete, payload),
416 extRequest: transport.request,
417 extNotification: transport.notify,
418 },
419 handleInitialize: (handler) =>
420 Effect.suspend(() => {
421 coreHandlers.initialize = handler;
422 return Effect.void;
423 }),
424 handleAuthenticate: (handler) =>
425 Effect.suspend(() => {
426 coreHandlers.authenticate = handler;
427 return Effect.void;
428 }),
429 handleLogout: (handler) =>
430 Effect.suspend(() => {
431 coreHandlers.logout = handler;
432 return Effect.void;
433 }),
434 handleCreateSession: (handler) =>
435 Effect.suspend(() => {
436 coreHandlers.createSession = handler;
437 return Effect.void;
438 }),
439 handleLoadSession: (handler) =>
440 Effect.suspend(() => {
441 coreHandlers.loadSession = handler;
442 return Effect.void;
443 }),
444 handleListSessions: (handler) =>
445 Effect.suspend(() => {
446 coreHandlers.listSessions = handler;
447 return Effect.void;
448 }),
449 handleForkSession: (handler) =>
450 Effect.suspend(() => {
451 coreHandlers.forkSession = handler;
452 return Effect.void;
453 }),
454 handleResumeSession: (handler) =>
455 Effect.suspend(() => {
456 coreHandlers.resumeSession = handler;
457 return Effect.void;
458 }),
459 handleCloseSession: (handler) =>
460 Effect.suspend(() => {
461 coreHandlers.closeSession = handler;
462 return Effect.void;
463 }),
464 handleSetSessionModel: (handler) =>
465 Effect.suspend(() => {
466 coreHandlers.setSessionModel = handler;
467 return Effect.void;
468 }),
469 handleSetSessionConfigOption: (handler) =>
470 Effect.suspend(() => {
471 coreHandlers.setSessionConfigOption = handler;
472 return Effect.void;
473 }),
474 handlePrompt: (handler) =>
475 Effect.suspend(() => {
476 coreHandlers.prompt = handler;
477 return Effect.void;
478 }),
479 handleCancel: (handler) =>
480 Effect.suspend(() => {
481 cancelHandlers.push(handler);
482 return Effect.void;
483 }),
484 handleUnknownExtRequest: (handler) =>
485 Effect.suspend(() => {
486 unknownExtRequestHandler = handler;
487 return Effect.void;
488 }),
489 handleUnknownExtNotification: (handler) =>
490 Effect.suspend(() => {
491 unknownExtNotificationHandler = handler;
492 return Effect.void;
493 }),
494 handleExtRequest: (method, payload, handler) =>
495 Effect.suspend(() => {
496 extRequestHandlers.set(method, decodeExtRequestRegistration(method, payload, handler));
497 return Effect.void;
498 }),
499 handleExtNotification: (method, payload, handler) =>
500 Effect.suspend(() => {
501 extNotificationHandlers.set(
502 method,
503 decodeExtNotificationRegistration(method, payload, handler),
504 );
505 return Effect.void;
506 }),
507 });
508});
509
510export const layer = (stdio: Stdio.Stdio, options: AcpAgentOptions = {}): Layer.Layer<AcpAgent> =>
511 Layer.effect(AcpAgent, make(stdio, options));
512
513export const layerStdio = (
514 options: AcpAgentOptions = {},
515): Layer.Layer<AcpAgent, never, Stdio.Stdio> =>
516 Layer.effect(
517 AcpAgent,
518 Effect.flatMap(Effect.service(Stdio.Stdio), (stdio) => make(stdio, options)),
519 );
packages/effect-acp/src/client.test.ts created+450
......@@ -0,0 +1,450 @@
1import * as Path from "effect/Path";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Queue from "effect/Queue";
8import * as Ref from "effect/Ref";
9import * as Schema from "effect/Schema";
10import * as Scope from "effect/Scope";
11import * as Stream from "effect/Stream";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { it, assert } from "@effect/vitest";
16
17import * as AcpClient from "./client.ts";
18import * as AcpSchema from "./_generated/schema.gen.ts";
19import * as AcpError from "./errors.ts";
20import { encodeJsonl, jsonRpcRequest, jsonRpcResponse } from "./_internal/shared.ts";
21import { makeInMemoryStdio } from "./_internal/stdio.ts";
22
23const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest);
24const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
25const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
26const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
27
28const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
29 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
30);
31
32it.layer(NodeServices.layer)("effect-acp client", (it) => {
33 const makeHandle = (env?: Record<string, string>) =>
34 Effect.gen(function* () {
35 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
36 const path = yield* Path.Path;
37 const command = ChildProcess.make("bun", ["run", yield* mockPeerPath], {
38 cwd: path.join(import.meta.dirname, ".."),
39 shell: process.platform === "win32",
40 ...(env ? { env: { ...process.env, ...env } } : {}),
41 });
42 return yield* spawner.spawn(command);
43 });
44
45 it.effect("initializes, prompts, receives updates, and handles permission requests", () =>
46 Effect.gen(function* () {
47 const updates = yield* Ref.make<Array<unknown>>([]);
48 const elicitationCompletions = yield* Ref.make<Array<unknown>>([]);
49 const typedRequests = yield* Ref.make<Array<unknown>>([]);
50 const typedNotifications = yield* Ref.make<Array<unknown>>([]);
51 const handle = yield* makeHandle();
52 const scope = yield* Scope.make();
53 const acpLayer = AcpClient.layerChildProcess(handle);
54 const context = yield* Layer.buildWithScope(acpLayer, scope);
55
56 const ext = yield* Effect.gen(function* () {
57 const acp = yield* AcpClient.AcpClient;
58
59 yield* acp.handleRequestPermission(() =>
60 Effect.succeed({
61 outcome: {
62 outcome: "selected",
63 optionId: "allow",
64 },
65 }),
66 );
67 yield* acp.handleElicitation(() =>
68 Effect.succeed({
69 action: {
70 action: "accept",
71 content: {
72 approved: true,
73 },
74 },
75 }),
76 );
77 yield* acp.handleSessionUpdate((notification) =>
78 Ref.update(updates, (current) => [...current, notification]),
79 );
80 yield* acp.handleElicitationComplete((notification) =>
81 Ref.update(elicitationCompletions, (current) => [...current, notification]),
82 );
83 yield* acp.handleExtRequest(
84 "x/typed_request",
85 Schema.Struct({ message: Schema.String }),
86 (payload) =>
87 Ref.update(typedRequests, (current) => [...current, payload]).pipe(
88 Effect.as({
89 ok: true,
90 echoedMessage: payload.message,
91 }),
92 ),
93 );
94 yield* acp.handleExtNotification(
95 "x/typed_notification",
96 Schema.Struct({ count: Schema.Number }),
97 (payload) => Ref.update(typedNotifications, (current) => [...current, payload]),
98 );
99
100 const init = yield* acp.agent.initialize({
101 protocolVersion: 1,
102 clientCapabilities: {
103 fs: { readTextFile: false, writeTextFile: false },
104 terminal: false,
105 },
106 clientInfo: {
107 name: "effect-acp-test",
108 version: "0.0.0",
109 },
110 });
111 assert.equal(init.protocolVersion, 1);
112
113 yield* acp.agent.authenticate({ methodId: "cursor_login" });
114
115 const session = yield* acp.agent.createSession({
116 cwd: process.cwd(),
117 mcpServers: [],
118 });
119 assert.equal(session.sessionId, "mock-session-1");
120
121 const prompt = yield* acp.agent.prompt({
122 sessionId: session.sessionId,
123 prompt: [{ type: "text", text: "hello" }],
124 });
125 assert.equal(prompt.stopReason, "end_turn");
126
127 const streamed = yield* Stream.runCollect(Stream.take(acp.raw.notifications, 2));
128 assert.equal(streamed.length, 2);
129 assert.equal(streamed[0]?._tag, "SessionUpdate");
130 assert.equal(streamed[1]?._tag, "ElicitationComplete");
131 assert.equal((yield* Ref.get(updates)).length, 1);
132 assert.equal((yield* Ref.get(elicitationCompletions)).length, 1);
133 assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]);
134 assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]);
135
136 return yield* acp.raw.request("x/echo", {
137 hello: "world",
138 });
139 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
140
141 assert.deepEqual(ext, {
142 echoedMethod: "x/echo",
143 echoedParams: {
144 hello: "world",
145 },
146 });
147 }),
148 );
149
150 it.effect(
151 "returns formatted invalid params when a typed extension request payload is wrong",
152 () =>
153 Effect.gen(function* () {
154 const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" });
155 const scope = yield* Scope.make();
156 const acpLayer = AcpClient.layerChildProcess(handle);
157 const context = yield* Layer.buildWithScope(acpLayer, scope);
158
159 const result = yield* Effect.gen(function* () {
160 const acp = yield* AcpClient.AcpClient;
161
162 yield* acp.handleRequestPermission(() =>
163 Effect.succeed({
164 outcome: {
165 outcome: "selected",
166 optionId: "allow",
167 },
168 }),
169 );
170 yield* acp.handleElicitation(() =>
171 Effect.succeed({
172 action: {
173 action: "accept",
174 content: {
175 approved: true,
176 },
177 },
178 }),
179 );
180 yield* acp.handleExtRequest(
181 "x/typed_request",
182 Schema.Struct({ message: Schema.String }),
183 () => Effect.succeed({ ok: true }),
184 );
185
186 yield* acp.agent.initialize({
187 protocolVersion: 1,
188 clientCapabilities: {
189 fs: { readTextFile: false, writeTextFile: false },
190 terminal: false,
191 },
192 clientInfo: {
193 name: "effect-acp-test",
194 version: "0.0.0",
195 },
196 });
197
198 yield* acp.agent.authenticate({ methodId: "cursor_login" });
199
200 const session = yield* acp.agent.createSession({
201 cwd: process.cwd(),
202 mcpServers: [],
203 });
204
205 return yield* Effect.exit(
206 acp.agent.prompt({
207 sessionId: session.sessionId,
208 prompt: [{ type: "text", text: "hello" }],
209 }),
210 );
211 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
212
213 if (result._tag !== "Failure") {
214 assert.fail("Expected prompt to fail for invalid typed extension payload");
215 }
216 const rendered = Cause.pretty(result.cause);
217 assert.include(rendered, "Invalid x/typed_request payload:");
218 assert.include(rendered, "Expected string, got 123");
219 }),
220 );
221
222 it.effect("replays buffered notifications to handlers registered after they arrive", () =>
223 Effect.gen(function* () {
224 const updates = yield* Ref.make<Array<unknown>>([]);
225 const elicitationCompletions = yield* Ref.make<Array<unknown>>([]);
226 const typedRequests = yield* Ref.make<Array<unknown>>([]);
227 const typedNotifications = yield* Ref.make<Array<unknown>>([]);
228 const handle = yield* makeHandle();
229 const scope = yield* Scope.make();
230 const acpLayer = AcpClient.layerChildProcess(handle);
231 const context = yield* Layer.buildWithScope(acpLayer, scope);
232
233 yield* Effect.gen(function* () {
234 const acp = yield* AcpClient.AcpClient;
235
236 yield* acp.handleRequestPermission(() =>
237 Effect.succeed({
238 outcome: {
239 outcome: "selected",
240 optionId: "allow",
241 },
242 }),
243 );
244 yield* acp.handleElicitation(() =>
245 Effect.succeed({
246 action: {
247 action: "accept",
248 content: {
249 approved: true,
250 },
251 },
252 }),
253 );
254 yield* acp.handleExtRequest(
255 "x/typed_request",
256 Schema.Struct({ message: Schema.String }),
257 (payload) =>
258 Ref.update(typedRequests, (current) => [...current, payload]).pipe(
259 Effect.as({
260 ok: true,
261 echoedMessage: payload.message,
262 }),
263 ),
264 );
265 yield* acp.handleExtNotification(
266 "x/typed_notification",
267 Schema.Struct({ count: Schema.Number }),
268 (payload) => Ref.update(typedNotifications, (current) => [...current, payload]),
269 );
270
271 yield* acp.agent.initialize({
272 protocolVersion: 1,
273 clientCapabilities: {
274 fs: { readTextFile: false, writeTextFile: false },
275 terminal: false,
276 },
277 clientInfo: {
278 name: "effect-acp-test",
279 version: "0.0.0",
280 },
281 });
282 yield* acp.agent.authenticate({ methodId: "cursor_login" });
283
284 const session = yield* acp.agent.createSession({
285 cwd: process.cwd(),
286 mcpServers: [],
287 });
288 yield* acp.agent.prompt({
289 sessionId: session.sessionId,
290 prompt: [{ type: "text", text: "hello" }],
291 });
292
293 yield* acp.handleSessionUpdate((notification) =>
294 Ref.update(updates, (current) => [...current, notification]),
295 );
296 yield* acp.handleElicitationComplete((notification) =>
297 Ref.update(elicitationCompletions, (current) => [...current, notification]),
298 );
299
300 assert.equal((yield* Ref.get(updates)).length, 1);
301 assert.equal((yield* Ref.get(elicitationCompletions)).length, 1);
302 assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]);
303 assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]);
304 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
305 }),
306 );
307
308 it.effect("continues dispatching session updates after one handler fails", () =>
309 Effect.gen(function* () {
310 const successfulHandlers = yield* Ref.make(0);
311 const handle = yield* makeHandle();
312 const scope = yield* Scope.make();
313 const acpLayer = AcpClient.layerChildProcess(handle);
314 const context = yield* Layer.buildWithScope(acpLayer, scope);
315
316 yield* Effect.gen(function* () {
317 const acp = yield* AcpClient.AcpClient;
318
319 yield* acp.handleRequestPermission(() =>
320 Effect.succeed({
321 outcome: {
322 outcome: "selected",
323 optionId: "allow",
324 },
325 }),
326 );
327 yield* acp.handleElicitation(() =>
328 Effect.succeed({
329 action: {
330 action: "accept",
331 content: {
332 approved: true,
333 },
334 },
335 }),
336 );
337 yield* acp.handleExtRequest(
338 "x/typed_request",
339 Schema.Struct({ message: Schema.String }),
340 () => Effect.succeed({ ok: true }),
341 );
342 yield* acp.handleExtNotification(
343 "x/typed_notification",
344 Schema.Struct({ count: Schema.Number }),
345 () => Effect.void,
346 );
347 yield* acp.handleSessionUpdate(() =>
348 Effect.fail(AcpError.AcpRequestError.internalError("session update handler failed")),
349 );
350 yield* acp.handleSessionUpdate(() => Ref.update(successfulHandlers, (count) => count + 1));
351
352 yield* acp.agent.initialize({
353 protocolVersion: 1,
354 clientCapabilities: {
355 fs: { readTextFile: false, writeTextFile: false },
356 terminal: false,
357 },
358 clientInfo: {
359 name: "effect-acp-test",
360 version: "0.0.0",
361 },
362 });
363 yield* acp.agent.authenticate({ methodId: "cursor_login" });
364
365 const session = yield* acp.agent.createSession({
366 cwd: process.cwd(),
367 mcpServers: [],
368 });
369 yield* acp.agent.prompt({
370 sessionId: session.sessionId,
371 prompt: [{ type: "text", text: "hello" }],
372 });
373
374 assert.equal(yield* Ref.get(successfulHandlers), 1);
375 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
376 }),
377 );
378
379 it.effect("uses distinct ids for RPC calls and extension requests", () =>
380 Effect.gen(function* () {
381 const { stdio, input, output } = yield* makeInMemoryStdio();
382 const scope = yield* Scope.make();
383 const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
384
385 const initializeFiber = yield* acp.agent
386 .initialize({
387 protocolVersion: 1,
388 clientCapabilities: {
389 fs: { readTextFile: false, writeTextFile: false },
390 terminal: false,
391 },
392 clientInfo: {
393 name: "effect-acp-test",
394 version: "0.0.0",
395 },
396 })
397 .pipe(Effect.forkScoped);
398 const extFiber = yield* acp.raw.request("x/test", { hello: "world" }).pipe(Effect.forkScoped);
399
400 const firstOutbound = yield* Queue.take(output);
401 const secondOutbound = yield* Queue.take(output);
402
403 const decodedInitialize = Schema.decodeEffect(Schema.fromJsonString(InitializeRequest));
404 const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest));
405 const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe(
406 Effect.match({
407 onFailure: () => false,
408 onSuccess: () => true,
409 }),
410 );
411
412 const initializeRequest = firstIsInitialize
413 ? yield* decodedInitialize(firstOutbound)
414 : yield* decodedInitialize(secondOutbound);
415 const extRequest = firstIsInitialize
416 ? yield* decodedExt(secondOutbound)
417 : yield* decodedExt(firstOutbound);
418
419 assert.notEqual(initializeRequest.id, extRequest.id);
420
421 yield* Queue.offer(
422 input,
423 yield* encodeJsonl(InitializeResponse, {
424 jsonrpc: "2.0",
425 id: initializeRequest.id,
426 result: {
427 protocolVersion: 1,
428 agentCapabilities: {},
429 agentInfo: {
430 name: "mock-agent",
431 version: "0.0.0",
432 },
433 },
434 }),
435 );
436 yield* Queue.offer(
437 input,
438 yield* encodeJsonl(ExtResponse, {
439 jsonrpc: "2.0",
440 id: extRequest.id,
441 result: { ok: true },
442 }),
443 );
444
445 yield* Fiber.join(initializeFiber);
446 assert.deepEqual(yield* Fiber.join(extFiber), { ok: true });
447 yield* Scope.close(scope, Exit.void);
448 }),
449 );
450});
packages/effect-acp/src/client.ts created+569
......@@ -0,0 +1,569 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Stdio from "effect/Stdio";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import * as Scope from "effect/Scope";
7import * as Stream from "effect/Stream";
8import * as RpcClient from "effect/unstable/rpc/RpcClient";
9import * as RpcServer from "effect/unstable/rpc/RpcServer";
10import { ChildProcessSpawner } from "effect/unstable/process";
11
12import * as AcpError from "./errors.ts";
13import * as AcpProtocol from "./protocol.ts";
14import * as AcpRpcs from "./rpc.ts";
15import * as AcpSchema from "./_generated/schema.gen.ts";
16import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";
17import {
18 callRpc,
19 decodeExtNotificationRegistration,
20 decodeExtRequestRegistration,
21 runHandler,
22} from "./_internal/shared.ts";
23import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts";
24
25export interface AcpClientOptions {
26 readonly logIncoming?: boolean;
27 readonly logOutgoing?: boolean;
28 readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
29}
30
31type AcpClientRaw = {
32 readonly notifications: Stream.Stream<AcpProtocol.AcpIncomingNotification>;
33 readonly request: (method: string, payload: unknown) => Effect.Effect<unknown, AcpError.AcpError>;
34 readonly notify: (method: string, payload: unknown) => Effect.Effect<void, AcpError.AcpError>;
35};
36
37export interface AcpClientShape {
38 readonly raw: AcpClientRaw;
39 readonly agent: {
40 /**
41 * Initializes the ACP session and negotiates capabilities.
42 * @see https://agentclientprotocol.com/protocol/schema#initialize
43 */
44 readonly initialize: (
45 payload: AcpSchema.InitializeRequest,
46 ) => Effect.Effect<AcpSchema.InitializeResponse, AcpError.AcpError>;
47 /**
48 * Performs ACP authentication when the agent requires it.
49 * @see https://agentclientprotocol.com/protocol/schema#authenticate
50 */
51 readonly authenticate: (
52 payload: AcpSchema.AuthenticateRequest,
53 ) => Effect.Effect<AcpSchema.AuthenticateResponse, AcpError.AcpError>;
54 /**
55 * Logs out the current ACP identity.
56 * @see https://agentclientprotocol.com/protocol/schema#logout
57 */
58 readonly logout: (
59 payload: AcpSchema.LogoutRequest,
60 ) => Effect.Effect<AcpSchema.LogoutResponse, AcpError.AcpError>;
61 /**
62 * Starts a new ACP session.
63 * @see https://agentclientprotocol.com/protocol/schema#session/new
64 */
65 readonly createSession: (
66 payload: AcpSchema.NewSessionRequest,
67 ) => Effect.Effect<AcpSchema.NewSessionResponse, AcpError.AcpError>;
68 /**
69 * Loads a previously saved ACP session.
70 * @see https://agentclientprotocol.com/protocol/schema#session/load
71 */
72 readonly loadSession: (
73 payload: AcpSchema.LoadSessionRequest,
74 ) => Effect.Effect<AcpSchema.LoadSessionResponse, AcpError.AcpError>;
75 /**
76 * Lists available ACP sessions.
77 * @see https://agentclientprotocol.com/protocol/schema#session/list
78 */
79 readonly listSessions: (
80 payload: AcpSchema.ListSessionsRequest,
81 ) => Effect.Effect<AcpSchema.ListSessionsResponse, AcpError.AcpError>;
82 /**
83 * Forks an ACP session.
84 * @see https://agentclientprotocol.com/protocol/schema#session/fork
85 */
86 readonly forkSession: (
87 payload: AcpSchema.ForkSessionRequest,
88 ) => Effect.Effect<AcpSchema.ForkSessionResponse, AcpError.AcpError>;
89 /**
90 * Resumes an ACP session.
91 * @see https://agentclientprotocol.com/protocol/schema#session/resume
92 */
93 readonly resumeSession: (
94 payload: AcpSchema.ResumeSessionRequest,
95 ) => Effect.Effect<AcpSchema.ResumeSessionResponse, AcpError.AcpError>;
96 /**
97 * Closes an ACP session.
98 * @see https://agentclientprotocol.com/protocol/schema#session/close
99 */
100 readonly closeSession: (
101 payload: AcpSchema.CloseSessionRequest,
102 ) => Effect.Effect<AcpSchema.CloseSessionResponse, AcpError.AcpError>;
103 /**
104 * Selects the active model for a session.
105 * @see https://agentclientprotocol.com/protocol/schema#session/set_model
106 */
107 readonly setSessionModel: (
108 payload: AcpSchema.SetSessionModelRequest,
109 ) => Effect.Effect<AcpSchema.SetSessionModelResponse, AcpError.AcpError>;
110 /**
111 * Updates a session configuration option.
112 * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option
113 */
114 readonly setSessionConfigOption: (
115 payload: AcpSchema.SetSessionConfigOptionRequest,
116 ) => Effect.Effect<AcpSchema.SetSessionConfigOptionResponse, AcpError.AcpError>;
117 /**
118 * Sends a prompt turn to the agent.
119 * @see https://agentclientprotocol.com/protocol/schema#session/prompt
120 */
121 readonly prompt: (
122 payload: AcpSchema.PromptRequest,
123 ) => Effect.Effect<AcpSchema.PromptResponse, AcpError.AcpError>;
124 /**
125 * Sends a real ACP `session/cancel` notification.
126 * @see https://agentclientprotocol.com/protocol/schema#session/cancel
127 */
128 readonly cancel: (
129 payload: AcpSchema.CancelNotification,
130 ) => Effect.Effect<void, AcpError.AcpError>;
131 };
132 /**
133 * Registers a handler for `session/request_permission`.
134 * @see https://agentclientprotocol.com/protocol/schema#session/request_permission
135 */
136 readonly handleRequestPermission: (
137 handler: (
138 request: AcpSchema.RequestPermissionRequest,
139 ) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpError.AcpError>,
140 ) => Effect.Effect<void>;
141 /**
142 * Registers a handler for `session/elicitation`.
143 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation
144 */
145 readonly handleElicitation: (
146 handler: (
147 request: AcpSchema.ElicitationRequest,
148 ) => Effect.Effect<AcpSchema.ElicitationResponse, AcpError.AcpError>,
149 ) => Effect.Effect<void>;
150 /**
151 * Registers a handler for `fs/read_text_file`.
152 * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file
153 */
154 readonly handleReadTextFile: (
155 handler: (
156 request: AcpSchema.ReadTextFileRequest,
157 ) => Effect.Effect<AcpSchema.ReadTextFileResponse, AcpError.AcpError>,
158 ) => Effect.Effect<void>;
159 /**
160 * Registers a handler for `fs/write_text_file`.
161 * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file
162 */
163 readonly handleWriteTextFile: (
164 handler: (
165 request: AcpSchema.WriteTextFileRequest,
166 ) => Effect.Effect<AcpSchema.WriteTextFileResponse | void, AcpError.AcpError>,
167 ) => Effect.Effect<void>;
168 /**
169 * Registers a handler for `terminal/create`.
170 * @see https://agentclientprotocol.com/protocol/schema#terminal/create
171 */
172 readonly handleCreateTerminal: (
173 handler: (
174 request: AcpSchema.CreateTerminalRequest,
175 ) => Effect.Effect<AcpSchema.CreateTerminalResponse, AcpError.AcpError>,
176 ) => Effect.Effect<void>;
177 /**
178 * Registers a handler for `terminal/output`.
179 * @see https://agentclientprotocol.com/protocol/schema#terminal/output
180 */
181 readonly handleTerminalOutput: (
182 handler: (
183 request: AcpSchema.TerminalOutputRequest,
184 ) => Effect.Effect<AcpSchema.TerminalOutputResponse, AcpError.AcpError>,
185 ) => Effect.Effect<void>;
186 /**
187 * Registers a handler for `terminal/wait_for_exit`.
188 * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit
189 */
190 readonly handleTerminalWaitForExit: (
191 handler: (
192 request: AcpSchema.WaitForTerminalExitRequest,
193 ) => Effect.Effect<AcpSchema.WaitForTerminalExitResponse, AcpError.AcpError>,
194 ) => Effect.Effect<void>;
195 /**
196 * Registers a handler for `terminal/kill`.
197 * @see https://agentclientprotocol.com/protocol/schema#terminal/kill
198 */
199 readonly handleTerminalKill: (
200 handler: (
201 request: AcpSchema.KillTerminalRequest,
202 ) => Effect.Effect<AcpSchema.KillTerminalResponse | void, AcpError.AcpError>,
203 ) => Effect.Effect<void>;
204 /**
205 * Registers a handler for `terminal/release`.
206 * @see https://agentclientprotocol.com/protocol/schema#terminal/release
207 */
208 readonly handleTerminalRelease: (
209 handler: (
210 request: AcpSchema.ReleaseTerminalRequest,
211 ) => Effect.Effect<AcpSchema.ReleaseTerminalResponse | void, AcpError.AcpError>,
212 ) => Effect.Effect<void>;
213 /**
214 * Registers a handler for `session/update`.
215 * @see https://agentclientprotocol.com/protocol/schema#session/update
216 */
217 readonly handleSessionUpdate: (
218 handler: (
219 notification: AcpSchema.SessionNotification,
220 ) => Effect.Effect<void, AcpError.AcpError>,
221 ) => Effect.Effect<void>;
222 /**
223 * Registers a handler for `session/elicitation/complete`.
224 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete
225 */
226 readonly handleElicitationComplete: (
227 handler: (
228 notification: AcpSchema.ElicitationCompleteNotification,
229 ) => Effect.Effect<void, AcpError.AcpError>,
230 ) => Effect.Effect<void>;
231 /**
232 * Registers a fallback extension request handler.
233 * @see https://agentclientprotocol.com/protocol/extensibility
234 */
235 readonly handleUnknownExtRequest: (
236 handler: (method: string, params: unknown) => Effect.Effect<unknown, AcpError.AcpError>,
237 ) => Effect.Effect<void>;
238 /**
239 * Registers a fallback extension notification handler.
240 * @see https://agentclientprotocol.com/protocol/extensibility
241 */
242 readonly handleUnknownExtNotification: (
243 handler: (method: string, params: unknown) => Effect.Effect<void, AcpError.AcpError>,
244 ) => Effect.Effect<void>;
245 /**
246 * Registers a typed extension request handler.
247 * @see https://agentclientprotocol.com/protocol/extensibility
248 */
249 readonly handleExtRequest: <A, I>(
250 method: string,
251 payload: Schema.Codec<A, I>,
252 handler: (payload: A) => Effect.Effect<unknown, AcpError.AcpError>,
253 ) => Effect.Effect<void>;
254 /**
255 * Registers a typed extension notification handler.
256 * @see https://agentclientprotocol.com/protocol/extensibility
257 */
258 readonly handleExtNotification: <A, I>(
259 method: string,
260 payload: Schema.Codec<A, I>,
261 handler: (payload: A) => Effect.Effect<void, AcpError.AcpError>,
262 ) => Effect.Effect<void>;
263}
264
265export class AcpClient extends Context.Service<AcpClient, AcpClientShape>()(
266 "effect-acp/AcpClient",
267) {}
268
269interface AcpCoreRequestHandlers {
270 requestPermission?: (
271 request: AcpSchema.RequestPermissionRequest,
272 ) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpError.AcpError>;
273 elicitation?: (
274 request: AcpSchema.ElicitationRequest,
275 ) => Effect.Effect<AcpSchema.ElicitationResponse, AcpError.AcpError>;
276 readTextFile?: (
277 request: AcpSchema.ReadTextFileRequest,
278 ) => Effect.Effect<AcpSchema.ReadTextFileResponse, AcpError.AcpError>;
279 writeTextFile?: (
280 request: AcpSchema.WriteTextFileRequest,
281 ) => Effect.Effect<AcpSchema.WriteTextFileResponse | void, AcpError.AcpError>;
282 createTerminal?: (
283 request: AcpSchema.CreateTerminalRequest,
284 ) => Effect.Effect<AcpSchema.CreateTerminalResponse, AcpError.AcpError>;
285 terminalOutput?: (
286 request: AcpSchema.TerminalOutputRequest,
287 ) => Effect.Effect<AcpSchema.TerminalOutputResponse, AcpError.AcpError>;
288 terminalWaitForExit?: (
289 request: AcpSchema.WaitForTerminalExitRequest,
290 ) => Effect.Effect<AcpSchema.WaitForTerminalExitResponse, AcpError.AcpError>;
291 terminalKill?: (
292 request: AcpSchema.KillTerminalRequest,
293 ) => Effect.Effect<AcpSchema.KillTerminalResponse | void, AcpError.AcpError>;
294 terminalRelease?: (
295 request: AcpSchema.ReleaseTerminalRequest,
296 ) => Effect.Effect<AcpSchema.ReleaseTerminalResponse | void, AcpError.AcpError>;
297}
298
299interface AcpNotificationHandlers {
300 readonly sessionUpdate: BufferedNotificationHandler<AcpSchema.SessionNotification>;
301 readonly elicitationComplete: BufferedNotificationHandler<AcpSchema.ElicitationCompleteNotification>;
302}
303
304interface BufferedNotificationHandler<A> {
305 readonly handlers: Array<(notification: A) => Effect.Effect<void, AcpError.AcpError>>;
306 readonly pending: Array<A>;
307}
308
309export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
310 stdio: Stdio.Stdio,
311 options: AcpClientOptions = {},
312 terminationError?: Effect.Effect<AcpError.AcpError>,
313): Effect.fn.Return<AcpClientShape, never, Scope.Scope> {
314 const coreHandlers: AcpCoreRequestHandlers = {};
315 const notificationHandlers: AcpNotificationHandlers = {
316 sessionUpdate: { handlers: [], pending: [] },
317 elicitationComplete: { handlers: [], pending: [] },
318 };
319 const extRequestHandlers = new Map<
320 string,
321 (params: unknown) => Effect.Effect<unknown, AcpError.AcpError>
322 >();
323 const extNotificationHandlers = new Map<
324 string,
325 (params: unknown) => Effect.Effect<void, AcpError.AcpError>
326 >();
327 let unknownExtRequestHandler:
328 | ((method: string, params: unknown) => Effect.Effect<unknown, AcpError.AcpError>)
329 | undefined;
330 let unknownExtNotificationHandler:
331 | ((method: string, params: unknown) => Effect.Effect<void, AcpError.AcpError>)
332 | undefined;
333
334 const runNotificationHandlers = <A>(
335 registration: BufferedNotificationHandler<A>,
336 notification: A,
337 ) =>
338 Effect.forEach(
339 registration.handlers,
340 (handler) => handler(notification).pipe(Effect.catch(() => Effect.void)),
341 { discard: true },
342 );
343
344 const flushBufferedNotifications = <A>(registration: BufferedNotificationHandler<A>) =>
345 Effect.suspend(() => {
346 if (registration.handlers.length === 0 || registration.pending.length === 0) {
347 return Effect.void;
348 }
349 const pending = registration.pending.splice(0, registration.pending.length);
350 return Effect.forEach(
351 pending,
352 (notification) => runNotificationHandlers(registration, notification),
353 {
354 discard: true,
355 },
356 );
357 });
358
359 const dispatchNotification = (notification: AcpProtocol.AcpIncomingNotification) => {
360 switch (notification._tag) {
361 case "SessionUpdate": {
362 if (notificationHandlers.sessionUpdate.handlers.length === 0) {
363 notificationHandlers.sessionUpdate.pending.push(notification.params);
364 return Effect.void;
365 }
366 return runNotificationHandlers(notificationHandlers.sessionUpdate, notification.params);
367 }
368 case "ElicitationComplete": {
369 if (notificationHandlers.elicitationComplete.handlers.length === 0) {
370 notificationHandlers.elicitationComplete.pending.push(notification.params);
371 return Effect.void;
372 }
373 return runNotificationHandlers(
374 notificationHandlers.elicitationComplete,
375 notification.params,
376 );
377 }
378 case "ExtNotification": {
379 const handler = extNotificationHandlers.get(notification.method);
380 if (handler) {
381 return handler(notification.params);
382 }
383 return unknownExtNotificationHandler
384 ? unknownExtNotificationHandler(notification.method, notification.params)
385 : Effect.void;
386 }
387 }
388 };
389
390 const dispatchExtRequest = (method: string, params: unknown) => {
391 const handler = extRequestHandlers.get(method);
392 if (handler) {
393 return handler(params);
394 }
395 return unknownExtRequestHandler
396 ? unknownExtRequestHandler(method, params)
397 : Effect.fail(AcpError.AcpRequestError.methodNotFound(method));
398 };
399
400 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
401 stdio: stdio,
402 ...(terminationError ? { terminationError } : {}),
403 serverRequestMethods: new Set(AcpRpcs.ClientRpcs.requests.keys()),
404 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),
405 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),
406 ...(options.logger ? { logger: options.logger } : {}),
407 onNotification: dispatchNotification,
408 onExtRequest: dispatchExtRequest,
409 });
410
411 const clientHandlerLayer = AcpRpcs.ClientRpcs.toLayer(
412 AcpRpcs.ClientRpcs.of({
413 [CLIENT_METHODS.session_request_permission]: (payload) =>
414 runHandler(
415 coreHandlers.requestPermission,
416 payload,
417 CLIENT_METHODS.session_request_permission,
418 ),
419 [CLIENT_METHODS.session_elicitation]: (payload) =>
420 runHandler(coreHandlers.elicitation, payload, CLIENT_METHODS.session_elicitation),
421 [CLIENT_METHODS.fs_read_text_file]: (payload) =>
422 runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file),
423 [CLIENT_METHODS.fs_write_text_file]: (payload) =>
424 runHandler(coreHandlers.writeTextFile, payload, CLIENT_METHODS.fs_write_text_file).pipe(
425 Effect.map((result) => result ?? {}),
426 ),
427 [CLIENT_METHODS.terminal_create]: (payload) =>
428 runHandler(coreHandlers.createTerminal, payload, CLIENT_METHODS.terminal_create),
429 [CLIENT_METHODS.terminal_output]: (payload) =>
430 runHandler(coreHandlers.terminalOutput, payload, CLIENT_METHODS.terminal_output),
431 [CLIENT_METHODS.terminal_wait_for_exit]: (payload) =>
432 runHandler(
433 coreHandlers.terminalWaitForExit,
434 payload,
435 CLIENT_METHODS.terminal_wait_for_exit,
436 ),
437 [CLIENT_METHODS.terminal_kill]: (payload) =>
438 runHandler(coreHandlers.terminalKill, payload, CLIENT_METHODS.terminal_kill).pipe(
439 Effect.map((result) => result ?? {}),
440 ),
441 [CLIENT_METHODS.terminal_release]: (payload) =>
442 runHandler(coreHandlers.terminalRelease, payload, CLIENT_METHODS.terminal_release).pipe(
443 Effect.map((result) => result ?? {}),
444 ),
445 }),
446 );
447
448 yield* RpcServer.make(AcpRpcs.ClientRpcs).pipe(
449 Effect.provideService(RpcServer.Protocol, transport.serverProtocol),
450 Effect.provide(clientHandlerLayer),
451 Effect.forkScoped,
452 );
453
454 let nextRpcRequestId = 1n << 32n;
455 const rpc = yield* RpcClient.make(AcpRpcs.AgentRpcs, {
456 generateRequestId: () => nextRpcRequestId++ as never,
457 }).pipe(Effect.provideService(RpcClient.Protocol, transport.clientProtocol));
458
459 return AcpClient.of({
460 raw: {
461 notifications: transport.incoming,
462 request: transport.request,
463 notify: transport.notify,
464 },
465 agent: {
466 initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)),
467 authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)),
468 logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)),
469 createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)),
470 loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)),
471 listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)),
472 forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)),
473 resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)),
474 closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)),
475 setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)),
476 setSessionConfigOption: (payload) =>
477 callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)),
478 prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)),
479 cancel: (payload) => transport.notify(AGENT_METHODS.session_cancel, payload),
480 },
481 handleRequestPermission: (handler) =>
482 Effect.suspend(() => {
483 coreHandlers.requestPermission = handler;
484 return Effect.void;
485 }),
486 handleElicitation: (handler) =>
487 Effect.suspend(() => {
488 coreHandlers.elicitation = handler;
489 return Effect.void;
490 }),
491 handleReadTextFile: (handler) =>
492 Effect.suspend(() => {
493 coreHandlers.readTextFile = handler;
494 return Effect.void;
495 }),
496 handleWriteTextFile: (handler) =>
497 Effect.suspend(() => {
498 coreHandlers.writeTextFile = handler;
499 return Effect.void;
500 }),
501 handleCreateTerminal: (handler) =>
502 Effect.suspend(() => {
503 coreHandlers.createTerminal = handler;
504 return Effect.void;
505 }),
506 handleTerminalOutput: (handler) =>
507 Effect.suspend(() => {
508 coreHandlers.terminalOutput = handler;
509 return Effect.void;
510 }),
511 handleTerminalWaitForExit: (handler) =>
512 Effect.suspend(() => {
513 coreHandlers.terminalWaitForExit = handler;
514 return Effect.void;
515 }),
516 handleTerminalKill: (handler) =>
517 Effect.suspend(() => {
518 coreHandlers.terminalKill = handler;
519 return Effect.void;
520 }),
521 handleTerminalRelease: (handler) =>
522 Effect.suspend(() => {
523 coreHandlers.terminalRelease = handler;
524 return Effect.void;
525 }),
526 handleSessionUpdate: (handler) =>
527 Effect.suspend(() => {
528 notificationHandlers.sessionUpdate.handlers.push(handler);
529 return flushBufferedNotifications(notificationHandlers.sessionUpdate);
530 }),
531 handleElicitationComplete: (handler) =>
532 Effect.suspend(() => {
533 notificationHandlers.elicitationComplete.handlers.push(handler);
534 return flushBufferedNotifications(notificationHandlers.elicitationComplete);
535 }),
536 handleUnknownExtRequest: (handler) =>
537 Effect.suspend(() => {
538 unknownExtRequestHandler = handler;
539 return Effect.void;
540 }),
541 handleUnknownExtNotification: (handler) =>
542 Effect.suspend(() => {
543 unknownExtNotificationHandler = handler;
544 return Effect.void;
545 }),
546 handleExtRequest: (method, payload, handler) =>
547 Effect.suspend(() => {
548 extRequestHandlers.set(method, decodeExtRequestRegistration(method, payload, handler));
549 return Effect.void;
550 }),
551 handleExtNotification: (method, payload, handler) =>
552 Effect.suspend(() => {
553 extNotificationHandlers.set(
554 method,
555 decodeExtNotificationRegistration(method, payload, handler),
556 );
557 return Effect.void;
558 }),
559 });
560});
561
562export const layerChildProcess = (
563 handle: ChildProcessSpawner.ChildProcessHandle,
564 options: AcpClientOptions = {},
565): Layer.Layer<AcpClient> => {
566 const stdio = makeChildStdio(handle);
567 const terminationError = makeTerminationError(handle);
568 return Layer.effect(AcpClient, make(stdio, options, terminationError));
569};
packages/effect-acp/src/errors.ts created+139
......@@ -0,0 +1,139 @@
1import * as Schema from "effect/Schema";
2
3import * as AcpSchema from "./_generated/schema.gen.ts";
4
5export class AcpSpawnError extends Schema.TaggedErrorClass<AcpSpawnError>()("AcpSpawnError", {
6 command: Schema.optional(Schema.String),
7 cause: Schema.Defect,
8}) {
9 override get message() {
10 return this.command
11 ? `Failed to spawn ACP process for command: ${this.command}`
12 : "Failed to spawn ACP process";
13 }
14}
15
16export class AcpProcessExitedError extends Schema.TaggedErrorClass<AcpProcessExitedError>()(
17 "AcpProcessExitedError",
18 {
19 code: Schema.optional(Schema.Number),
20 cause: Schema.optional(Schema.Defect),
21 },
22) {
23 override get message() {
24 return this.code === undefined
25 ? "ACP process exited"
26 : `ACP process exited with code ${this.code}`;
27 }
28}
29
30export class AcpProtocolParseError extends Schema.TaggedErrorClass<AcpProtocolParseError>()(
31 "AcpProtocolParseError",
32 {
33 detail: Schema.String,
34 cause: Schema.optional(Schema.Defect),
35 },
36) {
37 override get message() {
38 return `Failed to parse ACP protocol message: ${this.detail}`;
39 }
40}
41
42export class AcpTransportError extends Schema.TaggedErrorClass<AcpTransportError>()(
43 "AcpTransportError",
44 {
45 detail: Schema.String,
46 cause: Schema.Defect,
47 },
48) {}
49
50export class AcpRequestError extends Schema.TaggedErrorClass<AcpRequestError>()("AcpRequestError", {
51 code: AcpSchema.ErrorCode,
52 errorMessage: Schema.String,
53 data: Schema.optional(Schema.Unknown),
54}) {
55 override get message() {
56 return this.errorMessage;
57 }
58
59 static fromProtocolError(error: AcpSchema.Error) {
60 return new AcpRequestError({
61 code: error.code,
62 errorMessage: error.message,
63 ...(error.data !== undefined ? { data: error.data } : {}),
64 });
65 }
66
67 static parseError(message = "Parse error", data?: unknown) {
68 return new AcpRequestError({
69 code: -32700,
70 errorMessage: message,
71 ...(data !== undefined ? { data } : {}),
72 });
73 }
74
75 static invalidRequest(message = "Invalid request", data?: unknown) {
76 return new AcpRequestError({
77 code: -32600,
78 errorMessage: message,
79 ...(data !== undefined ? { data } : {}),
80 });
81 }
82
83 static methodNotFound(method: string) {
84 return new AcpRequestError({
85 code: -32601,
86 errorMessage: `Method not found: ${method}`,
87 });
88 }
89
90 static invalidParams(message = "Invalid params", data?: unknown) {
91 return new AcpRequestError({
92 code: -32602,
93 errorMessage: message,
94 ...(data !== undefined ? { data } : {}),
95 });
96 }
97
98 static internalError(message = "Internal error", data?: unknown) {
99 return new AcpRequestError({
100 code: -32603,
101 errorMessage: message,
102 ...(data !== undefined ? { data } : {}),
103 });
104 }
105
106 static authRequired(message = "Authentication required", data?: unknown) {
107 return new AcpRequestError({
108 code: -32000,
109 errorMessage: message,
110 ...(data !== undefined ? { data } : {}),
111 });
112 }
113
114 static resourceNotFound(message = "Resource not found", data?: unknown) {
115 return new AcpRequestError({
116 code: -32002,
117 errorMessage: message,
118 ...(data !== undefined ? { data } : {}),
119 });
120 }
121
122 toProtocolError() {
123 return AcpSchema.Error.make({
124 code: this.code,
125 message: this.errorMessage,
126 ...(this.data !== undefined ? { data: this.data } : {}),
127 });
128 }
129}
130
131export const AcpError = Schema.Union([
132 AcpRequestError,
133 AcpSpawnError,
134 AcpProcessExitedError,
135 AcpProtocolParseError,
136 AcpTransportError,
137]);
138
139export type AcpError = typeof AcpError.Type;
packages/effect-acp/src/protocol.test.ts created+448
......@@ -0,0 +1,448 @@
1import * as Path from "effect/Path";
2import * as AcpError from "./errors.ts";
3import * as Effect from "effect/Effect";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as Queue from "effect/Queue";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as Ref from "effect/Ref";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11
12import { it, assert } from "@effect/vitest";
13import * as NodeServices from "@effect/platform-node/NodeServices";
14
15import * as AcpSchema from "./_generated/schema.gen.ts";
16import * as AcpProtocol from "./protocol.ts";
17import {
18 encodeJsonl,
19 jsonRpcNotification,
20 jsonRpcRequest,
21 jsonRpcResponse,
22} from "./_internal/shared.ts";
23import { makeInMemoryStdio, makeTerminationError, makeChildStdio } from "./_internal/stdio.ts";
24
25const SessionCancelNotification = jsonRpcNotification(
26 "session/cancel",
27 AcpSchema.CancelNotification,
28);
29const SessionUpdateNotification = jsonRpcNotification(
30 "session/update",
31 AcpSchema.SessionNotification,
32);
33const ElicitationCompleteNotification = jsonRpcNotification(
34 "session/elicitation/complete",
35 AcpSchema.ElicitationCompleteNotification,
36);
37const RequestPermissionRequest = jsonRpcRequest(
38 "session/request_permission",
39 AcpSchema.RequestPermissionRequest,
40);
41const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
42const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
43const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
44
45const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
46 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
47);
48
49const makeHandle = (env?: Record<string, string>) =>
50 Effect.gen(function* () {
51 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
52 const path = yield* Path.Path;
53 const command = ChildProcess.make("bun", ["run", yield* mockPeerPath], {
54 cwd: path.join(import.meta.dirname, ".."),
55 shell: process.platform === "win32",
56 ...(env ? { env: { ...process.env, ...env } } : {}),
57 });
58 return yield* spawner.spawn(command);
59 });
60
61it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
62 it.effect(
63 "emits exact JSON-RPC notifications and decodes inbound session/update and elicitation completion",
64 () =>
65 Effect.gen(function* () {
66 const { stdio, input, output } = yield* makeInMemoryStdio();
67 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
68 stdio,
69 serverRequestMethods: new Set(),
70 });
71
72 const notifications =
73 yield* Deferred.make<ReadonlyArray<AcpProtocol.AcpIncomingNotification>>();
74 yield* transport.incoming.pipe(
75 Stream.take(2),
76 Stream.runCollect,
77 Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)),
78 Effect.forkScoped,
79 );
80
81 yield* transport.notify("session/cancel", { sessionId: "session-1" });
82 const outbound = yield* Queue.take(output);
83 assert.deepEqual(
84 yield* Schema.decodeEffect(Schema.fromJsonString(SessionCancelNotification))(outbound),
85 {
86 jsonrpc: "2.0",
87 method: "session/cancel",
88 params: {
89 sessionId: "session-1",
90 },
91 },
92 );
93
94 yield* Queue.offer(
95 input,
96 yield* encodeJsonl(SessionUpdateNotification, {
97 jsonrpc: "2.0",
98 method: "session/update",
99 params: {
100 sessionId: "session-1",
101 update: {
102 sessionUpdate: "plan",
103 entries: [
104 {
105 content: "Inspect repository",
106 priority: "high",
107 status: "in_progress",
108 },
109 ],
110 },
111 },
112 }),
113 );
114
115 yield* Queue.offer(
116 input,
117 yield* encodeJsonl(ElicitationCompleteNotification, {
118 jsonrpc: "2.0",
119 method: "session/elicitation/complete",
120 params: {
121 elicitationId: "elicitation-1",
122 },
123 }),
124 );
125
126 const [update, completion] = yield* Deferred.await(notifications);
127 assert.equal(update?._tag, "SessionUpdate");
128 assert.equal(completion?._tag, "ElicitationComplete");
129 }),
130 );
131
132 it.effect("logs outgoing notifications when logOutgoing is enabled", () =>
133 Effect.gen(function* () {
134 const { stdio } = yield* makeInMemoryStdio();
135 const events: Array<AcpProtocol.AcpProtocolLogEvent> = [];
136 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
137 stdio,
138 serverRequestMethods: new Set(),
139 logOutgoing: true,
140 logger: (event) =>
141 Effect.sync(() => {
142 events.push(event);
143 }),
144 });
145
146 yield* transport.notify("session/cancel", { sessionId: "session-1" });
147
148 assert.deepEqual(events, [
149 {
150 direction: "outgoing",
151 stage: "decoded",
152 payload: {
153 _tag: "Request",
154 id: "",
155 tag: "session/cancel",
156 payload: {
157 sessionId: "session-1",
158 },
159 headers: [],
160 },
161 },
162 {
163 direction: "outgoing",
164 stage: "raw",
165 payload:
166 '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"},"id":"","headers":[]}\n',
167 },
168 ]);
169 }),
170 );
171
172 it.effect("fails notification encoding through the declared ACP error channel", () =>
173 Effect.gen(function* () {
174 const { stdio } = yield* makeInMemoryStdio();
175 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
176 stdio,
177 serverRequestMethods: new Set(),
178 });
179
180 const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);
181 assert.instanceOf(bigintError, AcpError.AcpProtocolParseError);
182 assert.equal(bigintError.detail, "Failed to encode ACP message");
183
184 const circular: Record<string, unknown> = {};
185 circular.self = circular;
186 const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip);
187 assert.instanceOf(circularError, AcpError.AcpProtocolParseError);
188 assert.equal(circularError.detail, "Failed to encode ACP message");
189 }),
190 );
191
192 it.effect("supports generic extension requests over the patched transport", () =>
193 Effect.gen(function* () {
194 const { stdio, input, output } = yield* makeInMemoryStdio();
195 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
196 stdio,
197 serverRequestMethods: new Set(),
198 });
199
200 const response = yield* transport
201 .request("x/test", { hello: "world" })
202 .pipe(Effect.forkScoped);
203 const outbound = yield* Queue.take(output);
204 assert.deepEqual(yield* Schema.decodeEffect(Schema.fromJsonString(ExtRequest))(outbound), {
205 jsonrpc: "2.0",
206 id: 1,
207 method: "x/test",
208 params: {
209 hello: "world",
210 },
211 headers: [],
212 });
213
214 yield* Queue.offer(
215 input,
216 yield* encodeJsonl(ExtResponse, {
217 jsonrpc: "2.0",
218 id: 1,
219 result: {
220 ok: true,
221 },
222 }),
223 );
224
225 const resolved = yield* Fiber.join(response);
226 assert.deepEqual(resolved, { ok: true });
227 }),
228 );
229
230 it.effect("preserves zero-valued ids for inbound core client requests", () =>
231 Effect.gen(function* () {
232 const { stdio, input, output } = yield* makeInMemoryStdio();
233 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
234 stdio,
235 serverRequestMethods: new Set(["session/request_permission"]),
236 });
237 const inboundRequest = yield* Deferred.make<unknown>();
238
239 yield* transport.serverProtocol
240 .run((_clientId, message) => Deferred.succeed(inboundRequest, message).pipe(Effect.asVoid))
241 .pipe(Effect.forkScoped);
242
243 yield* Queue.offer(
244 input,
245 yield* encodeJsonl(RequestPermissionRequest, {
246 jsonrpc: "2.0",
247 id: 0,
248 method: "session/request_permission",
249 params: {
250 sessionId: "session-1",
251 toolCall: {
252 toolCallId: "tool-1",
253 title: "Allow mock action",
254 },
255 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
256 },
257 headers: [],
258 }),
259 );
260
261 const message = yield* Deferred.await(inboundRequest);
262 assert.deepEqual(message, {
263 _tag: "Request",
264 id: "0",
265 tag: "session/request_permission",
266 payload: {
267 sessionId: "session-1",
268 toolCall: {
269 toolCallId: "tool-1",
270 title: "Allow mock action",
271 },
272 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
273 },
274 headers: [],
275 });
276
277 yield* transport.serverProtocol.send(0, {
278 _tag: "Exit",
279 requestId: "0",
280 exit: {
281 _tag: "Success",
282 value: {
283 outcome: {
284 outcome: "selected",
285 optionId: "allow",
286 },
287 },
288 },
289 });
290
291 const outbound = yield* Queue.take(output);
292 assert.deepEqual(
293 yield* Schema.decodeEffect(Schema.fromJsonString(RequestPermissionResponse))(outbound),
294 {
295 jsonrpc: "2.0",
296 id: 0,
297 result: {
298 outcome: {
299 outcome: "selected",
300 optionId: "allow",
301 },
302 },
303 },
304 );
305 }),
306 );
307
308 it.effect("cleans up interrupted extension requests before a late response arrives", () =>
309 Effect.gen(function* () {
310 const { stdio, input, output } = yield* makeInMemoryStdio();
311 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
312 stdio,
313 serverRequestMethods: new Set(),
314 });
315 const lateResponse = yield* Deferred.make<unknown>();
316
317 yield* transport.clientProtocol
318 .run(0, (message) => Deferred.succeed(lateResponse, message).pipe(Effect.asVoid))
319 .pipe(Effect.forkScoped);
320
321 const response = yield* transport
322 .request("x/test", { hello: "world" })
323 .pipe(Effect.forkScoped);
324 const outbound = yield* Queue.take(output);
325 assert.deepEqual(yield* Schema.decodeEffect(Schema.fromJsonString(ExtRequest))(outbound), {
326 jsonrpc: "2.0",
327 id: 1,
328 method: "x/test",
329 params: {
330 hello: "world",
331 },
332 headers: [],
333 });
334
335 yield* Fiber.interrupt(response);
336 yield* Queue.offer(
337 input,
338 yield* encodeJsonl(ExtResponse, {
339 jsonrpc: "2.0",
340 id: 1,
341 result: {
342 ok: true,
343 },
344 }),
345 );
346
347 const message = yield* Deferred.await(lateResponse);
348 assert.deepEqual(message, {
349 _tag: "Exit",
350 requestId: "1",
351 exit: {
352 _tag: "Success",
353 value: {
354 ok: true,
355 },
356 },
357 });
358 }),
359 );
360
361 it.effect("propagates the real child exit code when the input stream ends", () =>
362 Effect.gen(function* () {
363 const handle = yield* makeHandle({ ACP_MOCK_EXIT_IMMEDIATELY_CODE: "7" });
364 const firstMessage = yield* Deferred.make<unknown>();
365 const termination = yield* Deferred.make<AcpError.AcpError>();
366 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
367 stdio: makeChildStdio(handle),
368 terminationError: makeTerminationError(handle),
369 serverRequestMethods: new Set(),
370 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
371 });
372
373 yield* transport.clientProtocol
374 .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid))
375 .pipe(Effect.forkScoped);
376
377 const message = yield* Deferred.await(firstMessage);
378 const exitError = yield* Deferred.await(termination);
379 assert.instanceOf(exitError, AcpError.AcpProcessExitedError);
380 assert.equal((exitError as AcpError.AcpProcessExitedError).code, 7);
381 assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError");
382 const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as {
383 readonly _tag: string;
384 readonly cause: unknown;
385 };
386 assert.equal(defect._tag, "RpcClientDefect");
387 assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError);
388 assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7);
389 }),
390 );
391
392 it.effect("does not emit a second process-exit error after a decode failure", () =>
393 Effect.gen(function* () {
394 const handle = yield* makeHandle({
395 ACP_MOCK_MALFORMED_OUTPUT: "1",
396 ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE: "23",
397 });
398 const terminationCalls = yield* Ref.make(0);
399 const firstMessage = yield* Deferred.make<unknown>();
400 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
401 stdio: makeChildStdio(handle),
402 terminationError: makeTerminationError(handle),
403 serverRequestMethods: new Set(),
404 onTermination: () => Ref.update(terminationCalls, (count) => count + 1),
405 });
406
407 yield* transport.clientProtocol
408 .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid))
409 .pipe(Effect.forkScoped);
410
411 const message = yield* Deferred.await(firstMessage);
412 assert.equal(yield* Ref.get(terminationCalls), 1);
413 assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError");
414 const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as {
415 readonly _tag: string;
416 readonly cause: unknown;
417 };
418 assert.equal(defect._tag, "RpcClientDefect");
419 assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError);
420 }),
421 );
422
423 it.effect("fails pending extension requests with the propagated exit code", () =>
424 Effect.gen(function* () {
425 const { stdio, input, output } = yield* makeInMemoryStdio();
426 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
427 stdio,
428 terminationError: Effect.succeed(new AcpError.AcpProcessExitedError({ code: 0 })),
429 serverRequestMethods: new Set(),
430 });
431
432 const response = yield* transport
433 .request("x/test", { hello: "world" })
434 .pipe(Effect.forkScoped);
435 yield* Queue.take(output);
436 yield* Queue.end(input);
437
438 const error = yield* Fiber.join(response).pipe(
439 Effect.match({
440 onFailure: (error) => error,
441 onSuccess: () => assert.fail("Expected request to fail after process exit"),
442 }),
443 );
444 assert.instanceOf(error, AcpError.AcpProcessExitedError);
445 assert.equal(error.code, 0);
446 }),
447 );
448});
packages/effect-acp/src/protocol.ts created+536
......@@ -0,0 +1,536 @@
1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import * as Deferred from "effect/Deferred";
4import * as Queue from "effect/Queue";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as Scope from "effect/Scope";
8import * as Stream from "effect/Stream";
9import * as Stdio from "effect/Stdio";
10import * as RpcClient from "effect/unstable/rpc/RpcClient";
11import * as RpcClientError from "effect/unstable/rpc/RpcClientError";
12import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
13import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
14import * as RpcServer from "effect/unstable/rpc/RpcServer";
15
16import * as AcpSchema from "./_generated/schema.gen.ts";
17import { CLIENT_METHODS } from "./_generated/meta.gen.ts";
18import * as AcpError from "./errors.ts";
19
20export interface AcpProtocolLogEvent {
21 readonly direction: "incoming" | "outgoing";
22 readonly stage: "raw" | "decoded" | "decode_failed";
23 readonly payload: unknown;
24}
25
26export type AcpIncomingNotification =
27 | {
28 readonly _tag: "SessionUpdate";
29 readonly method: typeof CLIENT_METHODS.session_update;
30 readonly params: typeof AcpSchema.SessionNotification.Type;
31 }
32 | {
33 readonly _tag: "ElicitationComplete";
34 readonly method: typeof CLIENT_METHODS.session_elicitation_complete;
35 readonly params: typeof AcpSchema.ElicitationCompleteNotification.Type;
36 }
37 | {
38 readonly _tag: "ExtNotification";
39 readonly method: string;
40 readonly params: unknown;
41 };
42
43export interface AcpPatchedProtocolOptions {
44 readonly stdio: Stdio.Stdio;
45 readonly terminationError?: Effect.Effect<AcpError.AcpError>;
46 readonly serverRequestMethods: ReadonlySet<string>;
47 readonly logIncoming?: boolean;
48 readonly logOutgoing?: boolean;
49 readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>;
50 readonly onNotification?: (
51 notification: AcpIncomingNotification,
52 ) => Effect.Effect<void, AcpError.AcpError, never>;
53 readonly onExtRequest?: (
54 method: string,
55 params: unknown,
56 ) => Effect.Effect<unknown, AcpError.AcpError, never>;
57 readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>;
58}
59
60export interface AcpPatchedProtocol {
61 readonly clientProtocol: RpcClient.Protocol["Service"];
62 readonly serverProtocol: RpcServer.Protocol["Service"];
63 readonly incoming: Stream.Stream<AcpIncomingNotification>;
64 readonly request: (method: string, payload: unknown) => Effect.Effect<unknown, AcpError.AcpError>;
65 readonly notify: (method: string, payload: unknown) => Effect.Effect<void, AcpError.AcpError>;
66}
67
68const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification);
69const decodeElicitationComplete = Schema.decodeUnknownEffect(
70 AcpSchema.ElicitationCompleteNotification,
71);
72const parserFactory = RpcSerialization.ndJsonRpc();
73
74export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* (
75 options: AcpPatchedProtocolOptions,
76): Effect.fn.Return<AcpPatchedProtocol, never, Scope.Scope> {
77 const parser = parserFactory.makeUnsafe();
78 const serverQueue = yield* Queue.unbounded<RpcMessage.FromClientEncoded>();
79 const clientQueue = yield* Queue.unbounded<RpcMessage.FromServerEncoded>();
80 const notificationQueue = yield* Queue.unbounded<AcpIncomingNotification>();
81 const disconnects = yield* Queue.unbounded<number>();
82 const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>();
83 const nextRequestId = yield* Ref.make(1n);
84 const terminationHandled = yield* Ref.make(false);
85 const extPending = yield* Ref.make(
86 new Map<string, Deferred.Deferred<unknown, AcpError.AcpError>>(),
87 );
88
89 const logProtocol = (event: AcpProtocolLogEvent) => {
90 if (event.direction === "incoming" && !options.logIncoming) {
91 return Effect.void;
92 }
93 if (event.direction === "outgoing" && !options.logOutgoing) {
94 return Effect.void;
95 }
96 return (
97 options.logger?.(event) ??
98 Effect.logDebug("ACP protocol event").pipe(Effect.annotateLogs({ event }))
99 );
100 };
101
102 const offerOutgoing = Effect.fn("offerOutgoing")(function* (
103 message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded,
104 ) {
105 yield* logProtocol({
106 direction: "outgoing",
107 stage: "decoded",
108 payload: message,
109 });
110
111 const encoded = yield* Effect.try({
112 try: () => parser.encode(message),
113 catch: (cause) =>
114 new AcpError.AcpProtocolParseError({
115 detail: "Failed to encode ACP message",
116 cause,
117 }),
118 });
119
120 if (encoded) {
121 yield* logProtocol({
122 direction: "outgoing",
123 stage: "raw",
124 payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded),
125 });
126
127 yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid);
128 }
129 });
130
131 const resolveExtPending = (
132 requestId: string,
133 onFound: (deferred: Deferred.Deferred<unknown, AcpError.AcpError>) => Effect.Effect<void>,
134 ) =>
135 Ref.modify(extPending, (pending) => {
136 const deferred = pending.get(requestId);
137 if (!deferred) {
138 return [Effect.void, pending] as const;
139 }
140 const next = new Map(pending);
141 next.delete(requestId);
142 return [onFound(deferred), next] as const;
143 }).pipe(Effect.flatten);
144
145 const removeExtPending = (requestId: string) =>
146 Ref.update(extPending, (pending) => {
147 if (!pending.has(requestId)) {
148 return pending;
149 }
150 const next = new Map(pending);
151 next.delete(requestId);
152 return next;
153 });
154
155 const completeExtPendingFailure = (requestId: string, error: AcpError.AcpError) =>
156 resolveExtPending(requestId, (deferred) => Deferred.fail(deferred, error));
157
158 const completeExtPendingSuccess = (requestId: string, value: unknown) =>
159 resolveExtPending(requestId, (deferred) => Deferred.succeed(deferred, value));
160
161 const failAllExtPending = (error: AcpError.AcpError) =>
162 Ref.getAndSet(extPending, new Map()).pipe(
163 Effect.flatMap((pending) =>
164 Effect.forEach([...pending.values()], (deferred) => Deferred.fail(deferred, error), {
165 discard: true,
166 }),
167 ),
168 );
169
170 const dispatchNotification = (notification: AcpIncomingNotification) =>
171 Queue.offer(notificationQueue, notification).pipe(
172 Effect.andThen(
173 options.onNotification
174 ? options.onNotification(notification).pipe(Effect.catch(() => Effect.void))
175 : Effect.void,
176 ),
177 Effect.asVoid,
178 );
179
180 const emitClientProtocolError = (error: AcpError.AcpError) =>
181 Queue.offer(clientQueue, {
182 _tag: "ClientProtocolError",
183 error: new RpcClientError.RpcClientError({
184 reason: new RpcClientError.RpcClientDefect({
185 message: error.message,
186 cause: error,
187 }),
188 }),
189 }).pipe(Effect.asVoid);
190
191 const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError | undefined>) =>
192 Ref.modify(terminationHandled, (handled) => {
193 if (handled) {
194 return [Effect.void, true] as const;
195 }
196 return [
197 Effect.gen(function* () {
198 yield* Queue.offer(disconnects, 0);
199 const error = yield* classify();
200 if (!error) {
201 return;
202 }
203 yield* failAllExtPending(error);
204 yield* emitClientProtocolError(error);
205 if (options.onTermination) {
206 yield* options.onTermination(error);
207 }
208 }),
209 true,
210 ] as const;
211 }).pipe(Effect.flatten);
212
213 const respondWithSuccess = (requestId: string, value: unknown) =>
214 offerOutgoing({
215 _tag: "Exit",
216 requestId,
217 exit: {
218 _tag: "Success",
219 value,
220 },
221 });
222
223 const respondWithError = (requestId: string, error: AcpError.AcpRequestError) =>
224 offerOutgoing({
225 _tag: "Exit",
226 requestId,
227 exit: {
228 _tag: "Failure",
229 cause: [
230 {
231 _tag: "Fail",
232 error: error.toProtocolError(),
233 },
234 ],
235 },
236 });
237
238 const handleExtRequest = (message: RpcMessage.RequestEncoded) => {
239 if (!options.onExtRequest) {
240 return respondWithError(message.id, AcpError.AcpRequestError.methodNotFound(message.tag));
241 }
242 return options.onExtRequest(message.tag, message.payload).pipe(
243 Effect.matchEffect({
244 onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)),
245 onSuccess: (value) => respondWithSuccess(message.id, value),
246 }),
247 );
248 };
249
250 const handleRequestEncoded = (message: RpcMessage.RequestEncoded) => {
251 if (message.id === "") {
252 if (message.tag === CLIENT_METHODS.session_update) {
253 return decodeSessionUpdate(message.payload).pipe(
254 Effect.map(
255 (params) =>
256 ({
257 _tag: "SessionUpdate",
258 method: CLIENT_METHODS.session_update,
259 params,
260 }) satisfies AcpIncomingNotification,
261 ),
262 Effect.mapError(
263 (cause) =>
264 new AcpError.AcpProtocolParseError({
265 detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`,
266 cause,
267 }),
268 ),
269 Effect.flatMap(dispatchNotification),
270 );
271 }
272 if (message.tag === CLIENT_METHODS.session_elicitation_complete) {
273 return decodeElicitationComplete(message.payload).pipe(
274 Effect.map(
275 (params) =>
276 ({
277 _tag: "ElicitationComplete",
278 method: CLIENT_METHODS.session_elicitation_complete,
279 params,
280 }) satisfies AcpIncomingNotification,
281 ),
282 Effect.mapError(
283 (cause) =>
284 new AcpError.AcpProtocolParseError({
285 detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`,
286 cause,
287 }),
288 ),
289 Effect.flatMap(dispatchNotification),
290 );
291 }
292 return dispatchNotification({
293 _tag: "ExtNotification",
294 method: message.tag,
295 params: message.payload,
296 });
297 }
298
299 if (!options.serverRequestMethods.has(message.tag)) {
300 return handleExtRequest(message).pipe(
301 Effect.catch(() => respondWithError(message.id, AcpError.AcpRequestError.internalError())),
302 Effect.asVoid,
303 );
304 }
305
306 return Queue.offer(serverQueue, message).pipe(Effect.asVoid);
307 };
308
309 const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) =>
310 Ref.get(extPending).pipe(
311 Effect.flatMap((pending) => {
312 if (!pending.has(message.requestId)) {
313 return Queue.offer(clientQueue, message).pipe(Effect.asVoid);
314 }
315 if (message.exit._tag === "Success") {
316 return completeExtPendingSuccess(message.requestId, message.exit.value);
317 }
318 const failure = message.exit.cause.find((entry) => entry._tag === "Fail");
319 if (failure && isProtocolError(failure.error)) {
320 return completeExtPendingFailure(
321 message.requestId,
322 AcpError.AcpRequestError.fromProtocolError(failure.error),
323 );
324 }
325 return completeExtPendingFailure(
326 message.requestId,
327 AcpError.AcpRequestError.internalError("Extension request failed"),
328 );
329 }),
330 );
331
332 const routeDecodedMessage = (
333 message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded,
334 ): Effect.Effect<void, AcpError.AcpError> => {
335 switch (message._tag) {
336 case "Request":
337 return handleRequestEncoded(message);
338 case "Exit":
339 return handleExitEncoded(message);
340 case "Chunk":
341 return Ref.get(extPending).pipe(
342 Effect.flatMap((pending) =>
343 pending.has(message.requestId)
344 ? completeExtPendingFailure(
345 message.requestId,
346 AcpError.AcpRequestError.internalError(
347 "Streaming extension responses are not supported",
348 ),
349 )
350 : Queue.offer(clientQueue, message).pipe(Effect.asVoid),
351 ),
352 );
353 case "Defect":
354 case "ClientProtocolError":
355 case "Pong":
356 return Queue.offer(clientQueue, message).pipe(Effect.asVoid);
357 case "Ack":
358 case "Interrupt":
359 case "Ping":
360 case "Eof":
361 return Queue.offer(serverQueue, message).pipe(Effect.asVoid);
362 }
363 };
364
365 yield* options.stdio.stdin.pipe(
366 Stream.runForEach((data) =>
367 logProtocol({
368 direction: "incoming",
369 stage: "raw",
370 payload: typeof data === "string" ? data : new TextDecoder().decode(data),
371 }).pipe(
372 Effect.flatMap(() =>
373 Effect.try({
374 try: () =>
375 parser.decode(data) as ReadonlyArray<
376 RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded
377 >,
378 catch: (cause) =>
379 new AcpError.AcpProtocolParseError({
380 detail: "Failed to decode ACP wire message",
381 cause,
382 }),
383 }),
384 ),
385 Effect.tap((messages) =>
386 logProtocol({
387 direction: "incoming",
388 stage: "decoded",
389 payload: messages,
390 }),
391 ),
392 Effect.tapErrorTag("AcpProtocolParseError", (error) =>
393 logProtocol({
394 direction: "incoming",
395 stage: "decode_failed",
396 payload: {
397 detail: error.detail,
398 cause: error.cause,
399 },
400 }),
401 ),
402 Effect.flatMap((messages) =>
403 Effect.forEach(messages, routeDecodedMessage, {
404 discard: true,
405 }),
406 ),
407 ),
408 ),
409 Effect.matchEffect({
410 onFailure: (error) => {
411 const normalized: AcpError.AcpError = Schema.is(AcpError.AcpError)(error)
412 ? error
413 : new AcpError.AcpTransportError({
414 detail: error instanceof Error ? error.message : String(error),
415 cause: error,
416 });
417 return handleTermination(() => Effect.succeed(normalized));
418 },
419 onSuccess: () =>
420 handleTermination(
421 () =>
422 options.terminationError ??
423 Effect.succeed(
424 new AcpError.AcpTransportError({
425 detail: "ACP input stream ended",
426 cause: new Error("ACP input stream ended"),
427 }),
428 ),
429 ),
430 }),
431 Effect.forkScoped,
432 );
433
434 yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped);
435
436 const clientProtocol = RpcClient.Protocol.of({
437 run: (_clientId, f) =>
438 Stream.fromQueue(clientQueue).pipe(
439 Stream.runForEach((message) => f(message)),
440 Effect.forever,
441 ),
442 send: (_clientId, request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)),
443 supportsAck: true,
444 supportsTransferables: false,
445 });
446
447 const serverProtocol = RpcServer.Protocol.of({
448 run: (f) =>
449 Stream.fromQueue(serverQueue).pipe(
450 Stream.runForEach((message) => f(0, message)),
451 Effect.forever,
452 ),
453 disconnects,
454 send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie),
455 end: (_clientId) => Queue.end(outgoing),
456 clientIds: Effect.succeed(new Set([0])),
457 initialMessage: Effect.succeedNone,
458 supportsAck: true,
459 supportsTransferables: false,
460 supportsSpanPropagation: true,
461 });
462
463 const sendNotification = Effect.fn("sendNotification")(function* (
464 method: string,
465 payload: unknown,
466 ) {
467 yield* offerOutgoing({
468 _tag: "Request",
469 id: "",
470 tag: method,
471 payload,
472 headers: [],
473 });
474 });
475
476 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {
477 const requestId = yield* Ref.modify(
478 nextRequestId,
479 (current) => [current, current + 1n] as const,
480 );
481 const deferred = yield* Deferred.make<unknown, AcpError.AcpError>();
482 yield* Ref.update(extPending, (pending) => new Map(pending).set(String(requestId), deferred));
483 yield* offerOutgoing({
484 _tag: "Request",
485 id: String(requestId),
486 tag: method,
487 payload,
488 headers: [],
489 }).pipe(
490 Effect.catch((error) =>
491 removeExtPending(String(requestId)).pipe(Effect.andThen(Effect.fail(error))),
492 ),
493 );
494 return yield* Deferred.await(deferred).pipe(
495 Effect.onInterrupt(() => removeExtPending(String(requestId))),
496 );
497 });
498
499 return {
500 clientProtocol,
501 serverProtocol,
502 get incoming() {
503 return Stream.fromQueue(notificationQueue);
504 },
505 request: sendRequest,
506 notify: sendNotification,
507 } satisfies AcpPatchedProtocol;
508});
509
510function isProtocolError(
511 value: unknown,
512): value is { code: number; message: string; data?: unknown } {
513 return (
514 typeof value === "object" &&
515 value !== null &&
516 "code" in value &&
517 typeof value.code === "number" &&
518 "message" in value &&
519 typeof value.message === "string"
520 );
521}
522
523function normalizeToRequestError(error: AcpError.AcpError): AcpError.AcpRequestError {
524 return Schema.is(AcpError.AcpRequestError)(error)
525 ? error
526 : AcpError.AcpRequestError.internalError(error.message);
527}
528
529function toRpcClientError(error: AcpError.AcpError): RpcClientError.RpcClientError {
530 return new RpcClientError.RpcClientError({
531 reason: new RpcClientError.RpcClientDefect({
532 message: error.message,
533 cause: error,
534 }),
535 });
536}
packages/effect-acp/src/rpc.ts created+158
......@@ -0,0 +1,158 @@
1import * as Rpc from "effect/unstable/rpc/Rpc";
2import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
3
4import * as AcpSchema from "./_generated/schema.gen.ts";
5import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";
6
7export const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, {
8 payload: AcpSchema.InitializeRequest,
9 success: AcpSchema.InitializeResponse,
10 error: AcpSchema.Error,
11});
12
13export const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, {
14 payload: AcpSchema.AuthenticateRequest,
15 success: AcpSchema.AuthenticateResponse,
16 error: AcpSchema.Error,
17});
18
19export const LogoutRpc = Rpc.make(AGENT_METHODS.logout, {
20 payload: AcpSchema.LogoutRequest,
21 success: AcpSchema.LogoutResponse,
22 error: AcpSchema.Error,
23});
24
25export const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, {
26 payload: AcpSchema.NewSessionRequest,
27 success: AcpSchema.NewSessionResponse,
28 error: AcpSchema.Error,
29});
30
31export const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, {
32 payload: AcpSchema.LoadSessionRequest,
33 success: AcpSchema.LoadSessionResponse,
34 error: AcpSchema.Error,
35});
36
37export const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, {
38 payload: AcpSchema.ListSessionsRequest,
39 success: AcpSchema.ListSessionsResponse,
40 error: AcpSchema.Error,
41});
42
43export const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, {
44 payload: AcpSchema.ForkSessionRequest,
45 success: AcpSchema.ForkSessionResponse,
46 error: AcpSchema.Error,
47});
48
49export const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, {
50 payload: AcpSchema.ResumeSessionRequest,
51 success: AcpSchema.ResumeSessionResponse,
52 error: AcpSchema.Error,
53});
54
55export const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, {
56 payload: AcpSchema.CloseSessionRequest,
57 success: AcpSchema.CloseSessionResponse,
58 error: AcpSchema.Error,
59});
60
61export const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, {
62 payload: AcpSchema.PromptRequest,
63 success: AcpSchema.PromptResponse,
64 error: AcpSchema.Error,
65});
66
67export const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, {
68 payload: AcpSchema.SetSessionModelRequest,
69 success: AcpSchema.SetSessionModelResponse,
70 error: AcpSchema.Error,
71});
72
73export const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, {
74 payload: AcpSchema.SetSessionConfigOptionRequest,
75 success: AcpSchema.SetSessionConfigOptionResponse,
76 error: AcpSchema.Error,
77});
78
79export const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, {
80 payload: AcpSchema.ReadTextFileRequest,
81 success: AcpSchema.ReadTextFileResponse,
82 error: AcpSchema.Error,
83});
84
85export const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, {
86 payload: AcpSchema.WriteTextFileRequest,
87 success: AcpSchema.WriteTextFileResponse,
88 error: AcpSchema.Error,
89});
90
91export const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, {
92 payload: AcpSchema.RequestPermissionRequest,
93 success: AcpSchema.RequestPermissionResponse,
94 error: AcpSchema.Error,
95});
96
97export const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, {
98 payload: AcpSchema.ElicitationRequest,
99 success: AcpSchema.ElicitationResponse,
100 error: AcpSchema.Error,
101});
102
103export const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, {
104 payload: AcpSchema.CreateTerminalRequest,
105 success: AcpSchema.CreateTerminalResponse,
106 error: AcpSchema.Error,
107});
108
109export const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, {
110 payload: AcpSchema.TerminalOutputRequest,
111 success: AcpSchema.TerminalOutputResponse,
112 error: AcpSchema.Error,
113});
114
115export const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, {
116 payload: AcpSchema.ReleaseTerminalRequest,
117 success: AcpSchema.ReleaseTerminalResponse,
118 error: AcpSchema.Error,
119});
120
121export const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, {
122 payload: AcpSchema.WaitForTerminalExitRequest,
123 success: AcpSchema.WaitForTerminalExitResponse,
124 error: AcpSchema.Error,
125});
126
127export const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, {
128 payload: AcpSchema.KillTerminalRequest,
129 success: AcpSchema.KillTerminalResponse,
130 error: AcpSchema.Error,
131});
132
133export const AgentRpcs = RpcGroup.make(
134 InitializeRpc,
135 AuthenticateRpc,
136 LogoutRpc,
137 NewSessionRpc,
138 LoadSessionRpc,
139 ListSessionsRpc,
140 ForkSessionRpc,
141 ResumeSessionRpc,
142 CloseSessionRpc,
143 PromptRpc,
144 SetSessionModelRpc,
145 SetSessionConfigOptionRpc,
146);
147
148export const ClientRpcs = RpcGroup.make(
149 ReadTextFileRpc,
150 WriteTextFileRpc,
151 RequestPermissionRpc,
152 ElicitationRpc,
153 CreateTerminalRpc,
154 TerminalOutputRpc,
155 ReleaseTerminalRpc,
156 WaitForTerminalExitRpc,
157 KillTerminalRpc,
158);
packages/effect-acp/src/schema.ts created+2
......@@ -0,0 +1,2 @@
1export * from "./_generated/schema.gen.ts";
2export * from "./_generated/meta.gen.ts";
packages/effect-acp/src/terminal.ts created+45
......@@ -0,0 +1,45 @@
1import * as Effect from "effect/Effect";
2
3import type * as AcpSchema from "./_generated/schema.gen.ts";
4import type * as AcpError from "./errors.ts";
5
6export interface AcpTerminal {
7 readonly sessionId: string;
8 readonly terminalId: string;
9 /** Reads buffered output from the terminal.
10 * Spec: https://agentclientprotocol.com/protocol/schema#terminal/output
11 */
12 readonly output: Effect.Effect<AcpSchema.TerminalOutputResponse, AcpError.AcpError>;
13 /** Waits for terminal exit and returns the exit result.
14 * Spec: https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit
15 */
16 readonly waitForExit: Effect.Effect<AcpSchema.WaitForTerminalExitResponse, AcpError.AcpError>;
17 /** Terminates the terminal process.
18 * Spec: https://agentclientprotocol.com/protocol/schema#terminal/kill
19 */
20 readonly kill: Effect.Effect<AcpSchema.KillTerminalResponse, AcpError.AcpError>;
21 /** Releases the terminal handle from the ACP session.
22 * Spec: https://agentclientprotocol.com/protocol/schema#terminal/release
23 */
24 readonly release: Effect.Effect<AcpSchema.ReleaseTerminalResponse, AcpError.AcpError>;
25}
26
27export interface MakeTerminalOptions {
28 readonly sessionId: string;
29 readonly terminalId: string;
30 readonly output: Effect.Effect<AcpSchema.TerminalOutputResponse, AcpError.AcpError>;
31 readonly waitForExit: Effect.Effect<AcpSchema.WaitForTerminalExitResponse, AcpError.AcpError>;
32 readonly kill: Effect.Effect<AcpSchema.KillTerminalResponse, AcpError.AcpError>;
33 readonly release: Effect.Effect<AcpSchema.ReleaseTerminalResponse, AcpError.AcpError>;
34}
35
36export function makeTerminal(options: MakeTerminalOptions): AcpTerminal {
37 return {
38 sessionId: options.sessionId,
39 terminalId: options.terminalId,
40 output: options.output,
41 waitForExit: options.waitForExit,
42 kill: options.kill,
43 release: options.release,
44 };
45}
packages/effect-acp/test/examples/cursor-acp-client.example.ts created+81
......@@ -0,0 +1,81 @@
1import * as Effect from "effect/Effect";
2import * as Console from "effect/Console";
3import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
7
8import * as AcpClient from "../../src/client.ts";
9
10const program = Effect.gen(function* () {
11 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12 const command = ChildProcess.make("cursor-agent", ["acp"], {
13 cwd: process.cwd(),
14 shell: process.platform === "win32",
15 });
16 const handle = yield* spawner.spawn(command);
17 const acpLayer = AcpClient.layerChildProcess(handle, {
18 logIncoming: true,
19 logOutgoing: true,
20 });
21
22 yield* Effect.gen(function* () {
23 const acp = yield* AcpClient.AcpClient;
24
25 yield* acp.handleRequestPermission(() =>
26 Effect.succeed({
27 outcome: {
28 outcome: "selected",
29 optionId: "allow",
30 },
31 }),
32 );
33 // yield* acp.handleSessionUpdate((notification) =>
34 // Console.log("session/update", JSON.stringify(notification)),
35 // );
36
37 const initialized = yield* acp.agent.initialize({
38 protocolVersion: 1,
39 clientCapabilities: {
40 fs: { readTextFile: false, writeTextFile: false },
41 terminal: false,
42 _meta: {
43 parameterizedModelPicker: true,
44 },
45 },
46 clientInfo: {
47 name: "effect-acp-example",
48 version: "0.0.0",
49 },
50 });
51 yield* Console.log("initialized", JSON.stringify(initialized, null, 4));
52
53 const session = yield* acp.agent.createSession({
54 cwd: process.cwd(),
55 mcpServers: [],
56 });
57
58 const config = yield* acp.agent.setSessionConfigOption({
59 sessionId: session.sessionId,
60 configId: "model",
61 value: "claude-opus-4-6",
62 });
63
64 yield* Console.log("config", JSON.stringify(config, null, 4));
65
66 const result = yield* acp.agent.prompt({
67 sessionId: session.sessionId,
68 prompt: [
69 {
70 type: "text",
71 text: "Illustrate your ability to create todo lists and then execute all of them. Do not write the list to disk, illustrate your built in ability!",
72 },
73 ],
74 });
75
76 yield* Console.log("prompt result", JSON.stringify(result));
77 yield* acp.agent.cancel({ sessionId: session.sessionId });
78 }).pipe(Effect.provide(acpLayer));
79});
80
81program.pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain);
packages/effect-acp/test/fixtures/acp-mock-peer.ts created+136
......@@ -0,0 +1,136 @@
1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6
7import * as AcpAgent from "../../src/agent.ts";
8
9if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") {
10 process.stdout.write("{not-json}\n");
11 process.exit(Number(process.env.ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE ?? "0"));
12}
13
14if (process.env.ACP_MOCK_EXIT_IMMEDIATELY_CODE !== undefined) {
15 process.exit(Number(process.env.ACP_MOCK_EXIT_IMMEDIATELY_CODE));
16}
17
18const sessionId = "mock-session-1";
19
20const program = Effect.gen(function* () {
21 const agent = yield* AcpAgent.AcpAgent;
22
23 yield* agent.handleInitialize(() =>
24 Effect.succeed({
25 protocolVersion: 1,
26 agentCapabilities: {
27 sessionCapabilities: {
28 list: {},
29 },
30 },
31 agentInfo: {
32 name: "mock-agent",
33 version: "0.0.0",
34 },
35 }),
36 );
37
38 yield* agent.handleAuthenticate(() => Effect.succeed({}));
39 yield* agent.handleLogout(() => Effect.succeed({}));
40 yield* agent.handleCreateSession(() =>
41 Effect.succeed({
42 sessionId,
43 }),
44 );
45 yield* agent.handleLoadSession(() => Effect.succeed({}));
46 yield* agent.handleListSessions(() =>
47 Effect.succeed({
48 sessions: [
49 {
50 sessionId,
51 cwd: process.cwd(),
52 },
53 ],
54 }),
55 );
56
57 yield* agent.handlePrompt(() =>
58 Effect.gen(function* () {
59 yield* agent.client.requestPermission({
60 sessionId,
61 options: [
62 {
63 optionId: "allow",
64 name: "Allow",
65 kind: "allow_once",
66 },
67 ],
68 toolCall: {
69 toolCallId: "tool-1",
70 title: "Read project files",
71 },
72 });
73
74 yield* agent.client.elicit({
75 sessionId,
76 message: "Need confirmation before continuing.",
77 mode: "form",
78 requestedSchema: {
79 type: "object",
80 title: "Need confirmation",
81 properties: {
82 approved: {
83 type: "boolean",
84 title: "Approved",
85 },
86 },
87 required: ["approved"],
88 },
89 });
90
91 yield* agent.client.sessionUpdate({
92 sessionId,
93 update: {
94 sessionUpdate: "plan",
95 entries: [
96 {
97 content: "Inspect the repository",
98 priority: "high",
99 status: "in_progress",
100 },
101 ],
102 },
103 });
104
105 yield* agent.client.elicitationComplete({
106 elicitationId: "elicitation-1",
107 });
108
109 yield* agent.client.extRequest("x/typed_request", {
110 message: process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1" ? 123 : "hello from typed request",
111 });
112
113 yield* agent.client.extNotification("x/typed_notification", {
114 count: 2,
115 });
116
117 return {
118 stopReason: "end_turn" as const,
119 };
120 }),
121 );
122
123 yield* agent.handleUnknownExtRequest((method, params) =>
124 Effect.succeed({
125 echoedMethod: method,
126 echoedParams: params ?? null,
127 }),
128 );
129
130 return yield* Effect.never;
131});
132
133program.pipe(
134 Effect.provide(Layer.provide(AcpAgent.layerStdio(), NodeServices.layer)),
135 NodeRuntime.runMain,
136);
packages/effect-acp/tsconfig.json created+18
......@@ -0,0 +1,18 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "plugins": [
5 {
6 "name": "@effect/language-service",
7 "namespaceImportPackages": ["@effect/platform-node"],
8 "diagnosticSeverity": {
9 "importFromBarrel": "error",
10 "anyUnknownInErrorContext": "warning",
11 "instanceOfSchema": "warning",
12 "deterministicKeys": "warning"
13 }
14 }
15 ]
16 },
17 "include": ["src", "scripts", "test"]
18}
packages/shared/package.json+4
......@@ -36,6 +36,10 @@
3636 "types": "./src/schemaJson.ts",
3737 "import": "./src/schemaJson.ts"
3838 },
39 "./toolActivity": {
40 "types": "./src/toolActivity.ts",
41 "import": "./src/toolActivity.ts"
42 },
3943 "./Struct": {
4044 "types": "./src/Struct.ts",
4145 "import": "./src/Struct.ts"
packages/shared/src/model.test.ts+6-41
......@@ -11,10 +11,8 @@ import {
1111 normalizeClaudeModelOptionsWithCapabilities,
1212 normalizeCodexModelOptionsWithCapabilities,
1313 normalizeModelSlug,
14 resolveApiModelId,
1514 resolveContextWindow,
1615 resolveEffort,
17 resolveModelSlug,
1816 resolveModelSlugForProvider,
1917 resolveSelectableModel,
2018 trimOrNull,
......@@ -61,17 +59,18 @@ describe("normalizeModelSlug", () => {
6159 });
6260});
6361
64describe("resolveModelSlug", () => {
62describe("resolveModelSlugForProvider", () => {
6563 it("returns defaults when the model is missing", () => {
66 expect(resolveModelSlug(undefined, "codex")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
67
64 expect(resolveModelSlugForProvider("codex", undefined)).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
6865 expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe(
6966 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,
7067 );
7168 });
7269
7370 it("preserves normalized unknown models", () => {
74 expect(resolveModelSlug("custom/internal-model", "codex")).toBe("custom/internal-model");
71 expect(resolveModelSlugForProvider("codex", "custom/internal-model")).toBe(
72 "custom/internal-model",
73 );
7574 });
7675});
7776
......@@ -137,6 +136,7 @@ describe("resolveEffort", () => {
137136
138137describe("misc helpers", () => {
139138 it("detects ultrathink prompts", () => {
139 expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true);
140140 expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true);
141141 expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false);
142142 });
......@@ -195,41 +195,6 @@ describe("resolveContextWindow", () => {
195195 });
196196});
197197
198describe("resolveApiModelId", () => {
199 it("appends [1m] suffix for 1m context window", () => {
200 expect(
201 resolveApiModelId({
202 provider: "claudeAgent",
203 model: "claude-opus-4-6",
204 options: { contextWindow: "1m" },
205 }),
206 ).toBe("claude-opus-4-6[1m]");
207 });
208
209 it("returns the model as-is for 200k context window", () => {
210 expect(
211 resolveApiModelId({
212 provider: "claudeAgent",
213 model: "claude-opus-4-6",
214 options: { contextWindow: "200k" },
215 }),
216 ).toBe("claude-opus-4-6");
217 });
218
219 it("returns the model as-is when no context window is set", () => {
220 expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe(
221 "claude-opus-4-6",
222 );
223 expect(
224 resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }),
225 ).toBe("claude-opus-4-6");
226 });
227
228 it("returns the model as-is for Codex selections", () => {
229 expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4");
230 });
231});
232
233198describe("normalize*ModelOptionsWithCapabilities", () => {
234199 it("preserves explicit false codex fast mode", () => {
235200 expect(
packages/shared/src/model.ts+32-46
......@@ -4,6 +4,7 @@ import {
44 type ClaudeAgentEffort,
55 type ClaudeModelOptions,
66 type CodexModelOptions,
7 type CursorModelOptions,
78 type ModelCapabilities,
89 type ModelSelection,
910 type OpenCodeModelOptions,
......@@ -16,8 +17,6 @@ export interface SelectableModelOption {
1617 name: string;
1718}
1819
19// ── Effort helpers ────────────────────────────────────────────────────
20
2120/** Check whether a capabilities object includes a given effort value. */
2221export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean {
2322 return caps.reasoningEffortLevels.some((l) => l.value === value);
......@@ -31,12 +30,9 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null {
3130/**
3231 * Resolve a raw effort option against capabilities.
3332 *
34 * Returns the effective effort value — the explicit value if supported and not
35 * prompt-injected, otherwise the model's default. Returns `undefined` only
36 * when the model has no effort levels at all.
37 *
38 * Prompt-injected efforts (e.g. "ultrathink") are excluded because they are
39 * applied via prompt text, not the effort API parameter.
33 * Returns the explicit supported value when present and not prompt-injected,
34 * otherwise the model default. Returns `undefined` when the model exposes no
35 * effort levels.
4036 */
4137export function resolveEffort(
4238 caps: ModelCapabilities,
......@@ -54,8 +50,6 @@ export function resolveEffort(
5450 return defaultValue ?? undefined;
5551}
5652
57// ── Context window helpers ───────────────────────────────────────────
58
5953/** Check whether a capabilities object includes a given context window value. */
6054export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean {
6155 return caps.contextWindowOptions.some((o) => o.value === value);
......@@ -69,14 +63,8 @@ export function getDefaultContextWindow(caps: ModelCapabilities): string | null
6963/**
7064 * Resolve a raw `contextWindow` option against capabilities.
7165 *
72 * Returns the effective context window value — the explicit value if supported,
73 * otherwise the model's default. Returns `undefined` only when the model has
74 * no context window options at all.
75 *
76 * Unlike effort levels (where the API has matching defaults), the context
77 * window requires an explicit API suffix (e.g. `[1m]`), so we always preserve
78 * the resolved value to avoid ambiguity between "user chose the default" and
79 * "not specified".
66 * Returns the explicit supported value when present, otherwise the model
67 * default. Returns `undefined` when the model exposes no context window options.
8068 */
8169export function resolveContextWindow(
8270 caps: ModelCapabilities,
......@@ -119,6 +107,23 @@ export function normalizeClaudeModelOptionsWithCapabilities(
119107 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
120108}
121109
110export function normalizeCursorModelOptionsWithCapabilities(
111 caps: ModelCapabilities,
112 modelOptions: CursorModelOptions | null | undefined,
113): CursorModelOptions | undefined {
114 const reasoning = resolveEffort(caps, modelOptions?.reasoning);
115 const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined;
116 const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined;
117 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
118 const nextOptions: CursorModelOptions = {
119 ...(reasoning ? { reasoning: reasoning as CursorModelOptions["reasoning"] } : {}),
120 ...(fastMode !== undefined ? { fastMode } : {}),
121 ...(thinking !== undefined ? { thinking } : {}),
122 ...(contextWindow !== undefined ? { contextWindow } : {}),
123 };
124 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
125}
126
122127function resolveLabeledOption(
123128 options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined,
124129 raw: string | null | undefined,
......@@ -155,6 +160,8 @@ export function normalizeProviderModelOptionsWithCapabilities(
155160 return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions);
156161 case "claudeAgent":
157162 return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions);
163 case "cursor":
164 return normalizeCursorModelOptionsWithCapabilities(caps, modelOptions as CursorModelOptions);
158165 case "opencode":
159166 return normalizeOpenCodeModelOptionsWithCapabilities(
160167 caps,
......@@ -220,7 +227,7 @@ export function resolveSelectableModel(
220227 return resolved ? resolved.slug : null;
221228}
222229
223export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string {
230function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string {
224231 const normalized = normalizeModelSlug(model, provider);
225232 if (!normalized) {
226233 return DEFAULT_MODEL_BY_PROVIDER[provider];
......@@ -260,6 +267,12 @@ export function createModelSelection(
260267 model,
261268 ...(options ? { options: options as ClaudeModelOptions } : {}),
262269 };
270 case "cursor":
271 return {
272 provider,
273 model,
274 ...(options ? { options: options as CursorModelOptions } : {}),
275 };
263276 case "opencode":
264277 return {
265278 provider,
......@@ -269,33 +282,6 @@ export function createModelSelection(
269282 }
270283}
271284
272/**
273 * Resolve the actual API model identifier from a model selection.
274 *
275 * Provider-aware: each provider can map `contextWindow` (or other options)
276 * to whatever the API requires — a model-id suffix, a separate parameter, etc.
277 * The canonical slug stored in the selection stays unchanged so the
278 * capabilities system keeps working.
279 *
280 * Expects `contextWindow` to already be resolved (via `resolveContextWindow`)
281 * to the effective value, not stripped to `undefined` for defaults.
282 */
283export function resolveApiModelId(modelSelection: ModelSelection): string {
284 switch (modelSelection.provider) {
285 case "claudeAgent": {
286 switch (modelSelection.options?.contextWindow) {
287 case "1m":
288 return `${modelSelection.model}[1m]`;
289 default:
290 return modelSelection.model;
291 }
292 }
293 default: {
294 return modelSelection.model;
295 }
296 }
297}
298
299285export function applyClaudePromptEffortPrefix(
300286 text: string,
301287 effort: ClaudeAgentEffort | null | undefined,
packages/shared/src/serverSettings.ts+16-7
......@@ -2,6 +2,7 @@ import {
22 ServerSettings,
33 type ClaudeModelOptions,
44 type CodexModelOptions,
5 type CursorModelOptions,
56 type OpenCodeModelOptions,
67 type ServerSettingsPatch,
78} from "@t3tools/contracts";
......@@ -90,12 +91,20 @@ export function applyServerSettingsPatch(
9091 selectionPatch.options as ClaudeModelOptions | undefined,
9192 ),
9293 }
93 : {
94 provider,
95 model,
96 ...withModelSelectionOptions(
97 selectionPatch.options as OpenCodeModelOptions | undefined,
98 ),
99 },
94 : provider === "cursor"
95 ? {
96 provider,
97 model,
98 ...withModelSelectionOptions(
99 selectionPatch.options as CursorModelOptions | undefined,
100 ),
101 }
102 : {
103 provider,
104 model,
105 ...withModelSelectionOptions(
106 selectionPatch.options as OpenCodeModelOptions | undefined,
107 ),
108 },
100109 };
101110}
packages/shared/src/toolActivity.test.ts created+57
......@@ -0,0 +1,57 @@
1import { describe, expect, it } from "vitest";
2
3import { deriveToolActivityPresentation } from "./toolActivity.ts";
4
5describe("toolActivity", () => {
6 it("normalizes command tools to a stable ran-command label", () => {
7 expect(
8 deriveToolActivityPresentation({
9 itemType: "command_execution",
10 title: "Terminal",
11 detail: "Terminal",
12 data: {
13 command: "bun run lint",
14 },
15 fallbackSummary: "Terminal",
16 }),
17 ).toEqual({
18 summary: "Ran command",
19 detail: "bun run lint",
20 });
21 });
22
23 it("uses structured file paths for read-file tools when available", () => {
24 expect(
25 deriveToolActivityPresentation({
26 itemType: "dynamic_tool_call",
27 title: "Read File",
28 detail: "Read File",
29 data: {
30 kind: "read",
31 locations: [{ path: "/tmp/app.ts" }],
32 },
33 fallbackSummary: "Read File",
34 }),
35 ).toEqual({
36 summary: "Read file",
37 detail: "/tmp/app.ts",
38 });
39 });
40
41 it("drops duplicated generic read-file detail when no path is available", () => {
42 expect(
43 deriveToolActivityPresentation({
44 itemType: "dynamic_tool_call",
45 title: "Read File",
46 detail: "Read File",
47 data: {
48 kind: "read",
49 rawInput: {},
50 },
51 fallbackSummary: "Read File",
52 }),
53 ).toEqual({
54 summary: "Read file",
55 });
56 });
57});
packages/shared/src/toolActivity.ts created+257
......@@ -0,0 +1,257 @@
1import type { ToolLifecycleItemType } from "@t3tools/contracts";
2
3function asRecord(value: unknown): Record<string, unknown> | undefined {
4 return value !== null && typeof value === "object" && !Array.isArray(value)
5 ? (value as Record<string, unknown>)
6 : undefined;
7}
8
9function asTrimmedString(value: unknown): string | undefined {
10 if (typeof value !== "string") {
11 return undefined;
12 }
13 const trimmed = value.trim();
14 return trimmed.length > 0 ? trimmed : undefined;
15}
16
17function normalizeCommandValue(value: unknown): string | undefined {
18 const direct = asTrimmedString(value);
19 if (direct) {
20 return direct;
21 }
22 if (!Array.isArray(value)) {
23 return undefined;
24 }
25 const parts = value
26 .map((entry) => asTrimmedString(entry))
27 .filter((entry): entry is string => entry !== undefined);
28 return parts.length > 0 ? parts.join(" ") : undefined;
29}
30
31function stripTrailingExitCode(value: string | undefined): string | undefined {
32 const trimmed = value?.trim();
33 if (!trimmed) {
34 return undefined;
35 }
36 const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code \d+>)\s*$/iu.exec(trimmed);
37 const output = match?.groups?.output?.trim() ?? trimmed;
38 return output.length > 0 ? output : undefined;
39}
40
41function extractCommandFromTitle(title: string | undefined): string | undefined {
42 if (!title) {
43 return undefined;
44 }
45 const backtickMatch = /`([^`]+)`/u.exec(title);
46 return backtickMatch?.[1]?.trim() || undefined;
47}
48
49function extractToolCommand(data: Record<string, unknown> | undefined, title: string | undefined) {
50 const item = asRecord(data?.item);
51 const itemInput = asRecord(item?.input);
52 const itemResult = asRecord(item?.result);
53 const rawInput = asRecord(data?.rawInput);
54 const candidates = [
55 normalizeCommandValue(item?.command),
56 normalizeCommandValue(itemInput?.command),
57 normalizeCommandValue(itemResult?.command),
58 normalizeCommandValue(data?.command),
59 normalizeCommandValue(rawInput?.command),
60 ];
61 const direct = candidates.find((candidate) => candidate !== undefined);
62 if (direct) {
63 return direct;
64 }
65 const executable = asTrimmedString(rawInput?.executable);
66 const args = normalizeCommandValue(rawInput?.args);
67 if (executable && args) {
68 return `${executable} ${args}`;
69 }
70 if (executable) {
71 return executable;
72 }
73 return extractCommandFromTitle(title);
74}
75
76function maybePathLike(value: string | undefined): string | undefined {
77 if (!value) {
78 return undefined;
79 }
80 if (
81 value.includes("/") ||
82 value.includes("\\") ||
83 value.startsWith(".") ||
84 /\.(?:[a-z0-9]{1,12})$/iu.test(value)
85 ) {
86 return value;
87 }
88 return undefined;
89}
90
91function collectPaths(value: unknown, paths: string[], seen: Set<string>, depth: number): void {
92 if (depth > 4 || paths.length >= 8) {
93 return;
94 }
95 if (Array.isArray(value)) {
96 for (const entry of value) {
97 collectPaths(entry, paths, seen, depth + 1);
98 if (paths.length >= 8) {
99 return;
100 }
101 }
102 return;
103 }
104 const record = asRecord(value);
105 if (!record) {
106 return;
107 }
108 for (const key of ["path", "filePath", "relativePath", "filename", "newPath", "oldPath"]) {
109 const candidate = maybePathLike(asTrimmedString(record[key]));
110 if (!candidate || seen.has(candidate)) {
111 continue;
112 }
113 seen.add(candidate);
114 paths.push(candidate);
115 if (paths.length >= 8) {
116 return;
117 }
118 }
119 for (const nestedKey of ["locations", "item", "input", "result", "rawInput", "data", "changes"]) {
120 if (!(nestedKey in record)) {
121 continue;
122 }
123 collectPaths(record[nestedKey], paths, seen, depth + 1);
124 if (paths.length >= 8) {
125 return;
126 }
127 }
128}
129
130function extractPrimaryPath(data: Record<string, unknown> | undefined): string | undefined {
131 const paths: string[] = [];
132 collectPaths(data, paths, new Set<string>(), 0);
133 return paths[0];
134}
135
136function normalizeEquivalentValue(value: string | undefined): string | undefined {
137 const trimmed = asTrimmedString(value);
138 if (!trimmed) {
139 return undefined;
140 }
141 return trimmed
142 .replace(/\s+/gu, " ")
143 .replace(/\s+(?:complete|completed|started)\s*$/iu, "")
144 .trim();
145}
146
147function isEquivalent(left: string | undefined, right: string | undefined): boolean {
148 const normalizedLeft = normalizeEquivalentValue(left)?.toLowerCase();
149 const normalizedRight = normalizeEquivalentValue(right)?.toLowerCase();
150 return normalizedLeft !== undefined && normalizedLeft === normalizedRight;
151}
152
153function classifyToolAction(input: {
154 readonly itemType?: ToolLifecycleItemType | null | undefined;
155 readonly title?: string | undefined;
156 readonly data?: Record<string, unknown> | undefined;
157}): "command" | "read" | "file_change" | "search" | "other" {
158 const itemType = input.itemType ?? undefined;
159 const kind = asTrimmedString(input.data?.kind)?.toLowerCase();
160 const title = asTrimmedString(input.title)?.toLowerCase();
161 if (itemType === "command_execution" || kind === "execute" || title === "terminal") {
162 return "command";
163 }
164 if (kind === "read" || title === "read file") {
165 return "read";
166 }
167 if (
168 itemType === "file_change" ||
169 kind === "edit" ||
170 kind === "move" ||
171 kind === "delete" ||
172 kind === "write"
173 ) {
174 return "file_change";
175 }
176 if (itemType === "web_search" || kind === "search" || title === "find" || title === "grep") {
177 return "search";
178 }
179 return "other";
180}
181
182export interface ToolActivityPresentationInput {
183 readonly itemType?: ToolLifecycleItemType | null | undefined;
184 readonly title?: string | null | undefined;
185 readonly detail?: string | null | undefined;
186 readonly data?: unknown;
187 readonly fallbackSummary?: string | null | undefined;
188}
189
190export interface ToolActivityPresentation {
191 readonly summary: string;
192 readonly detail?: string | undefined;
193}
194
195export function deriveToolActivityPresentation(
196 input: ToolActivityPresentationInput,
197): ToolActivityPresentation {
198 const title = asTrimmedString(input.title);
199 const detail = stripTrailingExitCode(asTrimmedString(input.detail));
200 const fallbackSummary = asTrimmedString(input.fallbackSummary) ?? "Tool";
201 const data = asRecord(input.data);
202 const command = extractToolCommand(data, title);
203 const primaryPath = extractPrimaryPath(data);
204 const action = classifyToolAction({
205 itemType: input.itemType,
206 title,
207 data,
208 });
209
210 if (action === "command") {
211 return {
212 summary: "Ran command",
213 ...(command ? { detail: command } : {}),
214 };
215 }
216
217 if (action === "read") {
218 if (primaryPath) {
219 return {
220 summary: "Read file",
221 detail: primaryPath,
222 };
223 }
224 return {
225 summary: "Read file",
226 };
227 }
228
229 if (action === "file_change") {
230 return {
231 summary: "Changed files",
232 ...(primaryPath ? { detail: primaryPath } : {}),
233 };
234 }
235
236 if (action === "search") {
237 const query =
238 asTrimmedString(asRecord(data?.rawInput)?.query) ??
239 asTrimmedString(asRecord(data?.rawInput)?.pattern) ??
240 asTrimmedString(asRecord(data?.rawInput)?.searchTerm);
241 return {
242 summary: "Searched files",
243 ...(query ? { detail: query } : {}),
244 };
245 }
246
247 if (detail && !isEquivalent(detail, title) && !isEquivalent(detail, fallbackSummary)) {
248 return {
249 summary: title ?? fallbackSummary,
250 detail,
251 };
252 }
253
254 return {
255 summary: title ?? fallbackSummary,
256 };
257}
scripts/release-smoke.ts+1
......@@ -24,6 +24,7 @@ const workspaceFiles = [
2424 "packages/client-runtime/package.json",
2525 "packages/contracts/package.json",
2626 "packages/shared/package.json",
27 "packages/effect-acp/package.json",
2728 "scripts/package.json",
2829] as const;
2930