源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ProviderListResponse } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { ProviderListItem } from "../src/app/types";
|
||||
import {
|
||||
computeModelAvailability,
|
||||
createUnavailableConfirmationGate,
|
||||
type ModelAvailability,
|
||||
type ModelAvailabilityContext,
|
||||
} from "../src/react-app/domains/session/surface/model-availability";
|
||||
|
||||
function provider(id: string, modelIds: string[]): ProviderListItem {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
source: "config",
|
||||
env: [],
|
||||
models: Object.fromEntries(modelIds.map((modelId) => [modelId, { name: modelId }])),
|
||||
} as unknown as ProviderListItem;
|
||||
}
|
||||
|
||||
function catalog(providers: Array<{ id: string; models: string[] }>): ProviderListResponse {
|
||||
return {
|
||||
all: providers.map((entry) => provider(entry.id, entry.models)),
|
||||
connected: providers.map((entry) => entry.id),
|
||||
default: {},
|
||||
} as unknown as ProviderListResponse;
|
||||
}
|
||||
|
||||
function context(overrides: Partial<ModelAvailabilityContext> = {}): ModelAvailabilityContext {
|
||||
return {
|
||||
workspaceReady: true,
|
||||
loading: false,
|
||||
signedIn: false,
|
||||
cloudProviderSyncReady: false,
|
||||
openWorkModelsSyncing: false,
|
||||
restrictToCloud: false,
|
||||
checkRestriction: () => false,
|
||||
cloudProviderList: null,
|
||||
providerList: catalog([
|
||||
{ id: "anthropic", models: ["claude-fable-5"] },
|
||||
{ id: "openai", models: ["gpt-5.5"] },
|
||||
]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionModel = { providerID: "anthropic", modelID: "claude-fable-5" };
|
||||
const defaultModel = { providerID: "openai", modelID: "gpt-5.5" };
|
||||
|
||||
describe("computeModelAvailability", () => {
|
||||
test("a model present in the settled catalog is available", () => {
|
||||
expect(computeModelAvailability(sessionModel, context())).toEqual({ status: "available" });
|
||||
});
|
||||
|
||||
test("a session model stays available when the global default is missing", () => {
|
||||
const shared = context({
|
||||
providerList: catalog([{ id: "anthropic", models: ["claude-fable-5"] }]),
|
||||
});
|
||||
expect(computeModelAvailability(sessionModel, shared)).toEqual({ status: "available" });
|
||||
expect(computeModelAvailability(defaultModel, shared)).toEqual({
|
||||
status: "unavailable",
|
||||
reason: "model_missing",
|
||||
});
|
||||
});
|
||||
|
||||
test("a genuinely missing session model is unavailable while the default stays available", () => {
|
||||
const shared = context({
|
||||
providerList: catalog([{ id: "openai", models: ["gpt-5.5"] }]),
|
||||
});
|
||||
expect(computeModelAvailability(sessionModel, shared)).toEqual({
|
||||
status: "unavailable",
|
||||
reason: "model_missing",
|
||||
});
|
||||
expect(computeModelAvailability(defaultModel, shared)).toEqual({ status: "available" });
|
||||
});
|
||||
|
||||
test("an unsettled catalog is pending, never unavailable", () => {
|
||||
expect(computeModelAvailability(sessionModel, context({ providerList: undefined }))).toEqual({
|
||||
status: "pending",
|
||||
});
|
||||
expect(computeModelAvailability(sessionModel, context({ providerList: null }))).toEqual({
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
test("a workspace that is still booting is pending", () => {
|
||||
expect(computeModelAvailability(sessionModel, context({ workspaceReady: false }))).toEqual({
|
||||
status: "pending",
|
||||
});
|
||||
expect(computeModelAvailability(sessionModel, context({ loading: true }))).toEqual({
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
test("no selection to validate is pending", () => {
|
||||
expect(computeModelAvailability(null, context())).toEqual({ status: "pending" });
|
||||
expect(
|
||||
computeModelAvailability({ providerID: "", modelID: "" }, context()),
|
||||
).toEqual({ status: "pending" });
|
||||
});
|
||||
|
||||
test("a cloud-managed model is pending until cloud provider sync settles", () => {
|
||||
const cloudModel = { providerID: "lpr_org_provider", modelID: "gpt-5.5" };
|
||||
expect(
|
||||
computeModelAvailability(cloudModel, context({ signedIn: true, cloudProviderSyncReady: false })),
|
||||
).toEqual({ status: "pending" });
|
||||
expect(
|
||||
computeModelAvailability(
|
||||
cloudModel,
|
||||
context({
|
||||
signedIn: true,
|
||||
cloudProviderSyncReady: true,
|
||||
cloudProviderList: catalog([{ id: "lpr_org_provider", models: ["gpt-5.5"] }]),
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "available" });
|
||||
expect(
|
||||
computeModelAvailability(
|
||||
cloudModel,
|
||||
context({
|
||||
signedIn: true,
|
||||
cloudProviderSyncReady: true,
|
||||
cloudProviderList: catalog([{ id: "lpr_other", models: ["gpt-5.5"] }]),
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "unavailable", reason: "model_missing" });
|
||||
});
|
||||
|
||||
test("cloud sync while OpenWork Models is reconciling stays pending", () => {
|
||||
const cloudModel = { providerID: "openwork", modelID: "gpt-5.5" };
|
||||
expect(
|
||||
computeModelAvailability(
|
||||
cloudModel,
|
||||
context({ signedIn: true, cloudProviderSyncReady: true, openWorkModelsSyncing: true }),
|
||||
),
|
||||
).toEqual({ status: "pending" });
|
||||
});
|
||||
|
||||
test("a policy-blocked provider is unavailable even while the catalog loads", () => {
|
||||
expect(
|
||||
computeModelAvailability(
|
||||
{ providerID: "opencode", modelID: "grok-code" },
|
||||
context({
|
||||
providerList: undefined,
|
||||
checkRestriction: ({ restriction }) => restriction === "allowZenModel",
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "unavailable", reason: "provider_blocked" });
|
||||
});
|
||||
|
||||
test("restrict-to-cloud marks non-connected custom providers unavailable", () => {
|
||||
expect(
|
||||
computeModelAvailability(
|
||||
sessionModel,
|
||||
context({
|
||||
restrictToCloud: true,
|
||||
providerList: catalog([{ id: "openai", models: ["gpt-5.5"] }]),
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "unavailable", reason: "provider_not_connected" });
|
||||
});
|
||||
|
||||
test("a disconnected provider's model is unavailable in a settled catalog", () => {
|
||||
const list = catalog([
|
||||
{ id: "anthropic", models: ["claude-fable-5"] },
|
||||
]);
|
||||
(list as { connected: string[] }).connected = [];
|
||||
expect(computeModelAvailability(sessionModel, context({ providerList: list }))).toEqual({
|
||||
status: "unavailable",
|
||||
reason: "model_missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUnavailableConfirmationGate", () => {
|
||||
const missing: ModelAvailability = { status: "unavailable", reason: "model_missing" };
|
||||
const available: ModelAvailability = { status: "available" };
|
||||
const pending: ModelAvailability = { status: "pending" };
|
||||
|
||||
function gateAt(startMs: number, confirmMs = 1_000) {
|
||||
let nowMs = startMs;
|
||||
const gate = createUnavailableConfirmationGate({ confirmMs, now: () => nowMs });
|
||||
return { gate, advance: (ms: number) => { nowMs += ms; }, nowMs: () => nowMs };
|
||||
}
|
||||
|
||||
test("a transient catalog denial renders as pending, never unavailable", () => {
|
||||
const { gate, advance } = gateAt(10_000);
|
||||
// Settings→thread transition: the catalog settles briefly without the model.
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(pending);
|
||||
advance(300);
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(pending);
|
||||
// The next refresh restores the model before the window matures.
|
||||
advance(300);
|
||||
expect(gate.confirm(sessionModel, available)).toEqual(available);
|
||||
// The denial history is cleared: a later blip starts a fresh window.
|
||||
advance(5_000);
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(pending);
|
||||
});
|
||||
|
||||
test("a genuine denial surfaces once it survives the confirmation window", () => {
|
||||
const { gate, advance } = gateAt(10_000);
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(pending);
|
||||
advance(1_000);
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(missing);
|
||||
// Steady state stays denied without flicker.
|
||||
advance(50);
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(missing);
|
||||
});
|
||||
|
||||
test("nextRecheckDelay schedules exactly the remaining confirmation time", () => {
|
||||
const { gate, advance, nowMs } = gateAt(10_000);
|
||||
expect(gate.nextRecheckDelay(nowMs())).toBeNull();
|
||||
gate.confirm(sessionModel, missing);
|
||||
expect(gate.nextRecheckDelay(nowMs())).toBe(1_000);
|
||||
advance(400);
|
||||
expect(gate.nextRecheckDelay(nowMs())).toBe(600);
|
||||
advance(700);
|
||||
expect(gate.nextRecheckDelay(nowMs())).toBe(0);
|
||||
// A recovered model clears the tracked denial.
|
||||
gate.confirm(sessionModel, available);
|
||||
expect(gate.nextRecheckDelay(nowMs())).toBeNull();
|
||||
});
|
||||
|
||||
test("denials are tracked per model identity", () => {
|
||||
const { gate, advance } = gateAt(10_000);
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(pending);
|
||||
advance(1_000);
|
||||
// The session model's denial matured; the default's is brand new.
|
||||
expect(gate.confirm(sessionModel, missing)).toEqual(missing);
|
||||
expect(gate.confirm(defaultModel, missing)).toEqual(pending);
|
||||
});
|
||||
|
||||
test("policy blocks, available, and pending pass through immediately", () => {
|
||||
const { gate } = gateAt(10_000);
|
||||
const blocked: ModelAvailability = { status: "unavailable", reason: "provider_blocked" };
|
||||
expect(gate.confirm(sessionModel, blocked)).toEqual(blocked);
|
||||
expect(gate.confirm(sessionModel, available)).toEqual(available);
|
||||
expect(gate.confirm(sessionModel, pending)).toEqual(pending);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user