From 8587c8501af0467640c9e5ab88501efedf3264d6 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 21:28:56 +0800 Subject: [PATCH 01/10] feat(runtime): bind new sessions to connection identity Persist canonical Connection identity for newly created Sessions and propagate it into AgentRun and derived execution state. Bound execution resolves by ID plus slug and never follows a reused slug. Existing and legacy Sessions reject ambiguous configuration rebinding until an exact-identity wire slice lands. Generated-by: Codex --- .../src/__tests__/agent-run-authority.test.ts | 15 +++ packages/core/src/agent-run.ts | 5 + packages/core/src/runtime-inputs.ts | 2 + packages/core/src/session.ts | 4 + .../__tests__/artifact-two-client-uds.test.ts | 2 + .../canonical-session-projection.test.ts | 2 + .../deep-research-two-client-uds.test.ts | 1 + .../__tests__/execution-composition.test.ts | 106 +++++++++++++++- .../execution-inspect-coordinator.test.ts | 2 + .../__tests__/execution-inspect-uds.test.ts | 1 + .../execution-model-composition.test.ts | 44 ++++++- .../fixtures/execution-host-suite.ts | 9 ++ .../src/__tests__/goal-coordinator.test.ts | 9 ++ .../src/__tests__/goal-root-authority.test.ts | 2 + .../__tests__/interaction-coordinator.test.ts | 4 + .../src/__tests__/oauth-coordinator.test.ts | 7 +- .../oauth-execution-authority.test.ts | 41 +++++- .../__tests__/oauth-two-client-uds.test.ts | 5 +- .../src/__tests__/plan-two-client-uds.test.ts | 1 + .../project-catalog-coordinator.test.ts | 1 + .../project-catalog-two-client-uds.test.ts | 1 + .../__tests__/root-turn-coordinator.test.ts | 6 + .../runtime-policy-coordinator.test.ts | 3 + .../session-catalog-coordinator.test.ts | 80 ++++++++++-- .../session-catalog-two-client-uds.test.ts | 5 + .../session-retirement-coordinator.test.ts | 1 + .../session-revision-graph-references.test.ts | 2 + .../session-revision-two-client-uds.test.ts | 9 ++ .../session-transcript-reader.test.ts | 2 + .../server/connection-effect-coordinator.ts | 1 + .../src/server/execution-composition.ts | 46 +++++-- .../src/server/execution-model-authority.ts | 24 +++- .../src/server/oauth-execution-authority.ts | 13 +- .../src/server/session-catalog-coordinator.ts | 61 ++++++++- .../server/session-revision-coordinator.ts | 1 + .../configured-subagent-catalog.test.ts | 15 ++- .../src/__tests__/session-manager.test.ts | 14 ++- packages/runtime/src/agent-run.ts | 3 + packages/runtime/src/ai-sdk-backend.ts | 1 + .../src/configured-subagent-catalog.ts | 60 ++++++--- packages/runtime/src/memory-extraction.ts | 5 +- packages/runtime/src/runtime-kernel.ts | 3 + packages/runtime/src/runtime-ledger-repair.ts | 3 + packages/runtime/src/session-manager.ts | 30 ++++- .../__tests__/runtime-policy-stores.test.ts | 117 +++++++++++++++--- .../src/__tests__/session-store.test.ts | 23 ++++ .../sqlite-session-metadata-store.test.ts | 3 + packages/storage/src/runtime-policy-stores.ts | 3 +- .../storage/src/runtime-policy/coordinator.ts | 30 ++++- .../storage/src/runtime-policy/operations.ts | 16 ++- packages/storage/src/session-store.ts | 4 + .../src/sqlite-session-metadata-store.ts | 1 + 52 files changed, 765 insertions(+), 84 deletions(-) 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/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/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.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/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__/deep-research-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts index 54cc1dd802..a3f1f1f9dd 100644 --- a/packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/deep-research-two-client-uds.test.ts @@ -55,6 +55,7 @@ test('two Clients and a restarted production Host share one Deep Research projec const deepResearch = await openInteractiveDeepResearchStoreForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'explore', diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 67180218e0..ad3b9db508 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -61,6 +61,7 @@ import { } from '../server/workspace-execution-composition.js'; const require = createRequire(import.meta.url); +const FAKE_CONNECTION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; test('filesystem worker follows the candidate executable runtime', () => { assert.equal(runtimeHostFilesystemWorkerRuntime({ electron: '43.1.1' }), 'electron'); @@ -134,6 +135,7 @@ test('production composition closes long-term memory after a later startup failu const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -173,12 +175,14 @@ test('production recovery preserves legacy Automation history and closes an orph const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const historical = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', }); const pending = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -398,6 +402,7 @@ test('production composition commits automatic titles through Host-owned Session try { const session = await manager.createSession({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -465,6 +470,7 @@ test('production composition orphans ownerless ShellRuns before serving Resource const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -511,6 +517,7 @@ test('production Skill catalog resolves a Graph child durable tool surface', asy const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const parent = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -606,7 +613,7 @@ test('new Full Access Plan Skill previews use the mutating tool surface', async }); }); -test('Skill capability previews omit unavailable Tavily search surfaces', async () => { +test('Skill capability previews keep a bound Session off a same-slug replacement', async () => { await withCompositionRoot(async ({ root, owner }) => { for (const [id, requiredTool] of [ ['web-search-preview', 'WebSearch'], @@ -669,13 +676,19 @@ test('Skill capability previews omit unavailable Tavily search surfaces', async }); assert.equal(webSearchEnabled.kind, 'committed'); assert.equal( - (await policy.operations.resolveExecutionConnection(connection.slug)).kind, + ( + await policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: connection.slug, + }) + ).kind, 'ready', ); const stores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await stores.sessionStore.create({ cwd: root, + llmConnectionId: connection.connectionId, llmConnectionSlug: connection.slug, model: 'fake-model', permissionMode: 'bypass', @@ -719,6 +732,93 @@ test('Skill capability previews omit unavailable Tavily search surfaces', async false, ); } + + assert.equal( + ( + await policy.credentialVault.set({ + locator: { scope: 'web_search', provider: 'tavily', kind: 'api_key' }, + expected: null, + secret: 'replacement-must-not-be-read', + }) + ).kind, + 'committed', + ); + const beforeRemoval = await policy.connectionCatalog.getSnapshot(); + const currentConnection = beforeRemoval.connections.find( + (candidate) => candidate.connectionId === connection.connectionId, + ); + assert.ok(currentConnection); + if (!currentConnection) return; + assert.equal( + ( + await policy.connectionCatalog.remove({ + expected: { + connectionId: currentConnection.connectionId, + revision: currentConnection.revision, + }, + }) + ).kind, + 'committed', + ); + const afterRemoval = await policy.connectionCatalog.getSnapshot(); + const replacementCreated = await policy.connectionCatalog.create({ + expectedCatalogRevision: afterRemoval.revision, + connection: { + slug: connection.slug, + name: 'Same-slug replacement', + providerType: 'ollama', + enabled: true, + enabledModelIds: ['fake-model'], + }, + }); + assert.equal(replacementCreated.kind, 'committed'); + if (replacementCreated.kind !== 'committed') return; + const replacement = replacementCreated.snapshot.connections[0]; + assert.ok(replacement); + if (!replacement) return; + const replacementFetch = await policy.operations.beginModelFetch(replacement.connectionId); + assert.equal(replacementFetch.kind, 'ready'); + if (replacementFetch.kind !== 'ready') return; + const replacementFetched = await policy.operations.completeModelFetch( + replacementFetch.ticket, + { + models: [{ id: 'fake-model' }], + source: 'fetched', + fetchedAt: Date.now(), + }, + ); + assert.equal(replacementFetched.kind, 'committed'); + if (replacementFetched.kind !== 'committed') return; + assert.equal( + ( + await policy.connectionCatalog.setDefaultTarget({ + expectedCatalogRevision: replacementFetched.snapshot.revision, + target: { connectionId: replacement.connectionId, modelId: 'fake-model' }, + }) + ).kind, + 'committed', + ); + + const boundSession = await query('session'); + assert.equal(boundSession.ok, true); + if (boundSession.ok && boundSession.result.kind === 'page') { + assert.equal( + boundSession.result.items.some( + (item) => item.id === 'web-search-preview' || item.id === 'web-research-preview', + ), + false, + ); + } + const replacementPreview = await query('new_session'); + assert.equal(replacementPreview.ok, true); + if (replacementPreview.ok && replacementPreview.result.kind === 'page') { + assert.equal( + replacementPreview.result.items.some( + (item) => item.id === 'web-search-preview' || item.id === 'web-research-preview', + ), + true, + ); + } } finally { await composition.close(); } @@ -731,6 +831,7 @@ test('production composition validates graph stop before aborting a claimed chil const claims = createAgentGraphControlStore(root); const parent = await stores.sessionStore.create({ cwd: root, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -1009,6 +1110,7 @@ async function createClaimedGraphChild(input: { const child = await input.stores.sessionStore.createSubagent({ cwd: input.root, name: `Graph operator ${input.suffix}`, + llmConnectionId: FAKE_CONNECTION_ID, llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'explore', diff --git a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts index 600d9007df..f0ed037956 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-coordinator.test.ts @@ -626,6 +626,7 @@ function sessionInput(name: string) { cwd: '/tmp/workspace', name, backend: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -639,6 +640,7 @@ function runHeader(sessionId: string, runId: string, createdAt: number): AgentRu turnId: `turn-${runId}`, status: 'completed', backendKind: 'fake', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', modelId: 'fake-model', cwd: '/tmp/workspace', diff --git a/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts b/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts index 49925fbbce..d272dac565 100644 --- a/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts +++ b/packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts @@ -50,6 +50,7 @@ test('a live Host serves Interactive inspection over its real endpoint while ret const session = await stores.sessionStore.create({ cwd: root, name: 'Live inspection', + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 1e98782511..f7aef103d3 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -144,6 +144,27 @@ const TEST_SANDBOX_DIAGNOSTICS = createSandboxDiagnosticsProvider({ canonicalizePath: async (path) => path, }); +test('backend creation resolves a bound Session by immutable Connection identity', async () => { + let observedRef: unknown; + await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + connectionId: '11111111-1111-4111-8111-111111111111', + resolveExecutionConnection: async (ref) => { + observedRef = ref; + return readyExecutionConnection(); + }, + readPricing: async () => ({ revision: 0, overrides: [] }), + }), + ); + + assert.deepEqual(observedRef, { + kind: 'bound', + connectionId: '11111111-1111-4111-8111-111111111111', + connectionSlug: 'backend-creation-connection', + }); +}); + test('backend creation aborts a stalled canonical connection read', async () => { const abort = new AbortController(); const creating = createHostAiSdkBackend( @@ -753,9 +774,13 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its const firstCreation = createHostAiSdkBackend( backendCreationFixture({ abortSignal: firstAbort.signal, + connectionId: connection.connectionId, modelId: subscriptionModelId, resolveExecutionConnection: () => - policy.operations.resolveExecutionConnection('backend-creation-connection'), + policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'backend-creation-connection', + }), runtimePolicy: policy, oauthCredentials: authority, readPricing: async () => ({ revision: 0, overrides: [] }), @@ -777,9 +802,13 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its secondBackend = await createHostAiSdkBackend( backendCreationFixture({ abortSignal: new AbortController().signal, + connectionId: connection.connectionId, modelId: subscriptionModelId, resolveExecutionConnection: () => - policy.operations.resolveExecutionConnection('backend-creation-connection'), + policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'backend-creation-connection', + }), runtimePolicy: policy, oauthCredentials: authority, readPricing: async () => ({ revision: 0, overrides: [] }), @@ -788,9 +817,10 @@ test('backend abort cannot cancel the authority-owned OAuth refresh used by its ); assert.equal(transports.refreshCalls, 1); - const resolved = await policy.operations.resolveExecutionConnection( - 'backend-creation-connection', - ); + const resolved = await policy.operations.resolveExecutionConnection({ + kind: 'catalog_slug', + connectionSlug: 'backend-creation-connection', + }); assert.equal(resolved.kind, 'ready'); if (resolved.kind === 'ready') { const persisted = JSON.parse( @@ -3451,7 +3481,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 +3544,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__/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__/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__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 5e041ebecd..a8be91cec6 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -1264,6 +1264,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 +1408,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 +2326,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 +4202,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 +4779,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, @@ -4935,6 +4940,7 @@ async function createFailureFixture(options: { await artifacts?.recover(); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts index fff900af22..dc03b56c84 100644 --- a/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts @@ -123,6 +123,7 @@ test('production composition shares one gate across mutation and backend activat const setupStores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -226,6 +227,7 @@ test('production mutation releases the gate before active-turn backend disposal const setupStores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', @@ -326,6 +328,7 @@ test('production policy mutation drains and poisons activation when cached backe const setupStores = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await setupStores.sessionStore.create({ cwd: root, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index f0c3d9d372..5f24fd3506 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), @@ -592,6 +594,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 () => { @@ -910,10 +913,60 @@ 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('configuration update does not adopt an account for a legacy Session', async () => { + const fixture = createFixture({ legacyConnectionIdentity: true }); + + 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: 'Legacy Session configuration requires an explicit account selection', + }, + }); + assert.equal(fixture.header().llmConnectionId, undefined); +}); + 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 +1321,16 @@ function createFixture( readonly connection?: FixtureConnection; readonly projectCatalog?: ProjectCatalog; readonly onProjectChanged?: () => void; + readonly legacyConnectionIdentity?: boolean; } = {}, ) { const sessionId = 'session-1'; let revision = 3; let header = sessionHeader(sessionId, options.labels ?? ['user-label']); + if (options.legacyConnectionIdentity) { + const { llmConnectionId: _legacyConnectionId, ...legacyHeader } = header; + header = legacyHeader; + } if (options.cwd) header = { ...header, cwd: options.cwd }; let drains = 0; @@ -1367,6 +1425,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 +1472,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, + } + ); + }, }, }; } @@ -1458,6 +1523,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-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts index 665548b33d..821d4f3747 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 @@ -733,6 +733,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 +770,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 +778,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 +786,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 +794,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-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-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/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/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/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 7264c27a88..8c9abdcbc7 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -145,6 +145,7 @@ export interface HostSessionCatalogCoordinatorOptions { } interface ResolvedSessionModel { + readonly connectionId: string; readonly connectionSlug: string; readonly model: string; } @@ -187,6 +188,7 @@ export class HostSessionCatalogCoordinator { this.#readRuntimePolicy(), ]); return { + llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: model.model, permissionMode: policy.policy.chatDefaults.permissionMode, @@ -410,6 +412,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 }), @@ -525,6 +528,7 @@ export class HostSessionCatalogCoordinator { const model = await this.#resolveModel( input.configuration.modelTarget, input.configuration.thinkingLevel ?? undefined, + current.header, ); const clearsConnectionBlock = current.header.blockedReason === 'NO_REAL_CONNECTION'; if ( @@ -543,6 +547,7 @@ export class HostSessionCatalogCoordinator { expectedRevision: input.expectedRevision, configuration: { backend: 'ai-sdk', + llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: model.model, thinkingLevel: input.configuration.thinkingLevel ?? undefined, @@ -749,12 +754,55 @@ export class HostSessionCatalogCoordinator { async #resolveModel( target: SessionModelTarget, thinkingLevel: SessionCreateInput['thinkingLevel'], + existing?: Pick, ): Promise { - const selected = await this.#selectModelTarget(target); + if (existing?.llmConnectionId === undefined && existing !== undefined) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Legacy Session configuration requires an explicit account selection', + ); + } + if ( + existing !== undefined && + target.kind === 'explicit' && + target.connectionSlug !== existing.llmConnectionSlug + ) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Session account changes require an exact Connection identity', + ); + } + const selected = + existing === undefined + ? await this.#selectModelTarget(target) + : { + connectionId: existing.llmConnectionId!, + connectionSlug: existing.llmConnectionSlug, + modelId: target.kind === 'explicit' ? target.model : existing.model, + }; const readiness = await this.#runtimePolicy.operations.resolveExecutionConnection( - selected.connectionSlug, + selected.connectionId === undefined + ? { kind: 'catalog_slug', connectionSlug: selected.connectionSlug } + : { + kind: 'bound', + connectionId: selected.connectionId, + connectionSlug: selected.connectionSlug, + }, ); - if (readiness.kind === 'not_found' || readiness.kind === 'disabled') { + 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,7 +868,11 @@ 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<{ @@ -880,6 +932,7 @@ function sessionConfigurationMatches( ): boolean { return ( header.backend === 'ai-sdk' && + header.llmConnectionId === model.connectionId && header.llmConnectionSlug === model.connectionSlug && header.model === model.model && header.thinkingLevel === (configuration.thinkingLevel ?? undefined) && 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..646d901c77 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -439,6 +439,7 @@ describe('SessionManager Plan control boundaries', () => { expectedRevision: 1, configuration: { backend: child.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: child.llmConnectionSlug, connectionLocked: true, model: child.model, @@ -474,6 +475,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', @@ -586,6 +588,7 @@ describe('SessionManager graph operator provisioning', () => { expectedRevision: 1, configuration: { backend: parent.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: parent.llmConnectionSlug, connectionLocked: true, model: parent.model, @@ -2348,6 +2351,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 +2393,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 +4296,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, @@ -4429,6 +4435,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => expectedRevision: 1, configuration: { backend: session.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: session.llmConnectionSlug, connectionLocked: true, model: session.model, @@ -4480,6 +4487,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => expectedRevision: 1, configuration: { backend: session.backend, + llmConnectionId: 'test-connection-id', llmConnectionSlug: session.llmConnectionSlug, connectionLocked: true, model: 'new-model', @@ -5095,7 +5103,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 +5116,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 +16539,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..3487bf4d17 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; @@ -824,7 +826,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; @@ -2513,6 +2515,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 +3058,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 +3398,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 +3539,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 +4682,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 +4916,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 +4981,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 +6157,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; From 5bce4c2adf97406f228b6291df0696742969d1ff Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 23:16:07 +0800 Subject: [PATCH 02/10] fix(runtime-host): resolve bound session defaults --- .../session-catalog-coordinator.test.ts | 103 +++++++++++++++++- .../src/server/session-catalog-coordinator.ts | 14 ++- 2 files changed, 113 insertions(+), 4 deletions(-) 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 5f24fd3506..dc69922051 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -44,6 +44,7 @@ import { } from '@maka/storage/execution-stores'; import { SESSION_CATALOG_RESULT_MAX_BYTES, + SESSION_CATALOG_MODEL_MAX_BYTES, SESSION_CATALOG_RUNNING_TURN_MAX_ITEMS, SESSION_TURN_QUERY_RESULT_MAX_BYTES, type SessionConfigurationUpdateInput, @@ -949,6 +950,102 @@ test('configuration update never rebinds a bound Session through a reused slug', assert.equal(fixture.header().llmConnectionId, 'connection-1'); }); +test('configuration update resolves default to the current model on the bound account', async () => { + const fixture = createFixture({ + connection: { + defaultModelId: 'model-2', + enabledModelIds: ['model-1', 'model-2'], + models: [{ id: 'model-1' }, { id: 'model-2' }], + }, + }); + const input = configurationInput(fixture.sessionId, fixture.revision()); + + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + ...input, + configuration: { + ...input.configuration, + modelTarget: { kind: 'default' }, + }, + }, + context, + ); + + assert.equal(outcome.ok, true); + if (!outcome.ok || outcome.result.kind !== 'committed') { + assert.fail('Default model update did not commit'); + } + assert.equal(fixture.header().llmConnectionId, 'connection-1'); + assert.equal(fixture.header().model, 'model-2'); +}); + +test('configuration update rejects a default that moved to another Connection', async () => { + let resolutionAttempts = 0; + const fixture = createFixture({ + connection: { + connectionId: 'connection-2', + onResolve: () => { + resolutionAttempts += 1; + }, + }, + }); + const input = configurationInput(fixture.sessionId, fixture.revision()); + + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + ...input, + configuration: { + ...input.configuration, + modelTarget: { kind: 'default' }, + }, + }, + context, + ); + + assert.deepEqual(outcome, { + ok: false, + error: { + code: 'operation_conflict', + message: 'Session account changes require an exact Connection identity', + }, + }); + assert.equal(resolutionAttempts, 0); + assert.equal(fixture.header().llmConnectionId, 'connection-1'); + assert.equal(fixture.header().model, 'model-1'); +}); + +test('configuration update rejects an oversized current default instead of retaining the old model', async () => { + const oversizedModel = 'm'.repeat(SESSION_CATALOG_MODEL_MAX_BYTES + 1); + const fixture = createFixture({ + connection: { + defaultModelId: oversizedModel, + enabledModelIds: ['model-1', oversizedModel], + models: [{ id: 'model-1' }, { id: oversizedModel }], + }, + }); + const input = configurationInput(fixture.sessionId, fixture.revision()); + + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + ...input, + configuration: { + ...input.configuration, + modelTarget: { kind: 'default' }, + }, + }, + context, + ); + + assert.deepEqual(outcome, { + ok: false, + error: { + code: 'invalid_request', + message: 'Session model identifier exceeds the wire limit', + }, + }); + assert.equal(fixture.header().model, 'model-1'); +}); + test('configuration update does not adopt an account for a legacy Session', async () => { const fixture = createFixture({ legacyConnectionIdentity: true }); @@ -1417,6 +1514,8 @@ function createFixture( } type FixtureConnection = { + readonly connectionId?: string; + readonly defaultModelId?: string; readonly providerType?: | 'claude-subscription' | 'deepseek' @@ -1440,7 +1539,7 @@ type FixtureConnection = { function runtimePolicyFixture(overrides: FixtureConnection): RuntimePolicy { const policy = createDefaultRuntimePolicy(); const connection = { - connectionId: 'connection-1', + connectionId: overrides.connectionId ?? 'connection-1', revision: 1, slug: 'test', name: 'Test', @@ -1463,7 +1562,7 @@ function runtimePolicyFixture(overrides: FixtureConnection): RuntimePolicy { revision: 1, defaultTarget: { connectionId: connection.connectionId, - modelId: 'model-1', + modelId: overrides.defaultModelId ?? 'model-1', }, connections: [connection], }), diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 8c9abdcbc7..fa1e7d61a0 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -773,13 +773,23 @@ export class HostSessionCatalogCoordinator { ); } const selected = - existing === undefined + existing === undefined || target.kind === 'default' ? await this.#selectModelTarget(target) : { connectionId: existing.llmConnectionId!, connectionSlug: existing.llmConnectionSlug, - modelId: target.kind === 'explicit' ? target.model : existing.model, + modelId: target.model, }; + if ( + existing !== undefined && + (selected.connectionId !== existing.llmConnectionId || + selected.connectionSlug !== existing.llmConnectionSlug) + ) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Session account changes require an exact Connection identity', + ); + } const readiness = await this.#runtimePolicy.operations.resolveExecutionConnection( selected.connectionId === undefined ? { kind: 'catalog_slug', connectionSlug: selected.connectionSlug } From fcafe9f4df57f597fc261854fcb7f44dd72583c2 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 26 Aug 2026 23:04:55 +0800 Subject: [PATCH 03/10] feat(runtime-host): bind session model targets exactly Generated-by: Codex --- .../app-shell-first-send-cleanup.test.ts | 1 + ...app-shell-session-settings-actions.test.ts | 9 + .../__tests__/model-catalog-choices.test.ts | 16 +- .../runtime-host-bot-session-adapter.test.ts | 1 + .../runtime-host-client-operations.test.ts | 33 +- .../__tests__/runtime-host-client-uds.test.ts | 20 +- .../runtime-host-desktop-candidate.test.ts | 1 + ...me-host-external-sessions-ipc-main.test.ts | 1 + .../runtime-host-search-ipc-main.test.ts | 1 + ...time-host-session-catalog-ipc-main.test.ts | 1 + ...host-session-catalog-running-turns.test.ts | 1 + ...me-host-session-execution-ipc-main.test.ts | 1 + .../src/main/__tests__/stale-sessions.test.ts | 8 +- .../__tests__/task-readiness-notice.test.ts | 6 +- apps/desktop/src/main/onboarding-service.ts | 9 +- apps/desktop/src/main/runtime-host-client.ts | 22 +- .../main/runtime-host-connections-ipc-main.ts | 6 +- .../runtime-host-session-catalog-ipc-main.ts | 12 +- apps/desktop/src/main/session-model-input.ts | 4 +- apps/desktop/src/preload/bridge-contract.d.ts | 4 +- apps/desktop/src/preload/preload.ts | 2 +- .../src/renderer/app-shell-chat-actions.ts | 2 + .../app-shell-session-settings-actions.ts | 14 +- .../desktop/src/renderer/composer-defaults.ts | 14 +- .../src/renderer/locales/conversation-copy.ts | 14 +- .../src/renderer/session-health-notice.ts | 11 +- .../settings/general-settings-page.tsx | 6 +- .../settings/settings-snapshot-cache.ts | 4 +- .../renderer/settings/settings-surface.tsx | 4 +- .../renderer/shell-chat-model-selection.ts | 50 ++- .../src/renderer/task-readiness-notice.ts | 10 +- .../src/renderer/use-shell-chat-model.ts | 61 +++- .../src/shared/desktop-connection-snapshot.ts | 4 +- docs/windows-test-inventory.md | 5 +- .../cli/src/__tests__/pi-tui-runner.test.ts | 67 +++- .../runtime-host-run-command.test.ts | 1 + .../runtime-host-session-driver.test.ts | 117 ++++++- packages/cli/src/pi-tui-contracts.ts | 2 + packages/cli/src/pi-tui-pickers.ts | 9 +- packages/cli/src/pi-tui-runner.ts | 84 ++++- packages/cli/src/runtime-host-onboarding.ts | 1 + packages/cli/src/runtime-host-run-command.ts | 1 + .../cli/src/runtime-host-session-driver.ts | 60 ++-- packages/cli/src/runtime-host-tui-command.ts | 8 +- packages/cli/src/runtime-host-tui-context.ts | 78 ++++- packages/cli/src/session-driver.ts | 2 +- .../src/__tests__/llm-connections.test.ts | 1 + .../__tests__/session-send-projection.test.ts | 292 ++++-------------- packages/core/src/chat-model-choice.ts | 8 +- packages/core/src/llm-connections.ts | 5 + packages/core/src/session-send-projection.ts | 99 ++---- packages/eval/src/maka-subject.ts | 16 +- .../execution-model-composition.test.ts | 1 + .../hosted-execution-coordinator.test.ts | 7 +- .../__tests__/hosted-execution-runner.test.ts | 7 +- .../__tests__/hosted-execution-target.test.ts | 12 +- .../hosted-execution-tool-profile.test.ts | 7 +- .../src/__tests__/owned-candidate.test.ts | 6 +- .../src/__tests__/protocol.test.ts | 4 + .../session-catalog-coordinator.test.ts | 166 +++++----- .../session-catalog-protocol.test.ts | 54 +++- .../session-catalog-two-client-uds.test.ts | 154 +++++++-- .../session-retirement-protocol.test.ts | 1 + .../session-revision-protocol.test.ts | 1 + .../src/client/hosted-execution-target.ts | 14 +- .../src/client/hosted-execution.ts | 34 +- packages/runtime-host/src/client/index.ts | 2 +- .../src/client/session-catalog-summary.ts | 1 + packages/runtime-host/src/protocol/index.ts | 5 +- .../src/protocol/session-catalog.ts | 76 +++-- .../src/server/scheduled-task-coordinator.ts | 8 + .../src/server/session-catalog-coordinator.ts | 170 +++++----- .../src/__tests__/session-manager.test.ts | 49 +++ packages/runtime/src/session-manager.ts | 5 +- packages/ui/src/chat-model-helpers.ts | 8 + packages/ui/src/chat-model-switcher.tsx | 50 ++- packages/ui/src/chat-view.tsx | 6 +- packages/ui/src/composer.tsx | 22 +- 78 files changed, 1325 insertions(+), 754 deletions(-) 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-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/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..61acc6480f 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', }, @@ -774,6 +785,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 +868,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 +877,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 +905,7 @@ describe('Runtime Host Maka Session driver', () => { name: 'New Chat', modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'openai-main', model: 'gpt-5', }, @@ -862,6 +933,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 +980,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 +1002,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 +1034,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 +1076,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 +1109,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 +1159,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 +1313,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 +1386,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 +1462,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 +1524,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 +1573,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 +1637,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 +1668,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 +1753,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 +1885,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 +1916,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 +1956,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 +2028,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 +2077,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 +2112,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 +2145,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 +2175,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 +2209,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 +2240,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 +2281,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 +2317,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 +2359,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 +2396,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 +2438,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 +2485,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 +2544,7 @@ class FakeConnection { redacted: false, }, }; + readonly configurationOutcomes: SessionUpdateResult[] = []; readonly value: RuntimeHostMakaSessionDriverInput['connection']; constructor( @@ -2526,11 +2629,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 +2892,7 @@ function sessionProjection( hasUnread: false, status: 'active', backend: 'ai-sdk', + llmConnectionId: 'connection-1', llmConnectionSlug: 'openai-main', connectionLocked: true, model: 'gpt-5', @@ -3065,6 +3171,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 +3370,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 +3406,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 +3454,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 +3499,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 +3536,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 +3576,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 +3617,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 +3680,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..768d4a9064 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; @@ -316,6 +322,34 @@ export function safeBoundaryResumeParkedCopy(reason: TurnResumeParkReason): { default: return { level: 'error', text: `Safe-boundary resume parked: ${reason}` }; } +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 { @@ -345,6 +379,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 +397,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 +1442,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 +1533,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 +1604,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 +2747,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__/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/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/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/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__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index f7aef103d3..e28257d32c 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1185,6 +1185,7 @@ test('hosted execution freezes the headless coding provider wire contract', asyn workspace: { kind: 'host_path', path: root }, modelTarget: { kind: 'explicit', + connectionId: connection.connectionId, connectionSlug: 'profile-deepseek', model: 'deepseek-v4-flash', }, 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__/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__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 3dfc54f86d..08ffb7af08 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 > 53); + }); + 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__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index dc69922051..e5f788bac5 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -44,7 +44,6 @@ import { } from '@maka/storage/execution-stores'; import { SESSION_CATALOG_RESULT_MAX_BYTES, - SESSION_CATALOG_MODEL_MAX_BYTES, SESSION_CATALOG_RUNNING_TURN_MAX_ITEMS, SESSION_TURN_QUERY_RESULT_MAX_BYTES, type SessionConfigurationUpdateInput, @@ -586,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, @@ -625,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, ); @@ -691,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, ); @@ -739,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, ); @@ -777,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, ); @@ -809,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, @@ -899,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', }, }, @@ -950,54 +978,14 @@ test('configuration update never rebinds a bound Session through a reused slug', assert.equal(fixture.header().llmConnectionId, 'connection-1'); }); -test('configuration update resolves default to the current model on the bound account', async () => { - const fixture = createFixture({ - connection: { - defaultModelId: 'model-2', - enabledModelIds: ['model-1', 'model-2'], - models: [{ id: 'model-1' }, { id: 'model-2' }], - }, - }); - const input = configurationInput(fixture.sessionId, fixture.revision()); - - const outcome = await fixture.coordinator.handlers['session.configuration.update']( - { - ...input, - configuration: { - ...input.configuration, - modelTarget: { kind: 'default' }, - }, - }, - context, - ); - - assert.equal(outcome.ok, true); - if (!outcome.ok || outcome.result.kind !== 'committed') { - assert.fail('Default model update did not commit'); - } - assert.equal(fixture.header().llmConnectionId, 'connection-1'); - assert.equal(fixture.header().model, 'model-2'); -}); - -test('configuration update rejects a default that moved to another Connection', async () => { - let resolutionAttempts = 0; - const fixture = createFixture({ - connection: { - connectionId: 'connection-2', - onResolve: () => { - resolutionAttempts += 1; - }, - }, - }); - const input = configurationInput(fixture.sessionId, fixture.revision()); +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']( { - ...input, - configuration: { - ...input.configuration, - modelTarget: { kind: 'default' }, - }, + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { permissionMode: 'bypass' }, }, context, ); @@ -1006,47 +994,40 @@ test('configuration update rejects a default that moved to another Connection', ok: false, error: { code: 'operation_conflict', - message: 'Session account changes require an exact Connection identity', + message: 'Legacy Session configuration requires an explicit account selection', }, }); - assert.equal(resolutionAttempts, 0); - assert.equal(fixture.header().llmConnectionId, 'connection-1'); - assert.equal(fixture.header().model, 'model-1'); + assert.equal(fixture.header().llmConnectionId, undefined); + assert.notEqual(fixture.header().permissionMode, 'bypass'); }); -test('configuration update rejects an oversized current default instead of retaining the old model', async () => { - const oversizedModel = 'm'.repeat(SESSION_CATALOG_MODEL_MAX_BYTES + 1); +test('only an explicit exact target recovers a legacy Session account binding', async () => { + let clearConnectionBlock: boolean | undefined; const fixture = createFixture({ - connection: { - defaultModelId: oversizedModel, - enabledModelIds: ['model-1', oversizedModel], - models: [{ id: 'model-1' }, { id: oversizedModel }], + 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 input = configurationInput(fixture.sessionId, fixture.revision()); const outcome = await fixture.coordinator.handlers['session.configuration.update']( - { - ...input, - configuration: { - ...input.configuration, - modelTarget: { kind: 'default' }, - }, - }, + configurationInput(fixture.sessionId, fixture.revision()), context, ); - assert.deepEqual(outcome, { - ok: false, - error: { - code: 'invalid_request', - message: 'Session model identifier exceeds the wire limit', - }, - }); - assert.equal(fixture.header().model, 'model-1'); + assert.equal(outcome.ok, true); + assert.equal(clearConnectionBlock, true); }); -test('configuration update does not adopt an account for a legacy Session', async () => { +test('explicit recovery persists the selected Connection entity identity', async () => { const fixture = createFixture({ legacyConnectionIdentity: true }); const outcome = await fixture.coordinator.handlers['session.configuration.update']( @@ -1054,14 +1035,10 @@ test('configuration update does not adopt an account for a legacy Session', asyn 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.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 () => { @@ -1419,11 +1396,13 @@ function createFixture( 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; @@ -1514,8 +1493,6 @@ function createFixture( } type FixtureConnection = { - readonly connectionId?: string; - readonly defaultModelId?: string; readonly providerType?: | 'claude-subscription' | 'deepseek' @@ -1539,7 +1516,7 @@ type FixtureConnection = { function runtimePolicyFixture(overrides: FixtureConnection): RuntimePolicy { const policy = createDefaultRuntimePolicy(); const connection = { - connectionId: overrides.connectionId ?? 'connection-1', + connectionId: 'connection-1', revision: 1, slug: 'test', name: 'Test', @@ -1562,7 +1539,7 @@ function runtimePolicyFixture(overrides: FixtureConnection): RuntimePolicy { revision: 1, defaultTarget: { connectionId: connection.connectionId, - modelId: overrides.defaultModelId ?? 'model-1', + modelId: 'model-1', }, connections: [connection], }), @@ -1593,9 +1570,10 @@ function configurationInput( return { sessionId, expectedRevision, - configuration: { + patch: { modelTarget: { kind: 'explicit', + connectionId: 'connection-1', connectionSlug: 'test', 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 821d4f3747..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, 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-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/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/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts index 10718225d1..9170807790 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -628,6 +628,13 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority if (!isSessionNotFoundError(error) && !isMissingRecord(error)) throw error; } const execution = task.effect.execution; + const catalog = await this.#runtimePolicy.connectionCatalog.getSnapshot(); + const connection = catalog.connections.find( + (candidate) => candidate.slug === execution.llmConnectionSlug, + ); + if (!connection) { + throw new Error('ScheduledTask model connection does not exist'); + } await this.#createSession({ sessionId: identity.sessionId, workspace: @@ -638,6 +645,7 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority labels: ['scheduled-task'], modelTarget: { kind: 'explicit', + connectionId: connection.connectionId, connectionSlug: execution.llmConnectionSlug, model: execution.model, }, diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index fa1e7d61a0..e885381950 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' @@ -525,16 +537,11 @@ export class HostSessionCatalogCoordinator { ); } - const model = await this.#resolveModel( - input.configuration.modelTarget, - input.configuration.thinkingLevel ?? undefined, - current.header, - ); - 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( @@ -545,17 +552,8 @@ export class HostSessionCatalogCoordinator { commitAttempted = true; await this.#manager.transitionSessionConfiguration(input.sessionId, { expectedRevision: input.expectedRevision, - configuration: { - backend: 'ai-sdk', - llmConnectionId: model.connectionId, - 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) { @@ -754,51 +752,13 @@ export class HostSessionCatalogCoordinator { async #resolveModel( target: SessionModelTarget, thinkingLevel: SessionCreateInput['thinkingLevel'], - existing?: Pick, ): Promise { - if (existing?.llmConnectionId === undefined && existing !== undefined) { - throw new SessionOperationFailure( - 'operation_conflict', - 'Legacy Session configuration requires an explicit account selection', - ); - } - if ( - existing !== undefined && - target.kind === 'explicit' && - target.connectionSlug !== existing.llmConnectionSlug - ) { - throw new SessionOperationFailure( - 'operation_conflict', - 'Session account changes require an exact Connection identity', - ); - } - const selected = - existing === undefined || target.kind === 'default' - ? await this.#selectModelTarget(target) - : { - connectionId: existing.llmConnectionId!, - connectionSlug: existing.llmConnectionSlug, - modelId: target.model, - }; - if ( - existing !== undefined && - (selected.connectionId !== existing.llmConnectionId || - selected.connectionSlug !== existing.llmConnectionSlug) - ) { - throw new SessionOperationFailure( - 'operation_conflict', - 'Session account changes require an exact Connection identity', - ); - } - const readiness = await this.#runtimePolicy.operations.resolveExecutionConnection( - selected.connectionId === undefined - ? { kind: 'catalog_slug', connectionSlug: selected.connectionSlug } - : { - kind: 'bound', - connectionId: selected.connectionId, - connectionSlug: selected.connectionSlug, - }, - ); + const selected = await this.#selectModelTarget(target); + 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') @@ -887,11 +847,12 @@ export class HostSessionCatalogCoordinator { 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, }; @@ -924,6 +885,62 @@ export class HostSessionCatalogCoordinator { }; } + async #mergeConfigurationPatch( + current: SessionHeader, + patch: SessionConfigurationUpdateInput['patch'], + ): Promise { + if (current.llmConnectionId === undefined && patch.modelTarget === undefined) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Legacy Session configuration requires an explicit account selection', + ); + } + const thinkingLevel = + patch.thinkingLevel === undefined + ? current.thinkingLevel + : (patch.thinkingLevel ?? undefined); + let model: { + readonly connectionId?: string; + readonly connectionSlug: string; + readonly model: string; + } = { + ...(current.llmConnectionId === undefined ? {} : { connectionId: current.llmConnectionId }), + connectionSlug: current.llmConnectionSlug, + model: current.model, + }; + if (patch.modelTarget !== undefined) { + model = await this.#resolveModel(patch.modelTarget, thinkingLevel); + } else if (patch.thinkingLevel !== undefined) { + const connectionId = current.llmConnectionId; + if (connectionId === undefined) { + throw new SessionOperationFailure( + 'operation_conflict', + 'Legacy Session configuration requires an explicit account selection', + ); + } + model = await this.#resolveModel( + { + kind: 'explicit', + connectionId, + connectionSlug: current.llmConnectionSlug, + model: current.model, + }, + thinkingLevel, + ); + } + return { + backend: 'ai-sdk', + ...(model.connectionId === undefined ? {} : { llmConnectionId: model.connectionId }), + llmConnectionSlug: model.connectionSlug, + model: model.model, + thinkingLevel, + connectionLocked: patch.modelTarget === undefined ? current.connectionLocked : true, + permissionMode: patch.permissionMode ?? current.permissionMode, + collaborationMode: patch.collaborationMode ?? current.collaborationMode ?? 'agent', + orchestrationMode: patch.orchestrationMode ?? current.orchestrationMode ?? 'default', + }; + } + async #readRuntimePolicy(): Promise< Awaited> > { @@ -937,16 +954,15 @@ export class HostSessionCatalogCoordinator { function sessionConfigurationMatches( header: SessionHeader, - model: ResolvedSessionModel, - configuration: SessionConfigurationUpdateInput['configuration'], + configuration: ResolvedSessionConfiguration, ): boolean { return ( header.backend === 'ai-sdk' && - header.llmConnectionId === model.connectionId && - 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 @@ -997,7 +1013,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'], @@ -1070,6 +1091,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/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 646d901c77..794572efb0 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -437,6 +437,7 @@ describe('SessionManager Plan control boundaries', () => { await assert.rejects( manager.transitionSessionConfiguration(child.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: { backend: child.backend, llmConnectionId: 'test-connection-id', @@ -586,6 +587,7 @@ describe('SessionManager graph operator provisioning', () => { const transition = manager .transitionSessionConfiguration(parent.id, { expectedRevision: 1, + clearConnectionBlock: false, configuration: { backend: parent.backend, llmConnectionId: 'test-connection-id', @@ -4310,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) => { @@ -4323,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); @@ -4332,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', @@ -4346,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(); @@ -4433,6 +4480,7 @@ 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', @@ -4485,6 +4533,7 @@ 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', diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 3487bf4d17..d9d31e06bf 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -574,7 +574,7 @@ export interface SessionConfigurationStoreUpdate { readonly expectedVersion: number; readonly configuration: { readonly backend: SessionHeader['backend']; - readonly llmConnectionId: string; + readonly llmConnectionId?: string; readonly llmConnectionSlug: string; readonly connectionLocked: boolean; readonly model: string; @@ -591,6 +591,7 @@ export interface SessionConfigurationStoreUpdate { export interface SessionConfigurationTransitionRequest { readonly expectedRevision: number; + readonly clearConnectionBlock: boolean; readonly configuration: Omit; } @@ -1177,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(), 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} From ec6c03965df628cd4d63752971304402c13d62ee Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 27 Aug 2026 18:33:38 +0800 Subject: [PATCH 04/10] test(desktop,ui): identify storybook connection fixtures ChatModelChoice and DesktopConnectionSnapshot now require immutable Connection identity; carry connectionId through the story fixtures so the storybook typecheck matches the bound session identity contract. --- apps/desktop/stories/app-shell.stories.tsx | 8 +++++--- apps/desktop/stories/composer-message-queue.stories.tsx | 1 + .../stories/settings/provider-settings.stories.tsx | 8 +++++--- apps/desktop/stories/settings/settings-pages.stories.tsx | 7 ++++--- packages/ui/stories/attachment.stories.tsx | 2 +- packages/ui/stories/model-picker.stories.tsx | 2 +- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 686d364a33..c8b200fd30 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', @@ -780,7 +782,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 +798,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 +823,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..e152b4e208 100644 --- a/apps/desktop/stories/composer-message-queue.stories.tsx +++ b/apps/desktop/stories/composer-message-queue.stories.tsx @@ -62,6 +62,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/packages/ui/stories/attachment.stories.tsx b/packages/ui/stories/attachment.stories.tsx index 07f585cdd7..e8989808e4 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() { diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index fe2418a19a..a3e4c02425 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -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[] = [ From 630e9d889ede4d324194d4beb43693e277a4bce3 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 27 Aug 2026 19:22:15 +0800 Subject: [PATCH 05/10] test(desktop): expect Connection identity in the Host connections projection The Host default-target projection now carries each Connection's immutable connectionId (the same IdentifiedLlmConnection contract the rest of the suite asserts); the without-inventing-a-second-authority expectation was the one row left comparing the pre-identity shape. --- .../src/main/__tests__/runtime-host-connections-ipc-main.test.ts | 1 + 1 file changed, 1 insertion(+) 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', From 0fb09780d6bf1f9ce2fe447a4d17c722a8d5a9af Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 28 Aug 2026 01:17:52 +0800 Subject: [PATCH 06/10] test: keep Session identity fixtures exact --- apps/desktop/stories/app-shell.stories.tsx | 1 + .../composer-message-queue.stories.tsx | 1 + .../src/__tests__/protocol.test.ts | 2 +- packages/ui/stories/attachment.stories.tsx | 1 + packages/ui/stories/model-picker.stories.tsx | 48 ++++++++++++++----- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index c8b200fd30..f68438570b 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -108,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', diff --git a/apps/desktop/stories/composer-message-queue.stories.tsx b/apps/desktop/stories/composer-message-queue.stories.tsx index e152b4e208..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', diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 08ffb7af08..ce859d7099 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -324,7 +324,7 @@ describe('Runtime Host bootstrap protocol', () => { }); test('publishes a new compatibility epoch for exact Session Connection identity', () => { - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 53); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 55); }); test('selects the highest mutually supported protocol and rejects a gap', () => { diff --git a/packages/ui/stories/attachment.stories.tsx b/packages/ui/stories/attachment.stories.tsx index e8989808e4..f49aca2cb5 100644 --- a/packages/ui/stories/attachment.stories.tsx +++ b/packages/ui/stories/attachment.stories.tsx @@ -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 a3e4c02425..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'; @@ -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)); + }} /> Date: Fri, 28 Aug 2026 07:57:52 +0800 Subject: [PATCH 07/10] fix(runtime-host): block legacy Session execution --- .../src/__tests__/context-coordinator.test.ts | 6 ++- .../host-session-availability.test.ts | 46 +++++++++++++++++++ .../src/__tests__/protocol.test.ts | 2 +- .../__tests__/root-turn-coordinator.test.ts | 42 ++++++++++++++++- .../src/server/host-session-availability.ts | 29 ++++++++++-- 5 files changed, 119 insertions(+), 6 deletions(-) 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__/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__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index ce859d7099..42e611cd6b 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -324,7 +324,7 @@ describe('Runtime Host bootstrap protocol', () => { }); test('publishes a new compatibility epoch for exact Session Connection identity', () => { - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 55); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); }); test('selects the highest mutually supported protocol and rejects a gap', () => { 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 a8be91cec6..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; @@ -4902,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; @@ -4940,7 +4978,9 @@ async function createFailureFixture(options: { await artifacts?.recover(); const session = await stores.sessionStore.create({ cwd: capability.canonicalPath, - llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + ...(options.legacyConnectionIdentity + ? {} + : { llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' }), llmConnectionSlug: 'fake', model: 'fake-model', permissionMode: 'ask', diff --git a/packages/runtime-host/src/server/host-session-availability.ts b/packages/runtime-host/src/server/host-session-availability.ts index 88c7b2e1fa..66661093ff 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' + > & { readonly backend?: SessionHeader['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,8 +74,14 @@ export function runtimeHostSafeBoundaryContinuationUnavailableReason( export function runtimeHostExecutionUnavailableReason( header: Pick< SessionHeader, - 'id' | 'role' | 'collaborationMode' | 'subagentWorkspace' | 'transcriptLedgerVersion' + | 'id' + | 'role' + | 'collaborationMode' + | 'subagentWorkspace' + | 'transcriptLedgerVersion' + | 'llmConnectionId' > & { + readonly backend?: SessionHeader['backend']; readonly toolProfile?: SessionToolProfile; readonly permissionMode?: SessionHeader['permissionMode']; readonly orchestrationMode?: SessionHeader['orchestrationMode']; @@ -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' && From aad359542f0700afe34c3ff5be31902c638ad851 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 28 Aug 2026 08:55:39 +0800 Subject: [PATCH 08/10] test(runtime-host): bind execution fixtures to connections --- .../src/__tests__/execution-model-composition.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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 e28257d32c..ecfba7fad1 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -321,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', @@ -1372,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', @@ -1688,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', @@ -1887,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', @@ -2084,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', @@ -2338,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', @@ -2415,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', From 91ce5bebb1f73d3d692e7d94c7a2ea2f98292663 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 28 Aug 2026 14:25:06 +0800 Subject: [PATCH 09/10] refactor(runtime-host): tighten session identity guards --- .../src/__tests__/runtime-host-session-driver.test.ts | 9 +++++++++ packages/cli/src/pi-tui-runner.ts | 2 ++ .../runtime-host/src/server/host-session-availability.ts | 6 +++--- .../src/server/session-catalog-coordinator.ts | 8 +------- 4 files changed, 15 insertions(+), 10 deletions(-) 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 61acc6480f..571afe359e 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -408,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: (() => { @@ -449,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: (() => { @@ -521,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'), @@ -559,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'), @@ -599,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'), @@ -636,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'), @@ -685,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'), @@ -728,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'), @@ -765,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'), diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 768d4a9064..d5db8c0568 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -322,6 +322,8 @@ export function safeBoundaryResumeParkedCopy(reason: TurnResumeParkReason): { default: return { level: 'error', text: `Safe-boundary resume parked: ${reason}` }; } +} + function sessionConnectionIdentityNotice( session: Pick, identities: MakaPiTuiInput['connectionIdentities'], diff --git a/packages/runtime-host/src/server/host-session-availability.ts b/packages/runtime-host/src/server/host-session-availability.ts index 66661093ff..2fced24a0b 100644 --- a/packages/runtime-host/src/server/host-session-availability.ts +++ b/packages/runtime-host/src/server/host-session-availability.ts @@ -56,8 +56,8 @@ export function runtimeHostExternalTurnUnavailableReason( export function runtimeHostSafeBoundaryContinuationUnavailableReason( header: Pick< SessionHeader, - 'id' | 'role' | 'subagentParent' | 'transcriptLedgerVersion' | 'llmConnectionId' - > & { readonly backend?: SessionHeader['backend'] }, + 'id' | 'role' | 'subagentParent' | 'transcriptLedgerVersion' | 'llmConnectionId' | 'backend' + >, ): string | undefined { return ( (isWorkHubCoordinationSessionTarget(header) @@ -80,8 +80,8 @@ export function runtimeHostExecutionUnavailableReason( | 'subagentWorkspace' | 'transcriptLedgerVersion' | 'llmConnectionId' + | 'backend' > & { - readonly backend?: SessionHeader['backend']; readonly toolProfile?: SessionToolProfile; readonly permissionMode?: SessionHeader['permissionMode']; readonly orchestrationMode?: SessionHeader['orchestrationMode']; diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index e885381950..e20e9f6be4 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -910,14 +910,8 @@ export class HostSessionCatalogCoordinator { }; if (patch.modelTarget !== undefined) { model = await this.#resolveModel(patch.modelTarget, thinkingLevel); - } else if (patch.thinkingLevel !== undefined) { + } else if (patch.thinkingLevel !== undefined && current.llmConnectionId !== undefined) { const connectionId = current.llmConnectionId; - if (connectionId === undefined) { - throw new SessionOperationFailure( - 'operation_conflict', - 'Legacy Session configuration requires an explicit account selection', - ); - } model = await this.#resolveModel( { kind: 'explicit', From 00eb09e95e33532a1fb80e505aa1190af96cae59 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 28 Aug 2026 21:03:40 +0800 Subject: [PATCH 10/10] fix(runtime-host): block slug-only scheduled agent runs --- .../src/__tests__/execution-host.test.ts | 50 +++++++++++++++++++ .../src/server/scheduled-task-coordinator.ts | 9 ++++ 2 files changed, 59 insertions(+) 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/server/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts index 9170807790..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 = {