From e7c3d7139eecf5e1ccc8a4ff8e630ba8b8b91af3 Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Fri, 4 Sep 2026 17:01:02 +0800 Subject: [PATCH 1/3] fix: tighten github copilot model discovery Signed-off-by: seekskyworld Generated-by: Codex --- .../__tests__/provider-contract-overrides.ts | 81 +++++++++++++------ packages/runtime/src/model-fetcher.ts | 4 +- .../runtime-policy-model-facts.test.ts | 80 ++++++++++++++++++ .../connection-catalog-document.ts | 27 ++++++- 4 files changed, 161 insertions(+), 31 deletions(-) diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index ca7c049a2f..db3afa655d 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -351,10 +351,31 @@ async function runGitHubCopilotDiscovery(): Promise { copilotModel('gpt-5.4', ['/responses']), copilotModel('claude-sonnet-4.6', ['/v1/messages']), copilotModel('gemini-3.1-pro-preview', ['/chat/completions']), + { + id: 'policy-free', + name: 'policy-free display', + model_picker_enabled: true, + supported_endpoints: ['/chat/completions'], + capabilities: { + limits: { + max_prompt_tokens: 400_000, + max_output_tokens: 128_000, + }, + supports: { + tool_calls: true, + vision: true, + reasoning_effort: ['low', 'medium', 'high'], + }, + }, + }, { ...copilotModel('disabled-by-policy', ['/chat/completions']), policy: { state: 'disabled' }, }, + { + ...copilotModel('not-configured', ['/chat/completions']), + policy: { state: 'unconfigured' }, + }, { ...copilotModel('hidden-from-picker', ['/chat/completions']), model_picker_enabled: false, @@ -383,32 +404,40 @@ async function runGitHubCopilotDiscovery(): Promise { ); assert.deepEqual(models, [ - { - id: 'gpt-5.4', - displayName: 'gpt-5.4 display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'openai-responses', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - { - id: 'claude-sonnet-4.6', - displayName: 'claude-sonnet-4.6 display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'anthropic-messages', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - { - id: 'gemini-3.1-pro-preview', - displayName: 'gemini-3.1-pro-preview display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'openai-chat', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - ]); -} + { + id: 'gpt-5.4', + displayName: 'gpt-5.4 display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-responses', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'claude-sonnet-4.6', + displayName: 'claude-sonnet-4.6 display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'anthropic-messages', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'gemini-3.1-pro-preview', + displayName: 'gemini-3.1-pro-preview display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-chat', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'policy-free', + displayName: 'policy-free display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-chat', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + ]); + } function copilotModel(id: string, supportedEndpoints: string[]): Record { return { diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index 7a24292cf8..ad6224e7b5 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -526,11 +526,13 @@ function contextWindowOfOpenAiCodexModel(model: RawOpenAiCodexModel): number | u } function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] { + if (model.policy !== undefined) { + if (model.policy.state !== 'enabled') return []; + } if ( typeof model.id !== 'string' || !model.id || model.model_picker_enabled !== true || - model.policy?.state === 'disabled' || model.capabilities?.supports?.tool_calls !== true ) return []; diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 5788a95ecd..4c53504cfb 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { RuntimePolicyCoordinator } from '../runtime-policy/coordinator.js'; +import { ConnectionCatalogDocumentOwner } from '../runtime-policy/connection-catalog-document.js'; test('runtime policy catalog overlays enabled custom model facts without changing the raw catalog', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-')); @@ -259,6 +260,85 @@ test('model fetch keeps enabled facts-backed models when provider inventory fill } }); +test('github copilot model fetch prunes fallback ids outside the live catalog', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-refresh-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0de'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback'], + models: [{ id: 'copilot-fallback' }], + modelSource: 'fallback' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [{ id: 'live-model' }], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, []); + assert.deepEqual(projected?.models.map((model) => model.id), ['live-model']); + assert.equal(refreshed.defaultTarget, null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github copilot model fetch clears a withdrawn default without picking a replacement', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-default-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0df'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback', 'retained-live'], + models: [{ id: 'copilot-fallback' }, { id: 'retained-live' }], + modelSource: 'fallback' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [{ id: 'retained-live' }], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, ['retained-live']); + assert.equal(refreshed.defaultTarget, null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('protocol model facts edits clear verification, supersede tickets, and warn on malformed input', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-external-edit-')); const emitWarning = process.emitWarning; diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 2ff3568290..df6b052cf0 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -471,13 +471,32 @@ export class ConnectionCatalogDocumentOwner { aliases: modelIdAliasesForProvider(previous.providerType), }, ); + // GitHub Copilot 的 `/models` 是账户当前可用目录,刷新时不能把旧 + // fallback 选择再次保留下来,否则选择器会回流不可用模型。 + const isGitHubCopilot = previous.providerType === 'github-copilot'; + const liveModelIds = isGitHubCopilot ? new Set(result.models.map(({ id }) => id)) : undefined; + const enabledModelIds = isGitHubCopilot + ? reconciled.enabledModelIds.filter((modelId) => liveModelIds?.has(modelId)) + : reconciled.enabledModelIds; + const defaultModel = + isGitHubCopilot && liveModelIds && !liveModelIds.has(reconciled.defaultModel) + ? '' + : reconciled.defaultModel; + // 目录里没有的 default 只应被清掉,不应顺手换成另一个 live 模型。 + // Copilot 的可用集是账户当前事实,不是可自动补位的偏好列表。 // Discovery MOVES a target: a provider's model rename carries the default // across by alias. A default outside the selection the reconciler just // decided is its own bug — fail closed where it is still attributable. const defaultTarget = currentDefaultTarget - ? { connectionId: previous.connectionId, modelId: reconciled.defaultModel } + ? defaultModel + ? { connectionId: previous.connectionId, modelId: defaultModel } + : null : current.defaultTarget; - if (currentDefaultTarget && !reconciled.enabledModelIds.includes(reconciled.defaultModel)) { + if ( + currentDefaultTarget && + !isGitHubCopilot && + !reconciled.enabledModelIds.includes(reconciled.defaultModel) + ) { throw codecError( 'invalid_document', 'Model discovery reconciled a default outside its own selection', @@ -489,14 +508,14 @@ export class ConnectionCatalogDocumentOwner { // next read. const relayModelProfiles = pruneRelayModelProfiles( previous.relayModelProfiles, - reconciled.enabledModelIds, + enabledModelIds, ); const { relayModelProfiles: _staleProfiles, ...previousWithoutProfiles } = previous; const discovered: ConnectionCatalogEntry = { ...previousWithoutProfiles, ...(relayModelProfiles ? { relayModelProfiles } : {}), revision: nextRevision(previous.revision), - enabledModelIds: reconciled.enabledModelIds, + enabledModelIds, models: result.models, modelSource: result.source, modelsFetchedAt: result.fetchedAt, From 5c5289e07161e50695ff619263c3faee275f690e Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Mon, 7 Sep 2026 11:23:51 +0800 Subject: [PATCH 2/3] fix(copilot): persist authoritative empty model catalogs Signed-off-by: seekskyworld --- .../connection-effect-coordinator.test.ts | 62 +++++++++++++++ .../connection-effects-protocol.test.ts | 5 ++ .../src/protocol/connection-effects.ts | 2 +- .../server/connection-effect-coordinator.ts | 7 +- .../__tests__/provider-contract-overrides.ts | 76 ++++++++++--------- packages/runtime/src/model-fetcher.ts | 11 ++- .../runtime-policy-model-facts.test.ts | 45 ++++++++++- .../connection-catalog-document.ts | 8 +- 8 files changed, 174 insertions(+), 42 deletions(-) diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index bffa2311b1..cee991eab8 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -1499,6 +1499,68 @@ test('provider discovery failure preserves the existing catalog and returns no s }); }); +test('commits an authoritative empty GitHub Copilot catalog', async () => { + await withFixture(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('copilot-empty', 'github-copilot'), + ); + const storedCredential = serializeOAuthSubscriptionTokens({ + access_token: 'gho_copilot_empty', + refresh_token: 'ghr_copilot_empty', + expires_at: Number.MAX_SAFE_INTEGER, + token_type: 'Bearer', + base_url: 'https://api.githubcopilot.com', + }); + const enrollment = await stores.operations.beginInteractiveOAuthLogin({ + attemptId: 'connection-effect-copilot-empty', + target: { kind: 'existing', connectionId: connection.connectionId }, + }); + assert.equal(enrollment.kind, 'ready'); + if (enrollment.kind !== 'ready') throw new Error('OAuth enrollment did not start'); + const credential = await stores.operations.completeInteractiveOAuthLogin( + enrollment.ticket, + storedCredential, + ); + assert.equal(credential.kind, 'committed'); + + const coordinator = new HostConnectionEffectCoordinator({ + stores, + activation: new RuntimePolicyActivationGate(), + oauthCredentials: new HostOAuthExecutionAuthority(stores), + now: () => 123, + createTransport: () => recordingTransport(() => undefined), + runModelDiscovery: async () => ({ ok: true, models: [] }), + }); + + const result = await coordinator.handlers['connection.models.fetch']( + { connectionId: connection.connectionId }, + context, + ); + assert.deepEqual(result, { + ok: true, + result: { + kind: 'committed', + catalogRevision: 2, + connection: { connectionId: connection.connectionId, revision: 2 }, + modelCount: 0, + source: 'fetched', + fetchedAt: 123, + }, + }); + + const snapshot = await stores.connectionCatalog.getSnapshot(); + const updated = snapshot.connections.find( + ({ connectionId }) => connectionId === connection.connectionId, + ); + assert.ok(updated); + assert.deepEqual(updated.models, []); + assert.deepEqual(updated.enabledModelIds, []); + assert.equal(snapshot.defaultTarget, null); + }); +}); + test('OAuth connection effects resolve the canonical access token instead of sending the vault payload', async () => { await withFixture(async ({ stores }) => { const connection = await createConnection( diff --git a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts index dd17dec549..e239b9d81f 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -203,6 +203,11 @@ describe('Runtime Host connection effects protocol', () => { }; const committed = response('connection.models.fetch', committedResult); assert.deepEqual(decodeHostFrame(committed), committed); + const emptyCommitted = response('connection.models.fetch', { + ...committedResult, + modelCount: 0, + }); + assert.deepEqual(decodeHostFrame(emptyCommitted), emptyCommitted); for (const result of [ { kind: 'failed', errorClass: 'timeout' }, diff --git a/packages/runtime-host/src/protocol/connection-effects.ts b/packages/runtime-host/src/protocol/connection-effects.ts index c57656f9ba..8d1a0e7646 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -467,7 +467,7 @@ export function decodeConnectionModelFetchResult(value: unknown): ConnectionMode modelCount: boundedInteger( committed.modelCount, 'model count', - 1, + 0, CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION, ), source: modelDiscoverySource(committed.source), diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index c1ca8a60f4..373f6f278d 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -152,7 +152,12 @@ export class HostConnectionEffectCoordinator { const effect = await this.#withTransport(prepared, (fetch, secret) => this.#runModelDiscovery(prepared.connection, secret, { fetch }), ); - if (!effect.ok || effect.models.length === 0) { + // Copilot's account catalog is authoritative: an empty successful + // response means the account currently has no selectable models. + if ( + !effect.ok || + (effect.models.length === 0 && prepared.connection.providerType !== 'github-copilot') + ) { return { kind: 'failed', errorClass: effect.ok ? 'invalid_response' : effect.error.kind, diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index db3afa655d..404e1143ca 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -376,6 +376,14 @@ async function runGitHubCopilotDiscovery(): Promise { ...copilotModel('not-configured', ['/chat/completions']), policy: { state: 'unconfigured' }, }, + { + ...copilotModel('null-policy', ['/chat/completions']), + policy: null, + }, + { + ...copilotModel('malformed-policy', ['/chat/completions']), + policy: 'enabled', + }, { ...copilotModel('hidden-from-picker', ['/chat/completions']), model_picker_enabled: false, @@ -404,40 +412,40 @@ async function runGitHubCopilotDiscovery(): Promise { ); assert.deepEqual(models, [ - { - id: 'gpt-5.4', - displayName: 'gpt-5.4 display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'openai-responses', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - { - id: 'claude-sonnet-4.6', - displayName: 'claude-sonnet-4.6 display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'anthropic-messages', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - { - id: 'gemini-3.1-pro-preview', - displayName: 'gemini-3.1-pro-preview display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'openai-chat', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - { - id: 'policy-free', - displayName: 'policy-free display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'openai-chat', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - ]); - } + { + id: 'gpt-5.4', + displayName: 'gpt-5.4 display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-responses', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'claude-sonnet-4.6', + displayName: 'claude-sonnet-4.6 display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'anthropic-messages', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'gemini-3.1-pro-preview', + displayName: 'gemini-3.1-pro-preview display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-chat', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'policy-free', + displayName: 'policy-free display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-chat', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + ]); +} function copilotModel(id: string, supportedEndpoints: string[]): Record { return { diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index ad6224e7b5..ddbad0512a 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -114,7 +114,7 @@ type RawGitHubCopilotModel = { name?: string; model_picker_enabled?: boolean; supported_endpoints?: string[]; - policy?: { state?: string }; + policy?: unknown; capabilities?: { limits?: { max_context_window_tokens?: number; @@ -527,7 +527,14 @@ function contextWindowOfOpenAiCodexModel(model: RawOpenAiCodexModel): number | u function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] { if (model.policy !== undefined) { - if (model.policy.state !== 'enabled') return []; + if ( + model.policy === null || + typeof model.policy !== 'object' || + Array.isArray(model.policy) || + (model.policy as { state?: unknown }).state !== 'enabled' + ) { + return []; + } } if ( typeof model.id !== 'string' || diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 4c53504cfb..66b3aa110b 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -293,7 +293,10 @@ test('github copilot model fetch prunes fallback ids outside the live catalog', const projected = refreshed.connections[0]; assert.deepEqual(projected?.enabledModelIds, []); - assert.deepEqual(projected?.models.map((model) => model.id), ['live-model']); + assert.deepEqual( + projected?.models.map((model) => model.id), + ['live-model'], + ); assert.equal(refreshed.defaultTarget, null); } finally { await rm(root, { recursive: true, force: true }); @@ -339,6 +342,46 @@ test('github copilot model fetch clears a withdrawn default without picking a re } }); +test('github copilot model fetch commits an authoritative empty catalog', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-empty-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0e0'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback'], + models: [{ id: 'copilot-fallback' }], + modelSource: 'fallback' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, []); + assert.deepEqual(projected?.models, []); + assert.equal(refreshed.defaultTarget, null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('protocol model facts edits clear verification, supersede tickets, and warn on malformed input', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-external-edit-')); const emitWarning = process.emitWarning; diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index df6b052cf0..1282243ad5 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -444,14 +444,16 @@ export class ConnectionCatalogDocumentOwner { rawResult: ConnectionModelDiscoveryResult, ): Promise { const result = decodeConnectionInput(() => normalizeConnectionModelDiscoveryResult(rawResult)); - if (result.models.length === 0) { - throw codecError('invalid_connection_input', 'Model discovery result must not be empty'); - } const index = findConnectionIndex(current, expected); const previous = current.connections[index]; if (!previous || previous.revision !== expected.revision) { throw codecError('invalid_document', 'Coordinator admitted a stale model discovery result'); } + // Copilot's account inventory can be authoritative even when empty; every + // other provider still needs a non-empty discovery result to commit. + if (result.models.length === 0 && previous.providerType !== 'github-copilot') { + throw codecError('invalid_connection_input', 'Model discovery result must not be empty'); + } const currentDefaultTarget = current.defaultTarget?.connectionId === previous.connectionId ? current.defaultTarget From 0aab913765d8c7452e816d85a6fa3e4e63e7cf2c Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Mon, 7 Sep 2026 12:50:21 +0800 Subject: [PATCH 3/3] fix(runtime-host): advance protocol epoch for empty catalogs Signed-off-by: seekskyworld --- packages/runtime-host/src/protocol/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 20fe84108d..16b6ce682c 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,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 = 130 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 131 as const; +// 131: GitHub Copilot model discovery may commit an authoritative empty +// catalog, so `connection.models.fetch` accepts `modelCount: 0`. Older peers +// reject that frame because their decoder requires at least one model. // 130: Turn contributions carry the optional bounded `failureMessage` diagnostic. // Epoch-129 peers reject this added field on the strict contribution shape. // 129: Turn states and Turn records drop `partialOutputRetained`. The fact was