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(...@@ -80,6 +80,7 @@ function createProviderServiceHarness(
80 respondToRequest: () => unsupported(),80 respondToRequest: () => unsupported(),
81 stopSession: () => unsupported(),81 stopSession: () => unsupported(),
82 listSessions,82 listSessions,
83 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
83 rollbackConversation,84 rollbackConversation,
84 stopAll: () => Effect.void,85 stopAll: () => Effect.void,
85 streamEvents: Stream.fromPubSub(runtimeEventPubSub),86 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+137
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
1import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";5import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
2import {6import {
3 ApprovalRequestId,7 ApprovalRequestId,
...@@ -100,10 +104,15 @@ describe("ProviderCommandReactor", () => {...@@ -100,10 +104,15 @@ describe("ProviderCommandReactor", () => {
100 typeof input === "object" && input !== null && "resumeCursor" in input104 typeof input === "object" && input !== null && "resumeCursor" in input
101 ? input.resumeCursor105 ? input.resumeCursor
102 : undefined;106 : undefined;
107 const model =
108 typeof input === "object" && input !== null && "model" in input && typeof input.model === "string"
109 ? input.model
110 : undefined;
103 const session: ProviderSession = {111 const session: ProviderSession = {
104 sessionId: asSessionId(`sess-${sessionIndex}`),112 sessionId: asSessionId(`sess-${sessionIndex}`),
105 provider,113 provider,
106 status: "ready" as const,114 status: "ready" as const,
115 ...(model !== undefined ? { model } : {}),
107 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),116 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
108 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },117 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
109 createdAt: now,118 createdAt: now,
...@@ -135,6 +144,25 @@ describe("ProviderCommandReactor", () => {...@@ -135,6 +144,25 @@ describe("ProviderCommandReactor", () => {
135 }144 }
136 }),145 }),
137 );146 );
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
139 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;167 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
140 const service: ProviderServiceShape = {168 const service: ProviderServiceShape = {
...@@ -144,6 +172,10 @@ describe("ProviderCommandReactor", () => {...@@ -144,6 +172,10 @@ describe("ProviderCommandReactor", () => {
144 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],172 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
145 stopSession: stopSession as ProviderServiceShape["stopSession"],173 stopSession: stopSession as ProviderServiceShape["stopSession"],
146 listSessions: () => Effect.succeed(runtimeSessions),174 listSessions: () => Effect.succeed(runtimeSessions),
175 getCapabilities: (provider) =>
176 Effect.succeed({
177 sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session",
178 }),
147 rollbackConversation: () => unsupported(),179 rollbackConversation: () => unsupported(),
148 stopAll: () => Effect.void,180 stopAll: () => Effect.void,
149 streamEvents: Stream.fromPubSub(runtimeEventPubSub),181 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
...@@ -371,6 +403,111 @@ describe("ProviderCommandReactor", () => {...@@ -371,6 +403,111 @@ describe("ProviderCommandReactor", () => {
371 expect(harness.stopSession.mock.calls.length).toBe(0);403 expect(harness.stopSession.mock.calls.length).toBe(0);
372 });404 });
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
374 it("restarts the provider session when runtime mode changes", async () => {511 it("restarts the provider session when runtime mode changes", async () => {
375 const harness = await createHarness();512 const harness = await createHarness();
376 const now = new Date().toISOString();513 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+39-12
...@@ -169,6 +169,7 @@ const make = Effect.gen(function* () {...@@ -169,6 +169,7 @@ const make = Effect.gen(function* () {
169 createdAt: string,169 createdAt: string,
170 options?: {170 options?: {
171 readonly provider?: ProviderKind;171 readonly provider?: ProviderKind;
172 readonly model?: string;
172 readonly approvalPolicy?: ProviderApprovalPolicy;173 readonly approvalPolicy?: ProviderApprovalPolicy;
173 readonly sandboxMode?: ProviderSandboxMode;174 readonly sandboxMode?: ProviderSandboxMode;
174 },175 },
...@@ -190,14 +191,15 @@ const make = Effect.gen(function* () {...@@ -190,14 +191,15 @@ const make = Effect.gen(function* () {
190 ? thread.session.providerName191 ? thread.session.providerName
191 : undefined;192 : undefined;
192 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;193 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
194 const desiredModel = options?.model ?? thread.model;
193 const effectiveCwd = resolveThreadWorkspaceCwd({195 const effectiveCwd = resolveThreadWorkspaceCwd({
194 thread,196 thread,
195 projects: readModel.projects,197 projects: readModel.projects,
196 });198 });
197199
198 const resolveResumeCursorForSession = (sessionId: ProviderSessionId) =>200 const resolveActiveSession = (sessionId: ProviderSessionId) =>
199 providerService.listSessions().pipe(201 providerService.listSessions().pipe(
200 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor),202 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)),
201 );203 );
202204
203 const startProviderSession = (input?: {205 const startProviderSession = (input?: {
...@@ -209,7 +211,7 @@ const make = Effect.gen(function* () {...@@ -209,7 +211,7 @@ const make = Effect.gen(function* () {
209 ? { provider: input?.provider ?? preferredProvider }211 ? { provider: input?.provider ?? preferredProvider }
210 : {}),212 : {}),
211 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),213 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
212 ...(thread.model ? { model: thread.model } : {}),214 ...(desiredModel ? { model: desiredModel } : {}),
213 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),215 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
214 approvalPolicy: desiredApprovalPolicy,216 approvalPolicy: desiredApprovalPolicy,
215 sandboxMode: desiredSandboxMode,217 sandboxMode: desiredSandboxMode,
...@@ -242,14 +244,29 @@ const make = Effect.gen(function* () {...@@ -242,14 +244,29 @@ const make = Effect.gen(function* () {
242 const sandboxModeChanged =244 const sandboxModeChanged =
243 options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode;245 options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode;
244 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;246 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;
245247 const activeSession = yield* resolveActiveSession(existingSessionId);
246 if (!approvalPolicyChanged && !sandboxModeChanged && !providerChanged) {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 ) {
247 return existingSessionId;263 return existingSessionId;
248 }264 }
249265
250 const resumeCursor = providerChanged266 const resumeCursor =
251 ? undefined267 providerChanged || shouldRestartForModelChange
252 : yield* resolveResumeCursorForSession(existingSessionId);268 ? undefined
269 : (activeSession?.resumeCursor ?? undefined);
253 const restartedSession = yield* startProviderSession({270 const restartedSession = yield* startProviderSession({
254 ...(resumeCursor !== undefined ? { resumeCursor } : {}),271 ...(resumeCursor !== undefined ? { resumeCursor } : {}),
255 ...(options?.provider !== undefined ? { provider: options.provider } : {}),272 ...(options?.provider !== undefined ? { provider: options.provider } : {}),
...@@ -267,9 +284,9 @@ const make = Effect.gen(function* () {...@@ -267,9 +284,9 @@ const make = Effect.gen(function* () {
267 return restartedSession.sessionId;284 return restartedSession.sessionId;
268 }285 }
269286
270 const startedSession = yield* startProviderSession({287 const startedSession = yield* startProviderSession(
271 ...(options?.provider !== undefined ? { provider: options.provider } : {}),288 options?.provider !== undefined ? { provider: options.provider } : undefined,
272 });289 );
273 yield* bindSessionToThread(startedSession);290 yield* bindSessionToThread(startedSession);
274 return startedSession.sessionId;291 return startedSession.sessionId;
275 });292 });
...@@ -291,17 +308,27 @@ const make = Effect.gen(function* () {...@@ -291,17 +308,27 @@ const make = Effect.gen(function* () {
291 }308 }
292 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {309 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {
293 ...(input.provider !== undefined ? { provider: input.provider } : {}),310 ...(input.provider !== undefined ? { provider: input.provider } : {}),
311 ...(input.model !== undefined ? { model: input.model } : {}),
294 approvalPolicy: input.approvalPolicy,312 approvalPolicy: input.approvalPolicy,
295 sandboxMode: input.sandboxMode,313 sandboxMode: input.sandboxMode,
296 });314 });
297 const normalizedInput = toNonEmptyProviderInput(input.messageText);315 const normalizedInput = toNonEmptyProviderInput(input.messageText);
298 const normalizedAttachments = input.attachments ?? [];316 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
300 yield* providerService.sendTurn({327 yield* providerService.sendTurn({
301 sessionId,328 sessionId,
302 ...(normalizedInput ? { input: normalizedInput } : {}),329 ...(normalizedInput ? { input: normalizedInput } : {}),
303 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),330 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
304 ...(input.model !== undefined ? { model: input.model } : {}),331 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
305 ...(input.effort !== undefined ? { effort: input.effort } : {}),332 ...(input.effort !== undefined ? { effort: input.effort } : {}),
306 });333 });
307 });334 });
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+44-6
...@@ -50,6 +50,7 @@ function createProviderServiceHarness() {...@@ -50,6 +50,7 @@ function createProviderServiceHarness() {
50 respondToRequest: () => unsupported(),50 respondToRequest: () => unsupported(),
51 stopSession: () => unsupported(),51 stopSession: () => unsupported(),
52 listSessions: () => Effect.succeed([]),52 listSessions: () => Effect.succeed([]),
53 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
53 rollbackConversation: () => unsupported(),54 rollbackConversation: () => unsupported(),
54 stopAll: () => Effect.void,55 stopAll: () => Effect.void,
55 streamEvents: Stream.fromPubSub(runtimeEventPubSub),56 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
...@@ -455,38 +456,48 @@ describe("ProviderRuntimeIngestion", () => {...@@ -455,38 +456,48 @@ describe("ProviderRuntimeIngestion", () => {
455 );456 );
456 });457 });
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 () => {
459 const harness = await createHarness();460 const harness = await createHarness();
460 const now = new Date().toISOString();461 const now = new Date().toISOString();
461462
462 harness.emit({463 harness.emit({
463 type: "message.delta",464 type: "content.delta",
464 eventId: asEventId("evt-message-delta-1"),465 eventId: asEventId("evt-message-delta-1"),
465 provider: "codex",466 provider: "codex",
466 sessionId: asSessionId("sess-1"),467 sessionId: asSessionId("sess-1"),
467 createdAt: now,468 createdAt: now,
468 turnId: asProviderTurnId("turn-2"),469 turnId: asProviderTurnId("turn-2"),
469 itemId: asItemId("item-1"),470 itemId: asItemId("item-1"),
470 delta: "hello",471 payload: {
472 streamKind: "assistant_text",
473 delta: "hello",
474 },
471 });475 });
472 harness.emit({476 harness.emit({
473 type: "message.delta",477 type: "content.delta",
474 eventId: asEventId("evt-message-delta-2"),478 eventId: asEventId("evt-message-delta-2"),
475 provider: "codex",479 provider: "codex",
476 sessionId: asSessionId("sess-1"),480 sessionId: asSessionId("sess-1"),
477 createdAt: now,481 createdAt: now,
478 turnId: asProviderTurnId("turn-2"),482 turnId: asProviderTurnId("turn-2"),
479 itemId: asItemId("item-1"),483 itemId: asItemId("item-1"),
480 delta: " world",484 payload: {
485 streamKind: "assistant_text",
486 delta: " world",
487 },
481 });488 });
482 harness.emit({489 harness.emit({
483 type: "message.completed",490 type: "item.completed",
484 eventId: asEventId("evt-message-completed"),491 eventId: asEventId("evt-message-completed"),
485 provider: "codex",492 provider: "codex",
486 sessionId: asSessionId("sess-1"),493 sessionId: asSessionId("sess-1"),
487 createdAt: now,494 createdAt: now,
488 turnId: asProviderTurnId("turn-2"),495 turnId: asProviderTurnId("turn-2"),
489 itemId: asItemId("item-1"),496 itemId: asItemId("item-1"),
497 payload: {
498 itemType: "assistant_message",
499 status: "completed",
500 },
490 });501 });
491502
492 const thread = await waitForThread(harness.engine, (entry) =>503 const thread = await waitForThread(harness.engine, (entry) =>
...@@ -497,6 +508,33 @@ describe("ProviderRuntimeIngestion", () => {...@@ -497,6 +508,33 @@ describe("ProviderRuntimeIngestion", () => {
497 expect(message?.streaming).toBe(false);508 expect(message?.streaming).toBe(false);
498 });509 });
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
500 it("buffers assistant deltas by default until completion", async () => {538 it("buffers assistant deltas by default until completion", async () => {
501 const harness = await createHarness();539 const harness = await createHarness();
502 const now = new Date().toISOString();540 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 {...@@ -76,12 +76,81 @@ function truncateDetail(value: string, limit = 180): string {
76 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;76 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
77}77}
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
79function runtimeEventToActivities(148function runtimeEventToActivities(
80 event: ProviderRuntimeEvent,149 event: ProviderRuntimeEvent,
81): ReadonlyArray<OrchestrationThreadActivity> {150): ReadonlyArray<OrchestrationThreadActivity> {
82 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};151 const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {};
83 switch (event.type) {152 switch (event.type) {
84 case "approval.requested": {153 case "request.opened": {
85 return [154 return [
86 {155 {
87 id: event.eventId,156 id: event.eventId,
...@@ -89,13 +158,15 @@ function runtimeEventToActivities(...@@ -89,13 +158,15 @@ function runtimeEventToActivities(
89 tone: "approval",158 tone: "approval",
90 kind: "approval.requested",159 kind: "approval.requested",
91 summary:160 summary:
92 event.requestKind === "command"161 event.payload.requestType === "command_execution_approval"
93 ? "Command approval requested"162 ? "Command approval requested"
94 : "File-change approval requested",163 : event.payload.requestType === "file_change_approval"
164 ? "File-change approval requested"
165 : "Approval requested",
95 payload: {166 payload: {
96 requestId: event.requestId,167 requestId: event.requestId,
97 requestKind: event.requestKind,168 requestType: event.payload.requestType,
98 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),169 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
99 },170 },
100 turnId: toTurnId(event.turnId) ?? null,171 turnId: toTurnId(event.turnId) ?? null,
101 ...maybeSequence,172 ...maybeSequence,
...@@ -103,7 +174,7 @@ function runtimeEventToActivities(...@@ -103,7 +174,7 @@ function runtimeEventToActivities(
103 ];174 ];
104 }175 }
105176
106 case "approval.resolved": {177 case "request.resolved": {
107 return [178 return [
108 {179 {
109 id: event.eventId,180 id: event.eventId,
...@@ -113,8 +184,8 @@ function runtimeEventToActivities(...@@ -113,8 +184,8 @@ function runtimeEventToActivities(
113 summary: "Approval resolved",184 summary: "Approval resolved",
114 payload: {185 payload: {
115 requestId: event.requestId,186 requestId: event.requestId,
116 ...(event.requestKind ? { requestKind: event.requestKind } : {}),187 requestType: event.payload.requestType,
117 ...(event.decision ? { decision: event.decision } : {}),188 ...(event.payload.decision ? { decision: event.payload.decision } : {}),
118 },189 },
119 turnId: toTurnId(event.turnId) ?? null,190 turnId: toTurnId(event.turnId) ?? null,
120 ...maybeSequence,191 ...maybeSequence,
...@@ -123,6 +194,10 @@ function runtimeEventToActivities(...@@ -123,6 +194,10 @@ function runtimeEventToActivities(
123 }194 }
124195
125 case "runtime.error": {196 case "runtime.error": {
197 const message = runtimeErrorMessageFromEvent(event);
198 if (!message) {
199 return [];
200 }
126 return [201 return [
127 {202 {
128 id: event.eventId,203 id: event.eventId,
...@@ -131,7 +206,7 @@ function runtimeEventToActivities(...@@ -131,7 +206,7 @@ function runtimeEventToActivities(
131 kind: "runtime.error",206 kind: "runtime.error",
132 summary: "Runtime error",207 summary: "Runtime error",
133 payload: {208 payload: {
134 message: truncateDetail(event.message),209 message: truncateDetail(message),
135 },210 },
136 turnId: toTurnId(event.turnId) ?? null,211 turnId: toTurnId(event.turnId) ?? null,
137 ...maybeSequence,212 ...maybeSequence,
...@@ -139,17 +214,20 @@ function runtimeEventToActivities(...@@ -139,17 +214,20 @@ function runtimeEventToActivities(
139 ];214 ];
140 }215 }
141216
142 case "tool.completed": {217 case "item.completed": {
218 if (!isToolLifecycleItemType(event.payload.itemType)) {
219 return [];
220 }
143 return [221 return [
144 {222 {
145 id: event.eventId,223 id: event.eventId,
146 createdAt: event.createdAt,224 createdAt: event.createdAt,
147 tone: "tool",225 tone: "tool",
148 kind: "tool.completed",226 kind: "tool.completed",
149 summary: `${event.title} complete`,227 summary: `${event.payload.title ?? "Tool"} complete`,
150 payload: {228 payload: {
151 toolKind: event.toolKind,229 itemType: event.payload.itemType,
152 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),230 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
153 },231 },
154 turnId: toTurnId(event.turnId) ?? null,232 turnId: toTurnId(event.turnId) ?? null,
155 ...maybeSequence,233 ...maybeSequence,
...@@ -157,17 +235,95 @@ function runtimeEventToActivities(...@@ -157,17 +235,95 @@ function runtimeEventToActivities(
157 ];235 ];
158 }236 }
159237
160 case "tool.started": {238 case "item.started": {
239 if (!isToolLifecycleItemType(event.payload.itemType)) {
240 return [];
241 }
161 return [242 return [
162 {243 {
163 id: event.eventId,244 id: event.eventId,
164 createdAt: event.createdAt,245 createdAt: event.createdAt,
165 tone: "tool",246 tone: "tool",
166 kind: "tool.started",247 kind: "tool.started",
167 summary: `${event.title} started`,248 summary: `${event.payload.title ?? "Tool"} started`,
168 payload: {249 payload: {
169 toolKind: event.toolKind,250 itemType: event.payload.itemType,
170 ...(event.detail ? { detail: truncateDetail(event.detail) } : {}),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) } : {}),
171 },327 },
172 turnId: toTurnId(event.turnId) ?? null,328 turnId: toTurnId(event.turnId) ?? null,
173 ...maybeSequence,329 ...maybeSequence,
...@@ -296,9 +452,12 @@ const make = Effect.gen(function* () {...@@ -296,9 +452,12 @@ const make = Effect.gen(function* () {
296 createdAt: string;452 createdAt: string;
297 commandTag: string;453 commandTag: string;
298 finalDeltaCommandTag: string;454 finalDeltaCommandTag: string;
455 fallbackText?: string;
299 }) =>456 }) =>
300 Effect.gen(function* () {457 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
303 if (text.length > 0) {462 if (text.length > 0) {
304 yield* orchestrationEngine.dispatch({463 yield* orchestrationEngine.dispatch({
...@@ -356,6 +515,7 @@ const make = Effect.gen(function* () {...@@ -356,6 +515,7 @@ const make = Effect.gen(function* () {
356 );515 );
357 if (!thread) return;516 if (!thread) return;
358517
518 const legacyType = legacyRuntimeType(event);
359 const now = event.createdAt;519 const now = event.createdAt;
360 const sessionProviderThreadId = thread.session?.providerThreadId ?? null;520 const sessionProviderThreadId = thread.session?.providerThreadId ?? null;
361 const scopedSessionProviderThreadId = isSyntheticClaudeThreadId(521 const scopedSessionProviderThreadId = isSyntheticClaudeThreadId(
...@@ -365,7 +525,7 @@ const make = Effect.gen(function* () {...@@ -365,7 +525,7 @@ const make = Effect.gen(function* () {
365 ? null525 ? null
366 : sessionProviderThreadId;526 : sessionProviderThreadId;
367 const eventProviderThreadId = toProviderThreadId(event.threadId);527 const eventProviderThreadId = toProviderThreadId(event.threadId);
368 const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined);528 const eventTurnId = toTurnId(event.turnId);
369 const activeTurnId = thread.session?.activeTurnId ?? null;529 const activeTurnId = thread.session?.activeTurnId ?? null;
370530
371 const matchesThreadScope =531 const matchesThreadScope =
...@@ -448,7 +608,7 @@ const make = Effect.gen(function* () {...@@ -448,7 +608,7 @@ const make = Effect.gen(function* () {
448 case "session.exited":608 case "session.exited":
449 return "stopped";609 return "stopped";
450 case "turn.completed":610 case "turn.completed":
451 return event.status === "failed" ? "error" : "ready";611 return runtimeTurnState(event) === "failed" ? "error" : "ready";
452 case "session.started":612 case "session.started":
453 case "thread.started":613 case "thread.started":
454 // Provider thread/session start notifications can arrive during an614 // Provider thread/session start notifications can arrive during an
...@@ -457,8 +617,8 @@ const make = Effect.gen(function* () {...@@ -457,8 +617,8 @@ const make = Effect.gen(function* () {
457 }617 }
458 })();618 })();
459 const lastError =619 const lastError =
460 event.type === "turn.completed" && event.status === "failed"620 event.type === "turn.completed" && runtimeTurnState(event) === "failed"
461 ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed")621 ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed")
462 : status === "ready"622 : status === "ready"
463 ? null623 ? null
464 : (thread.session?.lastError ?? null);624 : (thread.session?.lastError ?? null);
...@@ -485,7 +645,14 @@ const make = Effect.gen(function* () {...@@ -485,7 +645,14 @@ const make = Effect.gen(function* () {
485 }645 }
486 }646 }
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) {
489 const assistantMessageId = MessageId.makeUnsafe(656 const assistantMessageId = MessageId.makeUnsafe(
490 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,657 `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`,
491 );658 );
...@@ -496,7 +663,7 @@ const make = Effect.gen(function* () {...@@ -496,7 +663,7 @@ const make = Effect.gen(function* () {
496663
497 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);664 const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef);
498 if (assistantDeliveryMode === "buffered") {665 if (assistantDeliveryMode === "buffered") {
499 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, event.delta);666 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
500 if (spillChunk.length > 0) {667 if (spillChunk.length > 0) {
501 yield* orchestrationEngine.dispatch({668 yield* orchestrationEngine.dispatch({
502 type: "thread.message.assistant.delta",669 type: "thread.message.assistant.delta",
...@@ -514,15 +681,28 @@ const make = Effect.gen(function* () {...@@ -514,15 +681,28 @@ const make = Effect.gen(function* () {
514 commandId: providerCommandId(event, "assistant-delta"),681 commandId: providerCommandId(event, "assistant-delta"),
515 threadId: thread.id,682 threadId: thread.id,
516 messageId: assistantMessageId,683 messageId: assistantMessageId,
517 delta: event.delta,684 delta: assistantDelta,
518 ...(turnId ? { turnId } : {}),685 ...(turnId ? { turnId } : {}),
519 createdAt: now,686 createdAt: now,
520 });687 });
521 }688 }
522 }689 }
523690
524 if (event.type === "message.completed") {691 const assistantCompletion =
525 const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`);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;
526 const turnId = toTurnId(event.turnId);706 const turnId = toTurnId(event.turnId);
527 if (turnId) {707 if (turnId) {
528 yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId);708 yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId);
...@@ -536,6 +716,7 @@ const make = Effect.gen(function* () {...@@ -536,6 +716,7 @@ const make = Effect.gen(function* () {
536 createdAt: now,716 createdAt: now,
537 commandTag: "assistant-complete",717 commandTag: "assistant-complete",
538 finalDeltaCommandTag: "assistant-delta-finalize",718 finalDeltaCommandTag: "assistant-delta-finalize",
719 fallbackText: assistantCompletion.fallbackText,
539 });720 });
540721
541 if (turnId) {722 if (turnId) {
...@@ -570,6 +751,8 @@ const make = Effect.gen(function* () {...@@ -570,6 +751,8 @@ const make = Effect.gen(function* () {
570 }751 }
571752
572 if (event.type === "runtime.error") {753 if (event.type === "runtime.error") {
754 const runtimeErrorMessage = runtimeErrorMessageFromEvent(event) ?? "Provider runtime error";
755
573 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD756 const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD
574 ? true757 ? true
575 : matchesThreadScope &&758 : matchesThreadScope &&
...@@ -596,7 +779,7 @@ const make = Effect.gen(function* () {...@@ -596,7 +779,7 @@ const make = Effect.gen(function* () {
596 approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY,779 approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY,
597 sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE,780 sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE,
598 activeTurnId: eventTurnId ?? null,781 activeTurnId: eventTurnId ?? null,
599 lastError: event.message,782 lastError: runtimeErrorMessage,
600 updatedAt: now,783 updatedAt: now,
601 },784 },
602 createdAt: now,785 createdAt: now,
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+3
...@@ -1845,6 +1845,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1845,6 +1845,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
18451845
1846 return {1846 return {
1847 provider: PROVIDER,1847 provider: PROVIDER,
1848 capabilities: {
1849 sessionModelSwitch: "in-session",
1850 },
1848 startSession,1851 startSession,
1849 sendTurn,1852 sendTurn,
1850 interruptTurn,1853 interruptTurn,
apps/server/src/provider/Layers/CodexAdapter.ts+3
...@@ -1092,6 +1092,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1092,6 +1092,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
10921092
1093 return {1093 return {
1094 provider: PROVIDER,1094 provider: PROVIDER,
1095 capabilities: {
1096 sessionModelSwitch: "in-session",
1097 },
1095 startSession,1098 startSession,
1096 sendTurn,1099 sendTurn,
1097 interruptTurn,1100 interruptTurn,
apps/server/src/provider/Layers/CursorAdapter.test.ts+64-5
...@@ -14,6 +14,7 @@ class FakeCursorAcpProcess extends EventEmitter {...@@ -14,6 +14,7 @@ class FakeCursorAcpProcess extends EventEmitter {
14 readonly stdin = new PassThrough();14 readonly stdin = new PassThrough();
15 readonly stdout = new PassThrough();15 readonly stdout = new PassThrough();
16 readonly stderr = new PassThrough();16 readonly stderr = new PassThrough();
17 readonly requests: Array<{ method: string; params: unknown }> = [];
17 killed = false;18 killed = false;
1819
19 private readonly input = readline.createInterface({ input: this.stdin });20 private readonly input = readline.createInterface({ input: this.stdin });
...@@ -75,9 +76,29 @@ class FakeCursorAcpProcess extends EventEmitter {...@@ -75,9 +76,29 @@ class FakeCursorAcpProcess extends EventEmitter {
75 if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) {76 if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) {
76 return;77 return;
77 }78 }
79 this.requests.push({ method, params: message.params });
7880
79 switch (method) {81 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 }
81 this.emitServerMessage({102 this.emitServerMessage({
82 jsonrpc: "2.0",103 jsonrpc: "2.0",
83 id,104 id,
...@@ -90,6 +111,7 @@ class FakeCursorAcpProcess extends EventEmitter {...@@ -90,6 +111,7 @@ class FakeCursorAcpProcess extends EventEmitter {
90 },111 },
91 });112 });
92 return;113 return;
114 }
93 case "authenticate":115 case "authenticate":
94 this.emitServerMessage({116 this.emitServerMessage({
95 jsonrpc: "2.0",117 jsonrpc: "2.0",
...@@ -117,6 +139,13 @@ class FakeCursorAcpProcess extends EventEmitter {...@@ -117,6 +139,13 @@ class FakeCursorAcpProcess extends EventEmitter {
117 result: {},139 result: {},
118 });140 });
119 return;141 return;
142 case "session/set_model":
143 this.emitServerMessage({
144 jsonrpc: "2.0",
145 id,
146 result: {},
147 });
148 return;
120 case "session/prompt": {149 case "session/prompt": {
121 this.emitServerMessage({150 this.emitServerMessage({
122 jsonrpc: "2.0",151 jsonrpc: "2.0",
...@@ -252,7 +281,7 @@ describe("CursorAdapterLive", () => {...@@ -252,7 +281,7 @@ describe("CursorAdapterLive", () => {
252 return Effect.gen(function* () {281 return Effect.gen(function* () {
253 const adapter = yield* CursorAdapter;282 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(
256 Stream.runCollect,285 Stream.runCollect,
257 Effect.forkChild,286 Effect.forkChild,
258 );287 );
...@@ -273,6 +302,8 @@ describe("CursorAdapterLive", () => {...@@ -273,6 +302,8 @@ describe("CursorAdapterLive", () => {
273 events.map((event) => event.type),302 events.map((event) => event.type),
274 [303 [
275 "session.configured",304 "session.configured",
305 "auth.status",
306 "auth.status",
276 "session.started",307 "session.started",
277 "thread.started",308 "thread.started",
278 "session.state.changed",309 "session.state.changed",
...@@ -286,13 +317,13 @@ describe("CursorAdapterLive", () => {...@@ -286,13 +317,13 @@ describe("CursorAdapterLive", () => {
286 ],317 ],
287 );318 );
288319
289 const turnStarted = events[4];320 const turnStarted = events[6];
290 assert.equal(turnStarted?.type, "turn.started");321 assert.equal(turnStarted?.type, "turn.started");
291 if (turnStarted?.type === "turn.started") {322 if (turnStarted?.type === "turn.started") {
292 assert.equal(turnStarted.turnId, turn.turnId);323 assert.equal(turnStarted.turnId, turn.turnId);
293 }324 }
294325
295 const completion = events[10];326 const completion = events[12];
296 assert.equal(completion?.type, "turn.completed");327 assert.equal(completion?.type, "turn.completed");
297 if (completion?.type === "turn.completed") {328 if (completion?.type === "turn.completed") {
298 assert.equal(completion.payload.state, "completed");329 assert.equal(completion.payload.state, "completed");
...@@ -300,6 +331,34 @@ describe("CursorAdapterLive", () => {...@@ -300,6 +331,34 @@ describe("CursorAdapterLive", () => {
300 }).pipe(Effect.provide(layer));331 }).pipe(Effect.provide(layer));
301 });332 });
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
303 it.effect("bridges permission requests to request.opened/request.resolved", () => {362 it.effect("bridges permission requests to request.opened/request.resolved", () => {
304 const fake = new FakeCursorAcpProcess();363 const fake = new FakeCursorAcpProcess();
305 const layer = makeCursorAdapterLive({364 const layer = makeCursorAdapterLive({
...@@ -314,7 +373,7 @@ describe("CursorAdapterLive", () => {...@@ -314,7 +373,7 @@ describe("CursorAdapterLive", () => {
314 });373 });
315374
316 // consume startup events375 // consume startup events
317 yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain);376 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
318377
319 fake.emitPermissionRequest();378 fake.emitPermissionRequest();
320379
apps/server/src/provider/Layers/CursorAdapter.ts+80-5
...@@ -45,6 +45,7 @@ import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";...@@ -45,6 +45,7 @@ import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
4545
46const PROVIDER = "cursor" as const;46const PROVIDER = "cursor" as const;
47const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;47const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
48const CURSOR_ACP_PROTOCOL_VERSION = 1;
4849
49interface CursorResumeState {50interface CursorResumeState {
50 readonly acpSessionId?: string;51 readonly acpSessionId?: string;
...@@ -91,6 +92,7 @@ export interface CursorAdapterLiveOptions {...@@ -91,6 +92,7 @@ export interface CursorAdapterLiveOptions {
91 readonly binaryPath: string;92 readonly binaryPath: string;
92 readonly cwd: string;93 readonly cwd: string;
93 readonly env: NodeJS.ProcessEnv;94 readonly env: NodeJS.ProcessEnv;
95 readonly model?: string;
94 }) => ChildProcessWithoutNullStreams;96 }) => ChildProcessWithoutNullStreams;
95 readonly nativeEventLogPath?: string;97 readonly nativeEventLogPath?: string;
96}98}
...@@ -272,11 +274,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -272,11 +274,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
272 readonly binaryPath: string;274 readonly binaryPath: string;
273 readonly cwd: string;275 readonly cwd: string;
274 readonly env: NodeJS.ProcessEnv;276 readonly env: NodeJS.ProcessEnv;
277 readonly model?: string;
275 }): ChildProcessWithoutNullStreams => {278 }): ChildProcessWithoutNullStreams => {
276 if (options?.createProcess) {279 if (options?.createProcess) {
277 return options.createProcess(input);280 return options.createProcess(input);
278 }281 }
279 return spawn(input.binaryPath, ["acp"], {282 const args = input.model ? ["--model", input.model, "acp"] : ["acp"];
283 return spawn(input.binaryPath, args, {
280 cwd: input.cwd,284 cwd: input.cwd,
281 env: input.env,285 env: input.env,
282 stdio: ["pipe", "pipe", "pipe"],286 stdio: ["pipe", "pipe", "pipe"],
...@@ -862,6 +866,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -862,6 +866,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
862 binaryPath,866 binaryPath,
863 cwd,867 cwd,
864 env: process.env,868 env: process.env,
869 ...(input.model ? { model: input.model } : {}),
865 }),870 }),
866 catch: (cause) =>871 catch: (cause) =>
867 new ProviderAdapterProcessError({872 new ProviderAdapterProcessError({
...@@ -972,7 +977,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -972,7 +977,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
972 sessions.set(sessionId, context);977 sessions.set(sessionId, context);
973978
974 const initializeResult = yield* Effect.tryPromise({979 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 }),
976 catch: (cause) => toRequestError(sessionId, "initialize", cause),984 catch: (cause) => toRequestError(sessionId, "initialize", cause),
977 });985 });
978 const decodedInitialize = Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult);986 const decodedInitialize = Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult);
...@@ -997,11 +1005,75 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -997,11 +1005,75 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
997 },1005 },
998 });1006 });
9991007
1000 yield* Effect.tryPromise({1008 const authenticateRequest = { methodId: "cursor_login" };
1001 try: async () => sendRequest(context, "authenticate", { methodId: "cursor_login" }),1009 const authStartStamp = yield* makeEventStamp();
1002 catch: () => undefined,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 },
1003 });1027 });
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
1005 const acpSessionId = yield* Effect.tryPromise({1077 const acpSessionId = yield* Effect.tryPromise({
1006 try: async () => {1078 try: async () => {
1007 if (resumeState?.acpSessionId) {1079 if (resumeState?.acpSessionId) {
...@@ -1356,6 +1428,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1356,6 +1428,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13561428
1357 return {1429 return {
1358 provider: PROVIDER,1430 provider: PROVIDER,
1431 capabilities: {
1432 sessionModelSwitch: "unsupported",
1433 },
1359 startSession,1434 startSession,
1360 sendTurn,1435 sendTurn,
1361 interruptTurn,1436 interruptTurn,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+3
...@@ -14,6 +14,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -14,6 +14,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
1414
15const fakeCodexAdapter: CodexAdapterShape = {15const fakeCodexAdapter: CodexAdapterShape = {
16 provider: "codex",16 provider: "codex",
17 capabilities: { sessionModelSwitch: "in-session" },
17 startSession: vi.fn(),18 startSession: vi.fn(),
18 sendTurn: vi.fn(),19 sendTurn: vi.fn(),
19 interruptTurn: vi.fn(),20 interruptTurn: vi.fn(),
...@@ -29,6 +30,7 @@ const fakeCodexAdapter: CodexAdapterShape = {...@@ -29,6 +30,7 @@ const fakeCodexAdapter: CodexAdapterShape = {
2930
30const fakeClaudeAdapter: ClaudeCodeAdapterShape = {31const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
31 provider: "claudeCode",32 provider: "claudeCode",
33 capabilities: { sessionModelSwitch: "in-session" },
32 startSession: vi.fn(),34 startSession: vi.fn(),
33 sendTurn: vi.fn(),35 sendTurn: vi.fn(),
34 interruptTurn: vi.fn(),36 interruptTurn: vi.fn(),
...@@ -44,6 +46,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {...@@ -44,6 +46,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = {
4446
45const fakeCursorAdapter: CursorAdapterShape = {47const fakeCursorAdapter: CursorAdapterShape = {
46 provider: "cursor",48 provider: "cursor",
49 capabilities: { sessionModelSwitch: "unsupported" },
47 startSession: vi.fn(),50 startSession: vi.fn(),
48 sendTurn: vi.fn(),51 sendTurn: vi.fn(),
49 interruptTurn: vi.fn(),52 interruptTurn: vi.fn(),
apps/server/src/provider/Layers/ProviderService.test.ts+3
...@@ -161,6 +161,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -161,6 +161,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
161161
162 const adapter: ProviderAdapterShape<ProviderAdapterError> = {162 const adapter: ProviderAdapterShape<ProviderAdapterError> = {
163 provider,163 provider,
164 capabilities: {
165 sessionModelSwitch: "in-session",
166 },
164 startSession,167 startSession,
165 sendTurn,168 sendTurn,
166 interruptTurn,169 interruptTurn,
apps/server/src/provider/Layers/ProviderService.ts+4
...@@ -566,6 +566,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -566,6 +566,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
566 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),566 Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)),
567 );567 );
568568
569 const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) =>
570 registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities));
571
569 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) =>572 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) =>
570 Effect.gen(function* () {573 Effect.gen(function* () {
571 const input = yield* decodeInputOrValidationError({574 const input = yield* decodeInputOrValidationError({
...@@ -617,6 +620,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -617,6 +620,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
617 respondToRequest,620 respondToRequest,
618 stopSession,621 stopSession,
619 listSessions,622 listSessions,
623 getCapabilities,
620 rollbackConversation,624 rollbackConversation,
621 stopAll,625 stopAll,
622 streamEvents: Stream.fromPubSub(runtimeEventPubSub),626 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
apps/server/src/provider/Services/ProviderAdapter.ts+10
...@@ -23,6 +23,15 @@ import type {...@@ -23,6 +23,15 @@ import type {
23import type { Effect } from "effect";23import type { Effect } from "effect";
24import type { Stream } from "effect";24import 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
26export interface ProviderThreadTurnSnapshot {35export interface ProviderThreadTurnSnapshot {
27 readonly id: ProviderTurnId;36 readonly id: ProviderTurnId;
28 readonly items: ReadonlyArray<unknown>;37 readonly items: ReadonlyArray<unknown>;
...@@ -38,6 +47,7 @@ export interface ProviderAdapterShape<TError> {...@@ -38,6 +47,7 @@ export interface ProviderAdapterShape<TError> {
38 * Provider kind implemented by this adapter.47 * Provider kind implemented by this adapter.
39 */48 */
40 readonly provider: ProviderKind;49 readonly provider: ProviderKind;
50 readonly capabilities: ProviderAdapterCapabilities;
4151
42 /**52 /**
43 * Start a provider-backed session.53 * Start a provider-backed session.
apps/server/src/provider/Services/ProviderService.ts+9
...@@ -13,6 +13,7 @@...@@ -13,6 +13,7 @@
13 */13 */
14import type {14import type {
15 ProviderInterruptTurnInput,15 ProviderInterruptTurnInput,
16 ProviderKind,
16 ProviderRespondToRequestInput,17 ProviderRespondToRequestInput,
17 ProviderRuntimeEvent,18 ProviderRuntimeEvent,
18 ProviderSendTurnInput,19 ProviderSendTurnInput,
...@@ -27,6 +28,7 @@ import { ServiceMap } from "effect";...@@ -27,6 +28,7 @@ import { ServiceMap } from "effect";
27import type { Effect, Stream } from "effect";28import type { Effect, Stream } from "effect";
2829
29import type { ProviderServiceError } from "../Errors.ts";30import type { ProviderServiceError } from "../Errors.ts";
31import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts";
3032
31/**33/**
32 * ProviderServiceShape - Service API for provider session and turn orchestration.34 * ProviderServiceShape - Service API for provider session and turn orchestration.
...@@ -75,6 +77,13 @@ export interface ProviderServiceShape {...@@ -75,6 +77,13 @@ export interface ProviderServiceShape {
75 */77 */
76 readonly listSessions: () => Effect.Effect<ReadonlyArray<ProviderSession>>;78 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
78 /**87 /**
79 * Roll back provider conversation state by a number of turns.88 * Roll back provider conversation state by a number of turns.
80 */89 */
apps/server/src/wsServer.test.ts+1
...@@ -1170,6 +1170,7 @@ describe("WebSocket Server", () => {...@@ -1170,6 +1170,7 @@ describe("WebSocket Server", () => {
1170 respondToRequest: () => unsupported(),1170 respondToRequest: () => unsupported(),
1171 stopSession: () => unsupported(),1171 stopSession: () => unsupported(),
1172 listSessions: () => Effect.succeed([]),1172 listSessions: () => Effect.succeed([]),
1173 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
1173 rollbackConversation: () => unsupported(),1174 rollbackConversation: () => unsupported(),
1174 stopAll: () => Effect.void,1175 stopAll: () => Effect.void,
1175 streamEvents: Stream.fromPubSub(runtimeEventPubSub),1176 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
apps/web/src/components/ChatView.tsx+100-19
...@@ -137,7 +137,16 @@ import {...@@ -137,7 +137,16 @@ import {
137 MenuShortcut,137 MenuShortcut,
138 MenuTrigger,138 MenuTrigger,
139} from "./ui/menu";139} 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";
141import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";150import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
142import { Badge } from "./ui/badge";151import { Badge } from "./ui/badge";
143import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";152import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -670,10 +679,21 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -670,10 +679,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
670 ]);679 ]);
671680
672 const sessionProvider = activeThread?.session?.provider ?? null;681 const sessionProvider = activeThread?.session?.provider ?? null;
673 const selectedProvider: ProviderKind =682 const selectedProviderByThreadId = activeThread?.id
674 (activeThread?.id ? selectedProviderByThread[activeThread.id] : undefined) ??683 ? selectedProviderByThread[activeThread.id]
675 sessionProvider ??684 : undefined;
676 "codex";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;
677 const baseThreadModel = resolveModelSlugForProvider(697 const baseThreadModel = resolveModelSlugForProvider(
678 selectedProvider,698 selectedProvider,
679 (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ??699 (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ??
...@@ -705,7 +725,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -705,7 +725,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
705 selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel;725 selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel;
706 const searchableModelOptions = useMemo(726 const searchableModelOptions = useMemo(
707 () =>727 () =>
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) =>
709 getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({732 getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({
710 provider: option.value,733 provider: option.value,
711 providerLabel: option.label,734 providerLabel: option.label,
...@@ -716,12 +739,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -716,12 +739,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
716 searchProvider: option.label.toLowerCase(),739 searchProvider: option.label.toLowerCase(),
717 })),740 })),
718 ),741 ),
719 [],742 [lockedProvider],
720 );743 );
721 useEffect(() => {744 useEffect(() => {
722 if (!activeThread?.id || !sessionProvider) return;745 if (!activeThread?.id || !sessionProvider) return;
723 setSelectedProviderByThread((existing) => {746 setSelectedProviderByThread((existing) => {
724 if (existing[activeThread.id]) return existing;747 if (existing[activeThread.id] === sessionProvider) return existing;
725 return { ...existing, [activeThread.id]: sessionProvider };748 return { ...existing, [activeThread.id]: sessionProvider };
726 });749 });
727 }, [activeThread?.id, sessionProvider]);750 }, [activeThread?.id, sessionProvider]);
...@@ -2311,8 +2334,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2311,8 +2334,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
23112334
2312 const onProviderModelSelect = useCallback(2335 const onProviderModelSelect = useCallback(
2313 (provider: ProviderKind, model: ModelSlug) => {2336 (provider: ProviderKind, model: ModelSlug) => {
2314 const api = readNativeApi();
2315 if (!activeThread) return;2337 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();
2316 const resolvedModel =2347 const resolvedModel =
2317 provider === "cursor"2348 provider === "cursor"
2318 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))2349 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
...@@ -2325,7 +2356,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2325,7 +2356,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2325 ...existing,2356 ...existing,
2326 [activeThread.id]: resolvedModel,2357 [activeThread.id]: resolvedModel,
2327 }));2358 }));
2328 if (api) {2359 if (api && isServerThread) {
2329 void api.orchestration.dispatchCommand({2360 void api.orchestration.dispatchCommand({
2330 type: "thread.meta.update",2361 type: "thread.meta.update",
2331 commandId: newCommandId(),2362 commandId: newCommandId(),
...@@ -2335,7 +2366,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2335,7 +2366,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
2335 }2366 }
2336 scheduleComposerFocus();2367 scheduleComposerFocus();
2337 },2368 },
2338 [activeThread, scheduleComposerFocus],2369 [
2370 activeThread,
2371 cursorModelSelectionLockedReason,
2372 isServerThread,
2373 lockedProvider,
2374 scheduleComposerFocus,
2375 ],
2339 );2376 );
2340 const onCursorReasoningSelect = useCallback(2377 const onCursorReasoningSelect = useCallback(
2341 (reasoning: CursorReasoningOption) => {2378 (reasoning: CursorReasoningOption) => {
...@@ -2816,11 +2853,33 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2816,11 +2853,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
2816 <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">2853 <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">
2817 <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">2854 <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">
2818 {/* Provider/model picker */}2855 {/* Provider/model picker */}
2819 <ProviderModelPicker2856 {cursorModelSelectionLockedReason ? (
2820 provider={selectedProvider}2857 <Tooltip>
2821 model={selectedModelForPicker}2858 <TooltipTrigger
2822 onProviderModelChange={onProviderModelSelect}2859 render={
2823 />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
2825 {selectedProvider === "cursor" ? (2884 {selectedProvider === "cursor" ? (
2826 <>2885 <>
...@@ -4048,6 +4107,8 @@ function resolveModelForProviderPicker(...@@ -4048,6 +4107,8 @@ function resolveModelForProviderPicker(
4048const ProviderModelPicker = memo(function ProviderModelPicker(props: {4107const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4049 provider: ProviderKind;4108 provider: ProviderKind;
4050 model: ModelSlug;4109 model: ModelSlug;
4110 lockedProvider: ProviderKind | null;
4111 disabled?: boolean;
4051 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;4112 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
4052}) {4113}) {
4053 const [isMenuOpen, setIsMenuOpen] = useState(false);4114 const [isMenuOpen, setIsMenuOpen] = useState(false);
...@@ -4057,9 +4118,25 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -4057,9 +4118,25 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4057 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];4118 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
40584119
4059 return (4120 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 >
4061 <MenuTrigger4131 <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 }
4063 >4140 >
4064 <span className="flex min-w-0 items-center gap-2">4141 <span className="flex min-w-0 items-center gap-2">
4065 <ProviderIcon4142 <ProviderIcon
...@@ -4076,9 +4153,11 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -4076,9 +4153,11 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4076 <MenuPopup align="start">4153 <MenuPopup align="start">
4077 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {4154 {AVAILABLE_PROVIDER_OPTIONS.map((option) => {
4078 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];4155 const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
4156 const isDisabledByProviderLock =
4157 props.lockedProvider !== null && props.lockedProvider !== option.value;
4079 return (4158 return (
4080 <MenuSub key={option.value}>4159 <MenuSub key={option.value}>
4081 <MenuSubTrigger>4160 <MenuSubTrigger disabled={isDisabledByProviderLock}>
4082 <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" />4161 <OptionIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/85" />
4083 {option.label}4162 {option.label}
4084 </MenuSubTrigger>4163 </MenuSubTrigger>
...@@ -4087,6 +4166,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -4087,6 +4166,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4087 <MenuRadioGroup4166 <MenuRadioGroup
4088 value={props.provider === option.value ? props.model : ""}4167 value={props.provider === option.value ? props.model : ""}
4089 onValueChange={(value) => {4168 onValueChange={(value) => {
4169 if (props.disabled) return;
4170 if (isDisabledByProviderLock) return;
4090 if (!value) return;4171 if (!value) return;
4091 const resolvedModel = resolveModelForProviderPicker(option.value, value);4172 const resolvedModel = resolveModelForProviderPicker(option.value, value);
4092 if (!resolvedModel) return;4173 if (!resolvedModel) return;
apps/web/src/components/Icons.tsx+123
...@@ -20,6 +20,129 @@ export const CursorIcon: Icon = (props) => (...@@ -20,6 +20,129 @@ export const CursorIcon: Icon = (props) => (
20 </svg>20 </svg>
21);21);
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
23export const OpenAI: Icon = (props) => (146export const OpenAI: Icon = (props) => (
24 <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260" fill="currentColor">147 <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260" fill="currentColor">
25 <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" />148 <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 = [...@@ -19,21 +19,10 @@ export const CURSOR_MODEL_FAMILY_OPTIONS = [
19 { slug: "composer-1", name: "Composer 1" },19 { slug: "composer-1", name: "Composer 1" },
20 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },20 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
21 { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" },21 { 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" },
28 { slug: "opus-4.6", name: "Claude 4.6 Opus" },22 { slug: "opus-4.6", name: "Claude 4.6 Opus" },
29 { slug: "opus-4.5", name: "Claude 4.5 Opus" },23 { slug: "opus-4.5", name: "Claude 4.5 Opus" },
30 { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" },24 { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" },
31 { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" },
32 { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },25 { 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" },
37] as const satisfies readonly CursorModelFamilyOption[];26] as const satisfies readonly CursorModelFamilyOption[];
3827
39export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"];28export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"];
...@@ -63,33 +52,13 @@ export const MODEL_OPTIONS_BY_PROVIDER = {...@@ -63,33 +52,13 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
63 { slug: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex Extra High" },52 { slug: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex Extra High" },
64 { slug: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex Extra High Fast" },53 { slug: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex Extra High Fast" },
65 { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" },54 { 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)" },
81 { slug: "opus-4.6", name: "Claude 4.6 Opus" },55 { slug: "opus-4.6", name: "Claude 4.6 Opus" },
56 { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" },
82 { slug: "opus-4.5", name: "Claude 4.5 Opus" },57 { slug: "opus-4.5", name: "Claude 4.5 Opus" },
83 { slug: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" },58 { slug: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" },
84 { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" },59 { 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" },
87 { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" },60 { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" },
88 { slug: "sonnet-4.5", name: "Claude 4.5 Sonnet" },61 { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
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" },
93 ],62 ],
94} as const satisfies Record<ProviderKind, readonly ModelOption[]>;63} as const satisfies Record<ProviderKind, readonly ModelOption[]>;
9564
...@@ -140,48 +109,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa...@@ -140,48 +109,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa
140 defaultReasoning: "normal",109 defaultReasoning: "normal",
141 defaultThinking: false,110 defaultThinking: false,
142 },111 },
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 },
185 "opus-4.6": {112 "opus-4.6": {
186 supportsReasoning: false,113 supportsReasoning: false,
187 supportsFast: false,114 supportsFast: false,
...@@ -203,13 +130,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa...@@ -203,13 +130,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa
203 defaultReasoning: "normal",130 defaultReasoning: "normal",
204 defaultThinking: true,131 defaultThinking: true,
205 },132 },
206 "sonnet-4.5": {
207 supportsReasoning: false,
208 supportsFast: false,
209 supportsThinking: true,
210 defaultReasoning: "normal",
211 defaultThinking: true,
212 },
213 "gemini-3.1-pro": {133 "gemini-3.1-pro": {
214 supportsReasoning: false,134 supportsReasoning: false,
215 supportsFast: false,135 supportsFast: false,
...@@ -217,34 +137,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa...@@ -217,34 +137,6 @@ const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCa
217 defaultReasoning: "normal",137 defaultReasoning: "normal",
218 defaultThinking: false,138 defaultThinking: false,
219 },139 },
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 },
248};140};
249141
250const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>(142const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>(
...@@ -295,18 +187,11 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,...@@ -295,18 +187,11 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
295 "composer-1": "composer-1",187 "composer-1": "composer-1",
296 "gpt-5.3-codex": "gpt-5.3-codex",188 "gpt-5.3-codex": "gpt-5.3-codex",
297 "gpt-5.3-codex-spark": "gpt-5.3-codex-spark-preview",189 "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",
302 "gemini-3.1-pro": "gemini-3.1-pro",190 "gemini-3.1-pro": "gemini-3.1-pro",
303 "gemini-3-pro": "gemini-3-pro",
304 "claude-4.6-sonnet-thinking": "sonnet-4.6-thinking",191 "claude-4.6-sonnet-thinking": "sonnet-4.6-thinking",
305 "claude-4.5-sonnet-thinking": "sonnet-4.5-thinking",
306 "claude-4.6-opus-thinking": "opus-4.6-thinking",192 "claude-4.6-opus-thinking": "opus-4.6-thinking",
307 "claude-4.5-opus-thinking": "opus-4.5-thinking",193 "claude-4.5-opus-thinking": "opus-4.5-thinking",
308 "sonnet-4.6-thinking": "sonnet-4.6-thinking",194 "sonnet-4.6-thinking": "sonnet-4.6-thinking",
309 "sonnet-4.5-thinking": "sonnet-4.5-thinking",
310 "opus-4.6-thinking": "opus-4.6-thinking",195 "opus-4.6-thinking": "opus-4.6-thinking",
311 "opus-4.5-thinking": "opus-4.5-thinking",196 "opus-4.5-thinking": "opus-4.5-thinking",
312 },197 },
...@@ -354,25 +239,9 @@ function resolveCursorModelFamily(model: string | null | undefined): CursorModel...@@ -354,25 +239,9 @@ function resolveCursorModelFamily(model: string | null | undefined): CursorModel
354 return "gpt-5.3-codex";239 return "gpt-5.3-codex";
355 }240 }
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
370 if (normalized === "sonnet-4.6-thinking") {242 if (normalized === "sonnet-4.6-thinking") {
371 return "sonnet-4.6";243 return "sonnet-4.6";
372 }244 }
373 if (normalized === "sonnet-4.5-thinking") {
374 return "sonnet-4.5";
375 }
376 if (normalized === "opus-4.6-thinking") {245 if (normalized === "opus-4.6-thinking") {
377 return "opus-4.6";246 return "opus-4.6";
378 }247 }