authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 00:44:08-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log17e6cad479f0a0ac862a1f9cf8bd8d370ec9f10f
treebd44d0c4078f8f443e4f67017c6c9ffc97df38dc
parentfeff86638ef7f08f6766350b8e84b151972eaaa7

Respect provider model-switch limits and canonical runtime events

- Add provider capability plumbing for `sessionModelSwitch` and use it in session/turn routing - Keep Cursor sessions stable when in-session model switching is unsupported - Ingest canonical runtime events (`content.delta`, `item.*`, `request.*`) with legacy-shape compatibility

20 files changed, 1071 insertions(+), 208 deletions(-)

.tmp/cursor-acp-method-probe.mjs created+188
......@@ -0,0 +1,188 @@
1import { spawn } from 'node:child_process';
2import readline from 'node:readline';
3
4const REQUEST_TIMEOUT_MS = 15000;
5
6function now() {
7 return new Date().toISOString();
8}
9
10function parseJson(line) {
11 try {
12 return JSON.parse(line);
13 } catch {
14 return null;
15 }
16}
17
18class Client {
19 constructor() {
20 this.nextId = 1;
21 this.pending = new Map();
22 this.notifications = [];
23
24 this.child = spawn('agent', ['acp'], {
25 stdio: ['pipe', 'pipe', 'pipe'],
26 env: { ...process.env, NO_COLOR: '1' },
27 });
28
29 this.stdout = readline.createInterface({ input: this.child.stdout });
30 this.stderr = readline.createInterface({ input: this.child.stderr });
31
32 this.stderr.on('line', (line) => {
33 process.stderr.write(`[stderr] ${line}\n`);
34 });
35
36 this.stdout.on('line', (line) => {
37 const msg = parseJson(line);
38 if (!msg) {
39 process.stdout.write(`[stdout non-json] ${line}\n`);
40 return;
41 }
42
43 if (
44 Object.prototype.hasOwnProperty.call(msg, 'id') &&
45 !Object.prototype.hasOwnProperty.call(msg, 'method') &&
46 (Object.prototype.hasOwnProperty.call(msg, 'result') ||
47 Object.prototype.hasOwnProperty.call(msg, 'error'))
48 ) {
49 const pending = this.pending.get(msg.id);
50 if (!pending) return;
51 this.pending.delete(msg.id);
52 pending(msg);
53 return;
54 }
55
56 if (typeof msg.method === 'string') {
57 this.notifications.push(msg);
58 if (Object.prototype.hasOwnProperty.call(msg, 'id')) {
59 this.respondUnsupported(msg.id, msg.method);
60 }
61 }
62 });
63 }
64
65 respondUnsupported(id, method) {
66 const response = {
67 jsonrpc: '2.0',
68 id,
69 error: {
70 code: -32601,
71 message: `Unhandled server request in probe: ${method}`,
72 },
73 };
74 this.child.stdin.write(`${JSON.stringify(response)}\n`);
75 }
76
77 async send(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
78 const id = this.nextId++;
79 const message = { jsonrpc: '2.0', id, method, params };
80 this.child.stdin.write(`${JSON.stringify(message)}\n`);
81
82 return await new Promise((resolve) => {
83 const timer = setTimeout(() => {
84 this.pending.delete(id);
85 resolve({ timeout: true, id, method, params });
86 }, timeoutMs);
87
88 this.pending.set(id, (response) => {
89 clearTimeout(timer);
90 resolve({ timeout: false, id, method, params, response });
91 });
92 });
93 }
94
95 async close() {
96 try {
97 this.child.stdin.end();
98 } catch {}
99 this.child.kill('SIGTERM');
100 await new Promise((r) => setTimeout(r, 100));
101 this.child.kill('SIGKILL');
102 }
103}
104
105async function main() {
106 const client = new Client();
107 const results = [];
108
109 const init = await client.send('initialize', {
110 protocolVersion: 1,
111 clientCapabilities: {
112 fs: { readTextFile: false, writeTextFile: false },
113 terminal: false,
114 },
115 clientInfo: {
116 name: 't3-cursor-acp-method-probe',
117 version: '0.1.0',
118 },
119 });
120 results.push(init);
121
122 const auth = await client.send('authenticate', { methodId: 'cursor_login' });
123 results.push(auth);
124
125 const sessionNew = await client.send('session/new', {
126 cwd: process.cwd(),
127 mcpServers: [],
128 model: 'gpt-5.3-codex',
129 });
130 results.push(sessionNew);
131
132 const sessionId = sessionNew?.response?.result?.sessionId;
133 if (typeof sessionId !== 'string' || sessionId.length === 0) {
134 const out = {
135 ts: now(),
136 sessionId: null,
137 reason: 'Missing sessionId from session/new',
138 results,
139 notificationMethods: [...new Set(client.notifications.map((n) => n.method))].sort(),
140 notificationSamples: client.notifications.slice(0, 8),
141 };
142 process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
143 await client.close();
144 return;
145 }
146
147 const methodCandidates = [
148 { method: 'session/set_model', params: { sessionId, model: 'composer-1.5' } },
149 { method: 'session/setModel', params: { sessionId, model: 'composer-1.5' } },
150 { method: 'session/model/set', params: { sessionId, model: 'composer-1.5' } },
151 { method: 'session/update_model', params: { sessionId, model: 'composer-1.5' } },
152 { method: 'session/configure', params: { sessionId, model: 'composer-1.5' } },
153 { method: 'session/set_mode', params: { sessionId, modeId: 'ask' } },
154 { method: 'session/setMode', params: { sessionId, modeId: 'ask' } },
155 { method: 'session/mode/set', params: { sessionId, modeId: 'ask' } },
156 { method: 'session/update_mode', params: { sessionId, modeId: 'ask' } },
157 { method: 'session/change_mode', params: { sessionId, modeId: 'ask' } },
158 { method: 'session/set', params: { sessionId, model: 'composer-1.5' } },
159 ];
160
161 for (const candidate of methodCandidates) {
162 const result = await client.send(candidate.method, candidate.params);
163 results.push(result);
164 }
165
166 // Sanity: session/prompt should still work after candidates.
167 const prompt = await client.send('session/prompt', {
168 sessionId,
169 prompt: [{ type: 'text', text: 'Reply with exactly: ok' }],
170 }, 30000);
171 results.push(prompt);
172
173 const out = {
174 ts: now(),
175 sessionId,
176 results,
177 notificationMethods: [...new Set(client.notifications.map((n) => n.method))].sort(),
178 notificationSamples: client.notifications.slice(0, 8),
179 };
180
181 process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
182 await client.close();
183}
184
185main().catch(async (error) => {
186 process.stderr.write(`Probe failed: ${error?.stack || String(error)}\n`);
187 process.exitCode = 1;
188});
.tmp/cursor-acp-method-probe.output.json created+46
......@@ -0,0 +1,46 @@
1{
2 "ts": "2026-03-03T07:05:34.357Z",
3 "sessionId": null,
4 "reason": "Missing sessionId from session/new",
5 "results": [
6 {
7 "timeout": true,
8 "id": 1,
9 "method": "initialize",
10 "params": {
11 "protocolVersion": 1,
12 "clientCapabilities": {
13 "fs": {
14 "readTextFile": false,
15 "writeTextFile": false
16 },
17 "terminal": false
18 },
19 "clientInfo": {
20 "name": "t3-cursor-acp-method-probe",
21 "version": "0.1.0"
22 }
23 }
24 },
25 {
26 "timeout": true,
27 "id": 2,
28 "method": "authenticate",
29 "params": {
30 "methodId": "cursor_login"
31 }
32 },
33 {
34 "timeout": true,
35 "id": 3,
36 "method": "session/new",
37 "params": {
38 "cwd": "/Users/julius/.t3/worktrees/codething-mvp/codething-648ca884",
39 "mcpServers": [],
40 "model": "gpt-5.3-codex"
41 }
42 }
43 ],
44 "notificationMethods": [],
45 "notificationSamples": []
46}
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1
......@@ -80,6 +80,7 @@ function createProviderServiceHarness(
8080 respondToRequest: () => unsupported(),
8181 stopSession: () => unsupported(),
8282 listSessions,
83 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
8384 rollbackConversation,
8485 stopAll: () => Effect.void,
8586 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+137
......@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
15import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
26import {
37 ApprovalRequestId,
......@@ -100,10 +104,15 @@ describe("ProviderCommandReactor", () => {
100104 typeof input === "object" && input !== null && "resumeCursor" in input
101105 ? input.resumeCursor
102106 : undefined;
107 const model =
108 typeof input === "object" && input !== null && "model" in input && typeof input.model === "string"
109 ? input.model
110 : undefined;
103111 const session: ProviderSession = {
104112 sessionId: asSessionId(`sess-${sessionIndex}`),
105113 provider,
106114 status: "ready" as const,
115 ...(model !== undefined ? { model } : {}),
107116 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
108117 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
109118 createdAt: now,
......@@ -135,6 +144,25 @@ describe("ProviderCommandReactor", () => {
135144 }
136145 }),
137146 );
147 const renameBranch = vi.fn((input: unknown) =>
148 Effect.succeed({
149 branch:
150 typeof input === "object" &&
151 input !== null &&
152 "newBranch" in input &&
153 typeof input.newBranch === "string"
154 ? input.newBranch
155 : "renamed-branch",
156 }),
157 );
158 const generateBranchName = vi.fn(() =>
159 Effect.fail(
160 new TextGenerationError({
161 operation: "generateBranchName",
162 detail: "disabled in test harness",
163 }),
164 ),
165 );
138166
139167 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
140168 const service: ProviderServiceShape = {
......@@ -144,6 +172,10 @@ describe("ProviderCommandReactor", () => {
144172 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
145173 stopSession: stopSession as ProviderServiceShape["stopSession"],
146174 listSessions: () => Effect.succeed(runtimeSessions),
175 getCapabilities: (provider) =>
176 Effect.succeed({
177 sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session",
178 }),
147179 rollbackConversation: () => unsupported(),
148180 stopAll: () => Effect.void,
149181 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
......@@ -371,6 +403,111 @@ describe("ProviderCommandReactor", () => {
371403 expect(harness.stopSession.mock.calls.length).toBe(0);
372404 });
373405
406 it("reuses the same cursor session when requested model is unchanged", async () => {
407 const harness = await createHarness();
408 const now = new Date().toISOString();
409
410 await Effect.runPromise(
411 harness.engine.dispatch({
412 type: "thread.turn.start",
413 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-1"),
414 threadId: ThreadId.makeUnsafe("thread-1"),
415 message: {
416 messageId: asMessageId("user-message-cursor-model-same-1"),
417 role: "user",
418 text: "first",
419 attachments: [],
420 },
421 provider: "cursor",
422 model: "composer-1.5",
423 approvalPolicy: "on-request",
424 sandboxMode: "workspace-write",
425 createdAt: now,
426 }),
427 );
428
429 await waitFor(() => harness.startSession.mock.calls.length === 1);
430 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
431
432 await Effect.runPromise(
433 harness.engine.dispatch({
434 type: "thread.turn.start",
435 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-2"),
436 threadId: ThreadId.makeUnsafe("thread-1"),
437 message: {
438 messageId: asMessageId("user-message-cursor-model-same-2"),
439 role: "user",
440 text: "second",
441 attachments: [],
442 },
443 provider: "cursor",
444 model: "composer-1.5",
445 approvalPolicy: "on-request",
446 sandboxMode: "workspace-write",
447 createdAt: now,
448 }),
449 );
450
451 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
452 expect(harness.startSession.mock.calls.length).toBe(1);
453 expect(harness.stopSession.mock.calls.length).toBe(0);
454 });
455
456 it("keeps cursor session/model when model change is unsupported", async () => {
457 const harness = await createHarness();
458 const now = new Date().toISOString();
459
460 await Effect.runPromise(
461 harness.engine.dispatch({
462 type: "thread.turn.start",
463 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-1"),
464 threadId: ThreadId.makeUnsafe("thread-1"),
465 message: {
466 messageId: asMessageId("user-message-cursor-model-change-1"),
467 role: "user",
468 text: "first",
469 attachments: [],
470 },
471 provider: "cursor",
472 model: "gpt-5.3-codex",
473 approvalPolicy: "on-request",
474 sandboxMode: "workspace-write",
475 createdAt: now,
476 }),
477 );
478
479 await waitFor(() => harness.startSession.mock.calls.length === 1);
480 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
481
482 await Effect.runPromise(
483 harness.engine.dispatch({
484 type: "thread.turn.start",
485 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-2"),
486 threadId: ThreadId.makeUnsafe("thread-1"),
487 message: {
488 messageId: asMessageId("user-message-cursor-model-change-2"),
489 role: "user",
490 text: "second",
491 attachments: [],
492 },
493 provider: "cursor",
494 model: "composer-1.5",
495 approvalPolicy: "on-request",
496 sandboxMode: "workspace-write",
497 createdAt: now,
498 }),
499 );
500
501 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
502
503 expect(harness.stopSession.mock.calls.length).toBe(0);
504 expect(harness.startSession.mock.calls.length).toBe(1);
505 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
506 sessionId: asSessionId("sess-1"),
507 model: "gpt-5.3-codex",
508 });
509 });
510
374511 it("restarts the provider session when runtime mode changes", async () => {
375512 const harness = await createHarness();
376513 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+39-12
......@@ -169,6 +169,7 @@ const make = Effect.gen(function* () {
169169 createdAt: string,
170170 options?: {
171171 readonly provider?: ProviderKind;
172 readonly model?: string;
172173 readonly approvalPolicy?: ProviderApprovalPolicy;
173174 readonly sandboxMode?: ProviderSandboxMode;
174175 },
......@@ -190,14 +191,15 @@ const make = Effect.gen(function* () {
190191 ? thread.session.providerName
191192 : undefined;
192193 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
194 const desiredModel = options?.model ?? thread.model;
193195 const effectiveCwd = resolveThreadWorkspaceCwd({
194196 thread,
195197 projects: readModel.projects,
196198 });
197199
198 const resolveResumeCursorForSession = (sessionId: ProviderSessionId) =>
200 const resolveActiveSession = (sessionId: ProviderSessionId) =>
199201 providerService.listSessions().pipe(
200 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor),
202 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)),
201203 );
202204
203205 const startProviderSession = (input?: {
......@@ -209,7 +211,7 @@ const make = Effect.gen(function* () {
209211 ? { provider: input?.provider ?? preferredProvider }
210212 : {}),
211213 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
212 ...(thread.model ? { model: thread.model } : {}),
214 ...(desiredModel ? { model: desiredModel } : {}),
213215 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
214216 approvalPolicy: desiredApprovalPolicy,
215217 sandboxMode: desiredSandboxMode,
......@@ -242,14 +244,29 @@ const make = Effect.gen(function* () {
242244 const sandboxModeChanged =
243245 options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode;
244246 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;
245
246 if (!approvalPolicyChanged && !sandboxModeChanged && !providerChanged) {
247 const activeSession = yield* resolveActiveSession(existingSessionId);
248 const sessionModelSwitch =
249 currentProvider === undefined
250 ? "in-session"
251 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;
252 const modelChanged =
253 options?.model !== undefined && options.model !== activeSession?.model;
254 const shouldRestartForModelChange =
255 modelChanged && sessionModelSwitch === "restart-session";
256
257 if (
258 !approvalPolicyChanged &&
259 !sandboxModeChanged &&
260 !providerChanged &&
261 !shouldRestartForModelChange
262 ) {
247263 return existingSessionId;
248264 }
249265
250 const resumeCursor = providerChanged
251 ? undefined
252 : yield* resolveResumeCursorForSession(existingSessionId);
266 const resumeCursor =
267 providerChanged || shouldRestartForModelChange
268 ? undefined
269 : (activeSession?.resumeCursor ?? undefined);
253270 const restartedSession = yield* startProviderSession({
254271 ...(resumeCursor !== undefined ? { resumeCursor } : {}),
255272 ...(options?.provider !== undefined ? { provider: options.provider } : {}),
......@@ -267,9 +284,9 @@ const make = Effect.gen(function* () {
267284 return restartedSession.sessionId;
268285 }
269286
270 const startedSession = yield* startProviderSession({
271 ...(options?.provider !== undefined ? { provider: options.provider } : {}),
272 });
287 const startedSession = yield* startProviderSession(
288 options?.provider !== undefined ? { provider: options.provider } : undefined,
289 );
273290 yield* bindSessionToThread(startedSession);
274291 return startedSession.sessionId;
275292 });
......@@ -291,17 +308,27 @@ const make = Effect.gen(function* () {
291308 }
292309 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {
293310 ...(input.provider !== undefined ? { provider: input.provider } : {}),
311 ...(input.model !== undefined ? { model: input.model } : {}),
294312 approvalPolicy: input.approvalPolicy,
295313 sandboxMode: input.sandboxMode,
296314 });
297315 const normalizedInput = toNonEmptyProviderInput(input.messageText);
298316 const normalizedAttachments = input.attachments ?? [];
317 const activeSession = yield* providerService.listSessions().pipe(
318 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)),
319 );
320 const sessionModelSwitch =
321 activeSession === undefined
322 ? "in-session"
323 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;
324 const modelForTurn =
325 sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
299326
300327 yield* providerService.sendTurn({
301328 sessionId,
302329 ...(normalizedInput ? { input: normalizedInput } : {}),
303330 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
304 ...(input.model !== undefined ? { model: input.model } : {}),
331 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
305332 ...(input.effort !== undefined ? { effort: input.effort } : {}),
306333 });
307334 });
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+44-6
......@@ -50,6 +50,7 @@ function createProviderServiceHarness() {
5050 respondToRequest: () => unsupported(),
5151 stopSession: () => unsupported(),
5252 listSessions: () => Effect.succeed([]),
53 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
5354 rollbackConversation: () => unsupported(),
5455 stopAll: () => Effect.void,
5556 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
......@@ -455,38 +456,48 @@ describe("ProviderRuntimeIngestion", () => {
455456 );
456457 });
457458
458 it("maps message delta/completed into finalized assistant messages", async () => {
459 it("maps canonical content delta/item completed into finalized assistant messages", async () => {
459460 const harness = await createHarness();
460461 const now = new Date().toISOString();
461462
462463 harness.emit({
463 type: "message.delta",
464 type: "content.delta",
464465 eventId: asEventId("evt-message-delta-1"),
465466 provider: "codex",
466467 sessionId: asSessionId("sess-1"),
467468 createdAt: now,
468469 turnId: asProviderTurnId("turn-2"),
469470 itemId: asItemId("item-1"),
470 delta: "hello",
471 payload: {
472 streamKind: "assistant_text",
473 delta: "hello",
474 },
471475 });
472476 harness.emit({
473 type: "message.delta",
477 type: "content.delta",
474478 eventId: asEventId("evt-message-delta-2"),
475479 provider: "codex",
476480 sessionId: asSessionId("sess-1"),
477481 createdAt: now,
478482 turnId: asProviderTurnId("turn-2"),
479483 itemId: asItemId("item-1"),
480 delta: " world",
484 payload: {
485 streamKind: "assistant_text",
486 delta: " world",
487 },
481488 });
482489 harness.emit({
483 type: "message.completed",
490 type: "item.completed",
484491 eventId: asEventId("evt-message-completed"),
485492 provider: "codex",
486493 sessionId: asSessionId("sess-1"),
487494 createdAt: now,
488495 turnId: asProviderTurnId("turn-2"),
489496 itemId: asItemId("item-1"),
497 payload: {
498 itemType: "assistant_message",
499 status: "completed",
500 },
490501 });
491502
492503 const thread = await waitForThread(harness.engine, (entry) =>
......@@ -497,6 +508,33 @@ describe("ProviderRuntimeIngestion", () => {
497508 expect(message?.streaming).toBe(false);
498509 });
499510
511 it("uses assistant item completion detail when no assistant deltas were streamed", async () => {
512 const harness = await createHarness();
513 const now = new Date().toISOString();
514
515 harness.emit({
516 type: "item.completed",
517 eventId: asEventId("evt-assistant-item-completed-no-delta"),
518 provider: "codex",
519 sessionId: asSessionId("sess-1"),
520 createdAt: now,
521 turnId: asProviderTurnId("turn-no-delta"),
522 itemId: asItemId("item-no-delta"),
523 payload: {
524 itemType: "assistant_message",
525 status: "completed",
526 detail: "assistant-only final text",
527 },
528 });
529
530 const thread = await waitForThread(harness.engine, (entry) =>
531 entry.messages.some((message) => message.id === "assistant:item-no-delta" && !message.streaming),
532 );
533 const message = thread.messages.find((entry) => entry.id === "assistant:item-no-delta");
534 expect(message?.text).toBe("assistant-only final text");
535 expect(message?.streaming).toBe(false);
536 });
537
500538 it("buffers assistant deltas by default until completion", async () => {
501539 const harness = await createHarness();
502540 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+211-28
......@@ -76,12 +76,81 @@ function truncateDetail(value: string, limit = 180): string {
7676 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
7777}
7878
79function asString(value: unknown): string | undefined {
80 return typeof value === "string" ? value : undefined;
81}
82
83function legacyRuntimeType(event: ProviderRuntimeEvent): string | undefined {
84 return asString((event as Record<string, unknown>).type);
85}
86
87function legacyRuntimeStringField(
88 event: ProviderRuntimeEvent,
89 key: string,
90): string | undefined {
91 return asString((event as Record<string, unknown>)[key]);
92}
93
94function runtimePayloadRecord(event: ProviderRuntimeEvent): Record<string, unknown> | undefined {
95 const payload = (event as { payload?: unknown }).payload;
96 if (!payload || typeof payload !== "object") {
97 return undefined;
98 }
99 return payload as Record<string, unknown>;
100}
101
102function normalizeRuntimeTurnState(
103 value: string | undefined,
104): "completed" | "failed" | "interrupted" | "cancelled" {
105 switch (value) {
106 case "failed":
107 case "interrupted":
108 case "cancelled":
109 case "completed":
110 return value;
111 default:
112 return "completed";
113 }
114}
115
116function runtimeTurnState(
117 event: ProviderRuntimeEvent,
118): "completed" | "failed" | "interrupted" | "cancelled" {
119 const payloadState = asString(runtimePayloadRecord(event)?.state);
120 const legacyStatus = legacyRuntimeStringField(event, "status");
121 return normalizeRuntimeTurnState(payloadState ?? legacyStatus);
122}
123
124function runtimeTurnErrorMessage(event: ProviderRuntimeEvent): string | undefined {
125 const payloadErrorMessage = asString(runtimePayloadRecord(event)?.errorMessage);
126 const legacyErrorMessage = legacyRuntimeStringField(event, "errorMessage");
127 return payloadErrorMessage ?? legacyErrorMessage;
128}
129
130function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | undefined {
131 const payloadMessage = asString(runtimePayloadRecord(event)?.message);
132 const legacyMessage = legacyRuntimeStringField(event, "message");
133 return payloadMessage ?? legacyMessage;
134}
135
136function isToolLifecycleItemType(itemType: string): boolean {
137 return (
138 itemType === "command_execution" ||
139 itemType === "file_change" ||
140 itemType === "mcp_tool_call" ||
141 itemType === "dynamic_tool_call" ||
142 itemType === "collab_agent_tool_call" ||
143 itemType === "web_search" ||
144 itemType === "image_view"
145 );
146}
147
79148function runtimeEventToActivities(
80149 event: ProviderRuntimeEvent,
81150): ReadonlyArray<OrchestrationThreadActivity> {
82151 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};
83152 switch (event.type) {
84 case "approval.requested": {
153 case "request.opened": {
85154 return [
86155 {
87156 id: event.eventId,
......@@ -89,13 +158,15 @@ function runtimeEventToActivities(
89158 tone: "approval",
90159 kind: "approval.requested",
91160 summary:
92 event.requestKind === "command"
161 event.payload.requestType === "command_execution_approval"
93162 ? "Command approval requested"
94 : "File-change approval requested",
163 : event.payload.requestType === "file_change_approval"
164 ? "File-change approval requested"
165 : "Approval requested",
95166 payload: {
96167 requestId: event.requestId,
97 requestKind: event.requestKind,
98 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
168 requestType: event.payload.requestType,
169 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
99170 },
100171 turnId: toTurnId(event.turnId) ?? null,
101172 ...maybeSequence,
......@@ -103,7 +174,7 @@ function runtimeEventToActivities(
103174 ];
104175 }
105176
106 case "approval.resolved": {
177 case "request.resolved": {
107178 return [
108179 {
109180 id: event.eventId,
......@@ -113,8 +184,8 @@ function runtimeEventToActivities(
113184 summary: "Approval resolved",
114185 payload: {
115186 requestId: event.requestId,
116 ...(event.requestKind ? { requestKind: event.requestKind } : {}),
117 ...(event.decision ? { decision: event.decision } : {}),
187 requestType: event.payload.requestType,
188 ...(event.payload.decision ? { decision: event.payload.decision } : {}),
118189 },
119190 turnId: toTurnId(event.turnId) ?? null,
120191 ...maybeSequence,
......@@ -123,6 +194,10 @@ function runtimeEventToActivities(
123194 }
124195
125196 case "runtime.error": {
197 const message = runtimeErrorMessageFromEvent(event);
198 if (!message) {
199 return [];
200 }
126201 return [
127202 {
128203 id: event.eventId,
......@@ -131,7 +206,7 @@ function runtimeEventToActivities(
131206 kind: "runtime.error",
132207 summary: "Runtime error",
133208 payload: {
134 message: truncateDetail(event.message),
209 message: truncateDetail(message),
135210 },
136211 turnId: toTurnId(event.turnId) ?? null,
137212 ...maybeSequence,
......@@ -139,17 +214,20 @@ function runtimeEventToActivities(
139214 ];
140215 }
141216
142 case "tool.completed": {
217 case "item.completed": {
218 if (!isToolLifecycleItemType(event.payload.itemType)) {
219 return [];
220 }
143221 return [
144222 {
145223 id: event.eventId,
146224 createdAt: event.createdAt,
147225 tone: "tool",
148226 kind: "tool.completed",
149 summary: `${event.title} complete`,
227 summary: `${event.payload.title ?? "Tool"} complete`,
150228 payload: {
151 toolKind: event.toolKind,
152 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
229 itemType: event.payload.itemType,
230 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
153231 },
154232 turnId: toTurnId(event.turnId) ?? null,
155233 ...maybeSequence,
......@@ -157,17 +235,95 @@ function runtimeEventToActivities(
157235 ];
158236 }
159237
160 case "tool.started": {
238 case "item.started": {
239 if (!isToolLifecycleItemType(event.payload.itemType)) {
240 return [];
241 }
161242 return [
162243 {
163244 id: event.eventId,
164245 createdAt: event.createdAt,
165246 tone: "tool",
166247 kind: "tool.started",
167 summary: `${event.title} started`,
248 summary: `${event.payload.title ?? "Tool"} started`,
168249 payload: {
169 toolKind: event.toolKind,
170 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),
250 itemType: event.payload.itemType,
251 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
252 },
253 turnId: toTurnId(event.turnId) ?? null,
254 ...maybeSequence,
255 },
256 ];
257 }
258
259 default:
260 break;
261 }
262
263 // Backward-compatibility for legacy event shapes still emitted by older tests/adapters.
264 const legacyType = legacyRuntimeType(event);
265 switch (legacyType) {
266 case "approval.requested": {
267 const detail = legacyRuntimeStringField(event, "detail");
268 const requestKind = legacyRuntimeStringField(event, "requestKind");
269 return [
270 {
271 id: event.eventId,
272 createdAt: event.createdAt,
273 tone: "approval",
274 kind: "approval.requested",
275 summary:
276 requestKind === "command"
277 ? "Command approval requested"
278 : "File-change approval requested",
279 payload: {
280 requestId: event.requestId,
281 ...(requestKind ? { requestKind } : {}),
282 ...(detail ? { detail: truncateDetail(detail) } : {}),
283 },
284 turnId: toTurnId(event.turnId) ?? null,
285 ...maybeSequence,
286 },
287 ];
288 }
289
290 case "approval.resolved": {
291 const decision = legacyRuntimeStringField(event, "decision");
292 const requestKind = legacyRuntimeStringField(event, "requestKind");
293 return [
294 {
295 id: event.eventId,
296 createdAt: event.createdAt,
297 tone: "approval",
298 kind: "approval.resolved",
299 summary: "Approval resolved",
300 payload: {
301 requestId: event.requestId,
302 ...(requestKind ? { requestKind } : {}),
303 ...(decision ? { decision } : {}),
304 },
305 turnId: toTurnId(event.turnId) ?? null,
306 ...maybeSequence,
307 },
308 ];
309 }
310
311 case "tool.completed":
312 case "tool.started": {
313 const title = legacyRuntimeStringField(event, "title") ?? "Tool";
314 const detail = legacyRuntimeStringField(event, "detail");
315 const toolKind = legacyRuntimeStringField(event, "toolKind");
316 const activityKind = legacyType;
317 return [
318 {
319 id: event.eventId,
320 createdAt: event.createdAt,
321 tone: "tool",
322 kind: activityKind,
323 summary: `${title} ${legacyType === "tool.started" ? "started" : "complete"}`,
324 payload: {
325 ...(toolKind ? { toolKind } : {}),
326 ...(detail ? { detail: truncateDetail(detail) } : {}),
171327 },
172328 turnId: toTurnId(event.turnId) ?? null,
173329 ...maybeSequence,
......@@ -296,9 +452,12 @@ const make = Effect.gen(function* () {
296452 createdAt: string;
297453 commandTag: string;
298454 finalDeltaCommandTag: string;
455 fallbackText?: string;
299456 }) =>
300457 Effect.gen(function* () {
301 const text = yield* takeBufferedAssistantText(input.messageId);
458 const bufferedText = yield* takeBufferedAssistantText(input.messageId);
459 const text =
460 bufferedText.length > 0 ? bufferedText : (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : "";
302461
303462 if (text.length > 0) {
304463 yield* orchestrationEngine.dispatch({
......@@ -356,6 +515,7 @@ const make = Effect.gen(function* () {
356515 );
357516 if (!thread) return;
358517
518 const legacyType = legacyRuntimeType(event);
359519 const now = event.createdAt;
360520 const sessionProviderThreadId = thread.session?.providerThreadId ?? null;
361521 const scopedSessionProviderThreadId = isSyntheticClaudeThreadId(
......@@ -365,7 +525,7 @@ const make = Effect.gen(function* () {
365525 ? null
366526 : sessionProviderThreadId;
367527 const eventProviderThreadId = toProviderThreadId(event.threadId);
368 const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined);
528 const eventTurnId = toTurnId(event.turnId);
369529 const activeTurnId = thread.session?.activeTurnId ?? null;
370530
371531 const matchesThreadScope =
......@@ -448,7 +608,7 @@ const make = Effect.gen(function* () {
448608 case "session.exited":
449609 return "stopped";
450610 case "turn.completed":
451 return event.status === "failed" ? "error" : "ready";
611 return runtimeTurnState(event) === "failed" ? "error" : "ready";
452612 case "session.started":
453613 case "thread.started":
454614 // Provider thread/session start notifications can arrive during an
......@@ -457,8 +617,8 @@ const make = Effect.gen(function* () {
457617 }
458618 })();
459619 const lastError =
460 event.type === "turn.completed" && event.status === "failed"
461 ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed")
620 event.type === "turn.completed" && runtimeTurnState(event) === "failed"
621 ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed")
462622 : status === "ready"
463623 ? null
464624 : (thread.session?.lastError ?? null);
......@@ -485,7 +645,14 @@ const make = Effect.gen(function* () {
485645 }
486646 }
487647
488 if (event.type === "message.delta" && event.delta.length > 0) {
648 const assistantDelta =
649 event.type === "content.delta" && event.payload.streamKind === "assistant_text"
650 ? event.payload.delta
651 : legacyType === "message.delta"
652 ? legacyRuntimeStringField(event, "delta")
653 : undefined;
654
655 if (assistantDelta && assistantDelta.length > 0) {
489656 const assistantMessageId = MessageId.makeUnsafe(
490657 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,
491658 );
......@@ -496,7 +663,7 @@ const make = Effect.gen(function* () {
496663
497664 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);
498665 if (assistantDeliveryMode === "buffered") {
499 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, event.delta);
666 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
500667 if (spillChunk.length > 0) {
501668 yield* orchestrationEngine.dispatch({
502669 type: "thread.message.assistant.delta",
......@@ -514,15 +681,28 @@ const make = Effect.gen(function* () {
514681 commandId: providerCommandId(event, "assistant-delta"),
515682 threadId: thread.id,
516683 messageId: assistantMessageId,
517 delta: event.delta,
684 delta: assistantDelta,
518685 ...(turnId ? { turnId } : {}),
519686 createdAt: now,
520687 });
521688 }
522689 }
523690
524 if (event.type === "message.completed") {
525 const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`);
691 const assistantCompletion =
692 event.type === "item.completed" && event.payload.itemType === "assistant_message"
693 ? {
694 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`),
695 fallbackText: event.payload.detail,
696 }
697 : legacyType === "message.completed"
698 ? {
699 messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`),
700 fallbackText: undefined,
701 }
702 : undefined;
703
704 if (assistantCompletion) {
705 const assistantMessageId = assistantCompletion.messageId;
526706 const turnId = toTurnId(event.turnId);
527707 if (turnId) {
528708 yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId);
......@@ -536,6 +716,7 @@ const make = Effect.gen(function* () {
536716 createdAt: now,
537717 commandTag: "assistant-complete",
538718 finalDeltaCommandTag: "assistant-delta-finalize",
719 fallbackText: assistantCompletion.fallbackText,
539720 });
540721
541722 if (turnId) {
......@@ -570,6 +751,8 @@ const make = Effect.gen(function* () {
570751 }
571752
572753 if (event.type === "runtime.error") {
754 const runtimeErrorMessage = runtimeErrorMessageFromEvent(event) ?? "Provider runtime error";
755
573756 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
574757 ? true
575758 : matchesThreadScope &&
......@@ -596,7 +779,7 @@ const make = Effect.gen(function* () {
596779 approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY,
597780 sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE,
598781 activeTurnId: eventTurnId ?? null,
599 lastError: event.message,
782 lastError: runtimeErrorMessage,
600783 updatedAt: now,
601784 },
602785 createdAt: now,
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+3
......@@ -1845,6 +1845,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
18451845
18461846 return {
18471847 provider: PROVIDER,
1848 capabilities: {
1849 sessionModelSwitch: "in-session",
1850 },
18481851 startSession,
18491852 sendTurn,
18501853 interruptTurn,
apps/server/src/provider/Layers/CodexAdapter.ts+3
......@@ -1092,6 +1092,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10921092
10931093 return {
10941094 provider: PROVIDER,
1095 capabilities: {
1096 sessionModelSwitch: "in-session",
1097 },
10951098 startSession,
10961099 sendTurn,
10971100 interruptTurn,
apps/server/src/provider/Layers/CursorAdapter.test.ts+64-5
......@@ -14,6 +14,7 @@ class FakeCursorAcpProcess extends EventEmitter {
1414 readonly stdin = new PassThrough();
1515 readonly stdout = new PassThrough();
1616 readonly stderr = new PassThrough();
17 readonly requests: Array<{ method: string; params: unknown }> = [];
1718 killed = false;
1819
1920 private readonly input = readline.createInterface({ input: this.stdin });
......@@ -75,9 +76,29 @@ class FakeCursorAcpProcess extends EventEmitter {
7576 if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) {
7677 return;
7778 }
79 this.requests.push({ method, params: message.params });
7880
7981 switch (method) {
80 case "initialize":
82 case "initialize": {
83 const protocolVersion = (message.params as { protocolVersion?: unknown } | undefined)
84 ?.protocolVersion;
85 if (typeof protocolVersion !== "number") {
86 this.emitServerMessage({
87 jsonrpc: "2.0",
88 id,
89 error: {
90 code: -32602,
91 message: "Invalid params",
92 data: {
93 _errors: [],
94 protocolVersion: {
95 _errors: ["Invalid input: expected number, received undefined"],
96 },
97 },
98 },
99 });
100 return;
101 }
81102 this.emitServerMessage({
82103 jsonrpc: "2.0",
83104 id,
......@@ -90,6 +111,7 @@ class FakeCursorAcpProcess extends EventEmitter {
90111 },
91112 });
92113 return;
114 }
93115 case "authenticate":
94116 this.emitServerMessage({
95117 jsonrpc: "2.0",
......@@ -117,6 +139,13 @@ class FakeCursorAcpProcess extends EventEmitter {
117139 result: {},
118140 });
119141 return;
142 case "session/set_model":
143 this.emitServerMessage({
144 jsonrpc: "2.0",
145 id,
146 result: {},
147 });
148 return;
120149 case "session/prompt": {
121150 this.emitServerMessage({
122151 jsonrpc: "2.0",
......@@ -252,7 +281,7 @@ describe("CursorAdapterLive", () => {
252281 return Effect.gen(function* () {
253282 const adapter = yield* CursorAdapter;
254283
255 const eventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe(
284 const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe(
256285 Stream.runCollect,
257286 Effect.forkChild,
258287 );
......@@ -273,6 +302,8 @@ describe("CursorAdapterLive", () => {
273302 events.map((event) => event.type),
274303 [
275304 "session.configured",
305 "auth.status",
306 "auth.status",
276307 "session.started",
277308 "thread.started",
278309 "session.state.changed",
......@@ -286,13 +317,13 @@ describe("CursorAdapterLive", () => {
286317 ],
287318 );
288319
289 const turnStarted = events[4];
320 const turnStarted = events[6];
290321 assert.equal(turnStarted?.type, "turn.started");
291322 if (turnStarted?.type === "turn.started") {
292323 assert.equal(turnStarted.turnId, turn.turnId);
293324 }
294325
295 const completion = events[10];
326 const completion = events[12];
296327 assert.equal(completion?.type, "turn.completed");
297328 if (completion?.type === "turn.completed") {
298329 assert.equal(completion.payload.state, "completed");
......@@ -300,6 +331,34 @@ describe("CursorAdapterLive", () => {
300331 }).pipe(Effect.provide(layer));
301332 });
302333
334 it.effect("passes requested model to ACP process startup", () => {
335 const fake = new FakeCursorAcpProcess();
336 let createProcessInput:
337 | {
338 readonly binaryPath: string;
339 readonly cwd: string;
340 readonly env: NodeJS.ProcessEnv;
341 readonly model?: string;
342 }
343 | undefined;
344 const layer = makeCursorAdapterLive({
345 createProcess: (input) => {
346 createProcessInput = input;
347 return fake as never;
348 },
349 });
350
351 return Effect.gen(function* () {
352 const adapter = yield* CursorAdapter;
353 yield* adapter.startSession({
354 provider: "cursor",
355 model: "composer-1.5",
356 });
357
358 assert.deepEqual(createProcessInput?.model, "composer-1.5");
359 }).pipe(Effect.provide(layer));
360 });
361
303362 it.effect("bridges permission requests to request.opened/request.resolved", () => {
304363 const fake = new FakeCursorAcpProcess();
305364 const layer = makeCursorAdapterLive({
......@@ -314,7 +373,7 @@ describe("CursorAdapterLive", () => {
314373 });
315374
316375 // consume startup events
317 yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain);
376 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
318377
319378 fake.emitPermissionRequest();
320379
apps/server/src/provider/Layers/CursorAdapter.ts+80-5
......@@ -45,6 +45,7 @@ import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
4545
4646const PROVIDER = "cursor" as const;
4747const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
48const CURSOR_ACP_PROTOCOL_VERSION = 1;
4849
4950interface CursorResumeState {
5051 readonly acpSessionId?: string;
......@@ -91,6 +92,7 @@ export interface CursorAdapterLiveOptions {
9192 readonly binaryPath: string;
9293 readonly cwd: string;
9394 readonly env: NodeJS.ProcessEnv;
95 readonly model?: string;
9496 }) => ChildProcessWithoutNullStreams;
9597 readonly nativeEventLogPath?: string;
9698}
......@@ -272,11 +274,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
272274 readonly binaryPath: string;
273275 readonly cwd: string;
274276 readonly env: NodeJS.ProcessEnv;
277 readonly model?: string;
275278 }): ChildProcessWithoutNullStreams => {
276279 if (options?.createProcess) {
277280 return options.createProcess(input);
278281 }
279 return spawn(input.binaryPath, ["acp"], {
282 const args = input.model ? ["--model", input.model, "acp"] : ["acp"];
283 return spawn(input.binaryPath, args, {
280284 cwd: input.cwd,
281285 env: input.env,
282286 stdio: ["pipe", "pipe", "pipe"],
......@@ -862,6 +866,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
862866 binaryPath,
863867 cwd,
864868 env: process.env,
869 ...(input.model ? { model: input.model } : {}),
865870 }),
866871 catch: (cause) =>
867872 new ProviderAdapterProcessError({
......@@ -972,7 +977,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
972977 sessions.set(sessionId, context);
973978
974979 const initializeResult = yield* Effect.tryPromise({
975 try: async () => sendRequest(context, "initialize", {}),
980 try: async () =>
981 sendRequest(context, "initialize", {
982 protocolVersion: CURSOR_ACP_PROTOCOL_VERSION,
983 }),
976984 catch: (cause) => toRequestError(sessionId, "initialize", cause),
977985 });
978986 const decodedInitialize = Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult);
......@@ -997,11 +1005,75 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
9971005 },
9981006 });
9991007
1000 yield* Effect.tryPromise({
1001 try: async () => sendRequest(context, "authenticate", { methodId: "cursor_login" }),
1002 catch: () => undefined,
1008 const authenticateRequest = { methodId: "cursor_login" };
1009 const authStartStamp = yield* makeEventStamp();
1010 yield* offerRuntimeEvent({
1011 type: "auth.status",
1012 eventId: authStartStamp.eventId,
1013 provider: PROVIDER,
1014 sessionId,
1015 createdAt: authStartStamp.createdAt,
1016 payload: {
1017 isAuthenticating: true,
1018 },
1019 providerRefs: {
1020 providerSessionId: sessionId,
1021 },
1022 raw: {
1023 source: "cursor.acp.request",
1024 method: "authenticate",
1025 payload: authenticateRequest,
1026 },
10031027 });
10041028
1029 const authenticateResult = yield* Effect.tryPromise({
1030 try: async () => sendRequest(context, "authenticate", authenticateRequest),
1031 catch: (cause) => cause,
1032 });
1033 const authEndStamp = yield* makeEventStamp();
1034 if (authenticateResult instanceof Error) {
1035 yield* offerRuntimeEvent({
1036 type: "auth.status",
1037 eventId: authEndStamp.eventId,
1038 provider: PROVIDER,
1039 sessionId,
1040 createdAt: authEndStamp.createdAt,
1041 payload: {
1042 isAuthenticating: false,
1043 error: toMessage(authenticateResult, "Cursor authentication failed."),
1044 },
1045 providerRefs: {
1046 providerSessionId: sessionId,
1047 },
1048 raw: {
1049 source: "cursor.acp.response",
1050 method: "authenticate",
1051 payload: {
1052 error: toMessage(authenticateResult, "Cursor authentication failed."),
1053 },
1054 },
1055 });
1056 } else {
1057 yield* offerRuntimeEvent({
1058 type: "auth.status",
1059 eventId: authEndStamp.eventId,
1060 provider: PROVIDER,
1061 sessionId,
1062 createdAt: authEndStamp.createdAt,
1063 payload: {
1064 isAuthenticating: false,
1065 },
1066 providerRefs: {
1067 providerSessionId: sessionId,
1068 },
1069 raw: {
1070 source: "cursor.acp.response",
1071 method: "authenticate",
1072 payload: authenticateResult,
1073 },
1074 });
1075 }
1076
10051077 const acpSessionId = yield* Effect.tryPromise({
10061078 try: async () => {
10071079 if (resumeState?.acpSessionId) {
......@@ -1356,6 +1428,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13561428
13571429 return {
13581430 provider: PROVIDER,
1431 capabilities: {
1432 sessionModelSwitch: "unsupported",
1433 },
13591434 startSession,
13601435 sendTurn,
13611436 interruptTurn,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+3
......@@ -14,6 +14,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
1414
1515const fakeCodexAdapter: CodexAdapterShape = {
1616 provider: "codex",
17 capabilities: { sessionModelSwitch: "in-session" },
1718 startSession: vi.fn(),
1819 sendTurn: vi.fn(),
1920 interruptTurn: vi.fn(),
......@@ -29,6 +30,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
2930
3031const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
3132 provider: "claudeCode",
33 capabilities: { sessionModelSwitch: "in-session" },
3234 startSession: vi.fn(),
3335 sendTurn: vi.fn(),
3436 interruptTurn: vi.fn(),
......@@ -44,6 +46,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
4446
4547const fakeCursorAdapter: CursorAdapterShape = {
4648 provider: "cursor",
49 capabilities: { sessionModelSwitch: "unsupported" },
4750 startSession: vi.fn(),
4851 sendTurn: vi.fn(),
4952 interruptTurn: vi.fn(),
apps/server/src/provider/Layers/ProviderService.test.ts+3
......@@ -161,6 +161,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
161161
162162 const adapter: ProviderAdapterShape<ProviderAdapterError> = {
163163 provider,
164 capabilities: {
165 sessionModelSwitch: "in-session",
166 },
164167 startSession,
165168 sendTurn,
166169 interruptTurn,
apps/server/src/provider/Layers/ProviderService.ts+4
......@@ -566,6 +566,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
566566 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),
567567 );
568568
569 const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) =>
570 registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities));
571
569572 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) =>
570573 Effect.gen(function* () {
571574 const input = yield* decodeInputOrValidationError({
......@@ -617,6 +620,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
617620 respondToRequest,
618621 stopSession,
619622 listSessions,
623 getCapabilities,
620624 rollbackConversation,
621625 stopAll,
622626 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
apps/server/src/provider/Services/ProviderAdapter.ts+10
......@@ -23,6 +23,15 @@ import type {
2323import type { Effect } from "effect";
2424import type { Stream } from "effect";
2525
26export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported";
27
28export interface ProviderAdapterCapabilities {
29 /**
30 * Declares whether changing the model on an existing session is supported.
31 */
32 readonly sessionModelSwitch: ProviderSessionModelSwitchMode;
33}
34
2635export interface ProviderThreadTurnSnapshot {
2736 readonly id: ProviderTurnId;
2837 readonly items: ReadonlyArray<unknown>;
......@@ -38,6 +47,7 @@ export interface ProviderAdapterShape<TError> {
3847 * Provider kind implemented by this adapter.
3948 */
4049 readonly provider: ProviderKind;
50 readonly capabilities: ProviderAdapterCapabilities;
4151
4252 /**
4353 * Start a provider-backed session.
apps/server/src/provider/Services/ProviderService.ts+9
......@@ -13,6 +13,7 @@
1313 */
1414import type {
1515 ProviderInterruptTurnInput,
16 ProviderKind,
1617 ProviderRespondToRequestInput,
1718 ProviderRuntimeEvent,
1819 ProviderSendTurnInput,
......@@ -27,6 +28,7 @@ import { ServiceMap } from "effect";
2728import type { Effect, Stream } from "effect";
2829
2930import type { ProviderServiceError } from "../Errors.ts";
31import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts";
3032
3133/**
3234 * ProviderServiceShape - Service API for provider session and turn orchestration.
......@@ -75,6 +77,13 @@ export interface ProviderServiceShape {
7577 */
7678 readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>;
7779
80 /**
81 * Read static capabilities for a provider adapter.
82 */
83 readonly getCapabilities: (
84 provider: ProviderKind,
85 ) => Effect.Effect<ProviderAdapterCapabilities, ProviderServiceError>;
86
7887 /**
7988 * Roll back provider conversation state by a number of turns.
8089 */
apps/server/src/wsServer.test.ts+1
......@@ -1170,6 +1170,7 @@ describe("WebSocket Server", () => {
11701170 respondToRequest: () => unsupported(),
11711171 stopSession: () => unsupported(),
11721172 listSessions: () => Effect.succeed([]),
1173 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
11731174 rollbackConversation: () => unsupported(),
11741175 stopAll: () => Effect.void,
11751176 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
apps/web/src/components/ChatView.tsx+100-19
......@@ -137,7 +137,16 @@ import {
137137 MenuShortcut,
138138 MenuTrigger,
139139} from "./ui/menu";
140import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "./Icons";
140import {
141 ClaudeAI,
142 CursorIcon,
143 Gemini,
144 Icon,
145 OpenAI,
146 OpenCodeIcon,
147 VisualStudioCode,
148 Zed,
149} from "./Icons";
141150import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
142151import { Badge } from "./ui/badge";
143152import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -670,10 +679,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
670679 ]);
671680
672681 const sessionProvider = activeThread?.session?.provider ?? null;
673 const selectedProvider: ProviderKind =
674 (activeThread?.id ? selectedProviderByThread[activeThread.id] : undefined) ??
675 sessionProvider ??
676 "codex";
682 const selectedProviderByThreadId = activeThread?.id
683 ? selectedProviderByThread[activeThread.id]
684 : undefined;
685 const hasThreadStarted = Boolean(
686 activeThread &&
687 (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null),
688 );
689 const lockedProvider: ProviderKind | null = hasThreadStarted
690 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
691 : null;
692 const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";
693 const cursorModelSelectionLockedReason =
694 hasThreadStarted && selectedProvider === "cursor"
695 ? "Cursor currently does not support changing models after the first message in a thread."
696 : null;
677697 const baseThreadModel = resolveModelSlugForProvider(
678698 selectedProvider,
679699 (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ??
......@@ -705,7 +725,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
705725 selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel;
706726 const searchableModelOptions = useMemo(
707727 () =>
708 PROVIDER_OPTIONS.filter((option) => option.available).flatMap((option) =>
728 PROVIDER_OPTIONS.filter(
729 (option) =>
730 option.available && (lockedProvider === null || option.value === lockedProvider),
731 ).flatMap((option) =>
709732 getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({
710733 provider: option.value,
711734 providerLabel: option.label,
......@@ -716,12 +739,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
716739 searchProvider: option.label.toLowerCase(),
717740 })),
718741 ),
719 [],
742 [lockedProvider],
720743 );
721744 useEffect(() => {
722745 if (!activeThread?.id || !sessionProvider) return;
723746 setSelectedProviderByThread((existing) => {
724 if (existing[activeThread.id]) return existing;
747 if (existing[activeThread.id] === sessionProvider) return existing;
725748 return { ...existing, [activeThread.id]: sessionProvider };
726749 });
727750 }, [activeThread?.id, sessionProvider]);
......@@ -2311,8 +2334,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
23112334
23122335 const onProviderModelSelect = useCallback(
23132336 (provider: ProviderKind, model: ModelSlug) => {
2314 const api = readNativeApi();
23152337 if (!activeThread) return;
2338 if (cursorModelSelectionLockedReason !== null && provider === "cursor") {
2339 scheduleComposerFocus();
2340 return;
2341 }
2342 if (lockedProvider !== null && provider !== lockedProvider) {
2343 scheduleComposerFocus();
2344 return;
2345 }
2346 const api = readNativeApi();
23162347 const resolvedModel =
23172348 provider === "cursor"
23182349 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
......@@ -2325,7 +2356,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
23252356 ...existing,
23262357 [activeThread.id]: resolvedModel,
23272358 }));
2328 if (api) {
2359 if (api && isServerThread) {
23292360 void api.orchestration.dispatchCommand({
23302361 type: "thread.meta.update",
23312362 commandId: newCommandId(),
......@@ -2335,7 +2366,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
23352366 }
23362367 scheduleComposerFocus();
23372368 },
2338 [activeThread, scheduleComposerFocus],
2369 [
2370 activeThread,
2371 cursorModelSelectionLockedReason,
2372 isServerThread,
2373 lockedProvider,
2374 scheduleComposerFocus,
2375 ],
23392376 );
23402377 const onCursorReasoningSelect = useCallback(
23412378 (reasoning: CursorReasoningOption) => {
......@@ -2816,11 +2853,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
28162853 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">
28172854 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">
28182855 {/* Provider/model picker */}
2819 <ProviderModelPicker
2820 provider={selectedProvider}
2821 model={selectedModelForPicker}
2822 onProviderModelChange={onProviderModelSelect}
2823 />
2856 {cursorModelSelectionLockedReason ? (
2857 <Tooltip>
2858 <TooltipTrigger
2859 render={
2860 <span className="inline-flex">
2861 <ProviderModelPicker
2862 provider={selectedProvider}
2863 model={selectedModelForPicker}
2864 lockedProvider={lockedProvider}
2865 disabled
2866 onProviderModelChange={onProviderModelSelect}
2867 />
2868 </span>
2869 }
2870 />
2871 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-tight">
2872 {cursorModelSelectionLockedReason}
2873 </TooltipPopup>
2874 </Tooltip>
2875 ) : (
2876 <ProviderModelPicker
2877 provider={selectedProvider}
2878 model={selectedModelForPicker}
2879 lockedProvider={lockedProvider}
2880 onProviderModelChange={onProviderModelSelect}
2881 />
2882 )}
28242883
28252884 {selectedProvider === "cursor" ? (
28262885 <>
......@@ -4048,6 +4107,8 @@ function resolveModelForProviderPicker(
40484107const ProviderModelPicker = memo(function ProviderModelPicker(props: {
40494108 provider: ProviderKind;
40504109 model: ModelSlug;
4110 lockedProvider: ProviderKind | null;
4111 disabled?: boolean;
40514112 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
40524113}) {
40534114 const [isMenuOpen, setIsMenuOpen] = useState(false);
......@@ -4057,9 +4118,25 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
40574118 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
40584119
40594120 return (
4060 <Menu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
4121 <Menu
4122 open={isMenuOpen}
4123 onOpenChange={(open) => {
4124 if (props.disabled) {
4125 setIsMenuOpen(false);
4126 return;
4127 }
4128 setIsMenuOpen(open);
4129 }}
4130 >
40614131 <MenuTrigger
4062 render={<Button size="sm" variant="ghost" className="shrink-0 whitespace-nowrap px-2 sm:px-3" />}
4132 render={
4133 <Button
4134 size="sm"
4135 variant="ghost"
4136 className="shrink-0 whitespace-nowrap px-2 sm:px-3"
4137 disabled={props.disabled}
4138 />
4139 }
40634140 >
40644141 <span className="flex min-w-0 items-center gap-2">
40654142 <ProviderIcon
......@@ -4076,9 +4153,11 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
40764153 <MenuPopup align="start">
40774154 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {
40784155 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
4156 const isDisabledByProviderLock =
4157 props.lockedProvider !== null && props.lockedProvider !== option.value;
40794158 return (
40804159 <MenuSub key={option.value}>
4081 <MenuSubTrigger>
4160 <MenuSubTrigger disabled={isDisabledByProviderLock}>
40824161 <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" />
40834162 {option.label}
40844163 </MenuSubTrigger>
......@@ -4087,6 +4166,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
40874166 <MenuRadioGroup
40884167 value={props.provider === option.value ? props.model : ""}
40894168 onValueChange={(value) => {
4169 if (props.disabled) return;
4170 if (isDisabledByProviderLock) return;
40904171 if (!value) return;
40914172 const resolvedModel = resolveModelForProviderPicker(option.value, value);
40924173 if (!resolvedModel) return;
apps/web/src/components/Icons.tsx+123
......@@ -20,6 +20,129 @@ export const CursorIcon: Icon = (props) => (
2020 </svg>
2121);
2222
23export const VisualStudioCode: Icon = (props) => {
24 const id = useId();
25 const maskId = `${id}-vscode-a`;
26 const topShadowFilterId = `${id}-vscode-b`;
27 const sideShadowFilterId = `${id}-vscode-c`;
28 const overlayGradientId = `${id}-vscode-d`;
29
30 return (
31 <svg {...props} fill="none" viewBox="0 0 100 100">
32 <mask id={maskId} width="100" height="100" x="0" y="0" maskUnits="userSpaceOnUse">
33 <path
34 fill="#fff"
35 fillRule="evenodd"
36 d="M70.912 99.317a6.223 6.223 0 0 0 4.96-.19l20.589-9.907A6.25 6.25 0 0 0 100 83.587V16.413a6.25 6.25 0 0 0-3.54-5.632L75.874.874a6.226 6.226 0 0 0-7.104 1.21L29.355 38.04 12.187 25.01a4.162 4.162 0 0 0-5.318.236l-5.506 5.009a4.168 4.168 0 0 0-.004 6.162L16.247 50 1.36 63.583a4.168 4.168 0 0 0 .004 6.162l5.506 5.01a4.162 4.162 0 0 0 5.318.236l17.168-13.032L68.77 97.917a6.217 6.217 0 0 0 2.143 1.4ZM75.015 27.3 45.11 50l29.906 22.701V27.3Z"
37 clipRule="evenodd"
38 />
39 </mask>
40 <g mask={`url(#${maskId})`}>
41 <path
42 fill="#0065A9"
43 d="M96.461 10.796 75.857.876a6.23 6.23 0 0 0-7.107 1.207l-67.451 61.5a4.167 4.167 0 0 0 .004 6.162l5.51 5.009a4.167 4.167 0 0 0 5.32.236l81.228-61.62c2.725-2.067 6.639-.124 6.639 3.297v-.24a6.25 6.25 0 0 0-3.539-5.63Z"
44 />
45 <g filter={`url(#${topShadowFilterId})`}>
46 <path
47 fill="#007ACC"
48 d="m96.461 89.204-20.604 9.92a6.229 6.229 0 0 1-7.107-1.207l-67.451-61.5a4.167 4.167 0 0 1 .004-6.162l5.51-5.009a4.167 4.167 0 0 1 5.32-.236l81.228 61.62c2.725 2.067 6.639.124 6.639-3.297v.24a6.25 6.25 0 0 1-3.539 5.63Z"
49 />
50 </g>
51 <g filter={`url(#${sideShadowFilterId})`}>
52 <path
53 fill="#1F9CF0"
54 d="M75.858 99.126a6.232 6.232 0 0 1-7.108-1.21c2.306 2.307 6.25.674 6.25-2.588V4.672c0-3.262-3.944-4.895-6.25-2.589a6.232 6.232 0 0 1 7.108-1.21l20.6 9.908A6.25 6.25 0 0 1 100 16.413v67.174a6.25 6.25 0 0 1-3.541 5.633l-20.601 9.906Z"
55 />
56 </g>
57 <path
58 fill={`url(#${overlayGradientId})`}
59 fillRule="evenodd"
60 d="M70.851 99.317a6.224 6.224 0 0 0 4.96-.19L96.4 89.22a6.25 6.25 0 0 0 3.54-5.633V16.413a6.25 6.25 0 0 0-3.54-5.632L75.812.874a6.226 6.226 0 0 0-7.104 1.21L29.294 38.04 12.126 25.01a4.162 4.162 0 0 0-5.317.236l-5.507 5.009a4.168 4.168 0 0 0-.004 6.162L16.186 50 1.298 63.583a4.168 4.168 0 0 0 .004 6.162l5.507 5.009a4.162 4.162 0 0 0 5.317.236L29.294 61.96l39.414 35.958a6.218 6.218 0 0 0 2.143 1.4ZM74.954 27.3 45.048 50l29.906 22.701V27.3Z"
61 clipRule="evenodd"
62 opacity=".25"
63 style={{ mixBlendMode: "overlay" }}
64 />
65 </g>
66 <defs>
67 <filter
68 id={topShadowFilterId}
69 width="116.727"
70 height="92.246"
71 x="-8.394"
72 y="15.829"
73 colorInterpolationFilters="sRGB"
74 filterUnits="userSpaceOnUse"
75 >
76 <feFlood floodOpacity="0" result="BackgroundImageFix" />
77 <feColorMatrix
78 in="SourceAlpha"
79 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
80 />
81 <feOffset />
82 <feGaussianBlur stdDeviation="4.167" />
83 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
84 <feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow" />
85 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
86 </filter>
87 <filter
88 id={sideShadowFilterId}
89 width="47.917"
90 height="116.151"
91 x="60.417"
92 y="-8.076"
93 colorInterpolationFilters="sRGB"
94 filterUnits="userSpaceOnUse"
95 >
96 <feFlood floodOpacity="0" result="BackgroundImageFix" />
97 <feColorMatrix
98 in="SourceAlpha"
99 values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
100 />
101 <feOffset />
102 <feGaussianBlur stdDeviation="4.167" />
103 <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
104 <feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow" />
105 <feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
106 </filter>
107 <linearGradient
108 id={overlayGradientId}
109 x1="49.939"
110 x2="49.939"
111 y1=".258"
112 y2="99.742"
113 gradientUnits="userSpaceOnUse"
114 >
115 <stop stopColor="#fff" />
116 <stop offset="1" stopColor="#fff" stopOpacity="0" />
117 </linearGradient>
118 </defs>
119 </svg>
120 );
121};
122
123export const Zed: Icon = (props) => {
124 const id = useId();
125 const clipPathId = `${id}-zed-logo-a`;
126
127 return (
128 <svg {...props} fill="none" viewBox="0 0 96 96">
129 <g clipPath={`url(#${clipPathId})`}>
130 <path
131 fill="currentColor"
132 fillRule="evenodd"
133 d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z"
134 clipRule="evenodd"
135 />
136 </g>
137 <defs>
138 <clipPath id={clipPathId}>
139 <path fill="#fff" d="M0 0h96v96H0z" />
140 </clipPath>
141 </defs>
142 </svg>
143 );
144};
145
23146export const OpenAI: Icon = (props) => (
24147 <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260" fill="currentColor">
25148 <path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
packages/contracts/src/model.ts+2-133
......@@ -19,21 +19,10 @@ export const CURSOR_MODEL_FAMILY_OPTIONS = [
1919 { slug: "composer-1", name: "Composer 1" },
2020 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
2121 { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" },
22 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
23 { slug: "gpt-5.2", name: "GPT-5.2" },
24 { slug: "gpt-5.2-high", name: "GPT-5.2 High" },
25 { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
26 { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
27 { slug: "gpt-5.1-high", name: "GPT-5.1 High" },
2822 { slug: "opus-4.6", name: "Claude 4.6 Opus" },
2923 { slug: "opus-4.5", name: "Claude 4.5 Opus" },
3024 { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" },
31 { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" },
3225 { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
33 { slug: "gemini-3-pro", name: "Gemini 3 Pro" },
34 { slug: "gemini-3-flash", name: "Gemini 3 Flash" },
35 { slug: "grok", name: "Grok" },
36 { slug: "kimi-k2.5", name: "Kimi K2.5" },
3726] as const satisfies readonly CursorModelFamilyOption[];
3827
3928export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"];
......@@ -63,33 +52,13 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
6352 { slug: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex Extra High" },
6453 { slug: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex Extra High Fast" },
6554 { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" },
66 { slug: "gpt-5.2", name: "GPT-5.2" },
67 { slug: "gpt-5.2-codex-low", name: "GPT-5.2 Codex Low" },
68 { slug: "gpt-5.2-codex-low-fast", name: "GPT-5.2 Codex Low Fast" },
69 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
70 { slug: "gpt-5.2-codex-fast", name: "GPT-5.2 Codex Fast" },
71 { slug: "gpt-5.2-codex-high", name: "GPT-5.2 Codex High" },
72 { slug: "gpt-5.2-codex-high-fast", name: "GPT-5.2 Codex High Fast" },
73 { slug: "gpt-5.2-codex-xhigh", name: "GPT-5.2 Codex Extra High" },
74 { slug: "gpt-5.2-codex-xhigh-fast", name: "GPT-5.2 Codex Extra High Fast" },
75 { slug: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
76 { slug: "gpt-5.1-codex-max-high", name: "GPT-5.1 Codex Max High" },
77 { slug: "gpt-5.2-high", name: "GPT-5.2 High" },
78 { slug: "gpt-5.1-high", name: "GPT-5.1 High" },
79 { slug: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
80 { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" },
8155 { slug: "opus-4.6", name: "Claude 4.6 Opus" },
56 { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" },
8257 { slug: "opus-4.5", name: "Claude 4.5 Opus" },
8358 { slug: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" },
8459 { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" },
85 { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
86 { slug: "gemini-3-pro", name: "Gemini 3 Pro" },
8760 { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" },
88 { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" },
89 { slug: "sonnet-4.5-thinking", name: "Claude 4.5 Sonnet (Thinking)" },
90 { slug: "gemini-3-flash", name: "Gemini 3 Flash" },
91 { slug: "grok", name: "Grok" },
92 { slug: "kimi-k2.5", name: "Kimi K2.5" },
61 { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
9362 ],
9463} as const satisfies Record<ProviderKind, readonly ModelOption[]>;
9564
......@@ -140,48 +109,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa
140109 defaultReasoning: "normal",
141110 defaultThinking: false,
142111 },
143 "gpt-5.2-codex": {
144 supportsReasoning: true,
145 supportsFast: true,
146 supportsThinking: false,
147 defaultReasoning: "normal",
148 defaultThinking: false,
149 },
150 "gpt-5.2": {
151 supportsReasoning: false,
152 supportsFast: false,
153 supportsThinking: false,
154 defaultReasoning: "normal",
155 defaultThinking: false,
156 },
157 "gpt-5.2-high": {
158 supportsReasoning: false,
159 supportsFast: false,
160 supportsThinking: false,
161 defaultReasoning: "normal",
162 defaultThinking: false,
163 },
164 "gpt-5.1-codex-max": {
165 supportsReasoning: false,
166 supportsFast: false,
167 supportsThinking: false,
168 defaultReasoning: "normal",
169 defaultThinking: false,
170 },
171 "gpt-5.1-codex-mini": {
172 supportsReasoning: false,
173 supportsFast: false,
174 supportsThinking: false,
175 defaultReasoning: "normal",
176 defaultThinking: false,
177 },
178 "gpt-5.1-high": {
179 supportsReasoning: false,
180 supportsFast: false,
181 supportsThinking: false,
182 defaultReasoning: "normal",
183 defaultThinking: false,
184 },
185112 "opus-4.6": {
186113 supportsReasoning: false,
187114 supportsFast: false,
......@@ -203,13 +130,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa
203130 defaultReasoning: "normal",
204131 defaultThinking: true,
205132 },
206 "sonnet-4.5": {
207 supportsReasoning: false,
208 supportsFast: false,
209 supportsThinking: true,
210 defaultReasoning: "normal",
211 defaultThinking: true,
212 },
213133 "gemini-3.1-pro": {
214134 supportsReasoning: false,
215135 supportsFast: false,
......@@ -217,34 +137,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa
217137 defaultReasoning: "normal",
218138 defaultThinking: false,
219139 },
220 "gemini-3-pro": {
221 supportsReasoning: false,
222 supportsFast: false,
223 supportsThinking: false,
224 defaultReasoning: "normal",
225 defaultThinking: false,
226 },
227 "gemini-3-flash": {
228 supportsReasoning: false,
229 supportsFast: false,
230 supportsThinking: false,
231 defaultReasoning: "normal",
232 defaultThinking: false,
233 },
234 grok: {
235 supportsReasoning: false,
236 supportsFast: false,
237 supportsThinking: false,
238 defaultReasoning: "normal",
239 defaultThinking: false,
240 },
241 "kimi-k2.5": {
242 supportsReasoning: false,
243 supportsFast: false,
244 supportsThinking: false,
245 defaultReasoning: "normal",
246 defaultThinking: false,
247 },
248140};
249141
250142const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>(
......@@ -295,18 +187,11 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
295187 "composer-1": "composer-1",
296188 "gpt-5.3-codex": "gpt-5.3-codex",
297189 "gpt-5.3-codex-spark": "gpt-5.3-codex-spark-preview",
298 "gpt-5.2-codex": "gpt-5.2-codex",
299 "gpt-5.1-codex-max-high": "gpt-5.1-codex-max-high",
300 "gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
301 "gemini-3.1": "gemini-3.1-pro",
302190 "gemini-3.1-pro": "gemini-3.1-pro",
303 "gemini-3-pro": "gemini-3-pro",
304191 "claude-4.6-sonnet-thinking": "sonnet-4.6-thinking",
305 "claude-4.5-sonnet-thinking": "sonnet-4.5-thinking",
306192 "claude-4.6-opus-thinking": "opus-4.6-thinking",
307193 "claude-4.5-opus-thinking": "opus-4.5-thinking",
308194 "sonnet-4.6-thinking": "sonnet-4.6-thinking",
309 "sonnet-4.5-thinking": "sonnet-4.5-thinking",
310195 "opus-4.6-thinking": "opus-4.6-thinking",
311196 "opus-4.5-thinking": "opus-4.5-thinking",
312197 },
......@@ -354,25 +239,9 @@ function resolveCursorModelFamily(model: string | null | undefined): CursorModel
354239 return "gpt-5.3-codex";
355240 }
356241
357 if (
358 normalized === "gpt-5.2-codex" ||
359 normalized === "gpt-5.2-codex-fast" ||
360 normalized === "gpt-5.2-codex-low" ||
361 normalized === "gpt-5.2-codex-low-fast" ||
362 normalized === "gpt-5.2-codex-high" ||
363 normalized === "gpt-5.2-codex-high-fast" ||
364 normalized === "gpt-5.2-codex-xhigh" ||
365 normalized === "gpt-5.2-codex-xhigh-fast"
366 ) {
367 return "gpt-5.2-codex";
368 }
369
370242 if (normalized === "sonnet-4.6-thinking") {
371243 return "sonnet-4.6";
372244 }
373 if (normalized === "sonnet-4.5-thinking") {
374 return "sonnet-4.5";
375 }
376245 if (normalized === "opus-4.6-thinking") {
377246 return "opus-4.6";
378247 }