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