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 2f688e9764..bbe0d85849 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 @@ -103,6 +103,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 7a20e9ca77..27f630c2f9 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 } }; }, @@ -607,6 +614,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-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 7eda0a88ce..a452275c24 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -290,6 +290,7 @@ test('projects the Host default target without inventing a second Connection aut assert.deepEqual(connections, [ { + connectionId: 'connection-1', slug: 'openrouter', name: 'OpenRouter', providerType: 'openai-compatible', 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 bfef451ae1..031c2b9363 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 78c7c0ca20..b95338d162 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 @@ -1680,6 +1680,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 e07d625122..81e9775ec0 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 c7dee03160..3bfc79a6ce 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -195,7 +195,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; @@ -1074,7 +1074,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 eec694043d..155ac853c6 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2002,7 +2002,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 005013b326..3c501053b3 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -86,6 +86,7 @@ type MessageLoadErrorUpdater = (updater: (current: Record) => Re type InteractionQueueUpdater = (updater: (current: InteractionQueues) => InteractionQueues) => void; type PendingNewChatModel = { + llmConnectionId: string; llmConnectionSlug: string; model: string; } | null; @@ -464,6 +465,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 e13a5a5353..9bc8aed938 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 }; }; @@ -627,7 +631,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}" 没有默认模型,发送会失败。请到 设置 · 模型 选择一个模型。` }, @@ -858,7 +864,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 d5003d0e32..8addb78be9 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, @@ -1060,7 +1060,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/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 686d364a33..f68438570b 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -67,6 +67,7 @@ const noop = () => undefined; const modelChoices: ChatModelChoice[] = [ { + connectionId: 'connection-anthropic-main', connectionSlug: 'anthropic-main', providerType: 'anthropic', providerLabel: 'Anthropic', @@ -76,6 +77,7 @@ const modelChoices: ChatModelChoice[] = [ thinkingLevels: [], }, { + connectionId: 'connection-openai-main', connectionSlug: 'openai-main', providerType: 'openai', providerLabel: 'OpenAI', @@ -106,6 +108,7 @@ function makeSession(input: { status: input.status ?? 'active', lastMessageAt: input.lastMessageAt ?? NOW - 12 * 60_000, backend: 'ai-sdk', + llmConnectionId: 'connection-anthropic-main', llmConnectionSlug: 'anthropic-main', connectionLocked: false, model: 'claude-sonnet-4-5', @@ -780,7 +783,7 @@ export const NewChatComposer: Story = { session={null} chat={{ messages: [] }} composer={{ - newChatModel: { llmConnectionSlug: 'anthropic-main', model: 'claude-sonnet-4-5' }, + newChatModel: { llmConnectionId: 'connection-anthropic-main', llmConnectionSlug: 'anthropic-main', model: 'claude-sonnet-4-5' }, onPickNewChatModel: noop, onOpenModelSettings: noop, }} @@ -796,7 +799,7 @@ export const NewChatComposerEmptyLocalHost: Story = { session={null} chat={{ messages: [] }} composer={{ - newChatModel: { llmConnectionSlug: 'anthropic-main', model: 'claude-sonnet-4-5' }, + newChatModel: { llmConnectionId: 'connection-anthropic-main', llmConnectionSlug: 'anthropic-main', model: 'claude-sonnet-4-5' }, onPickNewChatModel: noop, onOpenModelSettings: noop, workspacePicker: { @@ -821,7 +824,7 @@ export const NewChatComposerProjectPending: Story = { session={null} chat={{ messages: [] }} composer={{ - newChatModel: { llmConnectionSlug: 'anthropic-main', model: 'claude-sonnet-4-5' }, + newChatModel: { llmConnectionId: 'connection-anthropic-main', llmConnectionSlug: 'anthropic-main', model: 'claude-sonnet-4-5' }, onPickNewChatModel: noop, onOpenModelSettings: noop, workspacePicker: { diff --git a/apps/desktop/stories/composer-message-queue.stories.tsx b/apps/desktop/stories/composer-message-queue.stories.tsx index 8dd5958c31..363f69fc08 100644 --- a/apps/desktop/stories/composer-message-queue.stories.tsx +++ b/apps/desktop/stories/composer-message-queue.stories.tsx @@ -53,6 +53,7 @@ function session(): SessionSummary { lastMessagePreview: '查一下 PR #3526 相关的 session。', status: 'running', backend: 'ai-sdk', + llmConnectionId: 'connection-anthropic-main', llmConnectionSlug: 'anthropic-main', connectionLocked: false, model: 'claude-sonnet-4-5', @@ -62,6 +63,7 @@ function session(): SessionSummary { const modelChoices: ChatModelChoice[] = [ { + connectionId: 'connection-anthropic-main', connectionSlug: 'anthropic-main', providerType: 'anthropic', providerLabel: 'Anthropic', diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index 3c0835f151..c83957e808 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -24,6 +24,7 @@ import { Layout, LayoutContent, LayoutHeader } from '@astryxdesign/core'; import { ToastProvider } from '@maka/ui'; import type { ConnectionTestResult, + IdentifiedLlmConnection, LlmConnection, ModelDiscoveryResult, ProviderType, @@ -69,8 +70,9 @@ function makeConnection(input: { lastTestMessage?: string; models?: LlmConnection['models']; modelSource?: LlmConnection['modelSource']; -}): LlmConnection { +}): IdentifiedLlmConnection { return { + connectionId: `connection-${input.slug}`, slug: input.slug, name: input.name, providerType: input.providerType, @@ -213,7 +215,7 @@ const problemConnections = [ ]; function createBridge(input: { - connections?: LlmConnection[]; + connections?: IdentifiedLlmConnection[]; defaultSlug?: string | null; failLoad?: boolean; loading?: boolean; @@ -250,7 +252,7 @@ function createBridge(input: { async update(slug, patch) { const current = connections.find((connection) => connection.slug === slug); if (!current) throw new Error('连接不存在'); - const updated: LlmConnection = { + const updated: IdentifiedLlmConnection = { ...current, ...patch, // UpdateConnectionInput.relayModelProfiles is tri-state (null clears); diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index f60373cd3a..b1dbd489c4 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -42,7 +42,7 @@ import type { HealthSignal, HealthSnapshot } from '@maka/core/health'; import type { DesktopExternalSessionCatalogItem } from '../../src/preload/external-session-catalog'; import type { SessionSummary } from '@maka/core/session'; import { revisionFamilySessionIds } from '@maka/core/session-revisions'; -import type { LlmConnection, ProviderType } from '@maka/core/llm-connections'; +import type { IdentifiedLlmConnection, LlmConnection, ProviderType } from '@maka/core/llm-connections'; import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import type { LocalMemoryBackupInfo, LocalMemoryEntryPreview, LocalMemoryState } from '@maka/core/local-memory'; import { buildHealthSnapshot } from '@maka/core/health'; @@ -102,8 +102,9 @@ function makeConnection(input: { name: string; providerType: ProviderType; enabled?: boolean; -}): LlmConnection { +}): IdentifiedLlmConnection { return { + connectionId: `connection-${input.slug}`, slug: input.slug, name: input.name, providerType: input.providerType, @@ -117,7 +118,7 @@ function makeConnection(input: { }; } -const connections: LlmConnection[] = [ +const connections: IdentifiedLlmConnection[] = [ makeConnection({ slug: 'zai-live', name: 'Z.AI Live', providerType: 'zai-coding-plan' }), makeConnection({ slug: 'openai-review', name: 'OpenAI Review', providerType: 'openai' }), makeConnection({ slug: 'ollama-local', name: 'Ollama Local', providerType: 'ollama' }), diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 2afa0fd825..53ffc8563a 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 24 | +| windows-backend-gap | 25 | | portable-candidate | 10 | | platform-contract | 35 | -Total Windows-excluded declarations: **69** +Total Windows-excluded declarations: **70** ## Inventory @@ -52,6 +52,7 @@ Total Windows-excluded declarations: **69** | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-resource-process.test.ts` real Host Runtime Resource process lifecycle | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-resource-two-client-uds.test.ts` a Host-owned PTY survives Desktop disconnect and transfers control to TUI | `process.platform === 'win32' ? 'POSIX UDS and shell integration' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts` two Clients share stable Session creation, CAS configuration, and catalog continuity | `process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts` deleted account identity survives same-slug reuse until explicit recovery | `process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts` stable Session creation survives response loss and Host restart | `process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts` two Clients share exact retryable Session branch and revision authority | `process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false` | | windows-backend-gap | `packages/runtime-host/src/__tests__/usage-pricing-client-correlation.test.ts` fails the connection for a canonical response with mismatched ${mismatch.name} | `process.platform === 'win32'` | diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 1c0b3eb283..f21001be00 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3446,10 +3446,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', @@ -3458,6 +3460,7 @@ describe('Maka Pi TUI runner', () => { isDefaultConnection: true, }, { + connectionId: 'connection-zai', connectionSlug: 'zai', connectionName: 'Z.ai', providerType: 'openai', @@ -3614,10 +3617,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', @@ -3625,6 +3630,7 @@ describe('Maka Pi TUI runner', () => { isDefaultConnection: true, }, { + connectionId: 'connection-openai', connectionSlug: 'openai', connectionName: 'OpenAI', providerType: 'openai', @@ -3669,10 +3675,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', @@ -3680,6 +3688,7 @@ describe('Maka Pi TUI runner', () => { isDefaultConnection: true, }, { + connectionId: 'connection-relay', connectionSlug: 'relay', connectionName: 'Relay', providerType: 'openai', @@ -7229,6 +7238,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(); @@ -7429,7 +7490,7 @@ abstract class FakeSessionDriver implements MakaSessionDriver { async stop(): Promise {} async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} async renameSession(_name: string): Promise {} - async setModel(_model: string, _connectionSlug?: string): Promise {} + async setModel(_model: string, _connectionSlug?: string, _connectionId?: string): Promise {} async setPermissionMode(_mode: PermissionMode): Promise {} async setThinkingLevel(_level: ThinkingLevel | undefined): Promise {} @@ -8137,6 +8198,7 @@ class SlashCommandDriver extends FakeSessionDriver { readonly displayPrompts: string[] = []; readonly models: string[] = []; readonly modelConnections: Array = []; + readonly modelConnectionIds: Array = []; readonly permissionModes: PermissionMode[] = []; readonly thinkingLevelUpdates: Array = []; readonly orchestrationModes: OrchestrationMode[] = []; @@ -8293,9 +8355,10 @@ class SlashCommandDriver extends FakeSessionDriver { }; } - 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 64db3be16c..571afe359e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -42,6 +42,7 @@ import { type OperationOutput, type SessionCatalogProjection, type SessionContinuitySnapshot, + type SessionUpdateResult, type SubscriptionFrame, } from '@maka/runtime-host/protocol'; import { projectSessionCatalogSummary } from '@maka/runtime-host/client'; @@ -89,6 +90,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' }, @@ -107,6 +109,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' }, @@ -114,6 +117,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', @@ -132,6 +136,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', @@ -147,6 +152,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.createSession({ cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -214,6 +220,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', @@ -228,6 +235,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.createSession({ cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -349,6 +357,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', @@ -357,6 +366,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', @@ -370,6 +380,7 @@ describe('Runtime Host Maka Session driver', () => { name: 'New Chat', modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'openai-main', model: 'gpt-5', }, @@ -397,6 +408,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: (() => { @@ -438,6 +450,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: (() => { @@ -510,6 +523,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: sequenceIds('id-1', 'id-2'), @@ -548,6 +562,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: sequenceIds('id-1', 'id-2'), @@ -588,6 +603,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: sequenceIds('id-1', 'id-2'), @@ -625,6 +641,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: sequenceIds('id-1', 'id-2'), @@ -674,6 +691,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: root, + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', newId: sequenceIds('id-1', 'id-2'), @@ -717,6 +735,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: sequenceIds('id-1', 'id-2'), @@ -754,6 +773,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: sequenceIds('id-1', 'id-2'), @@ -774,6 +794,63 @@ describe('Runtime Host Maka Session driver', () => { assert.equal(driver.getSessionId(), null); }); + 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 @@ -800,6 +877,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', @@ -808,6 +886,7 @@ describe('Runtime Host Maka Session driver', () => { await driver.createSession({ cwd: '/repo', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', permissionMode: 'ask', @@ -835,6 +914,7 @@ describe('Runtime Host Maka Session driver', () => { name: 'New Chat', modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'openai-main', model: 'gpt-5', }, @@ -862,6 +942,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) => { @@ -908,6 +989,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', }); @@ -929,6 +1011,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, @@ -960,6 +1043,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, @@ -1001,6 +1085,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1033,6 +1118,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1082,6 +1168,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, @@ -1235,6 +1322,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1307,6 +1395,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1382,6 +1471,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1443,6 +1533,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1491,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', }); @@ -1554,6 +1646,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1584,6 +1677,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('retract-1'), @@ -1668,6 +1762,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-${++nextId}`, @@ -1799,6 +1894,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, @@ -1829,6 +1925,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1868,6 +1965,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -1939,6 +2037,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', @@ -1987,6 +2086,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', }); @@ -2021,6 +2121,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -2053,6 +2154,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'), @@ -2082,6 +2184,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'), @@ -2115,6 +2218,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -2145,6 +2249,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -2185,6 +2290,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -2220,6 +2326,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -2261,6 +2368,7 @@ describe('Runtime Host Maka Session driver', () => { const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', model: 'gpt-5', }); @@ -2297,6 +2405,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, @@ -2338,6 +2447,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, @@ -2384,6 +2494,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, @@ -2442,6 +2553,7 @@ class FakeConnection { redacted: false, }, }; + readonly configurationOutcomes: SessionUpdateResult[] = []; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -2526,11 +2638,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; } @@ -2787,6 +2901,7 @@ function sessionProjection( hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', connectionLocked: true, model: 'gpt-5', @@ -3065,6 +3180,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, @@ -3263,6 +3379,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, @@ -3298,6 +3415,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, @@ -3345,6 +3463,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, @@ -3389,6 +3508,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, @@ -3425,6 +3545,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, @@ -3464,6 +3585,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, @@ -3504,6 +3626,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, @@ -3566,6 +3689,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 ff9974a792..d5db8c0568 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -183,6 +183,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; @@ -318,6 +324,36 @@ export function safeBoundaryResumeParkedCopy(reason: TurnResumeParkReason): { } } +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); @@ -345,6 +381,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. @@ -362,6 +399,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; @@ -1406,26 +1444,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 ) { @@ -1478,15 +1535,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; @@ -1539,8 +1606,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); @@ -2679,7 +2749,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 4728771a26..62f8536e42 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -168,6 +168,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 86dfc6c6f1..36ba9707c8 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -134,6 +134,7 @@ export interface RuntimeHostMakaSessionDriverInput { connection: RuntimeHostSessionDriverConnection; cwd: string; workspace?: WorkspaceTarget; + llmConnectionId?: string; llmConnectionSlug: string; model: string; /** @@ -200,6 +201,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 @@ -276,6 +278,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'; @@ -295,6 +298,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; @@ -588,22 +592,35 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { if (pending) this.#channel?.publishInteractionAnswer(answered, pending); } - setModel(model: string, connectionSlug?: string): Promise { - return this.#admit(() => this.#setModel(model, connectionSlug)); + setModel(model: string, connectionSlug?: string, connectionId?: string): Promise { + return this.#admit(() => this.#setModel(model, connectionSlug, connectionId)); } - 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; } @@ -1208,6 +1225,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, @@ -1215,6 +1235,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { name, modelTarget: { kind: 'explicit', + connectionId: this.#llmConnectionId, connectionSlug: this.#llmConnectionSlug, model: this.#model, }, @@ -1274,7 +1295,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; @@ -1284,30 +1310,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 1d91b8005c..7cb312e18f 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -87,10 +87,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 88452e5683..c31a03ee8b 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -72,8 +72,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[]; @@ -121,9 +127,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 @@ -140,8 +146,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, sessionCopyCleanupOwner: owner, @@ -161,16 +170,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(), @@ -317,16 +338,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 fafa03c2bb..4dac719577 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -165,7 +165,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 61f1e4a6e3..51ea84dfb5 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 ab9fe82cba..385b042a0e 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -623,6 +623,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, @@ -637,6 +638,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__/context-coordinator.test.ts b/packages/runtime-host/src/__tests__/context-coordinator.test.ts index cc152adaa9..d5052d589f 100644 --- a/packages/runtime-host/src/__tests__/context-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/context-coordinator.test.ts @@ -78,7 +78,11 @@ test('context compaction waits for terminal execution cleanup before preparing', }, sessions: { readHeaderSnapshot: async () => - ({ status: 'active', isArchived: false }) as unknown as SessionHeader, + ({ + status: 'active', + isArchived: false, + llmConnectionId: 'connection-context', + }) as unknown as SessionHeader, }, requestDrain: () => {}, newId: () => compacted.runId, 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-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index f4ac9d9394..662f2530d2 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -161,6 +161,56 @@ test('production Host resumes a Session through the ScheduledTask authority', { }); }); +test('production Host fails slug-only ScheduledTask Agent runs before binding execution identity', { + timeout: 30_000, +}, async () => { + await withExecutionRoot(async (fixture) => { + const host = await fixture.startHost(); + const desktop = await connectClient(fixture.root); + try { + const created = await desktop.request('scheduled-task.mutate', { + kind: 'create', + input: { + title: 'legacy agent-run identity proof', + intentBody: 'Do not execute with a replacement account.', + schedule: { kind: 'once', runAt: Date.now() + 60_000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: fixture.root, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + }, + }); + assert.equal(created.kind, 'task'); + if (created.kind !== 'task') return; + + const fired = await desktop.request('scheduled-task.mutate', { + kind: 'trigger_now', + taskId: created.task.id, + }); + assert.equal(fired.kind, 'task'); + if (fired.kind !== 'task') return; + assert.equal( + fired.task.lastError, + 'ScheduledTask Agent runs require an immutable model connection identity', + ); + assert.equal(fired.task.runs.length, 1); + assert.equal(fired.task.runs[0]?.outcome, 'failed'); + assert.equal(fired.task.runs[0]?.sessionId, undefined); + assert.equal(fired.task.runs[0]?.runId, undefined); + } finally { + await desktop.close(); + await fixture.stopHost(host); + } + }); +}); + test('production Host settles dispatched Client Capabilities before publishing Ready', async () => { await withExecutionRoot(async (fixture) => { const prepared = await seedDispatchedClientCapability(fixture); 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..ecfba7fad1 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( @@ -300,6 +321,7 @@ test('production Host executes current-boundary Bash and refreshes live sandbox const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await execution.sessionStore.create({ cwd: project, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'hosted-managed-bash-provider', model: MODEL_ID, permissionMode: 'ask', @@ -753,9 +775,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 +803,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 +818,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 +1186,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', }, @@ -1341,6 +1373,7 @@ test('production Host executes a canonical ai-sdk Session against a real provide const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await execution.sessionStore.create({ cwd: root, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'hosted-real-provider', model: MODEL_ID, permissionMode: 'ask', @@ -1657,6 +1690,7 @@ test('production Host executes and durably supervises an Agent Graph over a real const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await execution.sessionStore.create({ cwd: project, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'hosted-graph-provider', model: MODEL_ID, permissionMode: 'bypass', @@ -1856,6 +1890,7 @@ test('production Host executes a durable runnable child with an exact tool ceili const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const parent = await execution.sessionStore.create({ cwd: project, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'hosted-child-provider', model: MODEL_ID, permissionMode: 'bypass', @@ -2053,6 +2088,7 @@ test('production Host publishes and retires an implementation child patch', asyn const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const parent = await execution.sessionStore.create({ cwd: project, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'hosted-child-provider', model: MODEL_ID, permissionMode: 'bypass', @@ -2307,6 +2343,7 @@ test('Host auxiliary calls preserve resolved DeepSeek reasoning settings', async await publishConnectionModel(policy, connection.connectionId, 'deepseek-v4-flash'); const session = await execution.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'deepseek-auxiliary', model: 'deepseek-v4-flash', thinkingLevel: 'high', @@ -2384,6 +2421,7 @@ test('Host auxiliary models meter provider usage and abort physical requests', { await publishConnectionModel(policy, connection.connectionId, MODEL_ID); const session = await execution.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: connection.connectionId, llmConnectionSlug: 'goal-evaluator-provider', model: MODEL_ID, permissionMode: 'ask', @@ -3451,7 +3489,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 +3552,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__/host-session-availability.test.ts b/packages/runtime-host/src/__tests__/host-session-availability.test.ts index 82d601cb35..2b6fdc9d1d 100644 --- a/packages/runtime-host/src/__tests__/host-session-availability.test.ts +++ b/packages/runtime-host/src/__tests__/host-session-availability.test.ts @@ -25,6 +25,8 @@ import { } from '@maka/core/session'; import { runtimeHostExecutionUnavailableReason, + runtimeHostSafeBoundaryContinuationUnavailableReason, + LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON, WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON, WORKHUB_COORDINATION_TARGET_UNAVAILABLE_REASON, } from '../server/host-session-availability.js'; @@ -40,6 +42,8 @@ const base = { subagentWorkspace: undefined, transcriptLedgerVersion: 1 as const, toolProfile: 'workhub-coordination-v1' as const, + llmConnectionId: 'connection-workhub', + backend: 'ai-sdk' as const, }; test('WorkHub execution requires the exact reserved id, role, and zero-tool profile', () => { @@ -101,3 +105,45 @@ test('WorkHub execution requires the exact reserved id, role, and zero-tool prof WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON, ); }); + +test('legacy Session identity cannot enter Host execution before explicit account recovery', () => { + assert.equal( + runtimeHostExecutionUnavailableReason( + { + ...base, + id: 'legacy-connection-session', + role: undefined, + llmConnectionId: undefined, + }, + { kind: 'external_message' }, + ), + LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON, + ); +}); + +test('legacy Session identity cannot resume a safe-boundary continuation', () => { + assert.equal( + runtimeHostSafeBoundaryContinuationUnavailableReason({ + ...base, + id: 'legacy-safe-boundary-session', + role: undefined, + subagentParent: undefined, + llmConnectionId: undefined, + }), + LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON, + ); +}); + +test('legacy fake Session identity defers to the retired-backend product refusal', () => { + assert.equal( + runtimeHostSafeBoundaryContinuationUnavailableReason({ + ...base, + id: 'legacy-fake-safe-boundary-session', + role: undefined, + subagentParent: undefined, + llmConnectionId: undefined, + backend: 'fake', + }), + undefined, + ); +}); 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 3dfc54f86d..42e611cd6b 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -323,6 +323,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); }); + test('publishes a new compatibility epoch for exact Session Connection identity', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); + }); + 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 5e041ebecd..0d04bd9c60 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -216,6 +216,43 @@ test('turn.start rejects a corrupt Coordination role on an ordinary identity', a } }); +test('turn.start rejects a legacy Session until an explicit account recovery binds it', async () => { + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register('ai-sdk', (backendContext) => new FakeBackend(backendContext)), + legacyConnectionIdentity: true, + }); + try { + const outcome = await fixture.interactiveTurns.handlers['turn.start']( + { + sessionId: fixture.sessionId, + turnId: 'legacy-connection-identity-turn', + content: { text: 'This cannot select a replacement account implicitly.' }, + }, + operationContext(fixture.hostEpoch, fixture.acquireResidency), + ); + + assert.deepEqual(outcome, { + ok: false, + error: { + code: 'operation_unavailable', + message: 'This Session requires an explicit account selection before it can run.', + }, + }); + assert.equal( + await fixture.stores.agentRunStore.readRootTurnAdmission( + fixture.sessionId, + 'legacy-connection-identity-turn', + ), + undefined, + ); + } finally { + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + test('prepares a fresh Agent Graph epoch before durable external Turn admission', async () => { let fixture!: FailureFixture; let cutovers = 0; @@ -1264,6 +1301,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', @@ -1407,6 +1445,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', @@ -2324,6 +2363,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', @@ -4199,6 +4239,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', @@ -4775,6 +4816,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, @@ -4897,6 +4939,7 @@ async function registerSessionCapability( async function createFailureFixture(options: { registerBackend(backends: BackendRegistry): void; corruptSessionRole?: boolean; + legacyConnectionIdentity?: boolean; childTools?: MakaTool[]; wrapAdmissionStore?(store: RootTurnAdmissionStore): RootTurnAdmissionStore; wrapMessageAuthority?(authority: RuntimeMessageAuthority): RuntimeMessageAuthority; @@ -4935,6 +4978,9 @@ async function createFailureFixture(options: { await artifacts?.recover(); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + ...(options.legacyConnectionIdentity + ? {} + : { 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 dd1101d9fd..966cc2e789 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -134,7 +134,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 9999886345..9fe1549aa1 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -93,7 +93,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 = 60 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 61 as const; +// 61: 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. // 60: WorkHub stores a canonical delegation assignment record. Older peers // cannot decode this message during transcript recovery. // 59: Scheduled Turn provider-retry frames may carry an optional host-clock 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 46619ff9d8..d8d09a8da3 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -27,7 +27,11 @@ 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, WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { + isDeepResearchSession, + type SessionHeader, + WORKHUB_COORDINATION_SESSION_ID, +} 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 +84,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 +111,7 @@ import { createInteractiveRunComposerFactory, routeInteractiveRunToolSurface, } from './interactive-run-composer.js'; + import { createHostGoalEvaluator, createHostDailyReviewModel, @@ -168,6 +174,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 +692,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 +701,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 +749,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 +767,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 +825,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, @@ -883,7 +901,7 @@ export async function createExecutionRuntimeHostComposition( worktreePatchWriteBackAvailable: true, }).childTools; const { surface } = await resolveInteractiveToolSurface({ - connectionSlug: header.llmConnectionSlug, + connectionRef: sessionExecutionConnectionRef(header), modelId: header.model, hostTools: [], childTools, @@ -1774,6 +1792,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 1622d4b799..9cc68188b3 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/host-session-availability.ts b/packages/runtime-host/src/server/host-session-availability.ts index 88c7b2e1fa..2fced24a0b 100644 --- a/packages/runtime-host/src/server/host-session-availability.ts +++ b/packages/runtime-host/src/server/host-session-availability.ts @@ -35,24 +35,38 @@ export const WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON = 'WorkHub Coordination Session execution requires WorkHub authority'; export const WORKHUB_COORDINATION_TARGET_UNAVAILABLE_REASON = 'WorkHub Coordination execution requires the reserved Coordination Session'; +export const LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON = + 'This Session requires an explicit account selection before it can run.'; export function runtimeHostExternalTurnUnavailableReason( header: Pick< SessionHeader, - 'id' | 'role' | 'collaborationMode' | 'subagentWorkspace' | 'transcriptLedgerVersion' + | 'id' + | 'role' + | 'collaborationMode' + | 'subagentWorkspace' + | 'transcriptLedgerVersion' + | 'llmConnectionId' + | 'backend' >, ): string | undefined { return runtimeHostExecutionUnavailableReason(header, { kind: 'external_message' }); } export function runtimeHostSafeBoundaryContinuationUnavailableReason( - header: Pick, + header: Pick< + SessionHeader, + 'id' | 'role' | 'subagentParent' | 'transcriptLedgerVersion' | 'llmConnectionId' | 'backend' + >, ): string | undefined { return ( (isWorkHubCoordinationSessionTarget(header) ? WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON : undefined) ?? (header.transcriptLedgerVersion === 0 ? IMPORT_STAGING_UNAVAILABLE_REASON : undefined) ?? + (header.llmConnectionId === undefined && header.backend !== 'fake' + ? LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON + : undefined) ?? (header.subagentParent ? CHILD_CONTINUATION_UNAVAILABLE_REASON : undefined) ); } @@ -60,7 +74,13 @@ export function runtimeHostSafeBoundaryContinuationUnavailableReason( export function runtimeHostExecutionUnavailableReason( header: Pick< SessionHeader, - 'id' | 'role' | 'collaborationMode' | 'subagentWorkspace' | 'transcriptLedgerVersion' + | 'id' + | 'role' + | 'collaborationMode' + | 'subagentWorkspace' + | 'transcriptLedgerVersion' + | 'llmConnectionId' + | 'backend' > & { readonly toolProfile?: SessionToolProfile; readonly permissionMode?: SessionHeader['permissionMode']; @@ -88,6 +108,9 @@ export function runtimeHostExecutionUnavailableReason( ? WORKHUB_COORDINATION_EXECUTION_UNAVAILABLE_REASON : undefined) ?? (header.transcriptLedgerVersion === 0 ? IMPORT_STAGING_UNAVAILABLE_REASON : undefined) ?? + (header.llmConnectionId === undefined && header.backend !== 'fake' + ? LEGACY_CONNECTION_IDENTITY_EXECUTION_UNAVAILABLE_REASON + : undefined) ?? (header.collaborationMode === 'plan' && execution.kind !== 'external_message' && execution.kind !== 'regenerate' && 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..9e43bf84a6 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -61,6 +61,8 @@ import type { SessionCreateInput } from '../protocol/session-catalog.js'; const MAX_TIMER_DELAY_MS = 2_147_483_647; const NATIVE_PROVIDER_RETRY_MS = 5_000; +const SCHEDULED_AGENT_RUN_IDENTITY_REQUIRED = + 'ScheduledTask Agent runs require an immutable model connection identity'; type ScheduledTaskSessions = Pick; type ScheduledTaskRuntime = Pick; @@ -582,6 +584,13 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority ); } + // Persisted agent-run templates currently identify their model connection + // by reusable slug only. Do not resolve that slug to a potentially + // different Connection entity. #3927 will make the exact ID durable. + if (task.effect.kind === 'agent_run') { + return this.#settleFailure(claim, SCHEDULED_AGENT_RUN_IDENTITY_REQUIRED); + } + let execution = claim.execution; if (!execution) { execution = { @@ -628,6 +637,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 +654,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 7264c27a88..e20e9f6be4 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -118,6 +118,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' @@ -145,6 +157,7 @@ export interface HostSessionCatalogCoordinatorOptions { } interface ResolvedSessionModel { + readonly connectionId: string; readonly connectionSlug: string; readonly model: string; } @@ -187,6 +200,7 @@ export class HostSessionCatalogCoordinator { this.#readRuntimePolicy(), ]); return { + llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: model.model, permissionMode: policy.policy.chatDefaults.permissionMode, @@ -410,6 +424,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 }), @@ -522,15 +537,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( @@ -541,16 +552,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) { @@ -751,10 +754,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', @@ -820,16 +838,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, }; @@ -862,6 +885,56 @@ 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 && current.llmConnectionId !== undefined) { + const connectionId = current.llmConnectionId; + 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> > { @@ -875,15 +948,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 @@ -934,7 +1007,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'], @@ -1007,6 +1085,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 abd01c1748..794572efb0 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'); @@ -4291,6 +4298,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, @@ -4304,6 +4312,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => await assert.rejects( manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: baseConfiguration, }), (error: unknown) => { @@ -4317,6 +4326,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); @@ -4326,6 +4336,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => await assert.rejects( manager.transitionSessionConfiguration(session.id, { expectedRevision: 2, + clearConnectionBlock: false, configuration: { ...baseConfiguration, permissionMode: 'explore', @@ -4340,6 +4351,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(); @@ -4427,8 +4480,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, @@ -4478,8 +4533,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', @@ -5095,7 +5152,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, { @@ -5106,6 +5165,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(); }); @@ -16528,6 +16588,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: input.subagentParent !== undefined, model: input.model ?? 'fake-model', diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index b4b42299c6..176fbdf863 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -1117,6 +1117,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 dbb8c1c92f..14d104c353 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -5007,6 +5007,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 ede6ec1f0a..b91e7627c8 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -3301,6 +3301,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 30289e8d3a..d9d31e06bf 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, @@ -327,7 +328,7 @@ export interface SpawnChildSessionInput { } type ResolvedSpawnChildSessionInput = SpawnChildSessionInput & { - resolvedPreset?: SubagentPreset; + resolvedPreset?: ResolvedSubagentPreset; }; export interface SpawnChildSessionResult extends SpawnChildAgentResult { @@ -573,6 +574,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; @@ -589,6 +591,7 @@ export interface SessionConfigurationStoreUpdate { export interface SessionConfigurationTransitionRequest { readonly expectedRevision: number; + readonly clearConnectionBlock: boolean; readonly configuration: Omit; } @@ -824,7 +827,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; @@ -1175,7 +1178,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(), @@ -2513,6 +2516,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 @@ -3051,6 +3059,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 @@ -3386,6 +3399,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 || @@ -3525,6 +3540,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 || @@ -4666,6 +4683,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, @@ -4897,6 +4917,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, @@ -4959,6 +4982,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, @@ -6132,6 +6158,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 0a2cd3b2e9..39499758ba 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -1403,6 +1403,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 { @@ -1411,6 +1412,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 acbfce376a..363688af7c 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -2601,6 +2601,7 @@ describe('SqliteSessionMetadataStore', () => { expectedVersion: 1, configuration: { backend: 'ai-sdk' as const, + llmConnectionId: '11111111-1111-4111-8111-111111111111', llmConnectionSlug: 'openrouter', connectionLocked: true, model: 'openrouter/free', @@ -2638,6 +2639,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'); @@ -2683,6 +2685,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 f70f4504b0..5f1e4819e7 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -1232,6 +1232,7 @@ function buildSessionHeader( ...(input.revisionState ? { revisionState: input.revisionState } : {}), hasUnread: false, backend: 'ai-sdk', + ...(input.llmConnectionId === undefined ? {} : { llmConnectionId: input.llmConnectionId }), llmConnectionSlug: input.llmConnectionSlug, // A subagent Session's route is chosen by the spawn that created it and is // never re-targeted, so it is born frozen. Every other Session freezes on @@ -1289,6 +1290,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' && @@ -1517,6 +1520,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 dd053e6b99..f2f92162d0 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -371,6 +371,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 45c42037a3..8340af48c9 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 @@ -1967,7 +1975,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} diff --git a/packages/ui/stories/attachment.stories.tsx b/packages/ui/stories/attachment.stories.tsx index 07f585cdd7..f49aca2cb5 100644 --- a/packages/ui/stories/attachment.stories.tsx +++ b/packages/ui/stories/attachment.stories.tsx @@ -57,7 +57,7 @@ type ComposerProps = ComponentProps; type ChatViewProps = ComponentProps; const modelChoices: ChatModelChoice[] = [ - { connectionSlug: 'anthropic-main', providerType: 'anthropic', providerLabel: 'Anthropic', model: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', isDefault: true, thinkingLevels: [] }, + { connectionId: 'connection-anthropic-main', connectionSlug: 'anthropic-main', providerType: 'anthropic', providerLabel: 'Anthropic', model: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', isDefault: true, thinkingLevels: [] }, ]; function noop() { @@ -76,6 +76,7 @@ function session(o: Partial = {}): SessionSummary { lastMessagePreview: '帮我看下这几个文件。', status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-anthropic-main', llmConnectionSlug: 'anthropic-main', connectionLocked: false, model: 'claude-sonnet-4-5', diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index fe2418a19a..769f2ac493 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -25,7 +25,7 @@ import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SessionSummary } from '@maka/core/session'; import { ChatModelSwitcher, NewChatModelPicker, ThinkingLevelSelector } from '../src/chat-model-switcher.js'; import { - modelChoiceValue, + exactModelChoiceValue, type ChatModelChoice, } from '../src/chat-model-helpers.js'; import { ModelPicker } from '../src/model-picker.js'; @@ -49,7 +49,7 @@ function choice( model: string, label: string, ): ChatModelChoice { - return { connectionSlug, providerType, providerLabel, model, label, isDefault: false, thinkingLevels: [] }; + return { connectionId: `connection-${connectionSlug}`, connectionSlug, providerType, providerLabel, model, label, isDefault: false, thinkingLevels: [] }; } const CHOICES: ChatModelChoice[] = [ @@ -75,12 +75,25 @@ function providerMark(type: ProviderType) { return {labels[type] ?? 'M'}; } +function choiceValue(choice: ChatModelChoice) { + return exactModelChoiceValue(choice.connectionId, choice.connectionSlug, choice.model); +} + function selectedLabel(value: string) { - return CHOICES.find((choice) => modelChoiceValue(choice.connectionSlug, choice.model) === value)?.label ?? value; + return CHOICES.find((choice) => choiceValue(choice) === value)?.label ?? value; +} + +function choiceForTarget(input: { llmConnectionId: string; llmConnectionSlug: string; model: string }) { + return CHOICES.find( + (choice) => + choice.connectionId === input.llmConnectionId && + choice.connectionSlug === input.llmConnectionSlug && + choice.model === input.model, + ); } function ModelPickerFrame(props: { initialValue?: string }) { - const [value, setValue] = useState(props.initialValue ?? 'anthropic-team:claude-sonnet-4'); + const [value, setValue] = useState(props.initialValue ?? choiceValue(CHOICES[4]!)); return (
setValue(modelChoiceValue(llmConnectionSlug, model))} + onPick={(next) => { + const nextChoice = choiceForTarget(next); + if (nextChoice) setValue(choiceValue(nextChoice)); + }} />
); @@ -105,8 +121,7 @@ export const Default: Story = { // trigger and the new-chat picker below stay quiet. export const ExistingConversation: Story = { render: function ExistingConversationRender() { - const [value, setValue] = useState('anthropic-team:claude-sonnet-4'); - const [connectionSlug, model] = value.split(':', 2); + const [activeChoice, setActiveChoice] = useState(CHOICES[4]!); const activeSession = { id: 'storybook-model-switch', name: 'Model switch warning', @@ -116,22 +131,25 @@ export const ExistingConversation: Story = { hasUnread: false, status: 'active', backend: 'ai-sdk', - llmConnectionSlug: connectionSlug, + llmConnectionId: activeChoice.connectionId, + llmConnectionSlug: activeChoice.connectionSlug, connectionLocked: true, - model, + model: activeChoice.model, permissionMode: 'ask', } satisfies SessionSummary; return (
- setValue(modelChoiceValue(llmConnectionSlug, nextModel))} + onChange={(next) => { + const nextChoice = choiceForTarget(next); + if (nextChoice) setActiveChoice(nextChoice); + }} />
); @@ -192,6 +210,7 @@ export const EmptyConversation: Story = { hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-anthropic-team', llmConnectionSlug: 'anthropic-team', connectionLocked: false, model: 'claude-sonnet-4', @@ -243,7 +262,7 @@ export const EmptyCatalog: Story = { // Real path: quiet composer left footer — model + adjacent thinking menu. export const ThinkingLevelSeparate: Story = { render: function ThinkingLevelSeparateRender() { - const [value, setValue] = useState('anthropic-team:claude-sonnet-4'); + const [value, setValue] = useState(choiceValue(CHOICES[4]!)); const [thinkingLevel, setThinkingLevel] = useState('medium'); return (
@@ -253,7 +272,10 @@ export const ThinkingLevelSeparate: Story = { currentValue={value} currentProviderType="anthropic" renderProviderMark={providerMark} - onPick={({ llmConnectionSlug, model }) => setValue(modelChoiceValue(llmConnectionSlug, model))} + onPick={(next) => { + const nextChoice = choiceForTarget(next); + if (nextChoice) setValue(choiceValue(nextChoice)); + }} />