From 17e6cad479f0a0ac862a1f9cf8bd8d370ec9f10f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Mar 2026 00:44:08 -0800 Subject: [PATCH] Respect provider model-switch limits and canonical runtime events - Add provider capability plumbing for `sessionModelSwitch` and use it in session/turn routing - Keep Cursor sessions stable when in-session model switching is unsupported - Ingest canonical runtime events (`content.delta`, `item.*`, `request.*`) with legacy-shape compatibility --- .tmp/cursor-acp-method-probe.mjs | 188 ++++++++++++++ .tmp/cursor-acp-method-probe.output.json | 46 ++++ .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 137 ++++++++++ .../Layers/ProviderCommandReactor.ts | 49 +++- .../Layers/ProviderRuntimeIngestion.test.ts | 50 +++- .../Layers/ProviderRuntimeIngestion.ts | 239 ++++++++++++++++-- .../src/provider/Layers/ClaudeCodeAdapter.ts | 3 + .../src/provider/Layers/CodexAdapter.ts | 3 + .../src/provider/Layers/CursorAdapter.test.ts | 69 ++++- .../src/provider/Layers/CursorAdapter.ts | 85 ++++++- .../Layers/ProviderAdapterRegistry.test.ts | 3 + .../provider/Layers/ProviderService.test.ts | 3 + .../src/provider/Layers/ProviderService.ts | 4 + .../src/provider/Services/ProviderAdapter.ts | 10 + .../src/provider/Services/ProviderService.ts | 9 + apps/server/src/wsServer.test.ts | 1 + apps/web/src/components/ChatView.tsx | 119 +++++++-- apps/web/src/components/Icons.tsx | 123 +++++++++ packages/contracts/src/model.ts | 135 +--------- 20 files changed, 1070 insertions(+), 207 deletions(-) create mode 100644 .tmp/cursor-acp-method-probe.mjs create mode 100644 .tmp/cursor-acp-method-probe.output.json diff --git a/.tmp/cursor-acp-method-probe.mjs b/.tmp/cursor-acp-method-probe.mjs new file mode 100644 index 0000000000000000000000000000000000000000..8af1f54ed00c6c39ec87f5fc84d544f30fdf7d9e --- /dev/null +++ b/.tmp/cursor-acp-method-probe.mjs @@ -0,0 +1,188 @@ +import { spawn } from 'node:child_process'; +import readline from 'node:readline'; + +const REQUEST_TIMEOUT_MS = 15000; + +function now() { + return new Date().toISOString(); +} + +function parseJson(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} + +class Client { + constructor() { + this.nextId = 1; + this.pending = new Map(); + this.notifications = []; + + this.child = spawn('agent', ['acp'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }); + + this.stdout = readline.createInterface({ input: this.child.stdout }); + this.stderr = readline.createInterface({ input: this.child.stderr }); + + this.stderr.on('line', (line) => { + process.stderr.write(`[stderr] ${line}\n`); + }); + + this.stdout.on('line', (line) => { + const msg = parseJson(line); + if (!msg) { + process.stdout.write(`[stdout non-json] ${line}\n`); + return; + } + + if ( + Object.prototype.hasOwnProperty.call(msg, 'id') && + !Object.prototype.hasOwnProperty.call(msg, 'method') && + (Object.prototype.hasOwnProperty.call(msg, 'result') || + Object.prototype.hasOwnProperty.call(msg, 'error')) + ) { + const pending = this.pending.get(msg.id); + if (!pending) return; + this.pending.delete(msg.id); + pending(msg); + return; + } + + if (typeof msg.method === 'string') { + this.notifications.push(msg); + if (Object.prototype.hasOwnProperty.call(msg, 'id')) { + this.respondUnsupported(msg.id, msg.method); + } + } + }); + } + + respondUnsupported(id, method) { + const response = { + jsonrpc: '2.0', + id, + error: { + code: -32601, + message: `Unhandled server request in probe: ${method}`, + }, + }; + this.child.stdin.write(`${JSON.stringify(response)}\n`); + } + + async send(method, params, timeoutMs = REQUEST_TIMEOUT_MS) { + const id = this.nextId++; + const message = { jsonrpc: '2.0', id, method, params }; + this.child.stdin.write(`${JSON.stringify(message)}\n`); + + return await new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(id); + resolve({ timeout: true, id, method, params }); + }, timeoutMs); + + this.pending.set(id, (response) => { + clearTimeout(timer); + resolve({ timeout: false, id, method, params, response }); + }); + }); + } + + async close() { + try { + this.child.stdin.end(); + } catch {} + this.child.kill('SIGTERM'); + await new Promise((r) => setTimeout(r, 100)); + this.child.kill('SIGKILL'); + } +} + +async function main() { + const client = new Client(); + const results = []; + + const init = await client.send('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: 't3-cursor-acp-method-probe', + version: '0.1.0', + }, + }); + results.push(init); + + const auth = await client.send('authenticate', { methodId: 'cursor_login' }); + results.push(auth); + + const sessionNew = await client.send('session/new', { + cwd: process.cwd(), + mcpServers: [], + model: 'gpt-5.3-codex', + }); + results.push(sessionNew); + + const sessionId = sessionNew?.response?.result?.sessionId; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + const out = { + ts: now(), + sessionId: null, + reason: 'Missing sessionId from session/new', + results, + notificationMethods: [...new Set(client.notifications.map((n) => n.method))].sort(), + notificationSamples: client.notifications.slice(0, 8), + }; + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + await client.close(); + return; + } + + const methodCandidates = [ + { method: 'session/set_model', params: { sessionId, model: 'composer-1.5' } }, + { method: 'session/setModel', params: { sessionId, model: 'composer-1.5' } }, + { method: 'session/model/set', params: { sessionId, model: 'composer-1.5' } }, + { method: 'session/update_model', params: { sessionId, model: 'composer-1.5' } }, + { method: 'session/configure', params: { sessionId, model: 'composer-1.5' } }, + { method: 'session/set_mode', params: { sessionId, modeId: 'ask' } }, + { method: 'session/setMode', params: { sessionId, modeId: 'ask' } }, + { method: 'session/mode/set', params: { sessionId, modeId: 'ask' } }, + { method: 'session/update_mode', params: { sessionId, modeId: 'ask' } }, + { method: 'session/change_mode', params: { sessionId, modeId: 'ask' } }, + { method: 'session/set', params: { sessionId, model: 'composer-1.5' } }, + ]; + + for (const candidate of methodCandidates) { + const result = await client.send(candidate.method, candidate.params); + results.push(result); + } + + // Sanity: session/prompt should still work after candidates. + const prompt = await client.send('session/prompt', { + sessionId, + prompt: [{ type: 'text', text: 'Reply with exactly: ok' }], + }, 30000); + results.push(prompt); + + const out = { + ts: now(), + sessionId, + results, + notificationMethods: [...new Set(client.notifications.map((n) => n.method))].sort(), + notificationSamples: client.notifications.slice(0, 8), + }; + + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + await client.close(); +} + +main().catch(async (error) => { + process.stderr.write(`Probe failed: ${error?.stack || String(error)}\n`); + process.exitCode = 1; +}); diff --git a/.tmp/cursor-acp-method-probe.output.json b/.tmp/cursor-acp-method-probe.output.json new file mode 100644 index 0000000000000000000000000000000000000000..c87131c3a137dd66b624f2a72ebd967b41191926 --- /dev/null +++ b/.tmp/cursor-acp-method-probe.output.json @@ -0,0 +1,46 @@ +{ + "ts": "2026-03-03T07:05:34.357Z", + "sessionId": null, + "reason": "Missing sessionId from session/new", + "results": [ + { + "timeout": true, + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false + }, + "clientInfo": { + "name": "t3-cursor-acp-method-probe", + "version": "0.1.0" + } + } + }, + { + "timeout": true, + "id": 2, + "method": "authenticate", + "params": { + "methodId": "cursor_login" + } + }, + { + "timeout": true, + "id": 3, + "method": "session/new", + "params": { + "cwd": "/Users/julius/.t3/worktrees/codething-mvp/codething-648ca884", + "mcpServers": [], + "model": "gpt-5.3-codex" + } + } + ], + "notificationMethods": [], + "notificationSamples": [] +} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 7aaf8edb70cacdc8231687c5a92007aceb857c89..44bc2deb4494d2b1efae74be4713b6602f70b83d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -80,6 +80,7 @@ function createProviderServiceHarness( respondToRequest: () => unsupported(), stopSession: () => unsupported(), listSessions, + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation, stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index eece7658292becf34f719617836b5336b6e204c8..2f0da9b9652ee6a0adba0495e4f0d52d41e8e84b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -100,10 +104,15 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "resumeCursor" in input ? input.resumeCursor : undefined; + const model = + typeof input === "object" && input !== null && "model" in input && typeof input.model === "string" + ? input.model + : undefined; const session: ProviderSession = { sessionId: asSessionId(`sess-${sessionIndex}`), provider, status: "ready" as const, + ...(model !== undefined ? { model } : {}), threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, createdAt: now, @@ -135,6 +144,25 @@ describe("ProviderCommandReactor", () => { } }), ); + const renameBranch = vi.fn((input: unknown) => + Effect.succeed({ + branch: + typeof input === "object" && + input !== null && + "newBranch" in input && + typeof input.newBranch === "string" + ? input.newBranch + : "renamed-branch", + }), + ); + const generateBranchName = vi.fn(() => + Effect.fail( + new TextGenerationError({ + operation: "generateBranchName", + detail: "disabled in test harness", + }), + ), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -144,6 +172,10 @@ describe("ProviderCommandReactor", () => { respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], stopSession: stopSession as ProviderServiceShape["stopSession"], listSessions: () => Effect.succeed(runtimeSessions), + getCapabilities: (provider) => + Effect.succeed({ + sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session", + }), rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -371,6 +403,111 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("reuses the same cursor session when requested model is unchanged", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-same-1"), + role: "user", + text: "first", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-same-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); + }); + + it("keeps cursor session/model when model change is unsupported", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-change-1"), + role: "user", + text: "first", + attachments: [], + }, + provider: "cursor", + model: "gpt-5.3-codex", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-change-2"), + role: "user", + text: "second", + attachments: [], + }, + provider: "cursor", + model: "composer-1.5", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + sessionId: asSessionId("sess-1"), + model: "gpt-5.3-codex", + }); + }); + it("restarts the provider session when runtime mode changes", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index d2cbf152fdd2dc17d4587966e83a56710da07552..e1ebea54e1ed604d9328a6e7607e402733a305f9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -169,6 +169,7 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly provider?: ProviderKind; + readonly model?: string; readonly approvalPolicy?: ProviderApprovalPolicy; readonly sandboxMode?: ProviderSandboxMode; }, @@ -190,14 +191,15 @@ const make = Effect.gen(function* () { ? thread.session.providerName : undefined; const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; + const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, projects: readModel.projects, }); - const resolveResumeCursorForSession = (sessionId: ProviderSessionId) => + const resolveActiveSession = (sessionId: ProviderSessionId) => providerService.listSessions().pipe( - Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor), + Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)), ); const startProviderSession = (input?: { @@ -209,7 +211,7 @@ const make = Effect.gen(function* () { ? { provider: input?.provider ?? preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.model ? { model: thread.model } : {}), + ...(desiredModel ? { model: desiredModel } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), approvalPolicy: desiredApprovalPolicy, sandboxMode: desiredSandboxMode, @@ -242,14 +244,29 @@ const make = Effect.gen(function* () { const sandboxModeChanged = options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode; const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; + const activeSession = yield* resolveActiveSession(existingSessionId); + const sessionModelSwitch = + currentProvider === undefined + ? "in-session" + : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; + const modelChanged = + options?.model !== undefined && options.model !== activeSession?.model; + const shouldRestartForModelChange = + modelChanged && sessionModelSwitch === "restart-session"; - if (!approvalPolicyChanged && !sandboxModeChanged && !providerChanged) { + if ( + !approvalPolicyChanged && + !sandboxModeChanged && + !providerChanged && + !shouldRestartForModelChange + ) { return existingSessionId; } - const resumeCursor = providerChanged - ? undefined - : yield* resolveResumeCursorForSession(existingSessionId); + const resumeCursor = + providerChanged || shouldRestartForModelChange + ? undefined + : (activeSession?.resumeCursor ?? undefined); const restartedSession = yield* startProviderSession({ ...(resumeCursor !== undefined ? { resumeCursor } : {}), ...(options?.provider !== undefined ? { provider: options.provider } : {}), @@ -267,9 +284,9 @@ const make = Effect.gen(function* () { return restartedSession.sessionId; } - const startedSession = yield* startProviderSession({ - ...(options?.provider !== undefined ? { provider: options.provider } : {}), - }); + const startedSession = yield* startProviderSession( + options?.provider !== undefined ? { provider: options.provider } : undefined, + ); yield* bindSessionToThread(startedSession); return startedSession.sessionId; }); @@ -291,17 +308,27 @@ const make = Effect.gen(function* () { } const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), approvalPolicy: input.approvalPolicy, sandboxMode: input.sandboxMode, }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; + const activeSession = yield* providerService.listSessions().pipe( + Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)), + ); + const sessionModelSwitch = + activeSession === undefined + ? "in-session" + : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; + const modelForTurn = + sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; yield* providerService.sendTurn({ sessionId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), - ...(input.model !== undefined ? { model: input.model } : {}), + ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), ...(input.effort !== undefined ? { effort: input.effort } : {}), }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 228bc1d1db7c0b9dbce7d1416c0c22f6c12dc7cb..a58ed3a4e09b5a73db8d73d1925f823312fd67be 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -50,6 +50,7 @@ function createProviderServiceHarness() { respondToRequest: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -455,38 +456,48 @@ describe("ProviderRuntimeIngestion", () => { ); }); - it("maps message delta/completed into finalized assistant messages", async () => { + it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = new Date().toISOString(); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-1"), provider: "codex", sessionId: asSessionId("sess-1"), createdAt: now, turnId: asProviderTurnId("turn-2"), itemId: asItemId("item-1"), - delta: "hello", + payload: { + streamKind: "assistant_text", + delta: "hello", + }, }); harness.emit({ - type: "message.delta", + type: "content.delta", eventId: asEventId("evt-message-delta-2"), provider: "codex", sessionId: asSessionId("sess-1"), createdAt: now, turnId: asProviderTurnId("turn-2"), itemId: asItemId("item-1"), - delta: " world", + payload: { + streamKind: "assistant_text", + delta: " world", + }, }); harness.emit({ - type: "message.completed", + type: "item.completed", eventId: asEventId("evt-message-completed"), provider: "codex", sessionId: asSessionId("sess-1"), createdAt: now, turnId: asProviderTurnId("turn-2"), itemId: asItemId("item-1"), + payload: { + itemType: "assistant_message", + status: "completed", + }, }); const thread = await waitForThread(harness.engine, (entry) => @@ -497,6 +508,33 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("uses assistant item completion detail when no assistant deltas were streamed", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-assistant-item-completed-no-delta"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: now, + turnId: asProviderTurnId("turn-no-delta"), + itemId: asItemId("item-no-delta"), + payload: { + itemType: "assistant_message", + status: "completed", + detail: "assistant-only final text", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some((message) => message.id === "assistant:item-no-delta" && !message.streaming), + ); + const message = thread.messages.find((entry) => entry.id === "assistant:item-no-delta"); + expect(message?.text).toBe("assistant-only final text"); + expect(message?.streaming).toBe(false); + }); + it("buffers assistant deltas by default until completion", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 10b36ce76014f823956b2ec8db7a1597222c8791..23208be111e7b1b90a309fc59a108905bdd822f5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -76,12 +76,81 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function legacyRuntimeType(event: ProviderRuntimeEvent): string | undefined { + return asString((event as Record).type); +} + +function legacyRuntimeStringField( + event: ProviderRuntimeEvent, + key: string, +): string | undefined { + return asString((event as Record)[key]); +} + +function runtimePayloadRecord(event: ProviderRuntimeEvent): Record | undefined { + const payload = (event as { payload?: unknown }).payload; + if (!payload || typeof payload !== "object") { + return undefined; + } + return payload as Record; +} + +function normalizeRuntimeTurnState( + value: string | undefined, +): "completed" | "failed" | "interrupted" | "cancelled" { + switch (value) { + case "failed": + case "interrupted": + case "cancelled": + case "completed": + return value; + default: + return "completed"; + } +} + +function runtimeTurnState( + event: ProviderRuntimeEvent, +): "completed" | "failed" | "interrupted" | "cancelled" { + const payloadState = asString(runtimePayloadRecord(event)?.state); + const legacyStatus = legacyRuntimeStringField(event, "status"); + return normalizeRuntimeTurnState(payloadState ?? legacyStatus); +} + +function runtimeTurnErrorMessage(event: ProviderRuntimeEvent): string | undefined { + const payloadErrorMessage = asString(runtimePayloadRecord(event)?.errorMessage); + const legacyErrorMessage = legacyRuntimeStringField(event, "errorMessage"); + return payloadErrorMessage ?? legacyErrorMessage; +} + +function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | undefined { + const payloadMessage = asString(runtimePayloadRecord(event)?.message); + const legacyMessage = legacyRuntimeStringField(event, "message"); + return payloadMessage ?? legacyMessage; +} + +function isToolLifecycleItemType(itemType: string): boolean { + return ( + itemType === "command_execution" || + itemType === "file_change" || + itemType === "mcp_tool_call" || + itemType === "dynamic_tool_call" || + itemType === "collab_agent_tool_call" || + itemType === "web_search" || + itemType === "image_view" + ); +} + function runtimeEventToActivities( event: ProviderRuntimeEvent, ): ReadonlyArray { const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {}; switch (event.type) { - case "approval.requested": { + case "request.opened": { return [ { id: event.eventId, @@ -89,13 +158,15 @@ function runtimeEventToActivities( tone: "approval", kind: "approval.requested", summary: - event.requestKind === "command" + event.payload.requestType === "command_execution_approval" ? "Command approval requested" - : "File-change approval requested", + : event.payload.requestType === "file_change_approval" + ? "File-change approval requested" + : "Approval requested", payload: { requestId: event.requestId, - requestKind: event.requestKind, - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + requestType: event.payload.requestType, + ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -103,7 +174,7 @@ function runtimeEventToActivities( ]; } - case "approval.resolved": { + case "request.resolved": { return [ { id: event.eventId, @@ -113,8 +184,8 @@ function runtimeEventToActivities( summary: "Approval resolved", payload: { requestId: event.requestId, - ...(event.requestKind ? { requestKind: event.requestKind } : {}), - ...(event.decision ? { decision: event.decision } : {}), + requestType: event.payload.requestType, + ...(event.payload.decision ? { decision: event.payload.decision } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -123,6 +194,10 @@ function runtimeEventToActivities( } case "runtime.error": { + const message = runtimeErrorMessageFromEvent(event); + if (!message) { + return []; + } return [ { id: event.eventId, @@ -131,7 +206,7 @@ function runtimeEventToActivities( kind: "runtime.error", summary: "Runtime error", payload: { - message: truncateDetail(event.message), + message: truncateDetail(message), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -139,17 +214,20 @@ function runtimeEventToActivities( ]; } - case "tool.completed": { + case "item.completed": { + if (!isToolLifecycleItemType(event.payload.itemType)) { + return []; + } return [ { id: event.eventId, createdAt: event.createdAt, tone: "tool", kind: "tool.completed", - summary: `${event.title} complete`, + summary: `${event.payload.title ?? "Tool"} complete`, payload: { - toolKind: event.toolKind, - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + itemType: event.payload.itemType, + ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -157,17 +235,95 @@ function runtimeEventToActivities( ]; } - case "tool.started": { + case "item.started": { + if (!isToolLifecycleItemType(event.payload.itemType)) { + return []; + } return [ { id: event.eventId, createdAt: event.createdAt, tone: "tool", kind: "tool.started", - summary: `${event.title} started`, + summary: `${event.payload.title ?? "Tool"} started`, payload: { - toolKind: event.toolKind, - ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + itemType: event.payload.itemType, + ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + default: + break; + } + + // Backward-compatibility for legacy event shapes still emitted by older tests/adapters. + const legacyType = legacyRuntimeType(event); + switch (legacyType) { + case "approval.requested": { + const detail = legacyRuntimeStringField(event, "detail"); + const requestKind = legacyRuntimeStringField(event, "requestKind"); + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "approval", + kind: "approval.requested", + summary: + requestKind === "command" + ? "Command approval requested" + : "File-change approval requested", + payload: { + requestId: event.requestId, + ...(requestKind ? { requestKind } : {}), + ...(detail ? { detail: truncateDetail(detail) } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "approval.resolved": { + const decision = legacyRuntimeStringField(event, "decision"); + const requestKind = legacyRuntimeStringField(event, "requestKind"); + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "approval", + kind: "approval.resolved", + summary: "Approval resolved", + payload: { + requestId: event.requestId, + ...(requestKind ? { requestKind } : {}), + ...(decision ? { decision } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "tool.completed": + case "tool.started": { + const title = legacyRuntimeStringField(event, "title") ?? "Tool"; + const detail = legacyRuntimeStringField(event, "detail"); + const toolKind = legacyRuntimeStringField(event, "toolKind"); + const activityKind = legacyType; + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "tool", + kind: activityKind, + summary: `${title} ${legacyType === "tool.started" ? "started" : "complete"}`, + payload: { + ...(toolKind ? { toolKind } : {}), + ...(detail ? { detail: truncateDetail(detail) } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -296,9 +452,12 @@ const make = Effect.gen(function* () { createdAt: string; commandTag: string; finalDeltaCommandTag: string; + fallbackText?: string; }) => Effect.gen(function* () { - const text = yield* takeBufferedAssistantText(input.messageId); + const bufferedText = yield* takeBufferedAssistantText(input.messageId); + const text = + bufferedText.length > 0 ? bufferedText : (input.fallbackText?.trim().length ?? 0) > 0 ? input.fallbackText! : ""; if (text.length > 0) { yield* orchestrationEngine.dispatch({ @@ -356,6 +515,7 @@ const make = Effect.gen(function* () { ); if (!thread) return; + const legacyType = legacyRuntimeType(event); const now = event.createdAt; const sessionProviderThreadId = thread.session?.providerThreadId ?? null; const scopedSessionProviderThreadId = isSyntheticClaudeThreadId( @@ -365,7 +525,7 @@ const make = Effect.gen(function* () { ? null : sessionProviderThreadId; const eventProviderThreadId = toProviderThreadId(event.threadId); - const eventTurnId = toTurnId("turnId" in event ? event.turnId : undefined); + const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; const matchesThreadScope = @@ -448,7 +608,7 @@ const make = Effect.gen(function* () { case "session.exited": return "stopped"; case "turn.completed": - return event.status === "failed" ? "error" : "ready"; + return runtimeTurnState(event) === "failed" ? "error" : "ready"; case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -457,8 +617,8 @@ const make = Effect.gen(function* () { } })(); const lastError = - event.type === "turn.completed" && event.status === "failed" - ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") + event.type === "turn.completed" && runtimeTurnState(event) === "failed" + ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed") : status === "ready" ? null : (thread.session?.lastError ?? null); @@ -485,7 +645,14 @@ const make = Effect.gen(function* () { } } - if (event.type === "message.delta" && event.delta.length > 0) { + const assistantDelta = + event.type === "content.delta" && event.payload.streamKind === "assistant_text" + ? event.payload.delta + : legacyType === "message.delta" + ? legacyRuntimeStringField(event, "delta") + : undefined; + + if (assistantDelta && assistantDelta.length > 0) { const assistantMessageId = MessageId.makeUnsafe( `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`, ); @@ -496,7 +663,7 @@ const make = Effect.gen(function* () { const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, event.delta); + const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", @@ -514,15 +681,28 @@ const make = Effect.gen(function* () { commandId: providerCommandId(event, "assistant-delta"), threadId: thread.id, messageId: assistantMessageId, - delta: event.delta, + delta: assistantDelta, ...(turnId ? { turnId } : {}), createdAt: now, }); } } - if (event.type === "message.completed") { - const assistantMessageId = MessageId.makeUnsafe(`assistant:${event.itemId}`); + const assistantCompletion = + event.type === "item.completed" && event.payload.itemType === "assistant_message" + ? { + messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`), + fallbackText: event.payload.detail, + } + : legacyType === "message.completed" + ? { + messageId: MessageId.makeUnsafe(`assistant:${event.itemId ?? event.turnId ?? event.sessionId}`), + fallbackText: undefined, + } + : undefined; + + if (assistantCompletion) { + const assistantMessageId = assistantCompletion.messageId; const turnId = toTurnId(event.turnId); if (turnId) { yield* rememberAssistantMessageId(event.sessionId, turnId, assistantMessageId); @@ -536,6 +716,7 @@ const make = Effect.gen(function* () { createdAt: now, commandTag: "assistant-complete", finalDeltaCommandTag: "assistant-delta-finalize", + fallbackText: assistantCompletion.fallbackText, }); if (turnId) { @@ -570,6 +751,8 @@ const make = Effect.gen(function* () { } if (event.type === "runtime.error") { + const runtimeErrorMessage = runtimeErrorMessageFromEvent(event) ?? "Provider runtime error"; + const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD ? true : matchesThreadScope && @@ -596,7 +779,7 @@ const make = Effect.gen(function* () { approvalPolicy: thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY, sandboxMode: thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE, activeTurnId: eventTurnId ?? null, - lastError: event.message, + lastError: runtimeErrorMessage, updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 40736c18a9c5ffbfef7450e531406ba2bb958ebc..9d8f65f169ba54804647e1644b6c244c267d6201 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -1845,6 +1845,9 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { return { provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 16aa9aa9016940de248c13044684bf202a0604b9..9dd57bd2bc6f23394fbd64585983e36c95cdc06d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1092,6 +1092,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => return { provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 2daca1d9443921d1c80254b356f4b6dcc00a081a..671fdc23cc83dd99fff4dedc7b4b05c8ee3c5575 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -14,6 +14,7 @@ class FakeCursorAcpProcess extends EventEmitter { readonly stdin = new PassThrough(); readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); + readonly requests: Array<{ method: string; params: unknown }> = []; killed = false; private readonly input = readline.createInterface({ input: this.stdin }); @@ -75,9 +76,29 @@ class FakeCursorAcpProcess extends EventEmitter { if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) { return; } + this.requests.push({ method, params: message.params }); switch (method) { - case "initialize": + case "initialize": { + const protocolVersion = (message.params as { protocolVersion?: unknown } | undefined) + ?.protocolVersion; + if (typeof protocolVersion !== "number") { + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32602, + message: "Invalid params", + data: { + _errors: [], + protocolVersion: { + _errors: ["Invalid input: expected number, received undefined"], + }, + }, + }, + }); + return; + } this.emitServerMessage({ jsonrpc: "2.0", id, @@ -90,6 +111,7 @@ class FakeCursorAcpProcess extends EventEmitter { }, }); return; + } case "authenticate": this.emitServerMessage({ jsonrpc: "2.0", @@ -117,6 +139,13 @@ class FakeCursorAcpProcess extends EventEmitter { result: {}, }); return; + case "session/set_model": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; case "session/prompt": { this.emitServerMessage({ jsonrpc: "2.0", @@ -252,7 +281,7 @@ describe("CursorAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* CursorAdapter; - const eventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( Stream.runCollect, Effect.forkChild, ); @@ -273,6 +302,8 @@ describe("CursorAdapterLive", () => { events.map((event) => event.type), [ "session.configured", + "auth.status", + "auth.status", "session.started", "thread.started", "session.state.changed", @@ -286,13 +317,13 @@ describe("CursorAdapterLive", () => { ], ); - const turnStarted = events[4]; + const turnStarted = events[6]; assert.equal(turnStarted?.type, "turn.started"); if (turnStarted?.type === "turn.started") { assert.equal(turnStarted.turnId, turn.turnId); } - const completion = events[10]; + const completion = events[12]; assert.equal(completion?.type, "turn.completed"); if (completion?.type === "turn.completed") { assert.equal(completion.payload.state, "completed"); @@ -300,6 +331,34 @@ describe("CursorAdapterLive", () => { }).pipe(Effect.provide(layer)); }); + it.effect("passes requested model to ACP process startup", () => { + const fake = new FakeCursorAcpProcess(); + let createProcessInput: + | { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly model?: string; + } + | undefined; + const layer = makeCursorAdapterLive({ + createProcess: (input) => { + createProcessInput = input; + return fake as never; + }, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + yield* adapter.startSession({ + provider: "cursor", + model: "composer-1.5", + }); + + assert.deepEqual(createProcessInput?.model, "composer-1.5"); + }).pipe(Effect.provide(layer)); + }); + it.effect("bridges permission requests to request.opened/request.resolved", () => { const fake = new FakeCursorAcpProcess(); const layer = makeCursorAdapterLive({ @@ -314,7 +373,7 @@ describe("CursorAdapterLive", () => { }); // consume startup events - yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); fake.emitPermissionRequest(); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index f9db742836db40a76b37977429f5c20109352cff..2013ef1de27d2957f5e70891c2516f9bdddfa7b6 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -45,6 +45,7 @@ import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "cursor" as const; const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; +const CURSOR_ACP_PROTOCOL_VERSION = 1; interface CursorResumeState { readonly acpSessionId?: string; @@ -91,6 +92,7 @@ export interface CursorAdapterLiveOptions { readonly binaryPath: string; readonly cwd: string; readonly env: NodeJS.ProcessEnv; + readonly model?: string; }) => ChildProcessWithoutNullStreams; readonly nativeEventLogPath?: string; } @@ -272,11 +274,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { readonly binaryPath: string; readonly cwd: string; readonly env: NodeJS.ProcessEnv; + readonly model?: string; }): ChildProcessWithoutNullStreams => { if (options?.createProcess) { return options.createProcess(input); } - return spawn(input.binaryPath, ["acp"], { + const args = input.model ? ["--model", input.model, "acp"] : ["acp"]; + return spawn(input.binaryPath, args, { cwd: input.cwd, env: input.env, stdio: ["pipe", "pipe", "pipe"], @@ -862,6 +866,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { binaryPath, cwd, env: process.env, + ...(input.model ? { model: input.model } : {}), }), catch: (cause) => new ProviderAdapterProcessError({ @@ -972,7 +977,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { sessions.set(sessionId, context); const initializeResult = yield* Effect.tryPromise({ - try: async () => sendRequest(context, "initialize", {}), + try: async () => + sendRequest(context, "initialize", { + protocolVersion: CURSOR_ACP_PROTOCOL_VERSION, + }), catch: (cause) => toRequestError(sessionId, "initialize", cause), }); const decodedInitialize = Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult); @@ -997,11 +1005,75 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }, }); - yield* Effect.tryPromise({ - try: async () => sendRequest(context, "authenticate", { methodId: "cursor_login" }), - catch: () => undefined, + const authenticateRequest = { methodId: "cursor_login" }; + const authStartStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authStartStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: authStartStamp.createdAt, + payload: { + isAuthenticating: true, + }, + providerRefs: { + providerSessionId: sessionId, + }, + raw: { + source: "cursor.acp.request", + method: "authenticate", + payload: authenticateRequest, + }, }); + const authenticateResult = yield* Effect.tryPromise({ + try: async () => sendRequest(context, "authenticate", authenticateRequest), + catch: (cause) => cause, + }); + const authEndStamp = yield* makeEventStamp(); + if (authenticateResult instanceof Error) { + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authEndStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: authEndStamp.createdAt, + payload: { + isAuthenticating: false, + error: toMessage(authenticateResult, "Cursor authentication failed."), + }, + providerRefs: { + providerSessionId: sessionId, + }, + raw: { + source: "cursor.acp.response", + method: "authenticate", + payload: { + error: toMessage(authenticateResult, "Cursor authentication failed."), + }, + }, + }); + } else { + yield* offerRuntimeEvent({ + type: "auth.status", + eventId: authEndStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: authEndStamp.createdAt, + payload: { + isAuthenticating: false, + }, + providerRefs: { + providerSessionId: sessionId, + }, + raw: { + source: "cursor.acp.response", + method: "authenticate", + payload: authenticateResult, + }, + }); + } + const acpSessionId = yield* Effect.tryPromise({ try: async () => { if (resumeState?.acpSessionId) { @@ -1356,6 +1428,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return { provider: PROVIDER, + capabilities: { + sessionModelSwitch: "unsupported", + }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 75ed7576cb609b377cbe7351a0cd2c2350ca3e6a..ed56c5106d3423a9c32d4c4e75683d531980557f 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -14,6 +14,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; const fakeCodexAdapter: CodexAdapterShape = { provider: "codex", + capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -29,6 +30,7 @@ const fakeCodexAdapter: CodexAdapterShape = { const fakeClaudeAdapter: ClaudeCodeAdapterShape = { provider: "claudeCode", + capabilities: { sessionModelSwitch: "in-session" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), @@ -44,6 +46,7 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = { const fakeCursorAdapter: CursorAdapterShape = { provider: "cursor", + capabilities: { sessionModelSwitch: "unsupported" }, startSession: vi.fn(), sendTurn: vi.fn(), interruptTurn: vi.fn(), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ffe8acf5f7cf7e2e5ab6ef00d85b8469efcb016f..9445f9b455e4cb6009c86ebc76e856835b34cd60 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -161,6 +161,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const adapter: ProviderAdapterShape = { provider, + capabilities: { + sessionModelSwitch: "in-session", + }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 465da54858929f225a1143b1dd15e8b15cbaa4e9..cf67b4a6ad40b8fcb01a893c3cefe67f5b88c6d9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -566,6 +566,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), ); + const getCapabilities: ProviderServiceShape["getCapabilities"] = (provider) => + registry.getByProvider(provider).pipe(Effect.map((adapter) => adapter.capabilities)); + const rollbackConversation: ProviderServiceShape["rollbackConversation"] = (rawInput) => Effect.gen(function* () { const input = yield* decodeInputOrValidationError({ @@ -617,6 +620,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => respondToRequest, stopSession, listSessions, + getCapabilities, rollbackConversation, stopAll, streamEvents: Stream.fromPubSub(runtimeEventPubSub), diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index bb51ff8377e33c68bb15c7a15f09db282bc3d73a..f6bd4a7ac95a70139393e567bed336cb09735ed8 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -23,6 +23,15 @@ import type { import type { Effect } from "effect"; import type { Stream } from "effect"; +export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported"; + +export interface ProviderAdapterCapabilities { + /** + * Declares whether changing the model on an existing session is supported. + */ + readonly sessionModelSwitch: ProviderSessionModelSwitchMode; +} + export interface ProviderThreadTurnSnapshot { readonly id: ProviderTurnId; readonly items: ReadonlyArray; @@ -38,6 +47,7 @@ export interface ProviderAdapterShape { * Provider kind implemented by this adapter. */ readonly provider: ProviderKind; + readonly capabilities: ProviderAdapterCapabilities; /** * Start a provider-backed session. diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 656d25a9a7bf1c24e03a0756c0bff2fc73877802..07be22f57135ba0ac98ed462b30bc6b235851270 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -13,6 +13,7 @@ */ import type { ProviderInterruptTurnInput, + ProviderKind, ProviderRespondToRequestInput, ProviderRuntimeEvent, ProviderSendTurnInput, @@ -27,6 +28,7 @@ import { ServiceMap } from "effect"; import type { Effect, Stream } from "effect"; import type { ProviderServiceError } from "../Errors.ts"; +import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; /** * ProviderServiceShape - Service API for provider session and turn orchestration. @@ -75,6 +77,13 @@ export interface ProviderServiceShape { */ readonly listSessions: () => Effect.Effect>; + /** + * Read static capabilities for a provider adapter. + */ + readonly getCapabilities: ( + provider: ProviderKind, + ) => Effect.Effect; + /** * Roll back provider conversation state by a number of turns. */ diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index e0dc4a079d4d7b168f4059c45661c5453637a594..ce76f5b75fd0d50bfb5a9a2e1782d3a97e33a9bc 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1170,6 +1170,7 @@ describe("WebSocket Server", () => { respondToRequest: () => unsupported(), stopSession: () => unsupported(), listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e75f6f57949fce5aea4d3017af79eb9b610540d6..62f7c363bc4f3a883666f1470f3fdb966bbf7ad4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -137,7 +137,16 @@ import { MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "./Icons"; +import { + ClaudeAI, + CursorIcon, + Gemini, + Icon, + OpenAI, + OpenCodeIcon, + VisualStudioCode, + Zed, +} from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -670,10 +679,21 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const sessionProvider = activeThread?.session?.provider ?? null; - const selectedProvider: ProviderKind = - (activeThread?.id ? selectedProviderByThread[activeThread.id] : undefined) ?? - sessionProvider ?? - "codex"; + const selectedProviderByThreadId = activeThread?.id + ? selectedProviderByThread[activeThread.id] + : undefined; + const hasThreadStarted = Boolean( + activeThread && + (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null), + ); + const lockedProvider: ProviderKind | null = hasThreadStarted + ? (sessionProvider ?? selectedProviderByThreadId ?? null) + : null; + const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const cursorModelSelectionLockedReason = + hasThreadStarted && selectedProvider === "cursor" + ? "Cursor currently does not support changing models after the first message in a thread." + : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ?? @@ -705,7 +725,10 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; const searchableModelOptions = useMemo( () => - PROVIDER_OPTIONS.filter((option) => option.available).flatMap((option) => + PROVIDER_OPTIONS.filter( + (option) => + option.available && (lockedProvider === null || option.value === lockedProvider), + ).flatMap((option) => getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({ provider: option.value, providerLabel: option.label, @@ -716,12 +739,12 @@ export default function ChatView({ threadId }: ChatViewProps) { searchProvider: option.label.toLowerCase(), })), ), - [], + [lockedProvider], ); useEffect(() => { if (!activeThread?.id || !sessionProvider) return; setSelectedProviderByThread((existing) => { - if (existing[activeThread.id]) return existing; + if (existing[activeThread.id] === sessionProvider) return existing; return { ...existing, [activeThread.id]: sessionProvider }; }); }, [activeThread?.id, sessionProvider]); @@ -2311,8 +2334,16 @@ export default function ChatView({ threadId }: ChatViewProps) { const onProviderModelSelect = useCallback( (provider: ProviderKind, model: ModelSlug) => { - const api = readNativeApi(); if (!activeThread) return; + if (cursorModelSelectionLockedReason !== null && provider === "cursor") { + scheduleComposerFocus(); + return; + } + if (lockedProvider !== null && provider !== lockedProvider) { + scheduleComposerFocus(); + return; + } + const api = readNativeApi(); const resolvedModel = provider === "cursor" ? resolveCursorModelFromSelection(parseCursorModelSelection(model)) @@ -2325,7 +2356,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...existing, [activeThread.id]: resolvedModel, })); - if (api) { + if (api && isServerThread) { void api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), @@ -2335,7 +2366,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } scheduleComposerFocus(); }, - [activeThread, scheduleComposerFocus], + [ + activeThread, + cursorModelSelectionLockedReason, + isServerThread, + lockedProvider, + scheduleComposerFocus, + ], ); const onCursorReasoningSelect = useCallback( (reasoning: CursorReasoningOption) => { @@ -2816,11 +2853,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Provider/model picker */} - + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + + )} {selectedProvider === "cursor" ? ( <> @@ -4048,6 +4107,8 @@ function resolveModelForProviderPicker( const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; + lockedProvider: ProviderKind | null; + disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -4057,9 +4118,25 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; return ( - + { + if (props.disabled) { + setIsMenuOpen(false); + return; + } + setIsMenuOpen(open); + }} + > } + render={ +