| 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,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 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 1 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | 5 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; |
| 2 | import { | 6 | import { |
| 3 | ApprovalRequestId, | 7 | ApprovalRequestId, |
| ... | @@ -100,10 +104,15 @@ describe("ProviderCommandReactor", () => { | ... | @@ -100,10 +104,15 @@ describe("ProviderCommandReactor", () => { |
| 100 | typeof input === "object" && input !== null && "resumeCursor" in input | 104 | typeof input === "object" && input !== null && "resumeCursor" in input |
| 101 | ? input.resumeCursor | 105 | ? 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 | ); | ||
| 138 | 166 | ||
| 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 | }); |
| 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 | 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.providerName | 191 | ? 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 | }); |
| 197 | 199 | ||
| 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 | ); |
| 202 | 204 | ||
| 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; |
| 245 | 247 | 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 | } |
| 249 | 265 | ||
| 250 | const resumeCursor = providerChanged | 266 | const resumeCursor = |
| 251 | ? undefined | 267 | 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 | } |
| 269 | 286 | ||
| 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; | ||
| 299 | 326 | ||
| 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 | }); |
| 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 | const harness = await createHarness(); | 460 | const harness = await createHarness(); |
| 460 | const now = new Date().toISOString(); | 461 | const now = new Date().toISOString(); |
| 461 | 462 | ||
| 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 | }); |
| 491 | 502 | ||
| 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 | }); |
| 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 | 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 | } |
| 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 | function runtimeEventToActivities( | 148 | function 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 | } |
| 105 | 176 | ||
| 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 | } |
| 124 | 195 | ||
| 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 | } |
| 141 | 216 | ||
| 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 | } |
| 159 | 237 | ||
| 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! : ""; | ||
| 302 | 461 | ||
| 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; |
| 358 | 517 | ||
| 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 | ? null | 525 | ? 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; |
| 370 | 530 | ||
| 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 an | 614 | // 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 | ? null | 623 | ? 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 | } |
| 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 | 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* () { |
| 496 | 663 | ||
| 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 | } |
| 523 | 690 | ||
| 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 | }); |
| 540 | 721 | ||
| 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 | } |
| 571 | 752 | ||
| 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_GUARD | 756 | const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD |
| 574 | ? true | 757 | ? 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) { |
| 1845 | 1845 | ||
| 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) => |
| 1092 | 1092 | ||
| 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; |
| 18 | 19 | ||
| 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 }); | ||
| 78 | 80 | ||
| 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; |
| 254 | 283 | ||
| 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 | ); |
| 288 | 319 | ||
| 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 | } |
| 294 | 325 | ||
| 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 | }); |
| 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 | 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 | }); |
| 315 | 374 | ||
| 316 | // consume startup events | 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 | fake.emitPermissionRequest(); | 378 | fake.emitPermissionRequest(); |
| 320 | 379 |
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"; |
| 45 | 45 | ||
| 46 | const PROVIDER = "cursor" as const; | 46 | const PROVIDER = "cursor" as const; |
| 47 | const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; | 47 | const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; |
| 48 | const CURSOR_ACP_PROTOCOL_VERSION = 1; | ||
| 48 | 49 | ||
| 49 | interface CursorResumeState { | 50 | interface 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); |
| 973 | 978 | ||
| 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 | }); |
| 999 | 1007 | ||
| 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 | }); |
| 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 | 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) { |
| 1356 | 1428 | ||
| 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"; |
| 14 | 14 | ||
| 15 | const fakeCodexAdapter: CodexAdapterShape = { | 15 | const 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 = { |
| 29 | 30 | ||
| 30 | const fakeClaudeAdapter: ClaudeCodeAdapterShape = { | 31 | const 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 = { |
| 44 | 46 | ||
| 45 | const fakeCursorAdapter: CursorAdapterShape = { | 47 | const 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") { |
| 161 | 161 | ||
| 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 | ); |
| 568 | 568 | ||
| 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 { |
| 23 | import type { Effect } from "effect"; | 23 | import type { Effect } from "effect"; |
| 24 | import type { Stream } from "effect"; | 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 | export interface ProviderThreadTurnSnapshot { | 35 | export 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; | ||
| 41 | 51 | ||
| 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 | */ |
| 14 | import type { | 14 | import 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"; |
| 27 | import type { Effect, Stream } from "effect"; | 28 | import type { Effect, Stream } from "effect"; |
| 28 | 29 | ||
| 29 | import type { ProviderServiceError } from "../Errors.ts"; | 30 | import type { ProviderServiceError } from "../Errors.ts"; |
| 31 | import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; | ||
| 30 | 32 | ||
| 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>>; |
| 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 | * 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"; |
| 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 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 150 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 142 | import { Badge } from "./ui/badge"; | 151 | import { Badge } from "./ui/badge"; |
| 143 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 152 | import { 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 | ]); |
| 671 | 680 | ||
| 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) { |
| 2311 | 2334 | ||
| 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 | <ProviderModelPicker | 2856 | {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 | )} | ||
| 2824 | 2883 | ||
| 2825 | {selectedProvider === "cursor" ? ( | 2884 | {selectedProvider === "cursor" ? ( |
| 2826 | <> | 2885 | <> |
| ... | @@ -4048,6 +4107,8 @@ function resolveModelForProviderPicker( | ... | @@ -4048,6 +4107,8 @@ function resolveModelForProviderPicker( |
| 4048 | const ProviderModelPicker = memo(function ProviderModelPicker(props: { | 4107 | const 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]; |
| 4058 | 4119 | ||
| 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 | <MenuTrigger | 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 | <span className="flex min-w-0 items-center gap-2"> | 4141 | <span className="flex min-w-0 items-center gap-2"> |
| 4065 | <ProviderIcon | 4142 | <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 | <MenuRadioGroup | 4166 | <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 | ); |
| 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 | export const OpenAI: Icon = (props) => ( | 146 | export 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[]; |
| 38 | 27 | ||
| 39 | export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; | 28 | export 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[]>; |
| 95 | 64 | ||
| ... | @@ -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 | }; |
| 249 | 141 | ||
| 250 | const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>( | 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,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 | } |
| 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 | 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 | } |