Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-host/src/protocol/connection-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions packages/runtime/src/__tests__/provider-contract-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,39 @@ async function runGitHubCopilotDiscovery(): Promise<void> {
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('null-policy', ['/chat/completions']),
policy: null,
},
{
...copilotModel('malformed-policy', ['/chat/completions']),
policy: 'enabled',
},
{
...copilotModel('hidden-from-picker', ['/chat/completions']),
model_picker_enabled: false,
Expand Down Expand Up @@ -407,6 +436,14 @@ async function runGitHubCopilotDiscovery(): Promise<void> {
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 },
},
]);
}

Expand Down
13 changes: 11 additions & 2 deletions packages/runtime/src/model-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -526,11 +526,20 @@ function contextWindowOfOpenAiCodexModel(model: RawOpenAiCodexModel): number | u
}

function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] {
if (model.policy !== undefined) {
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' ||
!model.id ||
model.model_picker_enabled !== true ||
model.policy?.state === 'disabled' ||
model.capabilities?.supports?.tool_calls !== true
)
return [];
Expand Down
123 changes: 123 additions & 0 deletions packages/storage/src/__tests__/runtime-policy-model-facts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -259,6 +260,128 @@ 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('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;
Expand Down
Loading