diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 967d777dc9..6ab649a02e 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -175,6 +175,7 @@ describe('composer first-send cleanup', () => { const deps = { ...createActionsDeps(), newChatModel: { + llmConnectionId: 'connection-1', llmConnectionSlug: 'opencode-free', model: 'mimo-v2.5-free', }, diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 3b980403cf..6889c68786 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -44,6 +44,7 @@ function session(id: string): DesktopSessionSummary { hasUnread: false, status: 'active', backend: 'fake', + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', connectionLocked: true, model: 'claude-sonnet', @@ -216,6 +217,7 @@ describe('AppShell session settings actions', () => { const harness = createHarness(); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -242,6 +244,7 @@ describe('AppShell session settings actions', () => { }); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -260,6 +263,7 @@ describe('AppShell session settings actions', () => { const harness = createHarness(); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -278,11 +282,13 @@ describe('AppShell session settings actions', () => { }); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'relay', model: 'claude-sonnet', }); harness.modelResult.resolve({ ...session('session-a'), + llmConnectionId: 'connection-1', llmConnectionSlug: 'relay', }); await modelChange; @@ -297,6 +303,7 @@ describe('AppShell session settings actions', () => { const harness = createHarness(); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -318,6 +325,7 @@ describe('AppShell session settings actions', () => { const thinkingChange = harness.actions.setSessionThinkingLevel('high'); await harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); @@ -344,6 +352,7 @@ describe('AppShell session settings actions', () => { assert.deepEqual(harness.errorTargets, [{ sessionId: 'session-a' }]); const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', llmConnectionSlug: 'e2e', model: 'claude-opus', }); diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index cc8c995d42..f56b2a6984 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -19,14 +19,16 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import { pickNewChatModel } from '../../renderer/shell-chat-model-selection.js'; function connection( - overrides: Partial & Pick, -): LlmConnection { + overrides: Partial & + Pick, +): IdentifiedLlmConnection { return { + connectionId: `connection-${overrides.slug}`, name: overrides.slug, defaultModel: '', enabled: true, @@ -49,6 +51,7 @@ describe('model catalog picker helpers', () => { catalogDefault: undefined, choices: [ { + connectionId: 'connection-missing', connectionSlug: 'missing-key-first', providerType: 'anthropic', providerLabel: 'Anthropic', @@ -58,6 +61,7 @@ describe('model catalog picker helpers', () => { thinkingLevels: [], }, { + connectionId: 'connection-ready', connectionSlug: 'ready-second', providerType: 'opencode-free', providerLabel: 'OpenCode Zen', @@ -68,7 +72,11 @@ describe('model catalog picker helpers', () => { }, ], }), - { llmConnectionSlug: 'ready-second', model: 'ready-model' }, + { + llmConnectionId: 'connection-ready', + llmConnectionSlug: 'ready-second', + model: 'ready-model', + }, ); }); it('keeps API connection labels while redacting OAuth account identities', () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts index 6482a9a2bf..e9fd46f15a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts @@ -426,6 +426,7 @@ function session( hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'test-connection', connectionLocked: false, model: 'test-model', diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 6be9f11be2..b318d3b464 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -228,7 +228,7 @@ test('settles revision cleanup when abandon observes an already absent target', assert.equal(await client.removeSessionCopy('revision-copy'), 'removed'); }); -test('merges a configuration patch into each fresh CAS projection', async () => { +test('replays one exact model patch across fresh CAS projections', async () => { const { client, requests } = clientWithResponses([ { kind: 'session', session: session('session-1', 10) }, { kind: 'revision_conflict', expectedRevision: 10, actualRevision: 11 }, @@ -240,16 +240,22 @@ test('merges a configuration patch into each fresh CAS projection', async () => kind: 'committed', session: session('session-1', 12, { collaborationMode: 'plan', - permissionMode: 'ask', + llmConnectionId: 'connection-b', + model: 'model-b', }), }, ]); const updated = await client.updateSessionConfiguration('session-1', { - permissionMode: 'ask', + modelTarget: { + kind: 'explicit', + connectionId: 'connection-b', + connectionSlug: 'test-connection', + model: 'model-b', + }, }); - assert.equal(updated.permissionMode, 'ask'); + assert.equal(updated.llmConnectionId, 'connection-b'); assert.equal(updated.collaborationMode, 'plan'); assert.deepEqual( requests @@ -259,31 +265,25 @@ test('merges a configuration patch into each fresh CAS projection', async () => { sessionId: 'session-1', expectedRevision: 10, - configuration: { + patch: { modelTarget: { kind: 'explicit', + connectionId: 'connection-b', connectionSlug: 'test-connection', - model: 'test-model', + model: 'model-b', }, - thinkingLevel: null, - permissionMode: 'ask', - collaborationMode: 'agent', - orchestrationMode: 'default', }, }, { sessionId: 'session-1', expectedRevision: 11, - configuration: { + patch: { modelTarget: { kind: 'explicit', + connectionId: 'connection-b', connectionSlug: 'test-connection', - model: 'test-model', + model: 'model-b', }, - thinkingLevel: null, - permissionMode: 'ask', - collaborationMode: 'plan', - orchestrationMode: 'default', }, }, ], @@ -1002,6 +1002,7 @@ function session( hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'test-connection', connectionLocked: true, model: 'test-model', diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 887a852605..bb461f95f2 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -175,15 +175,22 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn 'session.configuration.update': async (input) => { assert.ok(projected); assert.equal(input.expectedRevision, projected.revision); + const { thinkingLevel: _thinkingLevel, ...withoutThinkingLevel } = projected; projected = session(projected.id, { - ...projected, + ...(input.patch.thinkingLevel === null ? withoutThinkingLevel : projected), revision: projected.revision + 1, - permissionMode: input.configuration.permissionMode, - collaborationMode: input.configuration.collaborationMode, - orchestrationMode: input.configuration.orchestrationMode, - ...(input.configuration.thinkingLevel === null + ...(input.patch.permissionMode === undefined + ? {} + : { permissionMode: input.patch.permissionMode }), + ...(input.patch.collaborationMode === undefined + ? {} + : { collaborationMode: input.patch.collaborationMode }), + ...(input.patch.orchestrationMode === undefined + ? {} + : { orchestrationMode: input.patch.orchestrationMode }), + ...(input.patch.thinkingLevel === null || input.patch.thinkingLevel === undefined ? {} - : { thinkingLevel: input.configuration.thinkingLevel }), + : { thinkingLevel: input.patch.thinkingLevel }), }); return { ok: true, result: { kind: 'committed', session: projected } }; }, @@ -610,6 +617,7 @@ function session( hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'test-connection', connectionLocked: true, model: 'test-model', diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index ea2d4b24b7..bb7566754c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1240,6 +1240,7 @@ function session(id: string): SessionCatalogProjection { hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'test-connection', connectionLocked: true, model: 'test-model', diff --git a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts index 3a5c46557f..d4d8fa388c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-external-sessions-ipc-main.test.ts @@ -214,6 +214,7 @@ function session(id: string): SessionCatalogProjection { hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'default', connectionLocked: true, model: 'gpt-5', diff --git a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts index 86de22e1c9..3f127d52a9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-search-ipc-main.test.ts @@ -171,6 +171,7 @@ function catalogSession(id: string, name: string): SessionCatalogProjection { hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'zai-live', connectionLocked: true, model: 'glm-5.1', diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts index cc765db891..a173957d97 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts @@ -54,6 +54,7 @@ function projection(overrides: Partial = {}): SessionC hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', connectionLocked: true, model: 'gpt-5', diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts index cba9d1a395..d2c3400c29 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts @@ -116,6 +116,7 @@ function session(id: string): SessionCatalogProjection { hasUnread: false, status: 'active', backend: 'fake', + llmConnectionId: null, llmConnectionSlug: 'fake', connectionLocked: true, model: 'fake-model', diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 18ae97ccbd..07eb80fd96 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -1557,6 +1557,7 @@ function session(cwd = "/workspace", id = 'session-1'): SessionCatalogProjection hasUnread: false, status: "active", backend: "ai-sdk", + llmConnectionId: "connection-1", llmConnectionSlug: "test-connection", connectionLocked: true, model: "test-model", diff --git a/apps/desktop/src/main/__tests__/stale-sessions.test.ts b/apps/desktop/src/main/__tests__/stale-sessions.test.ts index 92d186e864..dfc8771738 100644 --- a/apps/desktop/src/main/__tests__/stale-sessions.test.ts +++ b/apps/desktop/src/main/__tests__/stale-sessions.test.ts @@ -40,9 +40,9 @@ test('derives stale rows from each Session Host readiness projection', () => { connectionLocked: true, }, 'remote-rebind': { - kind: 'rebind', - connectionSlug: 'replacement', - model: 'model', + kind: 'blocked', + reason: 'connection_missing', + connectionLocked: false, }, // #3211: a retired backend reaches the rail as a projection reason like // any other. The row is no longer identified by reading its `backend`. @@ -53,7 +53,7 @@ test('derives stale rows from each Session Host readiness projection', () => { }, }, })], - ['remote-missing', 'legacy-fake'], + ['remote-missing', 'remote-rebind', 'legacy-fake'], ); }); diff --git a/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts index 43d1f59cd9..5fb9c4dd3f 100644 --- a/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts +++ b/apps/desktop/src/main/__tests__/task-readiness-notice.test.ts @@ -26,14 +26,14 @@ import { resolveTaskReadinessModelTarget, } from '../../renderer/task-readiness-notice.js'; -test('an unlocked stale session checks the send projection rebind target', () => { +test('an unlocked stale session keeps its stored target until explicit recovery', () => { assert.deepEqual( resolveTaskReadinessModelTarget( { llmConnectionSlug: 'stale', model: 'removed-model' }, - { kind: 'rebind', connectionSlug: 'healthy', model: 'ready-model' }, + { kind: 'blocked', reason: 'connection_missing', connectionLocked: false }, undefined, ), - { connectionSlug: 'healthy', model: 'ready-model' }, + { connectionSlug: 'stale', model: 'removed-model' }, ); }); diff --git a/apps/desktop/src/main/onboarding-service.ts b/apps/desktop/src/main/onboarding-service.ts index db98878289..84a60cd6d0 100644 --- a/apps/desktop/src/main/onboarding-service.ts +++ b/apps/desktop/src/main/onboarding-service.ts @@ -60,7 +60,7 @@ import { projectSessionSendOutcome, type SessionSendProjection } from '@maka/cor import { type SessionSummary } from '@maka/core/session'; import { buildChatModelChoices, type ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection, LlmConnection } from '@maka/core/llm-connections'; export interface OnboardingSnapshot { state: OnboardingState; @@ -71,14 +71,14 @@ export interface OnboardingSnapshot { */ sessions: SessionSummary[]; /** Default Host connection projection used to seed the shell. */ - connections: LlmConnection[]; + connections: IdentifiedLlmConnection[]; defaultSlug: string | null; chatModelChoices: ChatModelChoice[]; sessionSendOutcomes: Record; } export interface OnboardingServiceDeps { - listConnections(): Promise; + listConnections(): Promise; getDefaultSlug(): Promise; listSessions(): Promise; getMilestones(): Promise; @@ -196,7 +196,7 @@ function buildSnapshot( state: OnboardingState, milestones: OnboardingMilestone[], sessions: SessionSummary[], - connections: LlmConnection[], + connections: IdentifiedLlmConnection[], defaultSlug: string | null, secrets: Readonly>, ): OnboardingSnapshot { @@ -213,7 +213,6 @@ function buildSnapshot( projectSessionSendOutcome({ session, connections, - defaultSlug, hasSecret: (slug) => secrets[slug] ?? false, }), ]), diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index c1b539c1a3..c9815399d7 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -104,7 +104,7 @@ import { type ScheduledTaskChangedFrame, type SessionCatalogItem, type SessionCatalogProjection, - type SessionConfiguration, + type SessionConfigurationPatch, type SessionAssistantStreamIdentity, type SessionContinuitySnapshot, type SessionTranscriptBootstrap, @@ -143,7 +143,7 @@ const MAX_OPTIMISTIC_ATTEMPTS = 3; const MAX_SESSION_REVISION_ATTEMPTS = 8; const MAX_PRICING_SNAPSHOT_ATTEMPTS = 3; -export type DesktopSessionConfigurationPatch = Partial; +export type DesktopSessionConfigurationPatch = SessionConfigurationPatch; /** * How a remove settled. `restored` is not a failure: the task left the state @@ -900,23 +900,7 @@ export class DesktopRuntimeHostClient { this.request("session.configuration.update", { sessionId, expectedRevision: current.revision, - configuration: { - // An unlocked Session still follows the Host-owned default route. - // Once execution or an explicit model change locks it, the resolved - // catalog route is the explicit target that must survive this patch. - modelTarget: current.connectionLocked - ? { - kind: "explicit", - connectionSlug: current.llmConnectionSlug, - model: current.model, - } - : { kind: "default" }, - thinkingLevel: current.thinkingLevel ?? null, - permissionMode: current.permissionMode, - collaborationMode: current.collaborationMode, - orchestrationMode: current.orchestrationMode, - ...definedPatch, - }, + patch: definedPatch, }), ); } diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index 64716cf624..78951d41c4 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -20,6 +20,7 @@ import type { ConnectionTestResult, CreateConnectionInput, + IdentifiedLlmConnection, LlmConnection, SavedRequestHeaders, UpdateConnectionInput, @@ -325,13 +326,16 @@ export function projectHostConnectionTest(result: ConnectionTestRunResult): Conn }; } -export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmConnection[] { +export function projectHostConnections( + catalog: ConnectionCatalogSnapshot, +): IdentifiedLlmConnection[] { return catalog.connections.map((connection) => { const defaultModel = catalog.defaultTarget?.connectionId === connection.connectionId ? catalog.defaultTarget.modelId : ''; return { + connectionId: connection.connectionId, slug: connection.slug, name: connection.name, providerType: connection.providerType, diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index c2ccd258d8..1c9325ec0d 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -294,19 +294,23 @@ function normalizeSessionListFilter(value: unknown): SessionListFilter | undefin } function normalizeModelTarget(input: CreateSessionRequestInput | undefined): SessionModelTarget { + const connectionId = normalizeOptionalString(input?.llmConnectionId, 'model connection id'); const slug = normalizeOptionalString(input?.llmConnectionSlug, 'model connection'); const model = normalizeOptionalString(input?.model, 'model'); - if (slug === undefined && model === undefined) return { kind: 'default' }; - if (slug === undefined || model === undefined) { - throw new Error('Explicit model selection requires both connection and model'); + if (connectionId === undefined && slug === undefined && model === undefined) { + return { kind: 'default' }; } - return { kind: 'explicit', connectionSlug: slug, model }; + if (connectionId === undefined || slug === undefined || model === undefined) { + throw new Error('Explicit model selection requires connection id, connection, and model'); + } + return { kind: 'explicit', connectionId, connectionSlug: slug, model }; } function normalizeExplicitModel(input: unknown): Extract { const selection = normalizeSessionModelSelection(input); return { kind: 'explicit', + connectionId: selection.llmConnectionId, connectionSlug: selection.llmConnectionSlug, model: selection.model, }; diff --git a/apps/desktop/src/main/session-model-input.ts b/apps/desktop/src/main/session-model-input.ts index 3e476bc6f4..82f105b48f 100644 --- a/apps/desktop/src/main/session-model-input.ts +++ b/apps/desktop/src/main/session-model-input.ts @@ -18,6 +18,7 @@ */ export interface SessionModelSelection { + llmConnectionId: string; llmConnectionSlug: string; model: string; } @@ -27,9 +28,10 @@ export function normalizeSessionModelSelection(input: unknown): SessionModelSele throw new Error('Invalid model selection'); } const record = input as Record; + const llmConnectionId = normalizeRequiredString(record.llmConnectionId, 'model connection id'); const llmConnectionSlug = normalizeRequiredString(record.llmConnectionSlug, 'model connection'); const model = normalizeRequiredString(record.model, 'model'); - return { llmConnectionSlug, model }; + return { llmConnectionId, llmConnectionSlug, model }; } function normalizeRequiredString(value: unknown, label: string): string { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1b2482273a..73e43a1a8b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -182,7 +182,7 @@ export interface OnboardingSnapshot { state: OnboardingState; milestones: OnboardingMilestone[]; sessions: DesktopSessionSummary[]; - connections: import('@maka/core/llm-connections').LlmConnection[]; + connections: import('@maka/core/llm-connections').IdentifiedLlmConnection[]; defaultSlug: string | null; chatModelChoices: import('@maka/core/chat-model-choice').ChatModelChoice[]; sessionSendOutcomes: Record; @@ -966,7 +966,7 @@ export interface MakaBridge { executionId: string; }>; abandonPlanExecution(sessionId: string, executionId: string): Promise; - setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise; + setModel(sessionId: string, input: { llmConnectionId: string; llmConnectionSlug: string; model: string }): Promise; setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise; /** * `requireArchived` holds the caller's premise through the deletion: a task diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d3a6893433..d5a1146668 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1998,7 +1998,7 @@ const makaBridge = { abandonPlanExecution(sessionId: string, executionId: string): Promise { return invokeProjectedSessionRuntimeHost('plan-mode:abandonExecution', sessionId, executionId); }, - setModel(sessionId: string, input: { llmConnectionSlug: string; model: string }): Promise { + setModel(sessionId: string, input: { llmConnectionId: string; llmConnectionSlug: string; model: string }): Promise { return invokeSessionSummary('sessions:setModel', sessionId, input); }, setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise { diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 3c74ddfd47..b965ff422e 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -84,6 +84,7 @@ type MessageLoadErrorUpdater = (updater: (current: Record) => Re type InteractionQueueUpdater = (updater: (current: InteractionQueues) => InteractionQueues) => void; type PendingNewChatModel = { + llmConnectionId: string; llmConnectionSlug: string; model: string; } | null; @@ -385,6 +386,7 @@ export function createAppShellChatActions(deps: { name: DEFAULT_SESSION_NAME, ...(newChatModel ? { + llmConnectionId: newChatModel.llmConnectionId, llmConnectionSlug: newChatModel.llmConnectionSlug, model: newChatModel.model, } diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 3140d55a26..1b01820dce 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -51,7 +51,11 @@ type ToastApi = { export interface AppShellSessionSettingsActions { setPermissionMode(mode: PermissionMode): Promise; - setSessionModel(input: { llmConnectionSlug: string; model: string }): Promise; + setSessionModel(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): Promise; setSessionThinkingLevel(level: ThinkingLevel | undefined): Promise; } @@ -64,7 +68,7 @@ export function createAppShellSessionSettingsActions(deps: { pendingSessionModelChangesRef: RefBox>; refreshSessions: () => Promise; saveComposerDefaults: (patch: { - model: { llmConnectionSlug: string; model: string }; + model: { llmConnectionId: string; llmConnectionSlug: string; model: string }; }) => void; sessionsRef: RefBox; /** Persists the chat default; awaited so a failure surfaces as one. */ @@ -166,7 +170,11 @@ export function createAppShellSessionSettingsActions(deps: { } } - async function setSessionModel(input: { llmConnectionSlug: string; model: string }) { + async function setSessionModel(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }) { const sessionId = activeIdRef.current; if (!sessionId) return; const previous = sessionsRef.current.find((session) => session.id === sessionId); diff --git a/apps/desktop/src/renderer/composer-defaults.ts b/apps/desktop/src/renderer/composer-defaults.ts index 01c6890923..e161ebb222 100644 --- a/apps/desktop/src/renderer/composer-defaults.ts +++ b/apps/desktop/src/renderer/composer-defaults.ts @@ -30,7 +30,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage'; const STORAGE_KEY = 'maka-composer-defaults-v1'; export interface ComposerDefaults { - model: { llmConnectionSlug: string; model: string } | null; + model: { llmConnectionId?: string; llmConnectionSlug: string; model: string } | null; } const EMPTY: ComposerDefaults = { @@ -40,10 +40,18 @@ const EMPTY: ComposerDefaults = { function isString(value: unknown): value is string { return typeof value === 'string'; } -function isModel(value: unknown): value is { llmConnectionSlug: string; model: string } { +function isModel(value: unknown): value is { + llmConnectionId?: string; + llmConnectionSlug: string; + model: string; +} { if (!value || typeof value !== 'object') return false; const record = value as Record; - return isString(record.llmConnectionSlug) && isString(record.model); + return ( + (record.llmConnectionId === undefined || isString(record.llmConnectionId)) && + isString(record.llmConnectionSlug) && + isString(record.model) + ); } function parse(raw: string | null): ComposerDefaults | null { diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 14fe0721d6..67ddf45ade 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -18,6 +18,7 @@ */ import type { ChatConfigurationReason } from '@maka/core/connection-readiness'; +import type { SessionSendProjection } from '@maka/core/session-send-projection'; import type { ModelCallKind } from '@maka/core/model-call-attempt'; @@ -320,7 +321,10 @@ export interface DesktopConversationCopy { }; }; health: { - blocked: Record string }>; + blocked: Record< + Extract['reason'], + { label: string; tooltip: (connection: string, model: string) => string } + >; reauth: { label: string; tooltip: string }; testError: { label: string; tooltip: string }; }; @@ -625,7 +629,9 @@ const COPY = { fake_backend: { label: '任务已过期 · 请先配置真实模型', tooltip: () => '原任务使用旧的本地模拟连接,需要先到 设置 · 模型 添加并启用一个真实模型才能发送。' }, provider_retired: { label: '登录方式已停用', tooltip: (name) => `任务绑定的连接 "${name}" 使用的登录方式已从 Maka 移除,发送会失败。请到 设置 · 模型 改用其他连接。` }, missing_default_connection: { label: '未配置可用模型', tooltip: () => '当前任务没有可用的模型连接,发送会失败。请到 设置 · 模型 添加并启用一个模型。' }, - connection_missing: { label: '连接已删除', tooltip: () => '此任务依赖的模型连接已被删除,发送会失败。请到 设置 · 模型 检查连接配置。' }, + legacy_connection_identity: { label: '需要选择账号', tooltip: () => '此任务创建于账号实体绑定之前。请显式选择一个账号后再继续。' }, + connection_missing: { label: '原账号已删除', tooltip: () => '此任务绑定的原账号已被删除。只有显式选择新账号后才能继续。' }, + connection_identity_mismatch: { label: '账号身份不匹配', tooltip: () => '此任务保存的账号身份与当前连接不一致。请显式选择一个账号后再继续。' }, connection_disabled: { label: '连接已禁用', tooltip: (name) => `任务绑定的连接 "${name}" 已禁用,发送会失败。请到 设置 · 模型 启用它或选择其他连接。` }, missing_api_key: { label: '连接缺少密钥', tooltip: (name) => `连接 "${name}" 未填写 API key 或未完成登录,发送会失败。请到 设置 · 模型 补齐凭据。` }, missing_model: { label: '连接未选择模型', tooltip: (name) => `连接 "${name}" 没有默认模型,发送会失败。请到 设置 · 模型 选择一个模型。` }, @@ -856,7 +862,9 @@ const COPY = { fake_backend: { label: 'Stale task · Configure a real model', tooltip: () => 'This task used the retired local simulation. Add and enable a real model in Settings · Models before sending.' }, provider_retired: { label: 'Sign-in retired', tooltip: (name) => `The sign-in that connection "${name}" uses was removed from Maka, so sending fails. Switch to another connection in Settings · Models.` }, missing_default_connection: { label: 'No model configured', tooltip: () => 'This task has no available model connection. Add and enable one in Settings · Models.' }, - connection_missing: { label: 'Connection deleted', tooltip: () => 'The model connection used by this task was deleted. Check Settings · Models.' }, + legacy_connection_identity: { label: 'Choose an account', tooltip: () => 'This task predates account entity binding. Explicitly choose an account to continue.' }, + connection_missing: { label: 'Original account deleted', tooltip: () => 'The original account bound to this task was deleted. Explicitly choose a new account to continue.' }, + connection_identity_mismatch: { label: 'Account identity mismatch', tooltip: () => 'This task\'s saved account identity no longer matches its connection. Explicitly choose an account to continue.' }, connection_disabled: { label: 'Connection disabled', tooltip: (name) => `Connection "${name}" is disabled. Enable it or choose another connection in Settings · Models.` }, missing_api_key: { label: 'Connection credentials missing', tooltip: (name) => `Connection "${name}" has no API key or completed sign-in. Add credentials in Settings · Models.` }, missing_model: { label: 'No model selected', tooltip: (name) => `Connection "${name}" has no default model. Select one in Settings · Models.` }, diff --git a/apps/desktop/src/renderer/session-health-notice.ts b/apps/desktop/src/renderer/session-health-notice.ts index ddcc2dbcf3..fc137033ca 100644 --- a/apps/desktop/src/renderer/session-health-notice.ts +++ b/apps/desktop/src/renderer/session-health-notice.ts @@ -42,7 +42,7 @@ * stays silent. */ -import { type LlmConnection } from '@maka/core/llm-connections'; +import { type IdentifiedLlmConnection } from '@maka/core/llm-connections'; import { type SessionSendProjection, type SessionSendProjectionSession } from '@maka/core/session-send-projection'; @@ -61,7 +61,7 @@ export interface SessionHealthNoticeInput { /** Main-process projection from the latest onboarding snapshot. */ outcome: SessionSendProjection | undefined; /** Persisted connections are used only to name a blocked session's own connection. */ - connections: readonly LlmConnection[]; + connections: readonly IdentifiedLlmConnection[]; /** * The session's own connection's most recent credential test result. * Advisory reminder only — never interpreted as a send block (E4). @@ -91,7 +91,6 @@ export function deriveSessionHealthNotice( if (!session || !outcome) return undefined; if (outcome.kind === 'blocked') return blockedNotice(outcome, input); - if (outcome.kind === 'rebind') return undefined; return credentialReminderNotice(input.lastTestStatus, input.locale); } @@ -100,7 +99,11 @@ function blockedNotice( input: SessionHealthNoticeInput, ): SessionHealthNotice { const session = input.session!; - const own = input.connections.find((connection) => connection.slug === session.llmConnectionSlug); + const own = input.connections.find( + (connection) => + connection.connectionId === session.llmConnectionId && + connection.slug === session.llmConnectionSlug, + ); const name = own?.name ?? session.llmConnectionSlug; const copy = getDesktopConversationCopy(input.locale).health.blocked[outcome.reason]; return { diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 40e48cb63d..5662117c54 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -34,7 +34,7 @@ import type { UpdateAppSettingsResult, } from '@maka/core/settings'; import type { ThinkingLevel } from '@maka/core/model-thinking'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; import type { TestProxyInput } from "@maka/core/settings/network-settings"; import { buildChatModelChoices } from "@maka/core/chat-model-choice"; import { @@ -74,7 +74,7 @@ import { SettingsRowSkeleton } from './settings-skeleton.js'; export function GeneralSettingsPage(props: { settings: AppSettings; - connections: readonly LlmConnection[]; + connections: readonly IdentifiedLlmConnection[]; defaultSlug: string | null; connectionsBridge: Pick | undefined; runtimeHostAvailabilityStatus: 'loading' | 'ready' | 'unavailable' | 'error'; @@ -482,7 +482,7 @@ const FOLLOW_MODEL_DEFAULT = "__follow_model__"; const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; function GeneralDefaultsCard(props: { - connections: readonly LlmConnection[]; + connections: readonly IdentifiedLlmConnection[]; defaultSlug: string | null; connectionsBridge: Pick | undefined; connectionsAvailable: boolean; diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index cdfb1d7bf9..bd7c59a4b8 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -18,14 +18,14 @@ */ import type { AppSettings } from '@maka/core/settings'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; import type { DesktopRuntimeHostProfileSnapshot, DesktopRuntimeHostRef, } from '../../preload/bridge-contract.js'; export interface RuntimeHostConnectionsSnapshot { - readonly connections: LlmConnection[]; + readonly connections: IdentifiedLlmConnection[]; readonly defaultSlug: string | null; } diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index a9f3dd92f5..a889406f3d 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -49,7 +49,7 @@ import type { UsageRange, UsageStats, } from '@maka/core/settings'; -import type { LlmConnection, ProviderType } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection, ProviderType } from '@maka/core/llm-connections'; import type { DesktopRuntimeHostProfileChangedEvent, DesktopRuntimeHostProfileSnapshot, @@ -1020,7 +1020,7 @@ function SettingsPageBody(props: { section: SettingsSection; settings: AppSettings; usageStats: UsageStats | null; - connections: LlmConnection[]; + connections: IdentifiedLlmConnection[]; connectionsBridge: RuntimeHostSettingsConnectionsBridge | undefined; defaultSlug: string | null; runtimeHost: DesktopRuntimeHostRef | undefined; diff --git a/apps/desktop/src/renderer/shell-chat-model-selection.ts b/apps/desktop/src/renderer/shell-chat-model-selection.ts index 7e5769153d..34fae3045a 100644 --- a/apps/desktop/src/renderer/shell-chat-model-selection.ts +++ b/apps/desktop/src/renderer/shell-chat-model-selection.ts @@ -19,28 +19,60 @@ import type { ChatModelChoice } from '@maka/core/chat-model-choice'; -export type NewChatModel = { llmConnectionSlug: string; model: string }; +export type NewChatModel = { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; +}; + +export type NewChatModelCandidate = Omit & { + llmConnectionId?: string; +}; export function pickNewChatModel(input: { - pending: NewChatModel | null; - activationCandidate?: NewChatModel; - catalogDefault: NewChatModel | undefined; + pending: NewChatModelCandidate | null; + activationCandidate?: NewChatModelCandidate; + catalogDefault: NewChatModelCandidate | undefined; choices: readonly ChatModelChoice[]; }): NewChatModel | undefined { for (const candidate of [input.pending, input.activationCandidate, input.catalogDefault]) { - if (candidate && input.choices.some( - (choice) => choice.connectionSlug === candidate.llmConnectionSlug && choice.model === candidate.model, - )) return candidate; + if (!candidate) continue; + const choice = input.choices.find( + (entry) => + entry.connectionSlug === candidate.llmConnectionSlug && entry.model === candidate.model, + ); + if (choice && + (candidate.llmConnectionId === undefined || candidate.llmConnectionId === choice.connectionId)) { + return { + llmConnectionId: choice.connectionId, + llmConnectionSlug: choice.connectionSlug, + model: choice.model, + }; + } } const first = input.choices[0]; - return first ? { llmConnectionSlug: first.connectionSlug, model: first.model } : undefined; + return first + ? { + llmConnectionId: first.connectionId, + llmConnectionSlug: first.connectionSlug, + model: first.model, + } + : undefined; } export function chatModelChoiceLabel( choices: readonly ChatModelChoice[], + connectionId: string | undefined, connectionSlug: string | undefined, model: string | undefined, ): string | undefined { if (!connectionSlug || !model) return model; - return choices.find((choice) => choice.connectionSlug === connectionSlug && choice.model === model)?.label ?? model; + return ( + choices.find( + (choice) => + (connectionId === undefined || choice.connectionId === connectionId) && + choice.connectionSlug === connectionSlug && + choice.model === model, + )?.label ?? model + ); } diff --git a/apps/desktop/src/renderer/task-readiness-notice.ts b/apps/desktop/src/renderer/task-readiness-notice.ts index 5c4d2a12ab..3d09e170c9 100644 --- a/apps/desktop/src/renderer/task-readiness-notice.ts +++ b/apps/desktop/src/renderer/task-readiness-notice.ts @@ -24,19 +24,13 @@ import type { TaskSubmissionReadinessDimension, TaskSubmissionReadinessSnapshot import type { UiLocale } from '@maka/core/ui-locale'; /** - * Selects the model target for the renderer's readiness probe. A `rebind` - * projection supplies a compatible target for an empty legacy session; it - * does not mutate the session or admit the submission. Runtime Host owns - * those decisions. + * Selects the stored model target for the renderer's readiness probe. */ export function resolveTaskReadinessModelTarget( session: { llmConnectionSlug: string; model: string } | undefined, - sendOutcome: SessionSendProjection | undefined, + _sendOutcome: SessionSendProjection | undefined, newTaskTarget: { llmConnectionSlug: string; model: string } | undefined, ): { connectionSlug?: string; model?: string } { - if (session && sendOutcome?.kind === 'rebind') { - return optionalModelTarget(sendOutcome.connectionSlug, sendOutcome.model); - } return optionalModelTarget( session?.llmConnectionSlug ?? newTaskTarget?.llmConnectionSlug, session?.model ?? newTaskTarget?.model, diff --git a/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts index dc5bdcd8d7..01d578ddfc 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -19,7 +19,7 @@ import { useMemo } from 'react'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; import type { SessionSendProjection } from '@maka/core/session-send-projection'; import type { SessionSummary } from '@maka/core/session'; import type { SettingsSection } from '@maka/core/settings'; @@ -29,6 +29,7 @@ import { chatModelChoiceLabel, pickNewChatModel, type NewChatModel, + type NewChatModelCandidate, } from './shell-chat-model-selection'; import { deriveSessionHealthNotice } from './session-health-notice'; import type { ComposerDefaults } from './composer-defaults'; @@ -58,12 +59,12 @@ export type SessionHealthNoticeView = { */ export function useShellChatModel(options: { uiLocale: UiLocale; - connections: LlmConnection[]; + connections: IdentifiedLlmConnection[]; chatModelChoices: ChatModelChoice[]; sessionSendOutcome: SessionSendProjection | undefined; defaultConnection: string | null; newTaskKey: string; - activationCandidate?: NewChatModel; + activationCandidate?: NewChatModelCandidate; activeSession: SessionSummary | undefined; persistedComposerDefaults: ComposerDefaults | null; usePersistedComposerDefaults: boolean; @@ -72,7 +73,7 @@ export function useShellChatModel(options: { openSettingsSection: (section: SettingsSection) => void; }): { chatModelChoices: ChatModelChoice[]; - activeConnection: LlmConnection | undefined; + activeConnection: IdentifiedLlmConnection | undefined; activeConnectionLabel: string | undefined; activeModel: string | undefined; activeModelLabel: string | undefined; @@ -82,8 +83,8 @@ export function useShellChatModel(options: { newChatModelLabel: string | undefined; newChatThinkingLevels: readonly ThinkingLevel[]; newChatThinkingLevel: ThinkingLevel | undefined; - pendingNewChatModel: NewChatModel | null; - setPendingNewChatModel: (next: NewChatModel | null) => void; + pendingNewChatModel: NewChatModelCandidate | null; + setPendingNewChatModel: (next: NewChatModelCandidate | null) => void; pendingNewChatThinkingLevel: ThinkingLevel | null; setPendingNewChatThinkingLevel: (next: ThinkingLevel | null) => void; sessionHealthNotice: SessionHealthNoticeView | undefined; @@ -91,7 +92,7 @@ export function useShellChatModel(options: { const { uiLocale, connections, defaultConnection, activationCandidate, activeSession, persistedComposerDefaults, openSettingsSection } = options; const conversationCopy = getDesktopConversationCopy(uiLocale); const [pendingNewChatModelChoice, setPendingNewChatModel] = useNewTaskChoice< - NewChatModel | null + NewChatModelCandidate | null >( options.newTaskKey, ); @@ -101,7 +102,12 @@ export function useShellChatModel(options: { ? persistedComposerDefaults?.model ?? null : null; const activeConnection = activeSession - ? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug) + ? connections.find( + (connection) => + activeSession.llmConnectionId !== undefined && + connection.connectionId === activeSession.llmConnectionId && + connection.slug === activeSession.llmConnectionSlug, + ) : undefined; const { chatModelChoices } = options; // Home / empty-state composer: which model the next NEW chat starts with. @@ -131,7 +137,11 @@ export function useShellChatModel(options: { (choice) => choice.connectionSlug === defaultConnection && choice.isDefault, ); const catalogDefaultNewChatModel = catalogDefaultChoice - ? { llmConnectionSlug: catalogDefaultChoice.connectionSlug, model: catalogDefaultChoice.model } + ? { + llmConnectionId: catalogDefaultChoice.connectionId, + llmConnectionSlug: catalogDefaultChoice.connectionSlug, + model: catalogDefaultChoice.model, + } : undefined; const newChatModel = pickNewChatModel({ pending: pendingNewChatModel, @@ -154,12 +164,23 @@ export function useShellChatModel(options: { : activeSession?.model || activeConnection?.defaultModel; const activeModelLabel = isRetiredBackend ? undefined - : chatModelChoiceLabel(chatModelChoices, activeSession?.llmConnectionSlug, activeModel); + : activeSession?.llmConnectionId + ? chatModelChoiceLabel( + chatModelChoices, + activeSession.llmConnectionId, + activeSession.llmConnectionSlug, + activeModel, + ) + : activeModel; const activeThinkingLevels = useMemo( - () => chatModelChoices.find( - (choice) => choice.connectionSlug === activeSession?.llmConnectionSlug && choice.model === activeModel, - )?.thinkingLevels ?? [], - [activeSession?.llmConnectionSlug, activeModel, chatModelChoices], + () => + chatModelChoices.find( + (choice) => + choice.connectionId === activeSession?.llmConnectionId && + choice.connectionSlug === activeSession?.llmConnectionSlug && + choice.model === activeModel, + )?.thinkingLevels ?? [], + [activeSession?.llmConnectionId, activeSession?.llmConnectionSlug, activeModel, chatModelChoices], ); // Only surface a stored level when the current model still supports it; // if the model changed (setModel clears it) or the catalog reconfigured so @@ -174,7 +195,10 @@ export function useShellChatModel(options: { () => { if (!newChatModel) return []; return chatModelChoices.find( - (choice) => choice.connectionSlug === newChatModel.llmConnectionSlug && choice.model === newChatModel.model, + (choice) => + choice.connectionId === newChatModel.llmConnectionId && + choice.connectionSlug === newChatModel.llmConnectionSlug && + choice.model === newChatModel.model, )?.thinkingLevels ?? []; }, [newChatModel, chatModelChoices], @@ -185,7 +209,12 @@ export function useShellChatModel(options: { const newChatThinkingLevel = requestedNewChatThinkingLevel && newChatThinkingLevels.includes(requestedNewChatThinkingLevel) ? requestedNewChatThinkingLevel : undefined; - const newChatModelLabel = chatModelChoiceLabel(chatModelChoices, newChatModel?.llmConnectionSlug, newChatModel?.model); + const newChatModelLabel = chatModelChoiceLabel( + chatModelChoices, + newChatModel?.llmConnectionId, + newChatModel?.llmConnectionSlug, + newChatModel?.model, + ); // Notice derivation is a pure function (see `session-health-notice.ts`); we // wrap the returned `onClickTarget` here with the Settings-jump action. diff --git a/apps/desktop/src/shared/desktop-connection-snapshot.ts b/apps/desktop/src/shared/desktop-connection-snapshot.ts index fbdfc2006d..3a75acff13 100644 --- a/apps/desktop/src/shared/desktop-connection-snapshot.ts +++ b/apps/desktop/src/shared/desktop-connection-snapshot.ts @@ -18,10 +18,10 @@ */ import type { ChatModelChoice } from '@maka/core/chat-model-choice'; -import type { LlmConnection } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection } from '@maka/core/llm-connections'; export interface DesktopConnectionSnapshot { - readonly connections: LlmConnection[]; + readonly connections: IdentifiedLlmConnection[]; readonly defaultConnection: string | null; readonly chatModelChoices: ChatModelChoice[]; } diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index dfcbed939f..6a67666a3f 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2945,10 +2945,12 @@ describe('Maka Pi TUI runner', () => { driver, cwd: '/repo', model: 'gpt-5.5', + connectionId: 'connection-openai', connectionSlug: 'openai', providerType: 'openai', modelChoices: [ { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', @@ -2957,6 +2959,7 @@ describe('Maka Pi TUI runner', () => { isDefaultConnection: true, }, { + connectionId: 'connection-zai', connectionSlug: 'zai', connectionName: 'Z.ai', providerType: 'openai', @@ -3113,10 +3116,12 @@ describe('Maka Pi TUI runner', () => { driver, cwd: '/repo', model: 'gpt-5.5', + connectionId: 'connection-openai', connectionSlug: 'openai', providerType: 'openai', modelChoices: [ { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', @@ -3124,6 +3129,7 @@ describe('Maka Pi TUI runner', () => { isDefaultConnection: true, }, { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', @@ -3168,10 +3174,12 @@ describe('Maka Pi TUI runner', () => { driver, cwd: '/repo', model: 'shared-model', + connectionId: 'connection-primary', connectionSlug: 'primary', providerType: 'openai', modelChoices: [ { + connectionId: 'connection-primary', connectionSlug: 'primary', connectionName: 'Primary', providerType: 'openai', @@ -3179,6 +3187,7 @@ describe('Maka Pi TUI runner', () => { isDefaultConnection: true, }, { + connectionId: 'connection-relay', connectionSlug: 'relay', connectionName: 'Relay', providerType: 'openai', @@ -6573,6 +6582,58 @@ describe('Maka Pi TUI runner', () => { await run; }); + test('reports a deleted original account and recovers only through an exact model pick', async () => { + const terminal = new FakeTerminal(); + const deleted = { + ...fakeSessionSummary('session-2'), + llmConnectionId: 'connection-a', + llmConnectionSlug: 'shared', + model: 'shared-model', + }; + const driver = new SlashCommandDriver([deleted]); + const run = runMakaPiTui({ + title: 'Maka', + locale: 'en', + driver, + cwd: '/repo', + model: deleted.model, + connectionId: deleted.llmConnectionId, + connectionSlug: deleted.llmConnectionSlug, + connectionIdentities: [ + { connectionId: 'connection-b', connectionSlug: 'shared', enabled: true }, + ], + modelChoices: [ + { + connectionId: 'connection-b', + connectionSlug: 'shared', + connectionName: 'Replacement', + providerType: 'openai', + model: 'shared-model', + isDefaultConnection: true, + }, + ], + permissionMode: 'ask', + terminal, + resumeSessionId: deleted.id, + }); + + await waitFor(() => driver.sessionIds.length === 1); + await waitForTuiPaint(terminal); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('The original account was deleted'), + ); + terminal.input('/model'); + terminal.input('\r'); + await waitFor(() => terminal.output().includes('Replacement')); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /Replacement · current/); + terminal.input('\r'); + await waitFor(() => driver.modelConnectionIds.length === 1); + assert.deepEqual(driver.modelConnectionIds, ['connection-b']); + + exitMaka(terminal); + await run; + }); + test('resumes an active Host turn from its atomic transcript and continues live output', async () => { const terminal = new FakeTerminal(); const driver = new ActiveResumeDriver(); @@ -7631,6 +7692,7 @@ class SlashCommandDriver implements MakaSessionDriver { readonly displayPrompts: string[] = []; readonly models: string[] = []; readonly modelConnections: Array = []; + readonly modelConnectionIds: Array = []; readonly permissionModes: PermissionMode[] = []; readonly thinkingLevelUpdates: Array = []; readonly orchestrationModes: OrchestrationMode[] = []; @@ -7778,9 +7840,10 @@ class SlashCommandDriver implements MakaSessionDriver { async stop(): Promise {} async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} - async setModel(model: string, connectionSlug?: string): Promise { + async setModel(model: string, connectionSlug?: string, connectionId?: string): Promise { this.models.push(model); this.modelConnections.push(connectionSlug); + this.modelConnectionIds.push(connectionId); } async renameSession(name: string): Promise { this.renames.push(name); diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 94165620fc..7f5dc70c83 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -1748,6 +1748,7 @@ function sessionProjection(id: string): SessionCatalogProjection { hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', connectionLocked: true, model: 'gpt-5', diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 69467fbcbd..56a1c1b75e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -37,6 +37,7 @@ import { type OperationOutput, type SessionCatalogProjection, type SessionContinuitySnapshot, + type SessionUpdateResult, type SubscriptionFrame, } from '@maka/runtime-host/protocol'; import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; @@ -85,6 +86,7 @@ describe('Runtime Host Maka Session driver', () => { connection: new FakeConnection([]).value, cwd: '/client/workspace', workspace: { kind: 'project', projectId: 'project-1' }, + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', executionLocation: { kind: 'host' }, @@ -103,6 +105,7 @@ describe('Runtime Host Maka Session driver', () => { const driverWithoutProject = createRuntimeHostMakaSessionDriver({ connection: new FakeConnection([]).value, cwd: '/client/workspace', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', executionLocation: { kind: 'host' }, @@ -110,6 +113,7 @@ describe('Runtime Host Maka Session driver', () => { await assert.rejects( driverWithoutProject.createSession({ cwd: '/client/workspace', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -128,6 +132,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: () => 'session-id', @@ -143,6 +148,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.createSession({ cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -210,6 +216,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: () => 'session-id', @@ -224,6 +231,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.createSession({ cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -345,6 +353,7 @@ describe('Runtime Host Maka Session driver', () => { connection: connection.value, cwd: '/repo', workspace: { kind: 'project', projectId: 'project-a' }, + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: () => 'session-id', @@ -353,6 +362,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.createSession({ cwd: candidate.cwd, ...('projectId' in candidate ? { projectId: candidate.projectId } : {}), + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -366,6 +376,7 @@ describe('Runtime Host Maka Session driver', () => { name: 'New Chat', modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'openai-main', model: 'gpt-5', }, @@ -375,6 +386,63 @@ describe('Runtime Host Maka Session driver', () => { } }); + test('submits a same-slug model recovery with the newly selected Connection id', async () => { + const connection = new FakeConnection([ + new FakeSubscription(continuitySnapshot(), Promise.resolve([])), + ]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/repo', + llmConnectionId: 'connection-a', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + newId: () => 'session-id', + }); + + await driver.createSession({ + cwd: '/repo', + llmConnectionId: 'connection-a', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + connection.sessionQueries.push( + sessionProjection({ revision: 1, llmConnectionId: 'connection-a' }), + sessionProjection({ revision: 2, llmConnectionId: 'connection-a' }), + ); + connection.configurationOutcomes.push( + { kind: 'revision_conflict', expectedRevision: 1, actualRevision: 2 }, + { + kind: 'committed', + session: sessionProjection({ + revision: 3, + llmConnectionId: 'connection-b', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }), + }, + ); + await driver.setModel('gpt-5', 'openai-main', 'connection-b'); + + assert.deepEqual( + connection.requests + .filter(({ operation }) => operation === 'session.configuration.update') + .map(({ input }) => input), + [1, 2].map((expectedRevision) => ({ + sessionId: 'session-id', + expectedRevision, + patch: { + modelTarget: { + kind: 'explicit', + connectionId: 'connection-b', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, + thinkingLevel: null, + }, + })), + ); + }); + test('drops a per-session Full access elevation when a fresh Session starts (#3020)', async () => { // The TUI flow behind /new: session A is elevated to bypass, then the // driver is asked to start over. The next prompt lazily creates session B @@ -401,6 +469,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', prospectivePermissionMode: 'ask', @@ -409,6 +478,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.createSession({ cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -436,6 +506,7 @@ describe('Runtime Host Maka Session driver', () => { name: 'New Chat', modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'openai-main', model: 'gpt-5', }, @@ -463,6 +534,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: root, + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', inspectCwdChanges: async (cwd) => { @@ -509,6 +581,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: process.cwd(), + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -530,6 +603,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -561,6 +635,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -602,6 +677,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -634,6 +710,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -683,6 +760,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 60, @@ -749,6 +827,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -821,6 +900,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -896,6 +976,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -957,6 +1038,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1005,6 +1087,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1068,6 +1151,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1098,6 +1182,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: sequenceIds('message-1', 'retract-1'), @@ -1143,6 +1228,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 75, @@ -1173,6 +1259,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1212,6 +1299,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1283,6 +1371,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: () => 'side-1', @@ -1331,6 +1420,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: new FakeConnection([subscription]).value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1365,6 +1455,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1397,6 +1488,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: sequenceIds('turn-2'), @@ -1426,6 +1518,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: sequenceIds('turn-skill'), @@ -1458,6 +1551,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1488,6 +1582,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1528,6 +1623,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1563,6 +1659,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1604,6 +1701,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1640,6 +1738,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -1681,6 +1780,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -1727,6 +1827,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -1755,6 +1856,7 @@ class FakeConnection { readonly goalControlOutcomes: Array = []; /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ readonly goalQueryResults: Array = []; + readonly configurationOutcomes: SessionUpdateResult[] = []; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -1837,11 +1939,13 @@ class FakeConnection { } if (operation === 'session.configuration.update') { const update = input as OperationInput<'session.configuration.update'>; + const outcome = this.configurationOutcomes.shift(); + if (outcome) return outcome as OperationOutput; return { kind: 'committed', session: sessionProjection({ revision: update.expectedRevision + 1, - permissionMode: update.configuration.permissionMode, + permissionMode: update.patch.permissionMode ?? 'ask', }), } as OperationOutput; } @@ -2061,6 +2165,7 @@ function sessionProjection( hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', connectionLocked: true, model: 'gpt-5', @@ -2339,6 +2444,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2537,6 +2643,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2572,6 +2679,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2619,6 +2727,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2663,6 +2772,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2699,6 +2809,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2738,6 +2849,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2778,6 +2890,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, @@ -2840,6 +2953,7 @@ describe('turn consumer lag recovery (#3180)', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', now: () => 50, diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 9e1124182a..8dcb537771 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -23,6 +23,8 @@ import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js'; export interface ModelChoice { + /** Immutable account identity; required for a cross-connection selection. */ + connectionId?: string; connectionSlug: string; connectionName: string; providerType: ProviderType; diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 18dae33449..74d7ad4c9a 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -593,11 +593,13 @@ export function modelPickerItems( */ function modelChoicePickerItems( choices: readonly ModelChoice[], - current: { model: string; connectionSlug: string }, + current: { model: string; connectionId?: string; connectionSlug: string }, ): SelectItem[] { return choices.map((choice, index) => { const isCurrent = - choice.model === current.model && choice.connectionSlug === current.connectionSlug; + choice.model === current.model && + choice.connectionId === current.connectionId && + choice.connectionSlug === current.connectionSlug; const tags = [choice.connectionName || choice.connectionSlug]; if (isCurrent) tags.push('current'); else if (choice.isDefaultConnection) tags.push('default'); @@ -627,7 +629,7 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean { export interface ModelSearchOverlayInput { choices: readonly ModelChoice[]; - current: { model: string; connectionSlug: string }; + current: { model: string; connectionId?: string; connectionSlug: string }; showCacheWarning?: boolean; onSelect: (choice: ModelChoice) => void; onCancel: () => void; @@ -659,6 +661,7 @@ export class ModelSearchOverlay implements Component { this.initialIndex = input.choices.findIndex( (choice) => choice.model === input.current.model && + choice.connectionId === input.current.connectionId && choice.connectionSlug === input.current.connectionSlug, ); this.list = this.buildList(); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index d9b1fe517b..64fcbb2d48 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -168,6 +168,12 @@ export interface MakaPiTuiInput { * when absent. */ modelChoices?: readonly ModelChoice[]; + connectionId?: string; + connectionIdentities?: readonly { + readonly connectionId: string; + readonly connectionSlug: string; + readonly enabled: boolean; + }[]; connectionSlug: string; providerType?: ProviderType; permissionMode: PermissionMode; @@ -275,6 +281,36 @@ export function resolveTaskbarProgress( return environment.platform !== 'win32' && environment.windowsTerminalSession === undefined; } +function sessionConnectionIdentityNotice( + session: Pick, + identities: MakaPiTuiInput['connectionIdentities'], + locale: UiLocale, +): string | undefined { + if (!identities) return undefined; + if (!session.llmConnectionId) { + return locale === 'zh' + ? '此任务尚未绑定具体账号,请显式选择账号后继续。' + : 'This task is not bound to an exact account. Explicitly choose an account to continue.'; + } + const identified = identities.find((entry) => entry.connectionId === session.llmConnectionId); + if (!identified) { + return locale === 'zh' + ? '原账号已删除;请显式选择新账号后继续。' + : 'The original account was deleted. Explicitly choose a new account to continue.'; + } + if (identified.connectionSlug !== session.llmConnectionSlug) { + return locale === 'zh' + ? '任务保存的账号身份与当前连接不一致,请显式重新选择账号。' + : 'The saved account identity no longer matches its connection. Explicitly choose an account.'; + } + if (!identified.enabled) { + return locale === 'zh' + ? '原账号已停用;请启用该账号或显式选择新账号后继续。' + : 'The original account is disabled. Enable it or explicitly choose a new account.'; + } + return undefined; +} + export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const locale = input.locale ?? 'en'; const primaryGuidance = getTuiPrimaryGuidance(locale); @@ -295,6 +331,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; let cwd = input.cwd; let model = input.model; + let connectionId = input.connectionId; let connectionSlug = input.connectionSlug; // Mutable: a cross-connection /model switch rebinds the provider, which changes // both the connection and the thinking variants the new model supports. @@ -312,6 +349,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { (choice) => choice.connectionSlug === connectionSlug && choice.model === model, )?.thinkingLevels ?? (providerType ? thinkingVariantsForModel(providerType, model) : []); let sessionListScope: 'current' | 'all' = input.sessionListScope ?? 'current'; + let connectionIdentityNotice: string | undefined; let busy = false; let closed = false; let currentActivityCompletion: Promise | undefined; @@ -1450,26 +1488,45 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { ); } - const adoptSessionMetadata = (summary: SessionSummary) => { + const adoptSessionMetadata = (summary: SessionSummary, announceIdentity = true) => { cwd = summary.cwd ?? cwd; setSessionTitle(summary.name); const previousModel = model; + const previousConnectionId = connectionId; const previousConnectionSlug = connectionSlug; model = summary.model; + connectionId = summary.llmConnectionId; connectionSlug = summary.llmConnectionSlug; + const identityNotice = sessionConnectionIdentityNotice( + summary, + input.connectionIdentities, + input.locale ?? 'en', + ); + if (announceIdentity && identityNotice && identityNotice !== connectionIdentityNotice) { + state.entries.push({ kind: 'notice', level: 'error', text: identityNotice }); + } + connectionIdentityNotice = identityNotice; const matchingChoice = modelChoices?.find( - (choice) => choice.connectionSlug === summary.llmConnectionSlug, + (choice) => + choice.connectionId === summary.llmConnectionId && + choice.connectionSlug === summary.llmConnectionSlug, ); providerType = matchingChoice?.providerType ?? - (previousConnectionSlug === summary.llmConnectionSlug ? providerType : undefined); + (previousConnectionId === summary.llmConnectionId && + previousConnectionSlug === summary.llmConnectionSlug + ? providerType + : undefined); const contextWindowMatch = modelChoices?.find( (choice) => - choice.connectionSlug === summary.llmConnectionSlug && choice.model === summary.model, + choice.connectionId === summary.llmConnectionId && + choice.connectionSlug === summary.llmConnectionSlug && + choice.model === summary.model, ); if (contextWindowMatch) { modelContextWindow = contextWindowMatch.contextWindow; } else if ( + previousConnectionId !== summary.llmConnectionId || previousConnectionSlug !== summary.llmConnectionSlug || previousModel !== summary.model ) { @@ -1522,15 +1579,25 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Cross-connection /model: rebind the session to the chosen connection + model. // Updates the provider (and thus the thinking variants) and the status line. const setModelChoice = async (choice: ModelChoice) => { - if (choice.model === model && choice.connectionSlug === connectionSlug) return; + if ( + choice.model === model && + choice.connectionSlug === connectionSlug && + choice.connectionId === connectionId + ) { + return; + } + if (!choice.connectionId) { + throw new Error('Model choice is missing its exact Connection identity'); + } const previousModel = transcriptLastUsedModel ?? model; const previousConnectionSlug = connectionSlug; const previousChoice = modelChoices?.find( (candidate) => candidate.model === previousModel && candidate.connectionSlug === previousConnectionSlug, ); - await input.driver.setModel(choice.model, choice.connectionSlug); + await input.driver.setModel(choice.model, choice.connectionSlug, choice.connectionId); model = choice.model; + connectionId = choice.connectionId; connectionSlug = choice.connectionSlug; providerType = choice.providerType; modelContextWindow = choice.contextWindow; @@ -1567,8 +1634,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { messages, activeTurn, }: MakaSessionSwitchResult): Promise => { - adoptSessionMetadata(summary); + adoptSessionMetadata(summary, false); replaceTranscript(messages); + if (connectionIdentityNotice) { + state.entries.push({ kind: 'notice', level: 'error', text: connectionIdentityNotice }); + } shellRunHydration.reset(); if (input.listShellRunUpdates) { await shellRunHydration.hydrate(summary.id); @@ -2674,7 +2744,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let overlay: OverlayHandle | undefined; const picker = new ModelSearchOverlay(tui, { choices, - current: { model, connectionSlug }, + current: { model, connectionId, connectionSlug }, showCacheWarning: hasConversationHistory, onSelect: (choice) => { overlay?.hide(); diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 3673c277f2..167dba54fc 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -97,6 +97,7 @@ export function projectRuntimeHostModelChoices(catalog: ConnectionCatalogSnapsho } for (const model of ids) { choices.push({ + connectionId: connection.connectionId, connectionSlug: connection.slug, connectionName: connection.name, providerType: connection.providerType, diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 146db808e1..272d4a63e1 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -167,6 +167,7 @@ export function createRuntimeHostRunContext( const driver = contextDeps.createDriver({ connection, cwd: input.cwd, + llmConnectionId: target.connection.connectionId, llmConnectionSlug: target.connection.slug, model: target.model, executionLocation: diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index b65ba1a172..6b0fe767cf 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -111,6 +111,7 @@ export interface RuntimeHostMakaSessionDriverInput { connection: RuntimeHostSessionDriverConnection; cwd: string; workspace?: WorkspaceTarget; + llmConnectionId?: string; llmConnectionSlug: string; model: string; /** @@ -175,6 +176,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { #sessionId: string | null = null; #workspace: { target?: WorkspaceTarget; hostCwd: string }; #model: string; + #llmConnectionId: string | undefined; #llmConnectionSlug: string; #thinkingLevel: ThinkingLevel | undefined; // What a Session created right now would start in, for display only. Never @@ -242,6 +244,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { hostCwd: input.cwd, }; this.#model = input.model; + this.#llmConnectionId = input.llmConnectionId; this.#llmConnectionSlug = input.llmConnectionSlug; this.#prospectivePermissionMode = input.prospectivePermissionMode; this.#orchestrationMode = input.orchestrationMode ?? 'default'; @@ -261,6 +264,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { target: workspaceTargetForCreate(this.#workspace, input, this.#executionLocation), hostCwd: input.cwd, }; + if (input.llmConnectionId) this.#llmConnectionId = input.llmConnectionId; this.#llmConnectionSlug = input.llmConnectionSlug; this.#model = input.model; this.#thinkingLevel = input.thinkingLevel; @@ -430,18 +434,31 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { if (pending) this.#channel?.publishInteractionAnswer(answered, pending); } - async setModel(model: string, connectionSlug?: string): Promise { - const nextConnection = connectionSlug ?? this.#llmConnectionSlug; + async setModel(model: string, connectionSlug?: string, connectionId?: string): Promise { + if (connectionSlug !== undefined && connectionId === undefined) { + throw new Error('Cross-account model selection requires an exact Connection identity'); + } + const nextConnectionId = connectionId ?? this.#llmConnectionId; + const nextConnectionSlug = connectionSlug ?? this.#llmConnectionSlug; + if (!nextConnectionId) { + throw new Error('Model selection requires an exact Connection identity'); + } if (this.#sessionId) { const session = await this.#updateConfiguration(this.#sessionId, { - modelTarget: { kind: 'explicit', connectionSlug: nextConnection, model }, + modelTarget: { + kind: 'explicit', + connectionId: nextConnectionId, + connectionSlug: nextConnectionSlug, + model, + }, thinkingLevel: null, }); this.#adoptConfiguration(session); return; } this.#model = model; - this.#llmConnectionSlug = nextConnection; + this.#llmConnectionId = nextConnectionId; + this.#llmConnectionSlug = nextConnectionSlug; this.#thinkingLevel = undefined; } @@ -961,6 +978,9 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { throw new Error('A remote Runtime Host Session requires an explicit Project'); } const sessionId = this.#newId(); + if (!this.#llmConnectionId) { + throw new Error('Runtime Host Session creation requires an exact Connection identity'); + } const session = requireSession( await this.#request('session.create', { sessionId, @@ -968,6 +988,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { name, modelTarget: { kind: 'explicit', + connectionId: this.#llmConnectionId, connectionSlug: this.#llmConnectionSlug, model: this.#model, }, @@ -1047,7 +1068,12 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { async #updateConfiguration( sessionId: string, patch: { - modelTarget?: { kind: 'explicit'; connectionSlug: string; model: string }; + modelTarget?: { + kind: 'explicit'; + connectionId: string; + connectionSlug: string; + model: string; + }; thinkingLevel?: ThinkingLevel | null; permissionMode?: PermissionMode; orchestrationMode?: OrchestrationMode; @@ -1057,30 +1083,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#request('session.configuration.update', { sessionId, expectedRevision: current.revision, - configuration: { - modelTarget: - patch.modelTarget ?? - (current.connectionLocked - ? { - kind: 'explicit', - connectionSlug: current.llmConnectionSlug, - model: current.model, - } - : { kind: 'default' }), - thinkingLevel: - patch.thinkingLevel === undefined - ? (current.thinkingLevel ?? null) - : patch.thinkingLevel, - permissionMode: patch.permissionMode ?? current.permissionMode, - collaborationMode: current.collaborationMode, - orchestrationMode: patch.orchestrationMode ?? current.orchestrationMode, - }, + patch, }), ); } #adoptConfiguration(session: SessionCatalogProjection): void { this.#model = session.model; + this.#llmConnectionId = session.llmConnectionId ?? undefined; this.#llmConnectionSlug = session.llmConnectionSlug; this.#thinkingLevel = session.thinkingLevel; this.#permissionMode = session.permissionMode; diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index e55eb8b71e..d076646c49 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -84,10 +84,16 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< locale: input.locale, model: context.model, models: context.modelChoices - .filter((choice) => choice.connectionSlug === context.connectionSlug) + .filter( + (choice) => + choice.connectionId === context.connectionId && + choice.connectionSlug === context.connectionSlug, + ) .map((choice) => choice.model), modelChoices: context.modelChoices, connectionSlug: context.connectionSlug, + connectionId: context.connectionId, + connectionIdentities: context.connectionIdentities, providerType: context.providerType, modelContextWindow: context.modelContextWindow, permissionMode: context.prospectivePermissionMode, diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index 6a4e998857..dd6342c965 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -67,8 +67,14 @@ export interface RuntimeHostTuiContext { readonly driver: ReturnType; readonly cwd: string; readonly connectionSlug: string; + readonly connectionId?: string; + readonly connectionIdentities: readonly { + readonly connectionId: string; + readonly connectionSlug: string; + readonly enabled: boolean; + }[]; readonly connectionName: string; - readonly providerType: ConnectionCatalogEntry['providerType']; + readonly providerType?: ConnectionCatalogEntry['providerType']; readonly model: string; readonly modelContextWindow?: number; readonly modelChoices: readonly ModelChoice[]; @@ -115,9 +121,9 @@ export async function createRuntimeHostTuiContext( try { const catalog = connected.catalog; const workspace = await resolveRuntimeHostTuiWorkspace(connection, connected.profile, input); - const target = input.resumeSessionId + const selectedTarget = input.resumeSessionId ? await resolveResumeTarget(connection, catalog, input.resumeSessionId) - : resolveTarget(catalog); + : exactTuiTarget(resolveTarget(catalog)); const modelChoices = projectRuntimeHostModelChoices(catalog); // Display state, never a create input. Deriving it through the same // boundary mapping every other surface uses keeps a prospective Session and @@ -129,8 +135,11 @@ export async function createRuntimeHostTuiContext( const driverInput: RuntimeHostMakaSessionDriverInput = { connection, cwd: input.cwd, - llmConnectionSlug: target.connection.slug, - model: target.model, + ...(selectedTarget.connectionId === undefined + ? {} + : { llmConnectionId: selectedTarget.connectionId }), + llmConnectionSlug: selectedTarget.connectionSlug, + model: selectedTarget.model, prospectivePermissionMode, sessionCopyCleanupRoot: join(input.clientDataRoot, 'tui-session-copies'), executionLocation: @@ -148,16 +157,28 @@ export async function createRuntimeHostTuiContext( connection, }); } + const modelContextWindow = selectedTarget.connection?.models.find( + (model) => model.id === selectedTarget.model, + )?.contextWindow; return { connection, driver, cwd: input.cwd, - connectionSlug: target.connection.slug, - connectionName: target.connection.name, - providerType: target.connection.providerType, - model: target.model, - modelContextWindow: target.connection.models.find((model) => model.id === target.model) - ?.contextWindow, + connectionSlug: selectedTarget.connectionSlug, + ...(selectedTarget.connectionId === undefined + ? {} + : { connectionId: selectedTarget.connectionId }), + connectionIdentities: catalog.connections.map((entry) => ({ + connectionId: entry.connectionId, + connectionSlug: entry.slug, + enabled: entry.enabled, + })), + connectionName: selectedTarget.connection?.name ?? selectedTarget.connectionSlug, + ...(selectedTarget.connection + ? { providerType: selectedTarget.connection.providerType } + : {}), + model: selectedTarget.model, + ...(modelContextWindow === undefined ? {} : { modelContextWindow }), modelChoices, prospectivePermissionMode, turnActivity: createHostOwnedTurnActivity(), @@ -295,16 +316,43 @@ async function resolveResumeTarget( connection: RuntimeHostConnection, catalog: ConnectionCatalogSnapshot, sessionId: string, -): Promise<{ connection: ConnectionCatalogEntry; model: string }> { +): Promise { const result = await connection.request('session.catalog.query', { kind: 'get', sessionId }); const session = result.kind === 'session' ? result.session : null; if (session && !('kind' in session)) { const sessionConnection = catalog.connections.find( - (candidate) => candidate.slug === session.llmConnectionSlug && candidate.enabled, + (candidate) => + session.llmConnectionId !== null && + candidate.connectionId === session.llmConnectionId && + candidate.slug === session.llmConnectionSlug, ); - if (sessionConnection) return { connection: sessionConnection, model: session.model }; + return { + ...(session.llmConnectionId === null ? {} : { connectionId: session.llmConnectionId }), + connectionSlug: session.llmConnectionSlug, + model: session.model, + ...(sessionConnection ? { connection: sessionConnection } : {}), + }; } - return resolveTarget(catalog); + return exactTuiTarget(resolveTarget(catalog)); +} + +interface ResolvedTuiTarget { + readonly connectionId?: string; + readonly connectionSlug: string; + readonly model: string; + readonly connection?: ConnectionCatalogEntry; +} + +function exactTuiTarget(target: { + readonly connection: ConnectionCatalogEntry; + readonly model: string; +}): ResolvedTuiTarget { + return { + connectionId: target.connection.connectionId, + connectionSlug: target.connection.slug, + model: target.model, + connection: target.connection, + }; } function createHostOwnedTurnActivity(): MakaPiTuiTurnActivitySurface { diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index e6e253953e..cfad7470b7 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -120,7 +120,7 @@ export interface MakaSessionDriver { retractQueued?(): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; - setModel(model: string, connectionSlug?: string): Promise; + setModel(model: string, connectionSlug?: string, connectionId?: string): Promise; setThinkingLevel(level: ThinkingLevel | undefined): Promise; setPermissionMode(mode: PermissionMode): Promise; setOrchestrationMode?(mode: OrchestrationMode): Promise; diff --git a/packages/core/src/__tests__/agent-run-authority.test.ts b/packages/core/src/__tests__/agent-run-authority.test.ts index f7bf6d7afb..7a5f7de488 100644 --- a/packages/core/src/__tests__/agent-run-authority.test.ts +++ b/packages/core/src/__tests__/agent-run-authority.test.ts @@ -71,6 +71,21 @@ test('folds all retired AgentRun values only at the persistence boundary', () => ); }); +test('accepts both bound and legacy AgentRun connection identity', () => { + const legacy = decodePersistedAgentRunHeader(markPersisted(runHeader())); + assert.equal(legacy.llmConnectionId, undefined); + + const bound = decodeAgentRunHeader({ + ...runHeader(), + llmConnectionId: '11111111-1111-4111-8111-111111111111', + }); + assert.equal(bound.llmConnectionId, '11111111-1111-4111-8111-111111111111'); + assert.throws( + () => decodeAgentRunHeader({ ...runHeader(), llmConnectionId: '' }), + /Invalid AgentRun header schema/, + ); +}); + function runHeader(): AgentRunHeader { return { runId: 'run-1', diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 875c4c7034..1674479ed8 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -242,6 +242,7 @@ test('the model picker lists an enabled model a snapshot provider never listed', // predates — from vanishing out of every picker (#1584). const choices = buildChatModelChoices([ { + connectionId: 'connection-1', slug: 'ark-plan', name: 'Ark Agent Plan', providerType: 'volcengine-agent-plan', diff --git a/packages/core/src/__tests__/session-send-projection.test.ts b/packages/core/src/__tests__/session-send-projection.test.ts index e906536845..d450ff8d0c 100644 --- a/packages/core/src/__tests__/session-send-projection.test.ts +++ b/packages/core/src/__tests__/session-send-projection.test.ts @@ -17,21 +17,23 @@ * under the License. */ -import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { IdentifiedLlmConnection } from '../llm-connections.js'; import { projectSessionSendOutcome, type SessionSendProjectionInput, } from '../session-send-projection.js'; -import type { LlmConnection } from '../llm-connections.js'; -function connection(overrides: Partial = {}): LlmConnection { +function connection(overrides: Partial = {}): IdentifiedLlmConnection { return { + connectionId: 'connection-1', slug: 'openai-live', name: 'OpenAI Live', providerType: 'openai', defaultModel: 'gpt-4.1', enabled: true, + enabledModelIds: ['gpt-4.1'], models: [{ id: 'gpt-4.1', capabilities: { chat: true, functionCalling: true } }], modelSource: 'fetched', createdAt: 1, @@ -44,282 +46,108 @@ function input(overrides: Partial = {}): SessionSend return { session: { backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-live', model: 'gpt-4.1', connectionLocked: false, }, connections: [connection()], - defaultSlug: 'openai-live', hasSecret: () => true, ...overrides, }; } -describe('projectSessionSendOutcome — session’s own connection', () => { - it('ready when the bound connection passes the readiness gate with the session model', () => { +describe('projectSessionSendOutcome — exact Connection identity', () => { + it('is ready only when id, slug, model, and credentials match', () => { assert.deepEqual(projectSessionSendOutcome(input()), { kind: 'ready' }); }); - it('validates the sticky session model, not the provider default', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'openai-live', - model: 'gpt-4.1-mini', - connectionLocked: true, - }, + it('blocks a legacy Session until the user explicitly selects an account', () => { + const current = input(); + assert.deepEqual( + projectSessionSendOutcome({ + ...current, + session: { ...current.session, llmConnectionId: undefined }, }), + { kind: 'blocked', reason: 'legacy_connection_identity', connectionLocked: false }, ); - // gpt-4.1-mini is not in the connection's enabled list. - assert.deepEqual(outcome, { - kind: 'blocked', - reason: 'model_not_enabled', - connectionLocked: true, - }); }); - // Locked sessions isolate the own-connection reason: no rebind walk - // can rescue them, so the outcome surfaces the raw failure cause. - function lockedInput( - overrides: Partial = {}, - ): SessionSendProjectionInput { - const base = input(overrides); - return { ...base, session: { ...base.session, connectionLocked: true } }; - } - - it('fake backend session → fake_backend', () => { - const outcome = projectSessionSendOutcome( - lockedInput({ - session: { - backend: 'fake', - llmConnectionSlug: 'fake', - model: 'fake-model', - connectionLocked: true, - }, + it('does not adopt a replacement Connection that reuses a deleted slug', () => { + const current = input(); + assert.deepEqual( + projectSessionSendOutcome({ + ...current, + session: { ...current.session, llmConnectionId: 'deleted-connection' }, }), + { kind: 'blocked', reason: 'connection_missing', connectionLocked: false }, ); - assert.equal(outcome.kind, 'blocked'); - assert.equal(outcome.kind === 'blocked' && outcome.reason, 'fake_backend'); }); - it('legacy/empty slug → missing_default_connection', () => { - const outcome = projectSessionSendOutcome( - lockedInput({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: '', - model: 'gpt-4.1', - connectionLocked: true, - }, + it('blocks an identity mismatch even when the id exists under another slug', () => { + const current = input(); + assert.deepEqual( + projectSessionSendOutcome({ + ...current, + session: { ...current.session, llmConnectionSlug: 'stale-slug' }, }), + { kind: 'blocked', reason: 'connection_identity_mismatch', connectionLocked: false }, ); - assert.equal(outcome.kind === 'blocked' && outcome.reason, 'missing_default_connection'); }); - it('deleted connection slug → connection_missing', () => { - const outcome = projectSessionSendOutcome( - lockedInput({ + it('never silently rebinds an unlocked Session to another ready account', () => { + const current = input(); + assert.deepEqual( + projectSessionSendOutcome({ + ...current, session: { - backend: 'ai-sdk', + ...current.session, + llmConnectionId: 'deleted-connection', llmConnectionSlug: 'deleted-slug', - model: 'gpt-4.1', - connectionLocked: true, }, + connections: [connection(), connection({ connectionId: 'connection-2', slug: 'backup' })], }), + { kind: 'blocked', reason: 'connection_missing', connectionLocked: false }, ); - assert.equal(outcome.kind === 'blocked' && outcome.reason, 'connection_missing'); - }); - - it('enabled connection without secret → missing_api_key', () => { - const outcome = projectSessionSendOutcome(lockedInput({ hasSecret: () => false })); - assert.equal(outcome.kind === 'blocked' && outcome.reason, 'missing_api_key'); - }); - - it('disabled connection → connection_disabled', () => { - const outcome = projectSessionSendOutcome( - lockedInput({ connections: [connection({ enabled: false })] }), - ); - assert.equal(outcome.kind === 'blocked' && outcome.reason, 'connection_disabled'); }); -}); -describe('projectSessionSendOutcome — locked sessions never rebind', () => { - it('locked session blocks even when another ready connection exists', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-slug', - model: 'gpt-4.1', - connectionLocked: true, - }, - // openai-live (the default) is ready — but a locked session - // cannot move, so the send fails. - }), - ); - assert.deepEqual(outcome, { + it('preserves the exact connection readiness reason', () => { + assert.deepEqual(projectSessionSendOutcome(input({ hasSecret: () => false })), { kind: 'blocked', - reason: 'connection_missing', - connectionLocked: true, + reason: 'missing_api_key', + connectionLocked: false, }); - }); - - it('unlocked session with the same facts rebinds instead', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-slug', - model: 'gpt-4.1', - connectionLocked: false, - }, - }), - ); - assert.deepEqual(outcome, { kind: 'rebind', connectionSlug: 'openai-live', model: 'gpt-4.1' }); - }); -}); - -describe('projectSessionSendOutcome — silent rebind walk', () => { - it('rebindable failure walks to the ready default connection', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-connection', - model: 'fake-model', - connectionLocked: false, - }, - }), + assert.deepEqual( + projectSessionSendOutcome(input({ connections: [connection({ enabled: false })] })), + { kind: 'blocked', reason: 'connection_disabled', connectionLocked: false }, ); - assert.deepEqual(outcome, { kind: 'rebind', connectionSlug: 'openai-live', model: 'gpt-4.1' }); }); - it('walks past an unready default to another ready connection', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-connection', - model: 'fake-model', - connectionLocked: false, - }, - connections: [ - connection({ slug: 'default-broken', enabled: false }), - connection({ - slug: 'second-ready', - defaultModel: 'gpt-4.1-mini', - models: [{ id: 'gpt-4.1-mini' }], - }), - ], - defaultSlug: 'default-broken', + it('validates the sticky Session model rather than the provider default', () => { + const current = input(); + assert.deepEqual( + projectSessionSendOutcome({ + ...current, + session: { ...current.session, model: 'gpt-4.1-mini', connectionLocked: true }, }), + { kind: 'blocked', reason: 'model_not_enabled', connectionLocked: true }, ); - assert.deepEqual(outcome, { - kind: 'rebind', - connectionSlug: 'second-ready', - model: 'gpt-4.1-mini', - }); }); - it('a retired backend never rebinds, even unlocked with a ready connection available', () => { - // #3211: activation dispatches off the session header's own `backend`, so - // pointing this session at a healthy connection would still leave `'fake'` - // in the header and still be refused. Promising a rebind nothing performs - // is what pushed the rail and the composer to read `backend` directly. - const outcome = projectSessionSendOutcome( - input({ + it('continues to refuse a retired fake backend', () => { + const current = input(); + assert.deepEqual( + projectSessionSendOutcome({ + ...current, session: { + ...current.session, backend: 'fake', + llmConnectionId: undefined, llmConnectionSlug: 'fake', model: 'fake-model', - connectionLocked: false, }, }), + { kind: 'blocked', reason: 'fake_backend', connectionLocked: false }, ); - assert.deepEqual(outcome, { - kind: 'blocked', - reason: 'fake_backend', - connectionLocked: false, - }); - }); - - it('rebind requires a secret on the candidate connection', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-connection', - model: 'fake-model', - connectionLocked: false, - }, - hasSecret: () => false, - }), - ); - // Default exists and is enabled but has no key → send still fails. - assert.deepEqual(outcome, { - kind: 'blocked', - reason: 'connection_missing', - connectionLocked: false, - }); - }); - - it('non-rebindable failure blocks even when another ready connection exists', () => { - const outcome = projectSessionSendOutcome( - input({ - hasSecret: () => false, // own connection missing key - }), - ); - assert.deepEqual(outcome, { - kind: 'blocked', - reason: 'missing_api_key', - connectionLocked: false, - }); - }); - - it('deleted default slug is skipped during the walk', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-connection', - model: 'fake-model', - connectionLocked: false, - }, - defaultSlug: 'ghost-slug', - }), - ); - assert.deepEqual(outcome, { kind: 'rebind', connectionSlug: 'openai-live', model: 'gpt-4.1' }); - }); -}); - -describe('projectSessionSendOutcome — codex normalization', () => { - it('a codex connection with only unsupported models rebinds onto the fallback list', () => { - const outcome = projectSessionSendOutcome( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-connection', - model: 'fake-model', - connectionLocked: false, - }, - connections: [ - connection({ - slug: 'codex-sub', - providerType: 'openai-codex', - defaultModel: 'gpt-5-codex', - models: [{ id: 'gpt-5-codex' }], - }), - ], - defaultSlug: 'codex-sub', - }), - ); - // Normalization swaps the unservable list for the provider fallback - // list, whose first entry becomes the rebind model. - assert.deepEqual(outcome, { - kind: 'rebind', - connectionSlug: 'codex-sub', - model: 'gpt-5.6-sol', - }); }); }); diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 8c3dbd0275..5ef38f8e25 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -159,6 +159,8 @@ export interface AgentRunHeader { turnId: string; status: AgentRunStatus; backendKind: PersistedBackendKind; + /** Immutable Connection entity identity. Optional only on legacy run headers. */ + llmConnectionId?: string; llmConnectionSlug: string; modelId: string; cwd: string; @@ -567,6 +569,7 @@ const AGENT_RUN_HEADER_SHAPE = defineObjectShape()( ], [ 'invocationId', + 'llmConnectionId', 'completedAt', 'parentRunId', 'resumedFromRunId', @@ -643,6 +646,8 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { typeof value.turnId === 'string' && (AGENT_RUN_STATUSES as readonly unknown[]).includes(value.status) && isPersistedBackendKind(value.backendKind) && + (value.llmConnectionId === undefined || + (typeof value.llmConnectionId === 'string' && value.llmConnectionId.length > 0)) && typeof value.llmConnectionSlug === 'string' && typeof value.modelId === 'string' && typeof value.cwd === 'string' && diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index 3295b42586..66d6cf447c 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -24,7 +24,7 @@ import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, connectionEnabledModelIds, providerDefaultsOf, - type LlmConnection, + type IdentifiedLlmConnection, type ProviderType, } from './llm-connections.js'; @@ -42,6 +42,7 @@ const MODEL_MENU_PROVIDER_LABELS: Partial> = { }; export interface ChatModelChoice { + connectionId: string; connectionSlug: string; providerType: ProviderType; providerLabel: string; @@ -54,7 +55,9 @@ export interface ChatModelChoice { thinkingLevels: readonly ThinkingLevel[]; } -export function buildChatModelChoices(connections: readonly LlmConnection[]): ChatModelChoice[] { +export function buildChatModelChoices( + connections: readonly IdentifiedLlmConnection[], +): ChatModelChoice[] { const choices: ChatModelChoice[] = []; for (const rawConnection of connections) { const connection = normalizeOpenAiCodexConnection(rawConnection); @@ -73,6 +76,7 @@ export function buildChatModelChoices(connections: readonly LlmConnection[]): Ch continue; } choices.push({ + connectionId: rawConnection.connectionId, connectionSlug: connection.slug, providerType: connection.providerType, providerLabel: MODEL_MENU_PROVIDER_LABELS[connection.providerType] ?? provider.label, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 75bbe31742..89d8f2eee8 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -168,6 +168,11 @@ export interface LlmConnection extends RuntimeExecutionConnection { updatedAt: number; } +/** A persisted Connection entity projected with its immutable catalog identity. */ +export interface IdentifiedLlmConnection extends LlmConnection { + connectionId: string; +} + /** * Read-time normalizer: the model ids a stored connection exposes. * diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index 8615ba513d..44fc43d3c0 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -61,6 +61,8 @@ export interface CreateSessionInput { * legacy row is a real session whose connection slug resolves to nothing, * which is what the readiness projection already says about it. */ + /** Immutable Connection entity identity. Omitted only while copying legacy state. */ + llmConnectionId?: string; llmConnectionSlug: string; /** Falls back to the connection's defaultModel if omitted. */ model?: string; diff --git a/packages/core/src/session-send-projection.ts b/packages/core/src/session-send-projection.ts index 8ec69d26ab..74fb8b213e 100644 --- a/packages/core/src/session-send-projection.ts +++ b/packages/core/src/session-send-projection.ts @@ -24,18 +24,14 @@ * This is the shared compatibility projection used by Desktop onboarding and * the renderer session-health notice above the composer. Runtime Host owns the * authoritative submission and execution path; this projection only explains - * whether that target looks usable or whether an empty legacy session has a - * compatible fallback for presentation and readiness checks. + * whether that exact target looks usable for presentation and readiness checks. * * The compatibility rules are: * 1. The session's own connection must pass `isConnectionReady` with * the sticky session model. - * 2. A locked session (has user messages) can never select a fallback — any - * failure of its own connection projects as blocked. - * 3. An unlocked session may select a fallback only for reasons in - * `shouldRebindSessionToDefault`; the walk tries the default - * connection first, then every other persisted connection. - * 4. Otherwise the compatibility projection is blocked. + * 2. Legacy Sessions without an immutable connection id are blocked until + * the user explicitly selects an account. + * 3. A missing id, slug mismatch, or unusable exact connection is blocked. * * `lastTestStatus` deliberately plays no part here (E4): telemetry about * a past credential test must not gate send, so it must not gate the @@ -47,7 +43,7 @@ import { normalizeOpenAiCodexConnection, type ChatConfigurationReason, } from './connection-readiness.js'; -import type { LlmConnection } from './llm-connections.js'; +import type { IdentifiedLlmConnection, LlmConnection } from './llm-connections.js'; export interface SessionSendProjectionSession { /** @@ -57,6 +53,7 @@ export interface SessionSendProjectionSession { * normal connection readiness gate. */ backend: string; + llmConnectionId?: string; llmConnectionSlug: string; /** Sticky session model captured when the session was created. */ model: string; @@ -66,9 +63,8 @@ export interface SessionSendProjectionSession { export interface SessionSendProjectionInput { session: SessionSendProjectionSession; - /** Every persisted connection (the rebind walk considers all of them). */ - connections: readonly LlmConnection[]; - defaultSlug: string | null; + /** Every persisted connection. */ + connections: readonly IdentifiedLlmConnection[]; /** * Secret presence per connection slug, resolved by the caller. Only * consulted for connections that exist. @@ -78,40 +74,24 @@ export interface SessionSendProjectionInput { export type SessionSendProjection = | { kind: 'ready' } - | { kind: 'rebind'; connectionSlug: string; model: string } - | { kind: 'blocked'; reason: ChatConfigurationReason; connectionLocked: boolean }; + | { + kind: 'blocked'; + reason: + | ChatConfigurationReason + | 'legacy_connection_identity' + | 'connection_identity_mismatch'; + connectionLocked: boolean; + }; export function projectSessionSendOutcome( input: SessionSendProjectionInput, ): SessionSendProjection { - const { session, connections, defaultSlug, hasSecret } = input; + const { session, connections, hasSecret } = input; const ownReason = ownConnectionBlockReason(session, connections, hasSecret); if (ownReason === undefined) return { kind: 'ready' }; - // Once a session has user messages, its connection/model is sticky. - // Rebind remains only a recovery path for empty legacy placeholders. - if (session.connectionLocked) { - return { kind: 'blocked', reason: ownReason, connectionLocked: true }; - } - if (!shouldRebindSessionToDefault(ownReason)) { - return { kind: 'blocked', reason: ownReason, connectionLocked: false }; - } - - for (const slug of new Set([defaultSlug, ...connections.map((connection) => connection.slug)])) { - if (!slug) continue; - const connection = connections.find((entry) => entry.slug === slug); - if (!connection) continue; - const normalized = normalizeOpenAiCodexConnection(connection); - const verdict = isConnectionReady({ - connection: normalized, - hasSecret: hasSecret(normalized.slug), - }); - if (verdict.ready) { - return { kind: 'rebind', connectionSlug: normalized.slug, model: verdict.model }; - } - } - return { kind: 'blocked', reason: ownReason, connectionLocked: false }; + return { kind: 'blocked', reason: ownReason, connectionLocked: session.connectionLocked }; } /** @@ -143,35 +123,20 @@ function sessionOwnConnectionBlockReason( function ownConnectionBlockReason( session: SessionSendProjectionSession, - connections: readonly LlmConnection[], + connections: readonly IdentifiedLlmConnection[], hasSecret: (slug: string) => boolean, -): ChatConfigurationReason | undefined { - const own = connections.find((entry) => entry.slug === session.llmConnectionSlug) ?? null; +): + | ChatConfigurationReason + | 'legacy_connection_identity' + | 'connection_identity_mismatch' + | undefined { + if (session.backend === 'fake') return 'fake_backend'; + if (!session.llmConnectionId) return 'legacy_connection_identity'; + const identified = + connections.find((entry) => entry.connectionId === session.llmConnectionId) ?? null; + if (identified && identified.slug !== session.llmConnectionSlug) { + return 'connection_identity_mismatch'; + } + const own = identified; return sessionOwnConnectionBlockReason(session, own, hasSecret); } - -/** - * Whether an unlocked session whose own connection failed with `reason` may - * project another ready connection as a compatibility target. Failures not - * listed here (e.g. `missing_api_key`, `connection_disabled`) stay blocked - * even when unlocked because masking an explicitly configured connection - * would make the health/readiness UI misleading. - * - * `fake_backend` is deliberately absent (#3211). Every reason listed here - * names a broken *connection*, which another connection can stand in for. A - * retired backend is not: activation dispatches off the session header's own - * `backend`, so pointing the session at a healthy connection still leaves - * `'fake'` in the header and still gets refused. Claiming a rebind for these - * rows made the projection promise a recovery nothing performs — and the - * surfaces that must answer "is this task usable?" had to bypass the - * projection and read `backend` themselves to work around it. - */ -function shouldRebindSessionToDefault(reason: string | undefined): boolean { - return ( - reason === 'connection_missing' || - reason === 'missing_model' || - reason === 'empty_model_list' || - reason === 'model_not_enabled' || - reason === 'model_not_chat_capable' - ); -} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e9896dc2e2..51b1b17221 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -273,6 +273,8 @@ export interface SessionHeader { // Backend / model config backend: PersistedBackendKind; + /** Immutable Connection entity identity. Optional only on legacy Session records. */ + llmConnectionId?: string; llmConnectionSlug: string; /** True after first UserMessage is flushed. Storage self-heals (§5.2). */ connectionLocked: boolean; @@ -382,6 +384,8 @@ export interface SessionSummary { revisionIndex?: number; revisionState?: 'preparing' | 'committed'; backend: PersistedBackendKind; + /** Immutable Connection entity identity. Optional only on legacy summaries. */ + llmConnectionId?: string; llmConnectionSlug: string; /** * True once the session has user messages — its connection/model is diff --git a/packages/eval/src/maka-subject.ts b/packages/eval/src/maka-subject.ts index 5238023e66..a76b4305b3 100644 --- a/packages/eval/src/maka-subject.ts +++ b/packages/eval/src/maka-subject.ts @@ -19,10 +19,8 @@ import { randomUUID } from 'node:crypto'; import { isSessionToolProfile, type SessionToolProfile } from '@maka/core/session'; -import { - decodeHostedExecutionProjection, - type HostedExecutionStartInput, -} from '@maka/runtime-host/protocol'; +import { decodeHostedExecutionProjection } from '@maka/runtime-host/protocol'; +import type { RunHostedExecutionInput } from '@maka/runtime-host/client'; import type { JsonObject } from './experiment.js'; import { MAKA_RUNTIME_ARTIFACT_PATH, @@ -40,7 +38,7 @@ export function createMakaSubjectAdapter(): SubjectAdapter { async execute({ cell, context }) { const config = decodeConfig(cell.subject.config); const executionId = randomUUID(); - const input: HostedExecutionStartInput = { + const input: RunHostedExecutionInput['execution'] = { executionId, session: { workspace: { kind: 'host_path', path: context.cwd }, @@ -268,10 +266,10 @@ interface MakaConfig { readonly baseUrl: string; readonly connectionSlug: string; readonly model: string; - readonly thinkingLevel: HostedExecutionStartInput['session']['thinkingLevel']; - readonly permissionMode: HostedExecutionStartInput['session']['permissionMode']; - readonly collaborationMode: HostedExecutionStartInput['session']['collaborationMode']; - readonly orchestrationMode: HostedExecutionStartInput['session']['orchestrationMode']; + readonly thinkingLevel: RunHostedExecutionInput['execution']['session']['thinkingLevel']; + readonly permissionMode: RunHostedExecutionInput['execution']['session']['permissionMode']; + readonly collaborationMode: RunHostedExecutionInput['execution']['session']['collaborationMode']; + readonly orchestrationMode: RunHostedExecutionInput['execution']['session']['orchestrationMode']; readonly toolProfile: SessionToolProfile; } diff --git a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts index 6c49731e53..34c8ace521 100644 --- a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts @@ -292,12 +292,14 @@ async function seedExecutionRoot( stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', }); const otherSession = await stores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index 346da5eb3e..f33d606ffa 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -556,6 +556,7 @@ function sessionInput(root: string) { return { cwd: root, backend: 'fake' as const, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask' as const, @@ -570,6 +571,7 @@ function runHeader(sessionId: string): AgentRunHeader { turnId: 'turn-1', status: 'created', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: '/private/runtime-cwd', diff --git a/packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts index 54cc1dd802..a3f1f1f9dd 100644 --- a/packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts @@ -55,6 +55,7 @@ test('two Clients and a restarted production Host share one Deep Research projec const deepResearch = await openInteractiveDeepResearchStoreForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'explore', diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 67180218e0..ad3b9db508 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -61,6 +61,7 @@ import { } from '../server/workspace-execution-composition.js'; const require = createRequire(import.meta.url); +const FAKE_CONNECTION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; test('filesystem worker follows the candidate executable runtime', () => { assert.equal(runtimeHostFilesystemWorkerRuntime({ electron: '43.1.1' }), 'electron'); @@ -134,6 +135,7 @@ test('production composition closes long-term memory after a later startup failu const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -173,12 +175,14 @@ test('production recovery preserves legacy Automation history and closes an orph const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const historical = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', }); const pending = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -398,6 +402,7 @@ test('production composition commits automatic titles through Host-owned Session try { const session = await manager.createSession({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -465,6 +470,7 @@ test('production composition orphans ownerless ShellRuns before serving Resource const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -511,6 +517,7 @@ test('production Skill catalog resolves a Graph child durable tool surface', asy const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const parent = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -606,7 +613,7 @@ test('new Full Access Plan Skill previews use the mutating tool surface', async }); }); -test('Skill capability previews omit unavailable Tavily search surfaces', async () => { +test('Skill capability previews keep a bound Session off a same-slug replacement', async () => { await withCompositionRoot(async ({ root, owner }) => { for (const [id, requiredTool] of [ ['web-search-preview', 'WebSearch'], @@ -669,13 +676,19 @@ test('Skill capability previews omit unavailable Tavily search surfaces', async }); assert.equal(webSearchEnabled.kind, 'committed'); assert.equal( - (await policy.operations.resolveExecutionConnection(connection.slug)).kind, + ( + await policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: connection.slug, + }) + ).kind, 'ready', ); const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: connection.connectionId, llmConnectionSlug: connection.slug, model: 'fake-model', permissionMode: 'bypass', @@ -719,6 +732,93 @@ test('Skill capability previews omit unavailable Tavily search surfaces', async false, ); } + + assert.equal( + ( + await policy.credentialVault.set({ + locator: { scope: 'web_search', provider: 'tavily', kind: 'api_key' }, + expected: null, + secret: 'replacement-must-not-be-read', + }) + ).kind, + 'committed', + ); + const beforeRemoval = await policy.connectionCatalog.getSnapshot(); + const currentConnection = beforeRemoval.connections.find( + (candidate) => candidate.connectionId === connection.connectionId, + ); + assert.ok(currentConnection); + if (!currentConnection) return; + assert.equal( + ( + await policy.connectionCatalog.remove({ + expected: { + connectionId: currentConnection.connectionId, + revision: currentConnection.revision, + }, + }) + ).kind, + 'committed', + ); + const afterRemoval = await policy.connectionCatalog.getSnapshot(); + const replacementCreated = await policy.connectionCatalog.create({ + expectedCatalogRevision: afterRemoval.revision, + connection: { + slug: connection.slug, + name: 'Same-slug replacement', + providerType: 'ollama', + enabled: true, + enabledModelIds: ['fake-model'], + }, + }); + assert.equal(replacementCreated.kind, 'committed'); + if (replacementCreated.kind !== 'committed') return; + const replacement = replacementCreated.snapshot.connections[0]; + assert.ok(replacement); + if (!replacement) return; + const replacementFetch = await policy.operations.beginModelFetch(replacement.connectionId); + assert.equal(replacementFetch.kind, 'ready'); + if (replacementFetch.kind !== 'ready') return; + const replacementFetched = await policy.operations.completeModelFetch( + replacementFetch.ticket, + { + models: [{ id: 'fake-model' }], + source: 'fetched', + fetchedAt: Date.now(), + }, + ); + assert.equal(replacementFetched.kind, 'committed'); + if (replacementFetched.kind !== 'committed') return; + assert.equal( + ( + await policy.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: replacementFetched.snapshot.revision, + target: { connectionId: replacement.connectionId, modelId: 'fake-model' }, + }) + ).kind, + 'committed', + ); + + const boundSession = await query('session'); + assert.equal(boundSession.ok, true); + if (boundSession.ok && boundSession.result.kind === 'page') { + assert.equal( + boundSession.result.items.some( + (item) => item.id === 'web-search-preview' || item.id === 'web-research-preview', + ), + false, + ); + } + const replacementPreview = await query('new_session'); + assert.equal(replacementPreview.ok, true); + if (replacementPreview.ok && replacementPreview.result.kind === 'page') { + assert.equal( + replacementPreview.result.items.some( + (item) => item.id === 'web-search-preview' || item.id === 'web-research-preview', + ), + true, + ); + } } finally { await composition.close(); } @@ -731,6 +831,7 @@ test('production composition validates graph stop before aborting a claimed chil const claims = createAgentGraphControlStore(root); const parent = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -1009,6 +1110,7 @@ async function createClaimedGraphChild(input: { const child = await input.stores.sessionStore.createSubagent({ cwd: input.root, name: `Graph operator ${input.suffix}`, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'explore', diff --git a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts index 600d9007df..f0ed037956 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts @@ -626,6 +626,7 @@ function sessionInput(name: string) { cwd: '/tmp/workspace', name, backend: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -639,6 +640,7 @@ function runHeader(sessionId: string, runId: string, createdAt: number): AgentRu turnId: `turn-${runId}`, status: 'completed', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: '/tmp/workspace', diff --git a/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts index 49925fbbce..d272dac565 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts @@ -50,6 +50,7 @@ test('a live Host serves Interactive inspection over its real endpoint while ret const session = await stores.sessionStore.create({ cwd: root, name: 'Live inspection', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 1e98782511..e28257d32c 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -144,6 +144,27 @@ const TEST_SANDBOX_DIAGNOSTICS = createSandboxDiagnosticsProvider({ canonicalizePath: async (path) => path, }); +test('backend creation resolves a bound Session by immutable Connection identity', async () => { + let observedRef: unknown; + await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + connectionId: '11111111-1111-4111-8111-111111111111', + resolveExecutionConnection: async (ref) => { + observedRef = ref; + return readyExecutionConnection(); + }, + readPricing: async () => ({ revision: 0, overrides: [] }), + }), + ); + + assert.deepEqual(observedRef, { + kind: 'bound', + connectionId: '11111111-1111-4111-8111-111111111111', + connectionSlug: 'backend-creation-connection', + }); +}); + test('backend creation aborts a stalled canonical connection read', async () => { const abort = new AbortController(); const creating = createHostAiSdkBackend( @@ -753,9 +774,13 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its const firstCreation = createHostAiSdkBackend( backendCreationFixture({ abortSignal: firstAbort.signal, + connectionId: connection.connectionId, modelId: subscriptionModelId, resolveExecutionConnection: () => - policy.operations.resolveExecutionConnection('backend-creation-connection'), + policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'backend-creation-connection', + }), runtimePolicy: policy, oauthCredentials: authority, readPricing: async () => ({ revision: 0, overrides: [] }), @@ -777,9 +802,13 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its secondBackend = await createHostAiSdkBackend( backendCreationFixture({ abortSignal: new AbortController().signal, + connectionId: connection.connectionId, modelId: subscriptionModelId, resolveExecutionConnection: () => - policy.operations.resolveExecutionConnection('backend-creation-connection'), + policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'backend-creation-connection', + }), runtimePolicy: policy, oauthCredentials: authority, readPricing: async () => ({ revision: 0, overrides: [] }), @@ -788,9 +817,10 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its ); assert.equal(transports.refreshCalls, 1); - const resolved = await policy.operations.resolveExecutionConnection( - 'backend-creation-connection', - ); + const resolved = await policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'backend-creation-connection', + }); assert.equal(resolved.kind, 'ready'); if (resolved.kind === 'ready') { const persisted = JSON.parse( @@ -1155,6 +1185,7 @@ test('hosted execution freezes the headless coding provider wire contract', asyn workspace: { kind: 'host_path', path: root }, modelTarget: { kind: 'explicit', + connectionId: connection.connectionId, connectionSlug: 'profile-deepseek', model: 'deepseek-v4-flash', }, @@ -3451,7 +3482,8 @@ async function publishConnectionModel( function backendCreationFixture(input: { abortSignal: AbortSignal; - resolveExecutionConnection: () => Promise; + connectionId?: string; + resolveExecutionConnection: (ref?: unknown) => Promise; readPricing: () => Promise; runtimePolicy?: RuntimePolicyStoresWriter; oauthCredentials?: HostOAuthExecutionAuthority; @@ -3513,6 +3545,7 @@ function backendCreationFixture(input: { sessionId: 'backend-creation-session', workspaceRoot: '/workspace', header: { + llmConnectionId: input.connectionId ?? '11111111-1111-4111-8111-111111111111', llmConnectionSlug: 'backend-creation-connection', model: input.modelId ?? MODEL_ID, cwd: '/workspace', diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 986e4328d4..cb9df6eef3 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -82,6 +82,8 @@ import { type RuntimeHostConnection, type RuntimeHostSessionSubscription, } from '../../client/index.js'; + +const FAKE_CONNECTION_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; import { decodeHostFrame, encodeProtocolMessage, @@ -148,6 +150,7 @@ export class ExecutionFixture { stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: this.root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -183,6 +186,7 @@ export class ExecutionFixture { turnId: sourceTurnId, status: 'created', backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: this.root, @@ -495,6 +499,7 @@ export class ExecutionFixture { const child = await stores.sessionStore.createSubagent({ cwd: this.root, name: `${agentName} ${kind}`, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'explore', @@ -550,6 +555,7 @@ export class ExecutionFixture { turnId: `source-turn-${kind}`, status: 'created', backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: this.root, @@ -650,6 +656,7 @@ export class ExecutionFixture { turnId: graph.turnId, status: 'created', backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: this.root, @@ -879,6 +886,7 @@ export class ExecutionFixture { turnId, status: 'created', backendKind: 'fake', + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: this.root, @@ -1110,6 +1118,7 @@ export async function withExecutionRoot( stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index 497aeb1290..bbbf177c93 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -46,6 +46,7 @@ test('one Host Goal is shared across clients with CAS control and crash-clear re const goalStore = await openInteractiveGoalAuthorityForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -243,6 +244,7 @@ test('session retirement forgets a terminal Goal without recreating deleted auth try { const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -325,6 +327,7 @@ test('restart settles the durable current Goal execution through Hosted Executio try { const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -360,6 +363,7 @@ test('restart settles the durable current Goal execution through Hosted Executio turnId: execution.turnId, status: 'created', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: capability.canonicalPath, @@ -441,6 +445,7 @@ test('restart replaces a stale current execution with the current durable Goal i try { const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -559,6 +564,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf try { const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -673,6 +679,7 @@ test('a Goal armed but never carried by a Turn does not start itself after a res try { const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -774,6 +781,7 @@ test('resuming an armed Goal drives it, and a restart puts that drive back', asy try { const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -919,6 +927,7 @@ test('an arm admitted before the drain creates no Goal after it', async () => { try { const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index f2ef01bda8..d7dec4292d 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -540,6 +540,7 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro const goalStore = await openInteractiveGoalAuthorityForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -764,6 +765,7 @@ function runHeader(overrides: Partial): AgentRunHeader { turnId: 'turn-1', status: 'created', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: '/workspace', diff --git a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts index ed448e41dc..f8b41d18e4 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-coordinator.test.ts @@ -83,7 +83,12 @@ function input() { executionId: ID, session: { workspace: { kind: 'host_path' as const, path: '/workspace' }, - modelTarget: { kind: 'explicit' as const, connectionSlug: 'env-openai', model: 'model' }, + modelTarget: { + kind: 'explicit' as const, + connectionId: 'connection-1', + connectionSlug: 'env-openai', + model: 'model', + }, }, content: { text: 'solve' }, }; diff --git a/packages/runtime-host/src/__tests__/hosted-execution-runner.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-runner.test.ts index ca91d32413..614550554b 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-runner.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-runner.test.ts @@ -227,7 +227,12 @@ function input() { executionId: ID, session: { workspace: { kind: 'host_path' as const, path: '/workspace' }, - modelTarget: { kind: 'explicit' as const, connectionSlug: 'env-openai', model: 'model' }, + modelTarget: { + kind: 'explicit' as const, + connectionId: 'connection-1', + connectionSlug: 'env-openai', + model: 'model', + }, }, content: { text: 'solve' }, }; diff --git a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts index 7cf18a6fac..d67145d102 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-target.test.ts @@ -59,13 +59,13 @@ test('explicit hosted target preserves the default target and proves the request }, } as unknown as Pick; - assert.equal( + assert.deepEqual( await configureHostedExecutionTarget(connection, { connectionSlug: 'env-openai', model: 'deepseek-v4-flash', baseUrl: 'https://api.deepseek.com', }), - true, + { changed: true, connectionId: CONNECTION_ID, connectionSlug: 'env-openai' }, ); assert.deepEqual( @@ -123,13 +123,13 @@ test('explicit hosted target reports an already admitted target as unchanged', a }, } as unknown as Pick; - assert.equal( + assert.deepEqual( await configureHostedExecutionTarget(connection, { connectionSlug: 'env-openai', model: 'deepseek-v4-flash', baseUrl: 'https://api.deepseek.com', }), - false, + { changed: false, connectionId: CONNECTION_ID, connectionSlug: 'env-openai' }, ); }); @@ -171,13 +171,13 @@ test('explicit hosted target replaces a missing effective endpoint', async () => }, } as unknown as Pick; - assert.equal( + assert.deepEqual( await configureHostedExecutionTarget(connection, { connectionSlug: 'env-openai', model: 'deepseek-v4-flash', baseUrl: 'https://api.deepseek.com', }), - true, + { changed: true, connectionId: CONNECTION_ID, connectionSlug: 'env-openai' }, ); assert.deepEqual(operations, [ 'connection.catalog.query', diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts index 00100de786..c062d6262e 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts @@ -32,7 +32,12 @@ test('hosted execution tool profiles are durable Session creation inputs', () => executionId: '00000000-0000-4000-8000-000000000001', session: { workspace: { kind: 'host_path', path: '/workspace' }, - modelTarget: { kind: 'explicit', connectionSlug: 'provider', model: 'model' }, + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'provider', + model: 'model', + }, toolProfile: 'headless-coding-v1', }, content: { text: 'solve' }, diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index 1a8e5982f0..2c8de87694 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -124,6 +124,7 @@ describe('HostInteractionCoordinator', () => { await mkdir(workspace); const session = await stores.sessionStore.create({ cwd: workspace, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -255,6 +256,7 @@ describe('HostInteractionCoordinator', () => { await mkdir(workspace); const session = await stores.sessionStore.create({ cwd: workspace, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -326,6 +328,7 @@ describe('HostInteractionCoordinator', () => { await mkdir(workspace); const session = await stores.sessionStore.create({ cwd: workspace, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -501,6 +504,7 @@ describe('HostInteractionCoordinator', () => { await mkdir(workspace); const session = await stores.sessionStore.create({ cwd: workspace, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts index 3918a40144..1584eb3c18 100644 --- a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts @@ -563,9 +563,10 @@ test('Codex device login presents the one-time code and commits exchanged tokens assert.equal(polls, 1); assert.equal(fixture.invalidations, 1); assert.equal(fixture.activeResidencies, 0); - const resolved = await fixture.stores.operations.resolveExecutionConnection( - fixture.connection.slug, - ); + const resolved = await fixture.stores.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: fixture.connection.slug, + }); assert.equal(resolved.kind, 'ready'); if (resolved.kind === 'ready') { assert.deepEqual( diff --git a/packages/runtime-host/src/__tests__/oauth-execution-authority.test.ts b/packages/runtime-host/src/__tests__/oauth-execution-authority.test.ts index f64dfd118c..1dc9920c85 100644 --- a/packages/runtime-host/src/__tests__/oauth-execution-authority.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-execution-authority.test.ts @@ -52,6 +52,7 @@ test('one OAuth generation singleflights refresh and persists its lease with can const before = fixture.material; const binding = fixture.authority.bind({ providerType: 'github-copilot', + connectionId: connectionId(before), connectionSlug: CONNECTION_SLUG, material: before, createRefreshTransport: () => testRefreshTransport(unexpectedFetch), @@ -68,10 +69,28 @@ test('one OAuth generation singleflights refresh and persists its lease with can }); }); +test('rejects OAuth material from a different bound Connection entity', async () => { + await withCopilotCredential(currentTokens('access-v1'), async (fixture) => { + assert.throws( + () => + fixture.authority.bind({ + providerType: 'github-copilot', + connectionId: '11111111-1111-4111-8111-111111111111', + connectionSlug: CONNECTION_SLUG, + material: fixture.material, + createRefreshTransport: () => testRefreshTransport(unexpectedFetch), + }), + (error: unknown) => + error instanceof OAuthExecutionCredentialError && error.code === 'persistence_failed', + ); + }); +}); + test('an active OAuth binding cannot use a credential generation replaced by the user', async () => { await withCopilotCredential(currentTokens('old-access'), async (fixture) => { const oldBinding = fixture.authority.bind({ providerType: 'github-copilot', + connectionId: connectionId(fixture.material), connectionSlug: CONNECTION_SLUG, material: fixture.material, createRefreshTransport: () => testRefreshTransport(unexpectedFetch), @@ -89,6 +108,7 @@ test('an active OAuth binding cannot use a credential generation replaced by the const replacementMaterial = await readMaterial(fixture.stores); const newBinding = fixture.authority.bind({ providerType: 'github-copilot', + connectionId: connectionId(replacementMaterial), connectionSlug: CONNECTION_SLUG, material: replacementMaterial, createRefreshTransport: () => testRefreshTransport(unexpectedFetch), @@ -172,6 +192,7 @@ test('reconciles a published OAuth lease claim before the next demand', async () }); const binding = fixture.authority.bind({ providerType: 'openai-codex', + connectionId: connectionId(fixture.material), connectionSlug: CONNECTION_SLUG, material: fixture.material, createRefreshTransport: () => testRefreshTransport(providerFetch), @@ -183,10 +204,12 @@ test('reconciles a published OAuth lease claim before the next demand', async () await assert.rejects(() => binding.resolve(), isOAuthError('persistence_failed')); }); assert.equal(refreshCalls, 0); + const secondMaterial = await readMaterial(fixture.stores); const secondBinding = fixture.authority.bind({ providerType: 'openai-codex', + connectionId: connectionId(secondMaterial), connectionSlug: CONNECTION_SLUG, - material: await readMaterial(fixture.stores), + material: secondMaterial, createRefreshTransport: () => testRefreshTransport(providerFetch), }); leaseNow += 30_001; @@ -212,6 +235,7 @@ test('reconciles a published OAuth refresh finalization before the next demand', }); const binding = fixture.authority.bind({ providerType: 'openai-codex', + connectionId: connectionId(fixture.material), connectionSlug: CONNECTION_SLUG, material: fixture.material, createRefreshTransport: () => testRefreshTransport(providerFetch), @@ -245,6 +269,7 @@ test('reconciles a published OAuth lease release before retrying refresh', async }; const binding = fixture.authority.bind({ providerType: 'openai-codex', + connectionId: connectionId(fixture.material), connectionSlug: CONNECTION_SLUG, material: fixture.material, createRefreshTransport: () => testRefreshTransport(providerFetch), @@ -324,6 +349,7 @@ test('a Codex 401 force-refreshes canonical credentials and replays once', async }; const binding = fixture.authority.bind({ providerType: 'openai-codex', + connectionId: connectionId(fixture.material), connectionSlug: CONNECTION_SLUG, material: fixture.material, createRefreshTransport: () => testRefreshTransport(providerFetch), @@ -368,6 +394,7 @@ test('concurrent forced refreshes join one Host credential refresh', async () => let refreshCalls = 0; const binding = fixture.authority.bind({ providerType: 'openai-codex', + connectionId: connectionId(fixture.material), connectionSlug: CONNECTION_SLUG, material: fixture.material, createRefreshTransport: () => @@ -524,7 +551,10 @@ async function withSeededOAuthCredential( async function readMaterial( stores: RuntimePolicyStoresWriter, ): Promise { - const resolved = await stores.operations.resolveExecutionConnection(CONNECTION_SLUG); + const resolved = await stores.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: CONNECTION_SLUG, + }); assert.equal(resolved.kind, 'ready'); if (resolved.kind !== 'ready' || !resolved.secretMaterial.connection) { throw new Error('OAuth execution material was not ready'); @@ -532,6 +562,13 @@ async function readMaterial( return resolved.secretMaterial.connection; } +function connectionId(material: RuntimePolicyCredentialMaterial): string { + if (material.locator.scope !== 'connection') { + throw new Error('Expected connection-scoped OAuth material'); + } + return material.locator.connectionId; +} + function expiredTokens(accessToken: string, accountUuid?: string): OAuthSubscriptionTokens { return { access_token: accessToken, diff --git a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts index 38258865f3..395f113fbf 100644 --- a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts @@ -154,7 +154,10 @@ test('OAuth enrollment presents only on the initiating Client over the real endp const terminal = await waitForTerminal(second, 'uds-attempt'); assert.equal(terminal.phase, 'authenticated'); assert.deepEqual(presentations, ['tui']); - const resolved = await stores.operations.resolveExecutionConnection(connection.slug); + const resolved = await stores.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: connection.slug, + }); assert.equal(resolved.kind, 'ready'); if (resolved.kind === 'ready') { assert.deepEqual( diff --git a/packages/runtime-host/src/__tests__/owned-candidate.test.ts b/packages/runtime-host/src/__tests__/owned-candidate.test.ts index f0dd87c0ba..1d76f6c7d3 100644 --- a/packages/runtime-host/src/__tests__/owned-candidate.test.ts +++ b/packages/runtime-host/src/__tests__/owned-candidate.test.ts @@ -394,7 +394,11 @@ test('owned hosted execution closes its fresh Host after configuration fails', a executionId: '00000000-0000-4000-8000-000000000002', session: { workspace: { kind: 'host_path', path: rootPath }, - modelTarget: { kind: 'explicit', connectionSlug: 'missing', model: 'missing' }, + modelTarget: { + kind: 'explicit', + connectionSlug: 'missing', + model: 'missing', + }, }, content: { text: 'This request must not reach a provider.' }, }, diff --git a/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts index daf3c28b93..8cf756c728 100644 --- a/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts @@ -60,6 +60,7 @@ test('two Clients and a restarted production Host share one retry-safe Plan auth const planStore = await openInteractivePlanStoreForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'explore', diff --git a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts index 29e6868021..9495589165 100644 --- a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts @@ -260,6 +260,7 @@ function sessionInput(cwd: string, projectId: string) { cwd, projectId, backend: 'fake' as const, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask' as const, diff --git a/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts index 012a2e7401..632fbc888f 100644 --- a/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts @@ -164,6 +164,7 @@ function sessionInput(cwd: string, projectId: string) { cwd, projectId, backend: 'fake' as const, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask' as const, diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..20fa54f97e 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -242,6 +242,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 43); }); + test('publishes a new compatibility epoch for exact Session Connection identity', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 51); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 5f62a61b7b..99f1118997 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -1235,6 +1235,7 @@ test('linked child Sessions reject public safe-boundary continuation', async () const parent = await fixture.stores.sessionStore.readHeaderSnapshot(fixture.sessionId); const { header: child } = await fixture.stores.sessionStore.createSubagent({ cwd: parent.cwd, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -1378,6 +1379,7 @@ test('worktree child Sessions reject roots outside managed child execution', asy try { const { header: child } = await fixture.stores.sessionStore.createSubagent({ cwd: binding.worktreePath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -2295,6 +2297,7 @@ test('hosted linked child roots share admission, message, terminal, and stop aut const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const parent = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -4164,6 +4167,7 @@ test('post-start backend failure closes its owner without draining an unrelated try { const unrelatedSession = await fixture.stores.sessionStore.create({ cwd: '/tmp/unrelated-active-root', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -4740,6 +4744,7 @@ async function seedPendingSafeBoundaryContinuation( turnId: sourceTurnId, status: 'created' as const, backendKind: 'fake' as const, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: session.cwd, @@ -4900,6 +4905,7 @@ async function createFailureFixture(options: { await artifacts?.recover(); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index fff900af22..dc03b56c84 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -123,6 +123,7 @@ test('production composition shares one gate across mutation and backend activat const setupStores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -226,6 +227,7 @@ test('production mutation releases the gate before active-turn backend disposal const setupStores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -326,6 +328,7 @@ test('production policy mutation drains and poisons activation when cached backe const setupStores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index f0c3d9d372..e5f788bac5 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -560,6 +560,7 @@ test('creation on a relay connection honours declared levels via the catalog pro // passes is exactly what execution rebuilds the runtime connection from. let createAttempts = 0; let persistedThinkingLevel: unknown; + let persistedConnectionId: unknown; const fixture = createFixture({ connection: { providerType: 'openai-compatible', @@ -571,6 +572,7 @@ test('creation on a relay connection honours declared levels via the catalog pro createStableSession: async (args) => { createAttempts += 1; persistedThinkingLevel = args.input.thinkingLevel; + persistedConnectionId = args.input.llmConnectionId; return { kind: 'existing' as const, record: headerSnapshot(sessionHeader(args.sessionId, ['user-label']), 1), @@ -583,7 +585,12 @@ test('creation on a relay connection honours declared levels via the catalog pro { sessionId: fixture.sessionId, workspace: { kind: 'host_path', path: process.cwd() }, - modelTarget: { kind: 'explicit', connectionSlug: 'test', model: 'relay-model' }, + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'test', + model: 'relay-model', + }, thinkingLevel: 'low', }, context, @@ -592,6 +599,7 @@ test('creation on a relay connection honours declared levels via the catalog pro assert.equal(outcome.ok, true); assert.equal(createAttempts, 1); assert.equal(persistedThinkingLevel, 'low'); + assert.equal(persistedConnectionId, 'connection-1'); }); test('creation admits the enabled bootstrap DeepSeek model before discovery', async () => { @@ -621,7 +629,12 @@ test('creation admits the enabled bootstrap DeepSeek model before discovery', as { sessionId: fixture.sessionId, workspace: { kind: 'host_path', path: process.cwd() }, - modelTarget: { kind: 'explicit', connectionSlug: 'test', model: modelId }, + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'test', + model: modelId, + }, }, context, ); @@ -687,7 +700,12 @@ test('creation refuses a retired provider named explicitly', async () => { { sessionId: fixture.sessionId, workspace: { kind: 'host_path', path: process.cwd() }, - modelTarget: { kind: 'explicit', connectionSlug: 'test', model: 'model-1' }, + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'test', + model: 'model-1', + }, }, context, ); @@ -735,7 +753,12 @@ test('creation admits an enabled model a snapshot provider never listed', async { sessionId: fixture.sessionId, workspace: { kind: 'host_path', path: process.cwd() }, - modelTarget: { kind: 'explicit', connectionSlug: 'test', model: modelId }, + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'test', + model: modelId, + }, }, context, ); @@ -773,7 +796,12 @@ test('creation admits an enabled model a live list omits', async () => { { sessionId: fixture.sessionId, workspace: { kind: 'host_path', path: process.cwd() }, - modelTarget: { kind: 'explicit', connectionSlug: 'test', model: modelId }, + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'test', + model: modelId, + }, }, context, ); @@ -805,7 +833,12 @@ test('creation on a relay connection without declarations still fails closed on { sessionId: fixture.sessionId, workspace: { kind: 'host_path', path: process.cwd() }, - modelTarget: { kind: 'explicit', connectionSlug: 'test', model: 'relay-model' }, + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'test', + model: 'relay-model', + }, thinkingLevel: 'low', }, context, @@ -895,8 +928,7 @@ test('configuration update admits Plan mode through Runtime authority', async () const outcome = await fixture.coordinator.handlers['session.configuration.update']( { ...input, - configuration: { - ...input.configuration, + patch: { collaborationMode: 'plan', }, }, @@ -910,10 +942,105 @@ test('configuration update admits Plan mode through Runtime authority', async () assert.fail('Plan mode configuration returned an unsupported Session projection'); } assert.equal(outcome.result.session.collaborationMode, 'plan'); + assert.equal(fixture.header().llmConnectionId, 'connection-1'); assert.equal(fixture.header().collaborationMode, 'plan'); assert.equal(fixture.drainRequests(), 0); }); +test('configuration update never rebinds a bound Session through a reused slug', async () => { + let observedRef: unknown; + const fixture = createFixture({ + connection: { + executionResolution: { kind: 'not_found' }, + onResolve: (ref) => { + observedRef = ref; + }, + }, + }); + + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + configurationInput(fixture.sessionId, fixture.revision()), + context, + ); + + assert.deepEqual(outcome, { + ok: false, + error: { + code: 'operation_conflict', + message: 'Session model identity changed during selection', + }, + }); + assert.deepEqual(observedRef, { + kind: 'bound', + connectionId: 'connection-1', + connectionSlug: 'test', + }); + assert.equal(fixture.header().llmConnectionId, 'connection-1'); +}); + +test('identity-free configuration patch fails closed for a legacy Session', async () => { + const fixture = createFixture({ legacyConnectionIdentity: true }); + + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { permissionMode: 'bypass' }, + }, + context, + ); + + assert.deepEqual(outcome, { + ok: false, + error: { + code: 'operation_conflict', + message: 'Legacy Session configuration requires an explicit account selection', + }, + }); + assert.equal(fixture.header().llmConnectionId, undefined); + assert.notEqual(fixture.header().permissionMode, 'bypass'); +}); + +test('only an explicit exact target recovers a legacy Session account binding', async () => { + let clearConnectionBlock: boolean | undefined; + const fixture = createFixture({ + legacyConnectionIdentity: true, + header: { blockedReason: 'NO_REAL_CONNECTION' }, + manager: { + transitionSessionConfiguration: async (_sessionId, input) => { + clearConnectionBlock = input.clearConnectionBlock; + return { + header: fixture.header(), + revision: fixture.revision(), + committedAt: 1, + }; + }, + }, + }); + + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + configurationInput(fixture.sessionId, fixture.revision()), + context, + ); + + assert.equal(outcome.ok, true); + assert.equal(clearConnectionBlock, true); +}); + +test('explicit recovery persists the selected Connection entity identity', async () => { + const fixture = createFixture({ legacyConnectionIdentity: true }); + + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + configurationInput(fixture.sessionId, fixture.revision()), + context, + ); + + assert.equal(outcome.ok, true); + assert.equal(fixture.header().llmConnectionId, 'connection-1'); + assert.equal(fixture.header().llmConnectionSlug, 'test'); + assert.equal(fixture.header().model, 'model-1'); +}); + test('creation persists a canonical cwd while fingerprints retain exact target intent', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-create-cwd-')); const target = join(root, 'target'); @@ -1268,11 +1395,18 @@ function createFixture( readonly connection?: FixtureConnection; readonly projectCatalog?: ProjectCatalog; readonly onProjectChanged?: () => void; + readonly legacyConnectionIdentity?: boolean; + readonly header?: Partial; } = {}, ) { const sessionId = 'session-1'; let revision = 3; let header = sessionHeader(sessionId, options.labels ?? ['user-label']); + header = { ...header, ...options.header }; + if (options.legacyConnectionIdentity) { + const { llmConnectionId: _legacyConnectionId, ...legacyHeader } = header; + header = legacyHeader; + } if (options.cwd) header = { ...header, cwd: options.cwd }; let drains = 0; @@ -1367,6 +1501,9 @@ type FixtureConnection = { | 'volcengine-agent-plan'; /** Lets a case exercise a resolver verdict other than `ready`. */ readonly executionResolution?: ResolveExecutionConnectionResult; + readonly onResolve?: ( + ref: Parameters[0], + ) => void; readonly enabledModelIds?: readonly string[]; readonly models?: readonly { id: string }[]; // Mirrors what the codec allows: a non-empty inventory must carry a source, @@ -1411,13 +1548,17 @@ function runtimePolicyFixture(overrides: FixtureConnection): RuntimePolicy { getSnapshot: async () => ({ revision: 1, policy }), }, operations: { - resolveExecutionConnection: async () => - overrides.executionResolution ?? { - kind: 'ready', - connection, - secretMaterial: {}, - networkProxy: policy.networkProxy, - }, + resolveExecutionConnection: async (ref) => { + overrides.onResolve?.(ref); + return ( + overrides.executionResolution ?? { + kind: 'ready', + connection, + secretMaterial: {}, + networkProxy: policy.networkProxy, + } + ); + }, }, }; } @@ -1429,9 +1570,10 @@ function configurationInput( return { sessionId, expectedRevision, - configuration: { + patch: { modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'test', model: 'model-1', }, @@ -1458,6 +1600,7 @@ function sessionHeader(sessionId: string, labels: readonly string[]): SessionHea statusUpdatedAt: 1, hasUnread: false, backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'test', connectionLocked: true, model: 'model-1', diff --git a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts index 73b7446593..c70ebb66ce 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts @@ -204,6 +204,25 @@ describe('Session catalog protocol', () => { }), isProtocolError, ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-2b', + operation: 'session.configuration.update', + input: { + sessionId: 'session-1', + expectedRevision: 1, + patch: { + modelTarget: { + kind: 'explicit', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, + }, + }, + }), + isProtocolError, + ); }); test('decodes exact stable creation and full replacement configuration inputs', () => { @@ -218,6 +237,7 @@ describe('Session catalog protocol', () => { labels: ['catalog'], modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'openai-main', model: 'gpt-5', }, @@ -237,6 +257,7 @@ describe('Session catalog protocol', () => { labels: ['catalog'], modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'openai-main', model: 'gpt-5', }, @@ -276,8 +297,13 @@ describe('Session catalog protocol', () => { input: { sessionId: 'session-1', expectedRevision: 2, - configuration: { - modelTarget: { kind: 'default' }, + patch: { + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, thinkingLevel: null, permissionMode: 'bypass', collaborationMode: 'plan', @@ -291,8 +317,13 @@ describe('Session catalog protocol', () => { input: { sessionId: 'session-1', expectedRevision: 2, - configuration: { - modelTarget: { kind: 'default' }, + patch: { + modelTarget: { + kind: 'explicit', + connectionId: 'connection-1', + connectionSlug: 'openai-main', + model: 'gpt-5', + }, thinkingLevel: null, permissionMode: 'bypass', collaborationMode: 'plan', @@ -325,11 +356,8 @@ describe('Session catalog protocol', () => { input: { sessionId: 'session-1', expectedRevision: 1, - configuration: { + patch: { modelTarget: { kind: 'default' }, - thinkingLevel: null, - permissionMode: 'ask', - collaborationMode: 'agent', }, }, }), @@ -545,6 +573,15 @@ describe('Session catalog protocol', () => { ); }); + test('requires a nullable Connection identity in Session catalog projections', () => { + assert.deepEqual( + decodeSessionCatalogItem(projection({ llmConnectionId: null })), + projection({ llmConnectionId: null }), + ); + const { llmConnectionId: _omitted, ...withoutConnectionId } = projection(); + assert.throws(() => decodeSessionCatalogItem(withoutConnectionId), isProtocolError); + }); + test('bounds pages and preserves revision-pinned continuation results', () => { const sessions = Array.from({ length: SESSION_CATALOG_PAGE_MAX_ITEMS }, (_, index) => projection({ id: `session-${index}` }), @@ -591,6 +628,7 @@ function projection(overrides: Partial = {}): SessionC hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', connectionLocked: true, model: 'gpt-5', diff --git a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts index 665548b33d..4d32b5c5bc 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts @@ -39,6 +39,7 @@ import { import { openInteractiveTaskLedgerStoreForWrite } from '@maka/storage/task-ledger-authority'; import { connectRuntimeHost, + readRuntimeHostConnectionCatalog, RuntimeHostOperationError, type RuntimeHostConnection, } from '../client/index.js'; @@ -270,22 +271,16 @@ test('two Clients share stable Session creation, CAS configuration, and catalog desktop.request('session.configuration.update', { sessionId: created.id, expectedRevision: configurationRevision, - configuration: { - modelTarget: { kind: 'default' }, - thinkingLevel: null, + patch: { permissionMode: 'bypass', - collaborationMode: 'agent', orchestrationMode: 'graph', }, }), tui.request('session.configuration.update', { sessionId: created.id, expectedRevision: configurationRevision, - configuration: { - modelTarget: { kind: 'default' }, - thinkingLevel: null, + patch: { permissionMode: 'bypass', - collaborationMode: 'agent', orchestrationMode: 'default', }, }), @@ -309,16 +304,8 @@ test('two Clients share stable Session creation, CAS configuration, and catalog const unchangedConfiguration = await desktop.request('session.configuration.update', { sessionId: configuredSession.id, expectedRevision: configuredSession.revision, - configuration: { - modelTarget: { - kind: 'explicit', - connectionSlug: configuredSession.llmConnectionSlug, - model: configuredSession.model, - }, - thinkingLevel: configuredSession.thinkingLevel ?? null, + patch: { permissionMode: configuredSession.permissionMode, - collaborationMode: configuredSession.collaborationMode, - orchestrationMode: configuredSession.orchestrationMode, }, }); assert.deepEqual(unchangedConfiguration, { @@ -328,16 +315,8 @@ test('two Clients share stable Session creation, CAS configuration, and catalog const narrowedConfiguration = await desktop.request('session.configuration.update', { sessionId: configuredSession.id, expectedRevision: configuredSession.revision, - configuration: { - modelTarget: { - kind: 'explicit', - connectionSlug: configuredSession.llmConnectionSlug, - model: configuredSession.model, - }, - thinkingLevel: configuredSession.thinkingLevel ?? null, + patch: { permissionMode: 'explore', - collaborationMode: configuredSession.collaborationMode, - orchestrationMode: configuredSession.orchestrationMode, }, }); assert.equal(narrowedConfiguration.kind, 'committed'); @@ -400,15 +379,17 @@ test('two Clients share stable Session creation, CAS configuration, and catalog desktop.request('session.configuration.update', { sessionId: relocatedSession.id, expectedRevision: relocatedSession.revision, - configuration: { - modelTarget: { kind: 'default' }, - thinkingLevel: null, - permissionMode: relocatedSession.permissionMode, - collaborationMode: relocatedSession.collaborationMode, - orchestrationMode: relocatedSession.orchestrationMode, + patch: { + modelTarget: { + kind: 'explicit', + connectionId: relocatedSession.llmConnectionId!, + connectionSlug: relocatedSession.llmConnectionSlug, + model: WIRE_OVERSIZED_MODEL_ID, + }, }, }), - operationError('invalid_request'), + (error: unknown) => + error instanceof RuntimeHostProtocolError && error.code === 'invalid_frame', ); assert.deepEqual(await querySession(desktop, relocatedSession.id), { ...relocatedSession, @@ -663,6 +644,113 @@ test('two Clients share stable Session creation, CAS configuration, and catalog } }); +test('deleted account identity survives same-slug reuse until explicit recovery', { + skip: process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false, + timeout: 120_000, +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-session-identity-')); + const root = join(base, 'root'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const { connectionId: originalConnectionId } = await seedAuthority(root, capability); + let host: ExecutionHostHandle | undefined; + try { + host = await startHost(root, capability.rootId); + const client = await connectClient(root); + try { + const created = requireSessionProjection( + await client.request('session.create', { + sessionId: 'same-slug-recovery', + workspace: { kind: 'host_path', path: root }, + modelTarget: { kind: 'default' }, + }), + ); + assert.equal(created.llmConnectionId, originalConnectionId); + + const originalCatalog = await readRuntimeHostConnectionCatalog(client); + const original = originalCatalog.connections.find( + (entry) => entry.connectionId === originalConnectionId, + ); + assert.ok(original); + if (!original) assert.fail('Original Connection must exist'); + const removed = await client.request('connection.catalog.remove', { + expected: { + connectionId: original.connectionId, + revision: original.revision, + }, + }); + assert.equal(removed.kind, 'committed'); + + const afterRemoval = await readRuntimeHostConnectionCatalog(client); + const replacement = await client.request('connection.catalog.create', { + expectedCatalogRevision: afterRemoval.revision, + connection: { + slug: original.slug, + name: 'Replacement OpenAI', + providerType: 'openai', + enabled: true, + enabledModelIds: ['gpt-5'], + }, + }); + assert.equal(replacement.kind, 'committed'); + if (replacement.kind !== 'committed') assert.fail('Replacement Connection must commit'); + assert.notEqual(replacement.connection.connectionId, originalConnectionId); + const credential = await client.request('credential.vault.set', { + locator: { + scope: 'connection', + connectionId: replacement.connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: 'replacement-test-key', + }); + assert.equal(credential.kind, 'committed'); + + const preserved = await client.request('session.configuration.update', { + sessionId: created.id, + expectedRevision: created.revision, + patch: { permissionMode: 'bypass' }, + }); + assert.equal(preserved.kind, 'committed'); + if (preserved.kind !== 'committed' || 'kind' in preserved.session) { + assert.fail('Permission update must preserve the deleted account identity'); + } + assert.equal(preserved.session.llmConnectionId, originalConnectionId); + + const recovered = await client.request('session.configuration.update', { + sessionId: preserved.session.id, + expectedRevision: preserved.session.revision, + patch: { + modelTarget: { + kind: 'explicit', + connectionId: replacement.connection.connectionId, + connectionSlug: original.slug, + model: 'gpt-5', + }, + }, + }); + assert.equal(recovered.kind, 'committed'); + if (recovered.kind !== 'committed' || 'kind' in recovered.session) { + assert.fail('Explicit replacement recovery must commit'); + } + assert.equal(recovered.session.llmConnectionId, replacement.connection.connectionId); + assert.equal(recovered.session.llmConnectionSlug, original.slug); + assert.equal(recovered.session.model, 'gpt-5'); + } finally { + await client.close(); + } + await stopHost(host); + host = undefined; + } finally { + await terminateHost(host); + await rm(join(resolveRootControlNamespace(), capability.rootId), { + recursive: true, + force: true, + }); + await removePosixEndpointDirectories(capability.rootId); + await rm(base, { recursive: true, force: true }); + } +}); + test('stable Session creation survives response loss and Host restart', { skip: process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false, timeout: 120_000, @@ -733,6 +821,7 @@ async function seedAuthority( const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const unread = await execution.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -769,6 +858,7 @@ async function seedAuthority( 'visible', ...Array.from({ length: 700 }, (_, index) => `label-${index}`), ], + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -776,6 +866,7 @@ async function seedAuthority( const oversized = await execution.sessionStore.create({ cwd: root, projectId: 'p'.repeat(257), + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -783,6 +874,7 @@ async function seedAuthority( const retirement = await execution.sessionStore.create({ cwd: root, name: 'Retirement sidecars', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -790,6 +882,7 @@ async function seedAuthority( const recovery = await execution.sessionStore.create({ cwd: root, name: 'Retirement recovery', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index 4edfcf2188..21e19ef4f9 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -1319,6 +1319,7 @@ function sessionInput( ): CreateSessionInput { return { cwd: '/workspace', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts index ac131e064a..f5854bdd6b 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts @@ -128,6 +128,7 @@ function projection(overrides: Partial = {}): SessionC hasUnread: false, status: 'active', backend: 'fake', + llmConnectionId: null, llmConnectionSlug: 'fake', connectionLocked: false, model: 'fake-model', diff --git a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts index 1c61f354ba..3d09d00e3b 100644 --- a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts @@ -464,6 +464,7 @@ function sessionHeader(id: string): SessionHeader { status: 'active', hasUnread: false, backend: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', connectionLocked: true, model: 'fake-model', @@ -515,6 +516,7 @@ function agentRun(overrides: Partial = {}): AgentRunHeader { turnId: CHILD_TURN_ID, status: 'completed', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: '/workspace', diff --git a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts index 46c50e0439..79e64ee8f0 100644 --- a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts @@ -210,6 +210,7 @@ function sessionProjection(id: string): SessionCatalogProjection { hasUnread: false, status: 'active', backend: 'fake', + llmConnectionId: null, llmConnectionSlug: 'fake', connectionLocked: false, model: 'fake-model', diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index cd3d8d4ee0..7ba9c351de 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -754,6 +754,7 @@ async function seedSource( const source = await execution.sessionStore.create({ cwd: root, name: 'Source Session', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -761,6 +762,7 @@ async function seedSource( const busy = await execution.sessionStore.create({ cwd: root, name: 'Busy Session', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -768,6 +770,7 @@ async function seedSource( const linkedChildSource = await execution.sessionStore.create({ cwd: root, name: 'Linked Child Source Session', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -775,6 +778,7 @@ async function seedSource( const metadataLinkedSource = await execution.sessionStore.create({ cwd: root, name: 'Metadata-linked Source Session', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -782,6 +786,7 @@ async function seedSource( const archivedOwnedSource = await execution.sessionStore.create({ cwd: root, name: 'Archived-owned Source Session', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -789,6 +794,7 @@ async function seedSource( const continuationSource = await execution.sessionStore.create({ cwd: root, name: 'Continuation Source Session', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -1058,6 +1064,7 @@ async function seedSource( { cwd: root, name: 'Graph Worker', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -1295,6 +1302,7 @@ async function seedSource( const ordinaryLinkedChild = await execution.sessionStore.createSubagent({ cwd: root, name: 'Metadata-linked Child Session', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -1889,6 +1897,7 @@ function agentRunHeader( turnId, status: 'completed', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd, diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index 0baad3f799..c34c30db73 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -44,6 +44,7 @@ test('keeps durable history separate from the canonical active overlay', async ( const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -329,6 +330,7 @@ function runHeader(sessionId: string): AgentRunHeader { turnId: 'turn-1', status: 'running', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: '/tmp', diff --git a/packages/runtime-host/src/client/hosted-execution-target.ts b/packages/runtime-host/src/client/hosted-execution-target.ts index 2ba97b5821..212ca2131a 100644 --- a/packages/runtime-host/src/client/hosted-execution-target.ts +++ b/packages/runtime-host/src/client/hosted-execution-target.ts @@ -30,11 +30,17 @@ export interface HostedExecutionTargetInput { readonly baseUrl: string; } +export interface ConfiguredHostedExecutionTarget { + readonly changed: boolean; + readonly connectionId: string; + readonly connectionSlug: string; +} + export async function configureHostedExecutionTarget( connection: TargetConnection, input: HostedExecutionTargetInput, signal?: AbortSignal, -): Promise { +): Promise { const before = await abortable(() => readRuntimeHostConnectionCatalog(connection), signal); const target = before.connections.find((candidate) => candidate.slug === input.connectionSlug); if (!target) throw new Error('Runtime Host connection is unavailable'); @@ -91,7 +97,11 @@ export async function configureHostedExecutionTarget( ) { throw new Error('Runtime Host did not admit the requested model target'); } - return changed; + return { + changed, + connectionId: configured.connectionId, + connectionSlug: configured.slug, + }; } function canonicalBaseUrl(value: string): string | undefined { diff --git a/packages/runtime-host/src/client/hosted-execution.ts b/packages/runtime-host/src/client/hosted-execution.ts index fbce39b1f5..c9a0cdf085 100644 --- a/packages/runtime-host/src/client/hosted-execution.ts +++ b/packages/runtime-host/src/client/hosted-execution.ts @@ -30,12 +30,24 @@ import { configureHostedExecutionTarget } from './hosted-execution-target.js'; export interface RunHostedExecutionInput { readonly rootPath: string; - readonly execution: HostedExecutionStartInput; + readonly execution: HostedExecutionClientStartInput; readonly baseUrl?: string; readonly signal?: AbortSignal; readonly hostSettlementTimeoutMs?: number; } +type HostedExecutionClientStartInput = Omit & { + readonly session: Omit & { + readonly modelTarget: + | { readonly kind: 'default' } + | { + readonly kind: 'explicit'; + readonly connectionSlug: string; + readonly model: string; + }; + }; +}; + interface RunHostedExecutionDependencies { readonly connectOwnedRuntimeHost: typeof connectOwnedRuntimeHost; } @@ -79,9 +91,10 @@ export async function runHostedExecutionWithDependencies( try { input.signal?.throwIfAborted(); const target = input.execution.session.modelTarget; + let exactTarget: HostedExecutionStartInput['session']['modelTarget'] = { kind: 'default' }; if (target.kind === 'explicit') { if (!input.baseUrl) throw new Error('Explicit model target requires baseUrl'); - const changed = await configureHostedExecutionTarget( + const configured = await configureHostedExecutionTarget( connected.connection, { connectionSlug: target.connectionSlug, @@ -90,7 +103,13 @@ export async function runHostedExecutionWithDependencies( }, input.signal, ); - if (changed) { + exactTarget = { + kind: 'explicit', + connectionId: configured.connectionId, + connectionSlug: configured.connectionSlug, + model: target.model, + }; + if (configured.changed) { await connected.connection.close().catch(() => undefined); if (!(await connected.host.settle(input.hostSettlementTimeoutMs ?? 15_000))) { return indeterminate(input.execution.executionId, 'Runtime Host did not exit cleanly'); @@ -114,7 +133,14 @@ export async function runHostedExecutionWithDependencies( connected = reconnected; } } - projection = await executeHostedExecution(connected.connection, input.execution, input.signal); + projection = await executeHostedExecution( + connected.connection, + { + ...input.execution, + session: { ...input.execution.session, modelTarget: exactTarget }, + }, + input.signal, + ); } catch { projection = input.signal?.aborted ? indeterminate(input.execution.executionId, 'Hosted execution was cancelled') diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 7f73e67acf..b8f2934d6a 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -107,7 +107,7 @@ export { createRuntimeHostCandidateLaunchBarrier, type RuntimeHostCandidateLaunchBarrier, } from './candidate-launch-barrier.js'; -export { runHostedExecution } from './hosted-execution.js'; +export { runHostedExecution, type RunHostedExecutionInput } from './hosted-execution.js'; export { type ClientCapabilityProvider } from './client-capability.js'; export { readRuntimeHostAgentGraphEpochs, diff --git a/packages/runtime-host/src/client/session-catalog-summary.ts b/packages/runtime-host/src/client/session-catalog-summary.ts index f7a114f460..10d50521db 100644 --- a/packages/runtime-host/src/client/session-catalog-summary.ts +++ b/packages/runtime-host/src/client/session-catalog-summary.ts @@ -60,6 +60,7 @@ export function projectSessionCatalogSummary( ...(session.revisionIndex === undefined ? {} : { revisionIndex: session.revisionIndex }), ...(session.revisionState === undefined ? {} : { revisionState: session.revisionState }), backend: session.backend, + ...(session.llmConnectionId === null ? {} : { llmConnectionId: session.llmConnectionId }), llmConnectionSlug: session.llmConnectionSlug, connectionLocked: session.connectionLocked, model: session.model, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 414fec8c33..f4f6e0b828 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 52 as const; +// 52: Session explicit model targets carry immutable Connection identity, +// configuration updates are Host-merged patches, and projections expose the +// required nullable binding ID. Older peers cannot preserve these invariants. // 51: WorkHub exposes bounded coordination candidates and admits only typed // actions through the deterministic Runtime Host Action Gate. // 50: WorkHub can append durable coordination summaries and admit tool-free diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 2c38605979..3ea74f4fe2 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -101,6 +101,7 @@ const PROJECTION_REQUIRED_FIELDS = [ 'hasUnread', 'status', 'backend', + 'llmConnectionId', 'llmConnectionSlug', 'connectionLocked', 'model', @@ -142,6 +143,7 @@ export type SessionModelTarget = | { readonly kind: 'default' } | { readonly kind: 'explicit'; + readonly connectionId: string; readonly connectionSlug: string; readonly model: string; }; @@ -172,18 +174,18 @@ export interface SessionMetadataUpdateInput { readonly patch: SessionMetadataPatch; } -export interface SessionConfiguration { - readonly modelTarget: SessionModelTarget; - readonly thinkingLevel: ThinkingLevel | null; - readonly permissionMode: PermissionMode; - readonly collaborationMode: CollaborationMode; - readonly orchestrationMode: OrchestrationMode; +export interface SessionConfigurationPatch { + readonly modelTarget?: Extract; + readonly thinkingLevel?: ThinkingLevel | null; + readonly permissionMode?: PermissionMode; + readonly collaborationMode?: CollaborationMode; + readonly orchestrationMode?: OrchestrationMode; } export interface SessionConfigurationUpdateInput { readonly sessionId: string; readonly expectedRevision: number; - readonly configuration: SessionConfiguration; + readonly patch: SessionConfigurationPatch; } export interface SessionWorkspaceRelocateInput { @@ -234,6 +236,7 @@ export interface SessionCatalogProjection { readonly revisionIndex?: number; readonly revisionState?: 'preparing' | 'committed'; readonly backend: PersistedBackendKind; + readonly llmConnectionId: string | null; readonly llmConnectionSlug: string; readonly connectionLocked: boolean; readonly model: string; @@ -516,25 +519,38 @@ export function decodeSessionConfigurationUpdateInput( const input = requireExactRecord(value, 'Session configuration update input', [ 'sessionId', 'expectedRevision', - 'configuration', - ]); - const configuration = requireExactRecord(input.configuration, 'Session configuration', [ - 'modelTarget', - 'thinkingLevel', - 'permissionMode', - 'collaborationMode', - 'orchestrationMode', + 'patch', ]); + const patch = requireShapedRecord( + input.patch, + 'Session configuration patch', + [], + ['modelTarget', 'thinkingLevel', 'permissionMode', 'collaborationMode', 'orchestrationMode'], + ); + if (Object.keys(patch).length === 0) { + throw invalidProtocolFrame('Session configuration patch is empty'); + } return { sessionId: requireEntityId(input.sessionId, 'sessionId'), expectedRevision: positiveRevision(input.expectedRevision, 'expected Session revision'), - configuration: { - modelTarget: modelTarget(configuration.modelTarget), - thinkingLevel: - configuration.thinkingLevel === null ? null : thinkingLevel(configuration.thinkingLevel), - permissionMode: permissionMode(configuration.permissionMode), - collaborationMode: collaborationMode(configuration.collaborationMode), - orchestrationMode: orchestrationMode(configuration.orchestrationMode), + patch: { + ...(Object.hasOwn(patch, 'modelTarget') + ? { modelTarget: explicitModelTarget(patch.modelTarget) } + : {}), + ...(Object.hasOwn(patch, 'thinkingLevel') + ? { + thinkingLevel: patch.thinkingLevel === null ? null : thinkingLevel(patch.thinkingLevel), + } + : {}), + ...(Object.hasOwn(patch, 'permissionMode') + ? { permissionMode: permissionMode(patch.permissionMode) } + : {}), + ...(Object.hasOwn(patch, 'collaborationMode') + ? { collaborationMode: collaborationMode(patch.collaborationMode) } + : {}), + ...(Object.hasOwn(patch, 'orchestrationMode') + ? { orchestrationMode: orchestrationMode(patch.orchestrationMode) } + : {}), }, }; } @@ -669,6 +685,10 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr ...optionalRevisionIndex(record), ...optionalRevisionState(record), backend: backend(record.backend), + llmConnectionId: + record.llmConnectionId === null + ? null + : requireEntityId(record.llmConnectionId, 'Session Connection id'), llmConnectionSlug: requireUtf8String( record.llmConnectionSlug, 'Session connection slug', @@ -720,11 +740,13 @@ function modelTarget(value: unknown): SessionModelTarget { if (target.kind === 'explicit') { const exact = requireExactRecord(target, 'explicit Session model target', [ 'kind', + 'connectionId', 'connectionSlug', 'model', ]); return { kind: 'explicit', + connectionId: requireEntityId(exact.connectionId, 'Session Connection id'), connectionSlug: requireUtf8String( exact.connectionSlug, 'Session connection slug', @@ -736,6 +758,16 @@ function modelTarget(value: unknown): SessionModelTarget { throw invalidProtocolFrame('Invalid Session model target'); } +function explicitModelTarget( + value: unknown, +): Extract { + const target = modelTarget(value); + if (target.kind !== 'explicit') { + throw invalidProtocolFrame('Session configuration model target must be explicit'); + } + return target; +} + function labels(value: unknown): readonly string[] { if (!Array.isArray(value) || value.length > SESSION_CATALOG_LABEL_MAX_ITEMS) { throw invalidProtocolFrame('Invalid Session labels'); diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 84e591e2fd..5133a7903d 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -427,6 +427,7 @@ export class HostConnectionEffectCoordinator { if (!isOAuthSubscriptionProvider(prepared.connection.providerType)) return material.secret; const binding = this.#oauthCredentials.bind({ providerType: prepared.connection.providerType, + connectionId: prepared.connection.connectionId, connectionSlug: prepared.connection.slug, material, createRefreshTransport: () => this.#createTransport(proxy), diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index cf46c22b9c..6a21ffc70d 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -27,7 +27,7 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; -import { isDeepResearchSession } from '@maka/core/session'; +import { isDeepResearchSession, type SessionHeader } from '@maka/core/session'; import { filterModelVisibleTaskLedgerTasks } from '@maka/core/task-ledger'; import { AgentGraphCoordinator } from '@maka/runtime/stream-graph-coordinator'; import { AgentGraphSupervisorWakeCoordinator } from '@maka/runtime/agent-graph-supervisor-wake'; @@ -80,6 +80,7 @@ import { createExternalSessionAdapterRegistry } from '@maka/storage/external-ses import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor'; import { runWithStorageRootLease } from '@maka/storage/root-authority'; import { openStorageWriterComposition } from '@maka/storage/storage-writer-composition'; +import type { RuntimePolicyStoresWriter } from '@maka/storage/runtime-policy-stores'; import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; import { type ManagedWorkspaceFilesystemWorker } from '@maka/storage/managed-workspace-owner'; import { CanonicalSessionProjectionReader } from './canonical-session-projection.js'; @@ -106,6 +107,7 @@ import { createInteractiveRunComposerFactory, routeInteractiveRunToolSurface, } from './interactive-run-composer.js'; + import { createHostGoalEvaluator, createHostDailyReviewModel, @@ -168,6 +170,10 @@ import { HostUsagePricingCoordinator } from './usage-pricing-coordinator.js'; import { HostWebSearchCoordinator } from './web-search-coordinator.js'; import { HostWorkHubCoordinationCoordinator } from './workhub-coordination-coordinator.js'; import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; + +type ExecutionConnectionRef = Parameters< + RuntimePolicyStoresWriter['operations']['resolveExecutionConnection'] +>[0]; import { createHostWebSearchService, createHostWebSearchToolFromService, @@ -682,7 +688,7 @@ export async function createExecutionRuntimeHostComposition( requireRootCoordinator(rootCoordinator).stopSession(sessionId, input), }; const resolveInteractiveToolSurface = async (input: { - readonly connectionSlug?: string; + readonly connectionRef?: ExecutionConnectionRef; readonly modelId: string; readonly hostTools: readonly MakaTool[]; readonly boundTools?: readonly MakaTool[]; @@ -691,8 +697,8 @@ export async function createExecutionRuntimeHostComposition( }) => { const [runtimePolicy, resolved] = await Promise.all([ runtimePolicyStores.runtimePolicy.getSnapshot(), - input.connectionSlug - ? runtimePolicyStores.operations.resolveExecutionConnection(input.connectionSlug) + input.connectionRef + ? runtimePolicyStores.operations.resolveExecutionConnection(input.connectionRef) : Promise.resolve(undefined), ]); let connection: RuntimeExecutionConnection | undefined; @@ -739,7 +745,7 @@ export async function createExecutionRuntimeHostComposition( throw new Error('Subagent runtime tool snapshot is unavailable'); } const { surface } = await resolveInteractiveToolSurface({ - connectionSlug: header.llmConnectionSlug, + connectionRef: sessionExecutionConnectionRef(header), modelId: header.model, hostTools: [], boundTools: tools, @@ -757,7 +763,7 @@ export async function createExecutionRuntimeHostComposition( openedPlanStore.readState(sessionId), ]); const { runtimePolicy, surface } = await resolveInteractiveToolSurface({ - connectionSlug: header.llmConnectionSlug, + connectionRef: sessionExecutionConnectionRef(header), modelId: header.model, hostTools: [...hostTools, ...graphTools], childTools: childAgentTools.childTools, @@ -815,7 +821,15 @@ export async function createExecutionRuntimeHostComposition( ) : undefined; const { runtimePolicy, surface } = await resolveInteractiveToolSurface({ - ...(connection ? { connectionSlug: connection.slug } : {}), + ...(connection + ? { + connectionRef: { + kind: 'bound' as const, + connectionId: connection.connectionId, + connectionSlug: connection.slug, + }, + } + : {}), modelId: target?.modelId ?? '', hostTools, childTools: childAgentTools.childTools, @@ -880,7 +894,7 @@ export async function createExecutionRuntimeHostComposition( worktreePatchWriteBackAvailable: true, }).childTools; const { surface } = await resolveInteractiveToolSurface({ - connectionSlug: header.llmConnectionSlug, + connectionRef: sessionExecutionConnectionRef(header), modelId: header.model, hostTools: [], childTools, @@ -1717,6 +1731,18 @@ export async function createExecutionRuntimeHostComposition( } } +function sessionExecutionConnectionRef( + header: Pick, +): ExecutionConnectionRef { + return header.llmConnectionId === undefined + ? { kind: 'catalog_slug', connectionSlug: header.llmConnectionSlug } + : { + kind: 'bound', + connectionId: header.llmConnectionId, + connectionSlug: header.llmConnectionSlug, + }; +} + function requireRootCoordinator(coordinator: RootTurnCoordinator | undefined): RootTurnCoordinator { if (!coordinator) throw new Error('Runtime Host root coordinator is not composed'); return coordinator; diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 7df90ad64d..23b6a9f6f1 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -401,7 +401,10 @@ type AuxiliaryModelRequest = interface HostAuxiliaryModelCallInput { readonly transportContextId: string; readonly telemetrySessionId?: string; - readonly header: Pick; + readonly header: Pick< + SessionHeader, + 'llmConnectionId' | 'llmConnectionSlug' | 'model' | 'thinkingLevel' + >; readonly callKind: Exclude; readonly callId: string; readonly abortSignal: AbortSignal; @@ -739,7 +742,9 @@ interface ResolvedExecutionTarget { async function resolveDailyReviewHeader( runtimePolicy: RuntimePolicyStoresWriter, modelKey: string, -): Promise> { +): Promise< + Pick +> { const explicit = parseDailyReviewModelKey(modelKey); if (modelKey.trim() && !explicit) { throw new AuxiliaryModelCallConfigurationError('Daily Review model key is invalid'); @@ -762,6 +767,7 @@ async function resolveDailyReviewHeader( ); } return { + llmConnectionId: connection.connectionId, llmConnectionSlug: connection.slug, model: target.modelId, thinkingLevel: 'off', @@ -781,7 +787,10 @@ function parseDailyReviewModelKey( } export async function resolveExecutionTarget( - header: Pick, + header: Pick< + BackendFactoryContext['header'], + 'llmConnectionId' | 'llmConnectionSlug' | 'model' | 'thinkingLevel' + >, runtimePolicy: { readonly operations: Pick< RuntimePolicyStoresWriter['operations'], @@ -792,7 +801,13 @@ export async function resolveExecutionTarget( createFetchTransport: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport, ): Promise { const resolved = await runtimePolicy.operations.resolveExecutionConnection( - header.llmConnectionSlug, + header.llmConnectionId === undefined + ? { kind: 'catalog_slug', connectionSlug: header.llmConnectionSlug } + : { + kind: 'bound', + connectionId: header.llmConnectionId, + connectionSlug: header.llmConnectionSlug, + }, ); if (resolved.kind !== 'ready') { throw new AuxiliaryModelCallConfigurationError( @@ -855,6 +870,7 @@ export async function resolveExecutionTarget( requestHeaders, oauthBinding: oauthCredentials.bind({ providerType: resolved.connection.providerType, + connectionId: resolved.connection.connectionId, connectionSlug: resolved.connection.slug, material, createRefreshTransport: () => createFetchTransport(refreshProxy), diff --git a/packages/runtime-host/src/server/oauth-execution-authority.ts b/packages/runtime-host/src/server/oauth-execution-authority.ts index f3af8b099d..e5ac20c544 100644 --- a/packages/runtime-host/src/server/oauth-execution-authority.ts +++ b/packages/runtime-host/src/server/oauth-execution-authority.ts @@ -96,6 +96,7 @@ export class HostOAuthExecutionAuthority { bind(input: { providerType: RuntimeExecutionConnection['providerType']; + connectionId: string; connectionSlug: string; material: RuntimePolicyCredentialMaterial; createRefreshTransport: () => ProxiedFetchTransport; @@ -107,6 +108,12 @@ export class HostOAuthExecutionAuthority { ); } const locator = requireOAuthLocator(input.material.locator); + if (locator.connectionId !== input.connectionId) { + throw new OAuthExecutionCredentialError( + 'persistence_failed', + 'Canonical OAuth credential does not belong to the bound connection', + ); + } let state = this.#states.get(locator.connectionId); if (state) { if ( @@ -248,7 +255,11 @@ export class HostOAuthExecutionAuthority { let resolved; try { - resolved = await this.#stores.operations.resolveExecutionConnection(state.connectionSlug); + resolved = await this.#stores.operations.resolveExecutionConnection({ + kind: 'bound', + connectionId: state.locator.connectionId, + connectionSlug: state.connectionSlug, + }); } catch (error) { throw new OAuthExecutionCredentialError( 'persistence_failed', diff --git a/packages/runtime-host/src/server/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts index 10718225d1..9170807790 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -628,6 +628,13 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority if (!isSessionNotFoundError(error) && !isMissingRecord(error)) throw error; } const execution = task.effect.execution; + const catalog = await this.#runtimePolicy.connectionCatalog.getSnapshot(); + const connection = catalog.connections.find( + (candidate) => candidate.slug === execution.llmConnectionSlug, + ); + if (!connection) { + throw new Error('ScheduledTask model connection does not exist'); + } await this.#createSession({ sessionId: identity.sessionId, workspace: @@ -638,6 +645,7 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority labels: ['scheduled-task'], modelTarget: { kind: 'explicit', + connectionId: connection.connectionId, connectionSlug: execution.llmConnectionSlug, model: execution.model, }, diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 01f7556259..2186aa9dbf 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -117,6 +117,18 @@ type SessionConfigurationAuthority = Pick< >; type SessionContinuity = Pick; +interface ResolvedSessionConfiguration { + readonly backend: 'ai-sdk'; + readonly llmConnectionId?: string; + readonly llmConnectionSlug: string; + readonly model: string; + readonly thinkingLevel: SessionHeader['thinkingLevel']; + readonly connectionLocked: boolean; + readonly permissionMode: SessionHeader['permissionMode']; + readonly collaborationMode: NonNullable; + readonly orchestrationMode: NonNullable; +} + export type SessionOperationFailureCode = | 'operation_unavailable' | 'invalid_request' @@ -144,6 +156,7 @@ export interface HostSessionCatalogCoordinatorOptions { } interface ResolvedSessionModel { + readonly connectionId: string; readonly connectionSlug: string; readonly model: string; } @@ -186,6 +199,7 @@ export class HostSessionCatalogCoordinator { this.#readRuntimePolicy(), ]); return { + llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: model.model, permissionMode: policy.policy.chatDefaults.permissionMode, @@ -381,6 +395,7 @@ export class HostSessionCatalogCoordinator { ...(workspace.projectId === null ? {} : { projectId: workspace.projectId }), name: prepared.name, labels: [...prepared.labels], + llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: model.model, ...(input.thinkingLevel === undefined ? {} : { thinkingLevel: input.thinkingLevel }), @@ -493,15 +508,11 @@ export class HostSessionCatalogCoordinator { ); } - const model = await this.#resolveModel( - input.configuration.modelTarget, - input.configuration.thinkingLevel ?? undefined, - ); - const clearsConnectionBlock = current.header.blockedReason === 'NO_REAL_CONNECTION'; - if ( - !clearsConnectionBlock && - sessionConfigurationMatches(current.header, model, input.configuration) - ) { + const configuration = await this.#mergeConfigurationPatch(current.header, input.patch); + const clearsConnectionBlock = + input.patch.modelTarget !== undefined && + current.header.blockedReason === 'NO_REAL_CONNECTION'; + if (!clearsConnectionBlock && sessionConfigurationMatches(current.header, configuration)) { return configurationSuccess({ kind: 'committed', session: projectSessionCatalogRecord( @@ -512,16 +523,8 @@ export class HostSessionCatalogCoordinator { commitAttempted = true; await this.#manager.transitionSessionConfiguration(input.sessionId, { expectedRevision: input.expectedRevision, - configuration: { - backend: 'ai-sdk', - llmConnectionSlug: model.connectionSlug, - model: model.model, - thinkingLevel: input.configuration.thinkingLevel ?? undefined, - connectionLocked: true, - permissionMode: input.configuration.permissionMode, - collaborationMode: input.configuration.collaborationMode, - orchestrationMode: input.configuration.orchestrationMode, - }, + clearConnectionBlock: input.patch.modelTarget !== undefined, + configuration, }); return configurationSuccess(await this.#committedUpdate(input.sessionId, lease)); } catch (error) { @@ -722,10 +725,25 @@ export class HostSessionCatalogCoordinator { thinkingLevel: SessionCreateInput['thinkingLevel'], ): Promise { const selected = await this.#selectModelTarget(target); - const readiness = await this.#runtimePolicy.operations.resolveExecutionConnection( - selected.connectionSlug, - ); - if (readiness.kind === 'not_found' || readiness.kind === 'disabled') { + const readiness = await this.#runtimePolicy.operations.resolveExecutionConnection({ + kind: 'bound', + connectionId: selected.connectionId, + connectionSlug: selected.connectionSlug, + }); + if ( + selected.connectionId !== undefined && + (readiness.kind === 'not_found' || readiness.kind === 'identity_mismatch') + ) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Session model identity changed during selection', + ); + } + if ( + readiness.kind === 'not_found' || + readiness.kind === 'identity_mismatch' || + readiness.kind === 'disabled' + ) { throw new SessionOperationFailure( 'invalid_request', 'Session model connection is unavailable', @@ -791,16 +809,21 @@ export class HostSessionCatalogCoordinator { `Session model does not support thinking level ${thinkingLevel}`, ); } - return { connectionSlug: connection.slug, model: selected.modelId }; + return { + connectionId: connection.connectionId, + connectionSlug: connection.slug, + model: selected.modelId, + }; } async #selectModelTarget(target: SessionModelTarget): Promise<{ readonly connectionSlug: string; - readonly connectionId?: string; + readonly connectionId: string; readonly modelId: string; }> { if (target.kind === 'explicit') { return { + connectionId: target.connectionId, connectionSlug: target.connectionSlug, modelId: target.model, }; @@ -833,6 +856,62 @@ export class HostSessionCatalogCoordinator { }; } + async #mergeConfigurationPatch( + current: SessionHeader, + patch: SessionConfigurationUpdateInput['patch'], + ): Promise { + if (current.llmConnectionId === undefined && patch.modelTarget === undefined) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Legacy Session configuration requires an explicit account selection', + ); + } + const thinkingLevel = + patch.thinkingLevel === undefined + ? current.thinkingLevel + : (patch.thinkingLevel ?? undefined); + let model: { + readonly connectionId?: string; + readonly connectionSlug: string; + readonly model: string; + } = { + ...(current.llmConnectionId === undefined ? {} : { connectionId: current.llmConnectionId }), + connectionSlug: current.llmConnectionSlug, + model: current.model, + }; + if (patch.modelTarget !== undefined) { + model = await this.#resolveModel(patch.modelTarget, thinkingLevel); + } else if (patch.thinkingLevel !== undefined) { + const connectionId = current.llmConnectionId; + if (connectionId === undefined) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Legacy Session configuration requires an explicit account selection', + ); + } + model = await this.#resolveModel( + { + kind: 'explicit', + connectionId, + connectionSlug: current.llmConnectionSlug, + model: current.model, + }, + thinkingLevel, + ); + } + return { + backend: 'ai-sdk', + ...(model.connectionId === undefined ? {} : { llmConnectionId: model.connectionId }), + llmConnectionSlug: model.connectionSlug, + model: model.model, + thinkingLevel, + connectionLocked: patch.modelTarget === undefined ? current.connectionLocked : true, + permissionMode: patch.permissionMode ?? current.permissionMode, + collaborationMode: patch.collaborationMode ?? current.collaborationMode ?? 'agent', + orchestrationMode: patch.orchestrationMode ?? current.orchestrationMode ?? 'default', + }; + } + async #readRuntimePolicy(): Promise< Awaited> > { @@ -846,15 +925,15 @@ export class HostSessionCatalogCoordinator { function sessionConfigurationMatches( header: SessionHeader, - model: ResolvedSessionModel, - configuration: SessionConfigurationUpdateInput['configuration'], + configuration: ResolvedSessionConfiguration, ): boolean { return ( header.backend === 'ai-sdk' && - header.llmConnectionSlug === model.connectionSlug && - header.model === model.model && - header.thinkingLevel === (configuration.thinkingLevel ?? undefined) && - header.connectionLocked && + header.llmConnectionId === configuration.llmConnectionId && + header.llmConnectionSlug === configuration.llmConnectionSlug && + header.model === configuration.model && + header.thinkingLevel === configuration.thinkingLevel && + header.connectionLocked === configuration.connectionLocked && header.permissionMode === configuration.permissionMode && (header.collaborationMode ?? 'agent') === configuration.collaborationMode && (header.orchestrationMode ?? 'default') === configuration.orchestrationMode @@ -905,7 +984,12 @@ function createRequestFingerprint( prepared.labels, input.modelTarget.kind === 'default' ? ['default'] - : ['explicit', input.modelTarget.connectionSlug, input.modelTarget.model], + : [ + 'explicit', + input.modelTarget.connectionId, + input.modelTarget.connectionSlug, + input.modelTarget.model, + ], input.thinkingLevel ?? null, input.toolProfile ?? null, prepared.permissionMode ?? ['runtime_default'], @@ -978,6 +1062,7 @@ export function projectSessionCatalogRecord( ...(header.revisionIndex === undefined ? {} : { revisionIndex: header.revisionIndex }), ...(header.revisionState === undefined ? {} : { revisionState: header.revisionState }), backend: header.backend, + llmConnectionId: header.llmConnectionId ?? null, llmConnectionSlug: header.llmConnectionSlug, connectionLocked: header.connectionLocked, model: header.model, diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 4517cd8cf0..cd1e321f4e 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -640,6 +640,7 @@ export class HostSessionRevisionCoordinator { const common: ConversationCopyCreateInput = { cwd: source.cwd, ...(source.projectId !== undefined ? { projectId: source.projectId } : {}), + ...(source.llmConnectionId === undefined ? {} : { llmConnectionId: source.llmConnectionId }), llmConnectionSlug: source.llmConnectionSlug, model: source.model, ...(source.thinkingLevel !== undefined ? { thinkingLevel: source.thinkingLevel } : {}), diff --git a/packages/runtime/src/__tests__/configured-subagent-catalog.test.ts b/packages/runtime/src/__tests__/configured-subagent-catalog.test.ts index 81ae884a8f..8b0f363cc4 100644 --- a/packages/runtime/src/__tests__/configured-subagent-catalog.test.ts +++ b/packages/runtime/src/__tests__/configured-subagent-catalog.test.ts @@ -60,7 +60,10 @@ describe('configured subagent catalog', () => { ]; const catalog = createConfiguredSubagentCatalog({ getPresets: async () => settings.subagents.presets, - getConnection: async (slug) => (slug === connection.slug ? connection : null), + getConnection: async (slug) => + slug === connection.slug + ? { ...connection, connectionId: '11111111-1111-4111-8111-111111111111' } + : null, }); expect( @@ -75,7 +78,10 @@ describe('configured subagent catalog', () => { availability: { status: 'unavailable', reason: 'model_disabled' }, }, ]); - expect(await catalog.resolve('fast-reader')).toEqual(settings.subagents.presets[0]); + expect(await catalog.resolve('fast-reader')).toEqual({ + ...settings.subagents.presets[0], + connectionId: '11111111-1111-4111-8111-111111111111', + }); await assert.rejects(catalog.resolve('missing-model'), /model_disabled/); await assert.rejects(catalog.resolve('invented'), /Call agent_list/); }); @@ -107,7 +113,10 @@ describe('configured subagent catalog', () => { ]; const catalog = createConfiguredSubagentCatalog({ getPresets: async () => settings.subagents.presets, - getConnection: async (slug) => (slug === retired.slug ? retired : null), + getConnection: async (slug) => + slug === retired.slug + ? { ...retired, connectionId: '22222222-2222-4222-8222-222222222222' } + : null, }); expect((await catalog.list())[0]?.availability).toEqual({ diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index ea3fd51b75..7991b0fbe0 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -437,8 +437,10 @@ describe('SessionManager Plan control boundaries', () => { await assert.rejects( manager.transitionSessionConfiguration(child.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: { backend: child.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: child.llmConnectionSlug, connectionLocked: true, model: child.model, @@ -474,6 +476,7 @@ describe('SessionManager graph operator provisioning', () => { subagentCatalog: { list: async () => [], resolve: async (id) => ({ + connectionId: '22222222-2222-4222-8222-222222222222', id, name: 'Fast graph reader', description: 'Cheap graph scans', @@ -584,8 +587,10 @@ describe('SessionManager graph operator provisioning', () => { const transition = manager .transitionSessionConfiguration(parent.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: { backend: parent.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: parent.llmConnectionSlug, connectionLocked: true, model: parent.model, @@ -2348,6 +2353,7 @@ describe('SessionManager child-session runtime primitive', () => { resolve: async (id) => { if (id !== 'fast-reader') throw new Error('unknown preset'); return { + connectionId: '33333333-3333-4333-8333-333333333333', id, name: 'Fast reader', description: 'Cheap scans', @@ -2389,6 +2395,7 @@ describe('SessionManager child-session runtime primitive', () => { const child = await store.readHeader(result.childSessionId); expect(child.llmConnectionSlug).toBe('worker-connection'); + expect(child.llmConnectionId).toBe('33333333-3333-4333-8333-333333333333'); expect(child.model).toBe('worker-model'); expect(child.thinkingLevel).toBe('low'); expect(child.subagentRuntime?.presetId).toBe('fast-reader'); @@ -4354,6 +4361,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => ); const baseConfiguration = { backend: session.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: session.llmConnectionSlug, connectionLocked: true, model: session.model, @@ -4367,6 +4375,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => await assert.rejects( manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: baseConfiguration, }), (error: unknown) => { @@ -4380,6 +4389,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => kernel.activeRuns = false; const committed = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: baseConfiguration, }); assert.equal(committed.revision, 2); @@ -4389,6 +4399,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => await assert.rejects( manager.transitionSessionConfiguration(session.id, { expectedRevision: 2, + clearConnectionBlock: false, configuration: { ...baseConfiguration, permissionMode: 'explore', @@ -4403,6 +4414,48 @@ describe('SessionManager manual compaction and quiescent session changes', () => assert.deepEqual(kernel.disposed, [session.id]); }); + test('configuration transitions clear a connection block only on explicit Host authority', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(26_425), + }); + const session = await manager.createSession(makeInput()); + await store.updateHeader(session.id, { + status: 'blocked', + blockedReason: 'NO_REAL_CONNECTION', + }); + const configuration = { + backend: session.backend, + llmConnectionId: 'test-connection-id', + llmConnectionSlug: session.llmConnectionSlug, + connectionLocked: true, + model: session.model, + thinkingLevel: session.thinkingLevel, + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode ?? 'agent', + orchestrationMode: session.orchestrationMode ?? 'default', + }; + + const preserved = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: 1, + clearConnectionBlock: false, + configuration, + }); + assert.equal(preserved.header.blockedReason, 'NO_REAL_CONNECTION'); + assert.equal(preserved.header.status, 'blocked'); + + const recovered = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: 2, + clearConnectionBlock: true, + configuration, + }); + assert.equal(recovered.header.blockedReason, undefined); + assert.equal(recovered.header.status, 'active'); + }); + test('workspace relocation uses the same quiescent revision fence as execution configuration', async () => { const store = new VersionedConfigurationMemorySessionStore(); const kernel = new DelegatingRuntimeKernel(); @@ -4490,8 +4543,10 @@ describe('SessionManager manual compaction and quiescent session changes', () => const transitionResult = await manager .transitionSessionConfiguration(session.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: { backend: session.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: session.llmConnectionSlug, connectionLocked: true, model: session.model, @@ -4541,8 +4596,10 @@ describe('SessionManager manual compaction and quiescent session changes', () => }; const transition = manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: { backend: session.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: session.llmConnectionSlug, connectionLocked: true, model: 'new-model', @@ -5158,7 +5215,9 @@ describe('SessionManager permission mode updates', () => { newId: nextId(), now: nextNow(6_526), }); - const session = await manager.createSession(makeInput()); + const session = await manager.createSession( + makeInput({ llmConnectionId: '11111111-1111-4111-8111-111111111111' }), + ); const events = await collectSessionEvents( manager.sendMessage(session.id, { @@ -5169,6 +5228,7 @@ describe('SessionManager permission mode updates', () => { expect(events.map((event) => event.type)).toEqual(['text_complete', 'complete']); const [run] = await runStore.listSessionRuns(session.id); + expect(run?.llmConnectionId).toBe('11111111-1111-4111-8111-111111111111'); expect(run?.workspaceIdentity).toBeUndefined(); }); @@ -16592,6 +16652,7 @@ class MemorySessionStore implements SessionStore { ...(input.revisionState ? { revisionState: input.revisionState } : {}), hasUnread: false, backend: 'ai-sdk', + ...(input.llmConnectionId === undefined ? {} : { llmConnectionId: input.llmConnectionId }), llmConnectionSlug: input.llmConnectionSlug, connectionLocked: false, model: input.model ?? 'fake-model', diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index ee3cf91e6c..ea42771c3d 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -1129,6 +1129,9 @@ export class AgentRun { turnId: this.turnId, status: 'created', backendKind: this.header.backend, + ...(this.header.llmConnectionId === undefined + ? {} + : { llmConnectionId: this.header.llmConnectionId }), llmConnectionSlug: this.header.llmConnectionSlug, modelId: this.header.model, cwd: this.header.cwd, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 6f1a31eb36..b2bf2576d9 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -5020,6 +5020,7 @@ function memoryExtractionModelHeader( header: SessionHeader, ): MemoryExtractionSourceSnapshot['sourceHeader'] { return { + ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), llmConnectionSlug: header.llmConnectionSlug, model: header.model, ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), diff --git a/packages/runtime/src/configured-subagent-catalog.ts b/packages/runtime/src/configured-subagent-catalog.ts index 484e5981a5..de66aa25f5 100644 --- a/packages/runtime/src/configured-subagent-catalog.ts +++ b/packages/runtime/src/configured-subagent-catalog.ts @@ -24,29 +24,43 @@ import type { SubagentPresetListItem } from './agent-catalog.js'; export interface ConfiguredSubagentCatalog { list(): Promise; - resolve(id: string): Promise; + resolve(id: string): Promise; } +export type ResolvedSubagentPreset = SubagentPreset & { + readonly connectionId: string; +}; + export function createConfiguredSubagentCatalog(deps: { getPresets(): Promise; getConnection(slug: string): Promise<{ + readonly connectionId: string; readonly providerType: ProviderType; readonly enabled: boolean; readonly defaultModel?: string; readonly enabledModelIds?: readonly string[]; } | null>; }): ConfiguredSubagentCatalog { - const inspect = async (preset: SubagentPreset): Promise => { + const inspect = async ( + preset: SubagentPreset, + ): Promise<{ + readonly item: SubagentPresetListItem; + readonly connectionId?: string; + }> => { if (!preset.enabled) return { - ...preset, - availability: { status: 'unavailable', reason: 'disabled' }, + item: { + ...preset, + availability: { status: 'unavailable', reason: 'disabled' }, + }, }; const connection = await deps.getConnection(preset.connectionSlug); if (!connection) { return { - ...preset, - availability: { status: 'unavailable', reason: 'missing_connection' }, + item: { + ...preset, + availability: { status: 'unavailable', reason: 'missing_connection' }, + }, }; } // Before `enabled`: a retained retired connection stays enabled — the row @@ -55,40 +69,50 @@ export function createConfiguredSubagentCatalog(deps: { // it would persist a child that is unexecutable from birth. if (isRetiredProvider(connection.providerType)) { return { - ...preset, - availability: { status: 'unavailable', reason: 'provider_retired' }, + item: { + ...preset, + availability: { status: 'unavailable', reason: 'provider_retired' }, + }, }; } if (!connection.enabled) { return { - ...preset, - availability: { status: 'unavailable', reason: 'connection_disabled' }, + item: { + ...preset, + availability: { status: 'unavailable', reason: 'connection_disabled' }, + }, }; } if (!connectionEnabledModelIds(connection).includes(preset.model)) { return { - ...preset, - availability: { status: 'unavailable', reason: 'model_disabled' }, + item: { + ...preset, + availability: { status: 'unavailable', reason: 'model_disabled' }, + }, }; } - return { ...preset, availability: { status: 'available' } }; + return { + item: { ...preset, availability: { status: 'available' } }, + connectionId: connection.connectionId, + }; }; return { async list() { - return await Promise.all((await deps.getPresets()).map(inspect)); + return (await Promise.all((await deps.getPresets()).map(inspect))).map(({ item }) => item); }, async resolve(id) { const preset = (await deps.getPresets()).find((candidate) => candidate.id === id); if (!preset) throw new Error(`Unknown subagent_id "${id}". Call agent_list before spawning.`); const inspected = await inspect(preset); - if (inspected.availability.status !== 'available') { + if (inspected.item.availability.status !== 'available') { throw new Error( - `Subagent preset "${id}" is unavailable: ${inspected.availability.reason}.`, + `Subagent preset "${id}" is unavailable: ${inspected.item.availability.reason}.`, ); } - const { availability: _availability, ...resolved } = inspected; - return resolved; + if (!inspected.connectionId) throw new Error(`Subagent preset "${id}" lost its Connection.`); + const { availability: _availability, ...resolved } = inspected.item; + return { ...resolved, connectionId: inspected.connectionId }; }, }; } diff --git a/packages/runtime/src/memory-extraction.ts b/packages/runtime/src/memory-extraction.ts index bf7d589ebe..7a935319c5 100644 --- a/packages/runtime/src/memory-extraction.ts +++ b/packages/runtime/src/memory-extraction.ts @@ -78,7 +78,10 @@ export type MemoryExtractionGate = /** Frozen extraction request. Compaction may defer durable-prefix materialization to its lane. */ export interface MemoryExtractionSourceSnapshot { readonly trigger: MemoryExtractionTrigger; - readonly sourceHeader: Pick; + readonly sourceHeader: Pick< + SessionHeader, + 'llmConnectionId' | 'llmConnectionSlug' | 'model' | 'thinkingLevel' + >; readonly sourceSystemPrompt?: string; readonly sourceMessages: readonly ModelMessage[]; /** Compaction-only recipe: rebuild messages from its durable checkpoint boundary. */ diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 64a94e63f2..79195782c8 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -3303,6 +3303,9 @@ function continuationTargetRunHeaderForExecution(input: { turnId: continuation.turnId, status: 'created', backendKind: sessionHeader.backend, + ...(sessionHeader.llmConnectionId === undefined + ? {} + : { llmConnectionId: sessionHeader.llmConnectionId }), llmConnectionSlug: sessionHeader.llmConnectionSlug, modelId: sessionHeader.model, cwd: sessionHeader.cwd, diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index 6ac78dbe05..0c00e01f24 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -430,6 +430,9 @@ function transcriptRunHeader(input: { turnId: input.turn.turnId, status, backendKind: input.header.backend, + ...(input.header.llmConnectionId === undefined + ? {} + : { llmConnectionId: input.header.llmConnectionId }), llmConnectionSlug: input.header.llmConnectionSlug, modelId: input.header.model, cwd: input.header.cwd, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index a2cb3dec0e..46e3b26e65 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -137,6 +137,7 @@ import type { SubagentWorktreeExecutor, } from '@maka/core/subagent-workspace'; import type { SubagentPreset } from '@maka/core/subagent-settings'; +import type { ResolvedSubagentPreset } from './configured-subagent-catalog.js'; import { AGENT_GRAPH_OPERATOR_PROVISION_SCHEMA_VERSION } from '@maka/core/agent-graph-topology'; import { classifyRuntimeEventTerminalFact, @@ -328,7 +329,7 @@ export interface SpawnChildSessionInput { } type ResolvedSpawnChildSessionInput = SpawnChildSessionInput & { - resolvedPreset?: SubagentPreset; + resolvedPreset?: ResolvedSubagentPreset; }; export interface SpawnChildSessionResult extends SpawnChildAgentResult { @@ -574,6 +575,7 @@ export interface SessionConfigurationStoreUpdate { readonly expectedVersion: number; readonly configuration: { readonly backend: SessionHeader['backend']; + readonly llmConnectionId?: string; readonly llmConnectionSlug: string; readonly connectionLocked: boolean; readonly model: string; @@ -590,6 +592,7 @@ export interface SessionConfigurationStoreUpdate { export interface SessionConfigurationTransitionRequest { readonly expectedRevision: number; + readonly clearConnectionBlock: boolean; readonly configuration: Omit; } @@ -826,7 +829,7 @@ interface SessionManagerBaseDeps { /** Host-owned user catalog. Runtime receives ids from models, never raw model targets. */ subagentCatalog?: { list(): Promise; - resolve(id: string): Promise; + resolve(id: string): Promise; }; /** Host-owned filesystem isolation for worktree-backed child Sessions. */ worktreeChildExecutor?: SubagentWorktreeExecutor; @@ -1183,7 +1186,7 @@ export class SessionManager { labels, }, lifecycle: - current.header.blockedReason === 'NO_REAL_CONNECTION' + input.clearConnectionBlock && current.header.blockedReason === 'NO_REAL_CONNECTION' ? { kind: 'clear_connection_block', statusUpdatedAt: this.deps.now(), @@ -2554,6 +2557,11 @@ export class SessionManager { cwd: workspace?.worktreePath ?? parentHeader.cwd, ...(parentHeader.projectId !== undefined ? { projectId: parentHeader.projectId } : {}), name: resolvedPreset?.name ?? definition.name, + ...(resolvedPreset + ? { llmConnectionId: resolvedPreset.connectionId } + : parentHeader.llmConnectionId === undefined + ? {} + : { llmConnectionId: parentHeader.llmConnectionId }), llmConnectionSlug: resolvedPreset?.connectionSlug ?? parentHeader.llmConnectionSlug, model: resolvedPreset?.model ?? parentHeader.model, ...(resolvedPreset @@ -3092,6 +3100,11 @@ export class SessionManager { cwd: workspace?.worktreePath ?? parentHeader.cwd, ...(parentHeader.projectId !== undefined ? { projectId: parentHeader.projectId } : {}), name: input.name ?? input.resolvedPreset?.name ?? definition.name, + ...(input.resolvedPreset + ? { llmConnectionId: input.resolvedPreset.connectionId } + : parentHeader.llmConnectionId === undefined + ? {} + : { llmConnectionId: parentHeader.llmConnectionId }), llmConnectionSlug: input.resolvedPreset?.connectionSlug ?? parentHeader.llmConnectionSlug, model: input.resolvedPreset?.model ?? parentHeader.model, ...(input.resolvedPreset @@ -3427,6 +3440,8 @@ export class SessionManager { } if ( cursor.backendKind !== sessionHeader.backend || + (cursor.llmConnectionId !== undefined && + cursor.llmConnectionId !== sessionHeader.llmConnectionId) || cursor.llmConnectionSlug !== sessionHeader.llmConnectionSlug || cursor.modelId !== sessionHeader.model || cursor.cwd !== sessionHeader.cwd || @@ -3566,6 +3581,8 @@ export class SessionManager { } if ( cursor.backendKind !== child.backend || + (cursor.llmConnectionId !== undefined && + cursor.llmConnectionId !== child.llmConnectionId) || cursor.llmConnectionSlug !== child.llmConnectionSlug || cursor.modelId !== child.model || cursor.cwd !== child.cwd || @@ -4707,6 +4724,9 @@ export class SessionManager { turnId: input.turnId, status: 'created', backendKind: session.backend, + ...(session.llmConnectionId === undefined + ? {} + : { llmConnectionId: session.llmConnectionId }), llmConnectionSlug: session.llmConnectionSlug, modelId: session.model, cwd: session.cwd, @@ -4938,6 +4958,9 @@ export class SessionManager { { cwd: header.cwd, ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), + ...(header.llmConnectionId === undefined + ? {} + : { llmConnectionId: header.llmConnectionId }), llmConnectionSlug: header.llmConnectionSlug, model: header.model, thinkingLevel: header.thinkingLevel, @@ -5000,6 +5023,9 @@ export class SessionManager { { cwd: header.cwd, ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), + ...(header.llmConnectionId === undefined + ? {} + : { llmConnectionId: header.llmConnectionId }), llmConnectionSlug: header.llmConnectionSlug, model: header.model, thinkingLevel: header.thinkingLevel, @@ -6173,6 +6199,7 @@ export function headerToSummary(h: SessionHeader): SessionSummary { ...(h.revisionIndex !== undefined ? { revisionIndex: h.revisionIndex } : {}), ...(h.revisionState ? { revisionState: h.revisionState } : {}), backend: h.backend, + ...(h.llmConnectionId === undefined ? {} : { llmConnectionId: h.llmConnectionId }), llmConnectionSlug: h.llmConnectionSlug, connectionLocked: h.connectionLocked, model: h.model, diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 2cc00c131a..2d1bbfa26f 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -108,7 +108,9 @@ describe('runtime policy stores', () => { { names: ['x-tenant', 'X-Title'] }, ); - const resolved = await stores.operations.resolveExecutionConnection(connection.slug); + const resolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); assert.equal(resolved.kind, 'ready'); if (resolved.kind !== 'ready') return; assert.deepEqual(resolved.connection.requestBodyOverlay, { @@ -1308,20 +1310,34 @@ describe('runtime policy stores', () => { connectionDraft('execution-none', 'ollama', 'None'), ); - assert.deepEqual(await stores.operations.resolveExecutionConnection('missing'), { + assert.deepEqual(await stores.operations.resolveExecutionConnection(catalogSlug('missing')), { kind: 'not_found', }); - assert.deepEqual(await stores.operations.resolveExecutionConnection(disabled.slug), { - kind: 'disabled', - }); + await assert.rejects( + stores.operations.resolveExecutionConnection({ + kind: 'unexpected', + connectionSlug: 'missing', + } as never), + /Invalid execution Connection reference kind/, + ); + assert.deepEqual( + await stores.operations.resolveExecutionConnection(catalogSlug(disabled.slug)), + { + kind: 'disabled', + }, + ); - const missingRequired = await stores.operations.resolveExecutionConnection(required.slug); + const missingRequired = await stores.operations.resolveExecutionConnection( + catalogSlug(required.slug), + ); assert.equal(missingRequired.kind, 'credential_not_configured'); if (missingRequired.kind === 'credential_not_configured') { assert.deepEqual(missingRequired.status.locator, connectionCredential(required, 'api_key')); } for (const connection of [optional, none]) { - const resolved = await stores.operations.resolveExecutionConnection(connection.slug); + const resolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); assert.equal(resolved.kind, 'ready'); if (resolved.kind === 'ready') assert.deepEqual(resolved.secretMaterial, {}); } @@ -1338,9 +1354,12 @@ describe('runtime policy stores', () => { ); const retiredLogin = await stores.operations.beginInteractiveOAuthLogin(retired.connectionId); assert.equal(retiredLogin.kind, 'provider_action_unavailable'); - assert.deepEqual(await stores.operations.resolveExecutionConnection(retired.slug), { - kind: 'provider_retired', - }); + assert.deepEqual( + await stores.operations.resolveExecutionConnection(catalogSlug(retired.slug)), + { + kind: 'provider_retired', + }, + ); assert.equal( ( @@ -1360,7 +1379,9 @@ describe('runtime policy stores', () => { ).kind, 'committed', ); - const missingProxy = await stores.operations.resolveExecutionConnection(required.slug); + const missingProxy = await stores.operations.resolveExecutionConnection( + catalogSlug(required.slug), + ); assert.equal(missingProxy.kind, 'credential_not_configured'); if (missingProxy.kind === 'credential_not_configured') { assert.deepEqual(missingProxy.status.locator, proxyCredential()); @@ -1372,7 +1393,7 @@ describe('runtime policy stores', () => { expected: null, secret: 'execution-proxy-secret', }), - stores.operations.resolveExecutionConnection(required.slug), + stores.operations.resolveExecutionConnection(catalogSlug(required.slug)), ]); assert.equal(proxySet.kind, 'committed'); assert.equal(resolved.kind, 'ready'); @@ -1384,6 +1405,62 @@ describe('runtime policy stores', () => { }); }); + test('bound execution never follows a reused connection slug', async () => { + await withInteractiveOwner(async ({ stores }) => { + const original = await createConnection( + stores, + 0, + connectionDraft('reused-execution-slug', 'openai', 'Original'), + ); + await stores.credentialVault.set({ + locator: connectionCredential(original, 'api_key'), + expected: null, + secret: 'original-secret', + }); + + const bound = { + kind: 'bound' as const, + connectionId: original.connectionId, + connectionSlug: original.slug, + }; + assert.equal((await stores.operations.resolveExecutionConnection(bound)).kind, 'ready'); + assert.deepEqual( + await stores.operations.resolveExecutionConnection({ + ...bound, + connectionSlug: 'different-slug', + }), + { kind: 'identity_mismatch' }, + ); + + assert.equal( + (await stores.connectionCatalog.remove({ expected: connectionBasis(original) })).kind, + 'committed', + ); + const replacement = await createConnection( + stores, + 2, + connectionDraft(original.slug, 'openai', 'Replacement'), + ); + await stores.credentialVault.set({ + locator: connectionCredential(replacement, 'api_key'), + expected: null, + secret: 'replacement-secret', + }); + + assert.deepEqual(await stores.operations.resolveExecutionConnection(bound), { + kind: 'not_found', + }); + const current = await stores.operations.resolveExecutionConnection( + catalogSlug(original.slug), + ); + assert.equal(current.kind, 'ready'); + if (current.kind === 'ready') { + assert.equal(current.connection.connectionId, replacement.connectionId); + assert.equal(current.secretMaterial.connection?.secret, 'replacement-secret'); + } + }); + }); + test('refreshes only the matching OAuth credential generation without invalidating verification', async () => { await withInteractiveOwner(async ({ stores }) => { const connection = await createConnection( @@ -1429,7 +1506,9 @@ describe('runtime policy stores', () => { (await stores.connectionCatalog.getSnapshot()).connections[0]?.lastTest?.status, 'verified', ); - const resolved = await stores.operations.resolveExecutionConnection(connection.slug); + const resolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); assert.equal(resolved.kind, 'ready'); if (resolved.kind === 'ready') { assert.equal(resolved.secretMaterial.connection?.secret, replacementSecret); @@ -1441,7 +1520,9 @@ describe('runtime policy stores', () => { secret: 'stale-refresh-must-not-commit', }); assert.equal(stale.kind, 'superseded'); - const stillResolved = await stores.operations.resolveExecutionConnection(connection.slug); + const stillResolved = await stores.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); assert.equal(stillResolved.kind, 'ready'); if (stillResolved.kind === 'ready') { assert.equal(stillResolved.secretMaterial.connection?.secret, replacementSecret); @@ -3302,7 +3383,9 @@ describe('runtime policy stores', () => { if (!secondOwner) return; try { const second = await openInteractiveRuntimePolicyStoresForWrite(secondOwner.lease); - const resolved = await second.operations.resolveExecutionConnection(connection.slug); + const resolved = await second.operations.resolveExecutionConnection( + catalogSlug(connection.slug), + ); assert.equal(resolved.kind, 'ready'); if (resolved.kind !== 'ready') return; assert.equal(resolved.secretMaterial.connection?.secret, secret); @@ -3619,6 +3702,10 @@ function networkProxyMutation( }; } +function catalogSlug(connectionSlug: string) { + return { kind: 'catalog_slug' as const, connectionSlug }; +} + function isStoreError(code: RuntimePolicyStoreError['code']) { return (error: unknown) => error instanceof RuntimePolicyStoreError && error.code === code; } diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index cb2be2d93a..49cc55cf48 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -1285,6 +1285,7 @@ describe('SQLite SessionStore', () => { try { const [header] = await reopened.listHeaders(); assert.equal(header?.backend, 'fake'); + assert.equal(header?.llmConnectionId, undefined); assert.equal(header?.llmConnectionSlug, 'fake'); assert.equal((await reopened.readHeaderSnapshot(sessionId)).backend, 'fake'); } finally { @@ -1293,6 +1294,28 @@ describe('SQLite SessionStore', () => { } }); + test('persists an immutable Connection identity when supplied by Host admission', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-connection-identity-')); + const store = createSessionStore(root); + try { + const created = await store.create( + makeInput({ llmConnectionId: '11111111-1111-4111-8111-111111111111' }), + ); + assert.equal(created.llmConnectionId, '11111111-1111-4111-8111-111111111111'); + assert.equal( + (await store.readHeader(created.id)).llmConnectionId, + '11111111-1111-4111-8111-111111111111', + ); + assert.equal( + (await store.readCatalogRecord(created.id)).summary.llmConnectionId, + '11111111-1111-4111-8111-111111111111', + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('deletes metadata and messages through the same transaction boundary', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-delete-')); const store = createSessionStore(root); diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index f939ab6099..d7536c9508 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -2538,6 +2538,7 @@ describe('SqliteSessionMetadataStore', () => { expectedVersion: 1, configuration: { backend: 'ai-sdk' as const, + llmConnectionId: '11111111-1111-4111-8111-111111111111', llmConnectionSlug: 'openrouter', connectionLocked: true, model: 'openrouter/free', @@ -2575,6 +2576,7 @@ describe('SqliteSessionMetadataStore', () => { const updated = await store.updateSessionConfiguration('configured-session', configuration); assert.equal(updated.metadataVersion, 2); + assert.equal(updated.header.llmConnectionId, '11111111-1111-4111-8111-111111111111'); assert.equal(updated.header.model, 'openrouter/free'); assert.equal(updated.header.collaborationMode, 'plan'); assert.equal(updated.header.orchestrationMode, 'graph'); @@ -2620,6 +2622,7 @@ describe('SqliteSessionMetadataStore', () => { expectedVersion: 1, configuration: { backend: 'ai-sdk', + llmConnectionId: '11111111-1111-4111-8111-111111111111', llmConnectionSlug: 'openrouter', connectionLocked: true, model: 'openrouter/free', diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index 15b78c1161..98b3bc5574 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -238,8 +238,7 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic coordinator.getConnectionRequestHeaders(connectionId), replaceConnectionRequestHeaders: (connectionId, updates) => coordinator.replaceConnectionRequestHeaders(connectionId, updates), - resolveExecutionConnection: (connectionSlug) => - coordinator.resolveExecutionConnection(connectionSlug), + resolveExecutionConnection: (ref) => coordinator.resolveExecutionConnection(ref), resolveWebSearchExecution: (input) => coordinator.resolveWebSearchExecution(input), resolveWebFetchExecution: () => coordinator.resolveWebFetchExecution(), resolveNetworkProxyExecution: (input) => coordinator.resolveNetworkProxyExecution(input), diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 017beb0921..f4a3418b95 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -109,6 +109,7 @@ import { type InteractiveOAuthLoginProvider, type InteractiveOAuthLoginTicket, type ModelFetchTicket, + type ExecutionConnectionRef, type RuntimePolicyCredentialMaterial, type RuntimePolicyOperationSecretMaterial, type ResolveExecutionConnectionResult, @@ -620,12 +621,35 @@ export class RuntimePolicyCoordinator { }); } - resolveExecutionConnection(rawConnectionSlug: string): Promise { + resolveExecutionConnection( + rawRef: ExecutionConnectionRef, + ): Promise { return this.inLane(async (root) => { - const connectionSlug = decodeConnectionInput(() => decodeConnectionSlug(rawConnectionSlug)); + const ref = decodeConnectionInput(() => { + if (rawRef.kind === 'bound') { + return { + kind: rawRef.kind, + connectionId: decodeRuntimePolicyEntityId(rawRef.connectionId), + connectionSlug: decodeConnectionSlug(rawRef.connectionSlug), + } as const; + } + if (rawRef.kind === 'catalog_slug') { + return { + kind: rawRef.kind, + connectionSlug: decodeConnectionSlug(rawRef.connectionSlug), + } as const; + } + throw new Error('Invalid execution Connection reference kind'); + }); const catalog = await this.catalog.read(root); - const connection = catalog.connections.find((candidate) => candidate.slug === connectionSlug); + const connection = + ref.kind === 'bound' + ? catalog.connections.find((candidate) => candidate.connectionId === ref.connectionId) + : catalog.connections.find((candidate) => candidate.slug === ref.connectionSlug); if (!connection) return deepFreeze({ kind: 'not_found' as const }); + if (ref.kind === 'bound' && connection.slug !== ref.connectionSlug) { + return deepFreeze({ kind: 'identity_mismatch' as const }); + } if (!connection.enabled) return deepFreeze({ kind: 'disabled' as const }); // Ahead of the credential material: a retired connection keeps its stored // token, so `requiresSecret` is satisfied and every later check passes. diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index 367ab293f6..f947af87fa 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -295,6 +295,7 @@ export type CommitConnectionOnboardingResult = export type ResolveExecutionConnectionResult = | { readonly kind: 'not_found' } + | { readonly kind: 'identity_mismatch' } | { readonly kind: 'disabled' } /** * The provider was retired. Distinct from `disabled`, which the user chose @@ -310,6 +311,17 @@ export type ResolveExecutionConnectionResult = readonly networkProxy: RuntimePolicy['networkProxy']; }; +export type ExecutionConnectionRef = + | { + readonly kind: 'bound'; + readonly connectionId: string; + readonly connectionSlug: string; + } + | { + readonly kind: 'catalog_slug'; + readonly connectionSlug: string; + }; + export type ReplaceConnectionRequestHeadersResult = | ({ readonly kind: 'committed' | 'unchanged' } & SavedRequestHeaders) | { readonly kind: 'connection_not_found' }; @@ -323,7 +335,9 @@ export interface RuntimePolicyOperationCoordinator { connectionId: string, updates: readonly RequestHeaderUpdate[], ): Promise; - resolveExecutionConnection(connectionSlug: string): Promise; + resolveExecutionConnection( + ref: ExecutionConnectionRef, + ): Promise; resolveWebSearchExecution( input?: ResolveWebSearchExecutionInput, ): Promise; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 5361318930..eca2c74f92 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -1133,6 +1133,7 @@ function buildSessionHeader( ...(input.revisionState ? { revisionState: input.revisionState } : {}), hasUnread: false, backend: 'ai-sdk', + ...(input.llmConnectionId === undefined ? {} : { llmConnectionId: input.llmConnectionId }), llmConnectionSlug: input.llmConnectionSlug, connectionLocked: false, model: input.model ?? 'default', @@ -1187,6 +1188,8 @@ export function normalizeSessionHeader( (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && typeof header.hasUnread === 'boolean' && isPersistedBackendKind(header.backend) && + (header.llmConnectionId === undefined || + (typeof header.llmConnectionId === 'string' && header.llmConnectionId.length > 0)) && typeof header.llmConnectionSlug === 'string' && typeof header.connectionLocked === 'boolean' && typeof header.model === 'string' && @@ -1415,6 +1418,7 @@ function toSummary(header: SessionHeader, messages: StoredMessage[] = []): Sessi ...(header.revisionIndex !== undefined ? { revisionIndex: header.revisionIndex } : {}), ...(header.revisionState ? { revisionState: header.revisionState } : {}), backend: header.backend, + ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), llmConnectionSlug: header.llmConnectionSlug, connectionLocked: header.connectionLocked, model: header.model, diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 8943dea71f..15ceaa1f98 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -343,6 +343,7 @@ export interface SessionConfigurationMetadataUpdate { readonly expectedVersion: number; readonly configuration: { readonly backend: SessionHeader['backend']; + readonly llmConnectionId: string; readonly llmConnectionSlug: string; readonly connectionLocked: boolean; readonly model: string; diff --git a/packages/ui/src/chat-model-helpers.ts b/packages/ui/src/chat-model-helpers.ts index 65c286c006..385dc167da 100644 --- a/packages/ui/src/chat-model-helpers.ts +++ b/packages/ui/src/chat-model-helpers.ts @@ -144,6 +144,14 @@ export function modelChoiceValue(connectionSlug: string, model: string): string return `${encodeURIComponent(connectionSlug)}:${encodeURIComponent(model)}`; } +export function exactModelChoiceValue( + connectionId: string, + connectionSlug: string, + model: string, +): string { + return `${encodeURIComponent(connectionId)}:${modelChoiceValue(connectionSlug, model)}`; +} + export function parseModelChoiceValue(value: string): { llmConnectionSlug: string; model: string } | undefined { const idx = value.indexOf(':'); if (idx <= 0) return undefined; diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx index e102258695..79f72f4e45 100644 --- a/packages/ui/src/chat-model-switcher.tsx +++ b/packages/ui/src/chat-model-switcher.tsx @@ -43,6 +43,7 @@ import { ICON_SIZE, AlertTriangle, Check, Settings } from './icons.js'; import { type ChatModelChoice, type ModelMenuGroup, + exactModelChoiceValue, modelChoiceDescription, modelMenuGroups, modelChoiceValue, @@ -84,7 +85,11 @@ function ModelMenuItems(props: { leadingOption?: { label: string; providerType?: ProviderType }; renderProviderMark?(type: ProviderType): ReactNode; disabled?: boolean; - onPick(input: { llmConnectionSlug: string; model: string }): void | Promise; + onPick(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; }) { const locale = useUiLocale(); return ( @@ -106,7 +111,11 @@ function ModelMenuItems(props: { {group.heading} {group.choices.map((choice) => { - const value = modelChoiceValue(choice.connectionSlug, choice.model); + const value = exactModelChoiceValue( + choice.connectionId, + choice.connectionSlug, + choice.model, + ); return ( { - void props.onPick({ llmConnectionSlug: choice.connectionSlug, model: choice.model }); + void props.onPick({ + llmConnectionId: choice.connectionId, + llmConnectionSlug: choice.connectionSlug, + model: choice.model, + }); }} /> ); @@ -199,17 +212,31 @@ export function ChatModelSwitcher(props: { pending?: boolean; disabledReason?: string; renderProviderMark?(type: ProviderType): ReactNode; - onChange?(input: { llmConnectionSlug: string; model: string }): void | Promise; + onChange?(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; }) { const locale = useUiLocale(); const copy = getConversationCopy(locale).model; const currentModel = props.activeModel ?? props.activeSession.model; - const currentValue = modelChoiceValue(props.activeSession.llmConnectionSlug, currentModel); + const currentValue = props.activeSession.llmConnectionId + ? exactModelChoiceValue( + props.activeSession.llmConnectionId, + props.activeSession.llmConnectionSlug, + currentModel, + ) + : undefined; const pending = Boolean(props.pending); const [menuOpen, setMenuOpen] = useState(false); const disabled = pending || Boolean(props.disabledReason) || !props.onChange || props.choices.length === 0; const grouped = modelMenuGroups(props.choices, locale); - const currentKnownChoice = props.choices.some((choice) => modelChoiceValue(choice.connectionSlug, choice.model) === currentValue); + const currentKnownChoice = props.choices.some( + (choice) => + exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model) === + currentValue, + ); const displayLabel = props.activeModelLabel ?? currentModel; const title = pending ? `${copy.switching}…` @@ -250,6 +277,7 @@ export function ChatModelSwitcher(props: { onPick={async (next) => { if ( next.llmConnectionSlug === props.activeSession.llmConnectionSlug && + next.llmConnectionId === props.activeSession.llmConnectionId && next.model === currentModel ) return; try { @@ -287,14 +315,20 @@ export function NewChatModelPicker(props: { currentValue?: string; currentProviderType?: ProviderType; renderProviderMark?(type: ProviderType): ReactNode; - onPick(input: { llmConnectionSlug: string; model: string }): void | Promise; + onPick(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; }) { const locale = useUiLocale(); const copy = getConversationCopy(locale).model; const grouped = modelMenuGroups(props.choices, locale); const currentValue = props.currentValue ?? ''; const currentKnownChoice = props.choices.some( - (choice) => modelChoiceValue(choice.connectionSlug, choice.model) === currentValue, + (choice) => + exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model) === + currentValue, ); return ( ; + onModelChange?(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; /** Personalized user label shown on user messages. Falls back to "你". */ userLabel?: string; /** diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 49a09e979c..71e3c43c06 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -55,7 +55,7 @@ import { } from './chat-model-switcher.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; -import { type ChatModelChoice, modelChoiceValue } from './chat-model-helpers.js'; +import { type ChatModelChoice, exactModelChoiceValue } from './chat-model-helpers.js'; import { appendPromptContextDraft, isReferenceSizedPaste } from './composer-helpers.js'; import { stripQuoteHeadingMarkers } from './quote-ref-chip.js'; import { WorkspacePicker, type WorkspacePickerModel } from './workspace-picker.js'; @@ -314,7 +314,11 @@ export const Composer = forwardRef< * injected by the desktop app to keep the provider SVG library out of @maka/ui. */ renderProviderMark?(type: ProviderType): ReactNode; modelChangePending?: boolean; - onModelChange?(input: { llmConnectionSlug: string; model: string }): void | Promise; + onModelChange?(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; /** Per-model thinking-level variants for the active model; empty/undefined hides the switcher. */ activeThinkingLevels?: readonly import('@maka/core/model-thinking').ThinkingLevel[]; activeThinkingLevel?: import('@maka/core/model-thinking').ThinkingLevel; @@ -328,9 +332,13 @@ export const Composer = forwardRef< * the otherwise-static model chip becomes a real dropdown so the user can * choose the new-chat model inline instead of only via Settings · 模型. */ - newChatModel?: { llmConnectionSlug: string; model: string }; + newChatModel?: { llmConnectionId: string; llmConnectionSlug: string; model: string }; newChatProviderType?: ProviderType; - onPickNewChatModel?(input: { llmConnectionSlug: string; model: string }): void | Promise; + onPickNewChatModel?(input: { + llmConnectionId: string; + llmConnectionSlug: string; + model: string; + }): void | Promise; /** * Empty-state only: no models are configured yet, so the model chip is a * non-interactive label. When provided, the chip becomes a button into @@ -1933,7 +1941,11 @@ export const Composer = forwardRef< choices={props.modelChoices ?? []} currentValue={ props.newChatModel - ? modelChoiceValue(props.newChatModel.llmConnectionSlug, props.newChatModel.model) + ? exactModelChoiceValue( + props.newChatModel.llmConnectionId, + props.newChatModel.llmConnectionSlug, + props.newChatModel.model, + ) : undefined } currentProviderType={props.newChatProviderType}