From a887ac135612f158708d5073d53ae20d01e68175 Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 26 Oct 2026 15:44:54 +0100 Subject: [PATCH 1/2] feat(prism): retain advertised model options in Colors Colors now retain provider model options alongside effort, including Codex Fast speed. Preserve options through Prism and Spectrum launches, validate advertised selections, and keep tier-only Colors distinct. Written with gpt-6.1-sol through the Codex harness. --- GLOSSARY.md | 5 +- apps/server/src/mcp/OrchestratorMcpService.ts | 42 +---- apps/server/src/prism/PrismService.test.ts | 176 ++++++++++++++++++ apps/server/src/prism/PrismService.ts | 62 +++--- apps/server/src/prism/modelOptions.ts | 64 +++++++ .../src/spectrum/Launch.integration.test.ts | 42 +++++ apps/server/src/spectrum/LaunchService.ts | 24 +-- apps/server/src/spectrum/settings.test.ts | 17 ++ apps/server/src/spectrum/settings.ts | 6 +- .../settings/ColorPicker.browser.test.tsx | 91 +++++++++ .../settings/ColorPicker.logic.test.ts | 82 ++++++++ .../components/settings/ColorPicker.logic.ts | 85 +++++++-- .../src/components/settings/ColorPicker.tsx | 59 +++++- .../settings/PrismSettings.logic.test.ts | 18 ++ .../settings/PrismSettings.logic.ts | 2 +- docs/fork-features.md | 3 + docs/map.md | 2 +- packages/contracts/src/prism.test.ts | 48 ++++- packages/contracts/src/prism.ts | 6 +- 19 files changed, 734 insertions(+), 100 deletions(-) create mode 100644 apps/server/src/prism/modelOptions.ts create mode 100644 apps/web/src/components/settings/ColorPicker.browser.test.tsx diff --git a/GLOSSARY.md b/GLOSSARY.md index e6f666e00a..04360e05dc 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -60,8 +60,9 @@ or in free mode. _Avoid_: room, channel, group chat **Color**: -One harness, model and effort used together, such as Claude Opus 5.5 at high -effort. A Drafter runs on one Color. +One harness, model, effort and advertised model options used together, such as +Codex at high effort with Fast speed. Different options make distinct Colors. +A Drafter runs on one Color. _Avoid_: model config, route **White**: diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 041ab44832..f960f6b971 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -23,6 +23,7 @@ import { callerLinkOrigin, assertSameLink } from "./linkOrigin.ts"; import { SpectrumMcpInterrupt } from "../spectrum/mcpInterrupt.ts"; import { SpectrumMcpSend } from "../spectrum/mcpSend.ts"; import * as Prism from "../prism/PrismService.ts"; +import { invalidOptionSelections } from "../prism/modelOptions.ts"; import { CommandId, type RunId, @@ -76,8 +77,6 @@ import { type OrchestratorMcpThreadWaitInput, type OrchestratorMcpThreadWaitResult, type ProviderInteractionMode, - type ProviderOptionDescriptor, - type ProviderOptionSelection, type RuntimeMode, type ScheduledTask, type ScheduledTaskError, @@ -377,45 +376,6 @@ function providerConstraints( return constraints; } -/** - * Models without descriptors skip the descriptor checks (mirroring how model slugs are only - * validated when the provider advertises models), but duplicate ids always fail: downstream - * consumers disagree on whether the first or last value of a duplicated id wins. - */ -function invalidOptionSelections( - selections: ReadonlyArray, - descriptors: ReadonlyArray | undefined, -): ReadonlyArray { - const problems: Array = []; - const seen = new Set(); - for (const selection of selections) { - if (seen.has(selection.id)) { - problems.push(`Option ${selection.id} was specified more than once.`); - continue; - } - seen.add(selection.id); - if (descriptors === undefined) continue; - const descriptor = descriptors.find((candidate) => candidate.id === selection.id); - if (descriptor === undefined) { - const known = descriptors.map((candidate) => candidate.id).join(", "); - problems.push(`Unknown option ${selection.id}; supported options: ${known || "none"}.`); - continue; - } - if (descriptor.type === "boolean" && typeof selection.value !== "boolean") { - problems.push(`Option ${selection.id} expects a boolean value.`); - continue; - } - if ( - descriptor.type === "select" && - !descriptor.options.some((choice) => choice.id === selection.value) - ) { - const choices = descriptor.options.map((choice) => choice.id).join(", "); - problems.push(`Option ${selection.id} must be one of: ${choices}.`); - } - } - return problems; -} - function taskStatusForRun( run: Pick | undefined, ): OrchestratorMcpDelegateTaskResult["status"] { diff --git a/apps/server/src/prism/PrismService.test.ts b/apps/server/src/prism/PrismService.test.ts index c65bac1aba..421c87d703 100644 --- a/apps/server/src/prism/PrismService.test.ts +++ b/apps/server/src/prism/PrismService.test.ts @@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider, + type ProviderOptionSelection, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -13,6 +14,7 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import { buildUnavailableProviderSnapshot } from "../provider/unavailableProviderSnapshot.ts"; import * as Settings from "../serverSettings.ts"; import * as Prism from "./PrismService.ts"; +import * as Adapters from "../orchestration-v2/ProviderAdapterRegistry.ts"; const instanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project:prism"); @@ -542,3 +544,177 @@ describe("PrismService", () => { assert.strictEqual(Prism.delegatedPrismRole(), "worker"); }); }); + +const optionDescriptors = [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: [{ id: "high", label: "High" }], + }, + { + id: "serviceTier", + label: "Speed", + type: "select" as const, + options: [ + { id: "priority", label: "Fast" }, + { id: "default", label: "Standard" }, + ], + }, + { id: "enabled", label: "Enabled", type: "boolean" as const }, +]; +const advertised = Effect.map(provider, (p) => ({ + ...p, + installed: true, + status: "ready" as const, + models: p.models.map((m) => ({ ...m, aliases: ["alias"], capabilities: { optionDescriptors } })), +})); +const fastOptions = [{ id: "serviceTier", value: "priority" }]; + +it.effect( + "preserves tier-only distinct Colors, effort and inherited options without a validation callback", + () => + Effect.gen(function* () { + const p = yield* advertised; + yield* Effect.gen(function* () { + const service = yield* Prism.PrismService; + const result = yield* service.resolve({ projectId, role: "reviewer", count: 2 }); + assert.deepStrictEqual(result.modelSelections, [ + { + ...selection, + options: [ + { id: "serviceTier", value: "default" }, + { id: "reasoningEffort", value: "high" }, + ], + }, + { ...selection, options: [...fastOptions, { id: "reasoningEffort", value: "high" }] }, + ]); + const inherited = { + ...selection, + options: [...fastOptions, { id: "enabled", value: false }], + }; + assert.deepStrictEqual( + (yield* service.resolve({ projectId, role: "planner", inherited })).modelSelection, + inherited, + ); + assert.deepStrictEqual( + (yield* service.resolve({ projectId, role: "promachos" })).modelSelection.options, + [...fastOptions, { id: "reasoningEffort", value: "high" }], + ); + }).pipe( + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([p]), + Settings.layerTest({ + prismRoles: { + reviewer: { + models: [ + { + ...selection, + effort: "high", + options: [{ id: "serviceTier", value: "default" }], + }, + { ...selection, model: "alias", effort: "high", options: fastOptions }, + ], + }, + promachos: { models: [{ ...selection, effort: "high", options: fastOptions }] }, + }, + }), + ), + ), + ), + ), + ); + }), +); + +it.effect( + "rejects unknown ids, values, types and duplicate Color options without falling back or needing a callback", + () => + Effect.gen(function* () { + const p = yield* advertised; + const cases: readonly (readonly ProviderOptionSelection[])[] = [ + [{ id: "unknown", value: "priority" }], + [{ id: "serviceTier", value: "turbo" }], + [{ id: "serviceTier", value: true }], + [{ id: "enabled", value: "true" }], + [{ id: "serviceTier", value: "default" }, ...fastOptions], + [{ id: "reasoningEffort", value: "invalid" }], + ]; + for (const options of cases) { + const error = yield* Effect.gen(function* () { + const service = yield* Prism.PrismService; + return yield* service.resolve({ projectId, role: "reviewer" }); + }).pipe( + Effect.flip, + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([p]), + Settings.layerTest({ + prismRoles: { + reviewer: { models: [{ ...selection, effort: "high", options }, selection] }, + }, + }), + ), + ), + ), + ), + ); + assert.strictEqual(error.code, "invalid_request"); + } + const error = yield* Effect.gen(function* () { + return yield* (yield* Prism.PrismService).resolve({ projectId, role: "reviewer" }); + }).pipe( + Effect.flip, + Effect.provide( + Prism.layer.pipe( + Layer.provide( + Layer.mergeAll( + registry([yield* provider]), + Settings.layerTest({ + prismRoles: { reviewer: { models: [{ ...selection, options: fastOptions }] } }, + }), + ), + ), + ), + ), + ); + assert.strictEqual(error.code, "invalid_request"); + }), +); + +it.effect( + "native launch validation canonicalizes the model and applies explicit-target option validation", + () => + Effect.gen(function* () { + const p = yield* advertised; + yield* Effect.gen(function* () { + const valid = yield* Prism.validateLaunchSelection({ + ...selection, + model: "alias", + options: fastOptions, + }); + assert.deepStrictEqual(valid, { ...selection, options: fastOptions }); + const error = yield* Effect.flip( + Prism.validateLaunchSelection({ + ...selection, + options: [{ id: "serviceTier", value: "turbo" }], + }), + ); + assert.strictEqual(error.code, "invalid_request"); + }).pipe( + Effect.provide( + Layer.mergeAll( + registry([p]), + Layer.mock(Adapters.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([instanceId]), + }), + ), + ), + ); + }), +); diff --git a/apps/server/src/prism/PrismService.ts b/apps/server/src/prism/PrismService.ts index dc7e393c19..818b5a6166 100644 --- a/apps/server/src/prism/PrismService.ts +++ b/apps/server/src/prism/PrismService.ts @@ -1,3 +1,4 @@ +import { colorModelSelection, invalidOptionSelections } from "./modelOptions.ts"; import { unsupportedBare } from "@t3tools/provider-core/server/bareLaunch"; import { DEFAULT_PRISM_LANE, @@ -87,6 +88,15 @@ export const validateLaunchSelection = Effect.fn("Prism.validateLaunchSelection" code: "model_unavailable", message: `Model ${selection.model} is not advertised by provider ${selection.instanceId}.`, }); + const invalid = invalidOptionSelections( + selection.options ?? [], + model.capabilities?.optionDescriptors, + ); + if (invalid.length > 0) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `Model ${model.slug} on provider ${selection.instanceId} rejected options: ${invalid.join(" ")}`, + }); return { ...selection, model: model.slug }; }); @@ -111,19 +121,6 @@ function blocked(provider: ServerProvider, now: number) { ); } -function effortOption(driver: string) { - switch (driver) { - case "codex": - case "grok": - case "muse": - return "reasoningEffort"; - case "opencode": - return "variant"; - default: - return "effort"; - } -} - export class PrismService extends Context.Service< PrismService, { @@ -280,16 +277,10 @@ const make = Effect.gen(function* () { ); if (!model && !rechecking && (preferences.length > 0 || provider.models.length > 0)) continue; - const effort = "effort" in candidate ? candidate.effort : undefined; - const selection: ModelSelection = { - instanceId: candidate.instanceId, - model: model?.slug ?? candidate.model, - ...(effort === undefined - ? "options" in candidate - ? { options: candidate.options } - : {} - : { options: [{ id: effortOption(provider.driver), value: effort }] }), - }; + const selection = colorModelSelection( + { ...candidate, model: model?.slug ?? candidate.model }, + provider.driver, + ); const validated = yield* ( input.validate ? input.validate(selection) : Effect.succeed(selection) ).pipe( @@ -299,7 +290,30 @@ const make = Effect.gen(function* () { : Effect.fail(error), ), ); - if (validated !== null) started.push(validated); + if (validated === null) continue; + const advertisedModel = rechecking + ? (yield* registry.getProviders) + .find((p) => p.instanceId === validated.instanceId) + ?.models.find( + (m) => m.slug === validated.model || m.aliases?.includes(validated.model), + ) + : model; + // New Color options must be advertised; legacy effort still works with older catalogs. + const descriptors = advertisedModel?.capabilities?.optionDescriptors; + const invalidColorOptions = invalidOptionSelections( + candidate.options ?? [], + preferences.length > 0 ? (descriptors ?? []) : descriptors, + ); + const invalid = + invalidColorOptions.length > 0 + ? invalidColorOptions + : invalidOptionSelections(validated.options ?? [], descriptors); + if (invalid.length > 0) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `Model ${validated.model} on provider ${validated.instanceId} rejected options: ${invalid.join(" ")}`, + }); + started.push(validated); } const [first, ...rest] = started; if (first !== undefined) diff --git a/apps/server/src/prism/modelOptions.ts b/apps/server/src/prism/modelOptions.ts new file mode 100644 index 0000000000..56a7edc670 --- /dev/null +++ b/apps/server/src/prism/modelOptions.ts @@ -0,0 +1,64 @@ +import type { + Color, + ModelSelection, + ProviderOptionDescriptor, + ProviderOptionSelection, +} from "@t3tools/contracts"; + +/** Legacy effort overrides its option while retaining the selection's other model options. */ +export function colorModelSelection(color: Color, driver: string | undefined): ModelSelection { + const { effort, ...selection } = color; + if (effort === undefined) return selection; + const id = + driver === "codex" || driver === "grok" || driver === "muse" + ? "reasoningEffort" + : driver === "opencode" + ? "variant" + : "effort"; + return { + ...selection, + options: [ + ...(selection.options ?? []).filter((option) => option.id !== id), + { id, value: effort }, + ], + }; +} + +/** + * Models without descriptors skip the descriptor checks (mirroring how model slugs are only + * validated when the provider advertises models), but duplicate ids always fail: downstream + * consumers disagree on whether the first or last value of a duplicated id wins. + */ +export function invalidOptionSelections( + selections: ReadonlyArray, + descriptors: ReadonlyArray | undefined, +): ReadonlyArray { + const problems: Array = []; + const seen = new Set(); + for (const selection of selections) { + if (seen.has(selection.id)) { + problems.push(`Option ${selection.id} was specified more than once.`); + continue; + } + seen.add(selection.id); + if (descriptors === undefined) continue; + const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + if (descriptor === undefined) { + const known = descriptors.map((candidate) => candidate.id).join(", "); + problems.push(`Unknown option ${selection.id}; supported options: ${known || "none"}.`); + continue; + } + if (descriptor.type === "boolean" && typeof selection.value !== "boolean") { + problems.push(`Option ${selection.id} expects a boolean value.`); + continue; + } + if ( + descriptor.type === "select" && + !descriptor.options.some((choice) => choice.id === selection.value) + ) { + const choices = descriptor.options.map((choice) => choice.id).join(", "); + problems.push(`Option ${selection.id} must be one of: ${choices}.`); + } + } + return problems; +} diff --git a/apps/server/src/spectrum/Launch.integration.test.ts b/apps/server/src/spectrum/Launch.integration.test.ts index 6f13fc89d2..3a438d026e 100644 --- a/apps/server/src/spectrum/Launch.integration.test.ts +++ b/apps/server/src/spectrum/Launch.integration.test.ts @@ -431,3 +431,45 @@ it.effect("persists peer origin on the Spectrum and Colors, fencing each descend } }).pipe(Effect.provide(runtime)), ); + +it.effect( + "Spectrum preserves model options when applying effort to participants, fallbacks and White", + () => + Effect.gen(function* () { + yield* setup; + const fast = { + ...selection, + options: [ + { id: "serviceTier", value: "priority" }, + { id: "reasoningEffort", value: "low" }, + ], + }; + const { moderator: _moderator, ...request } = input; + const state = yield* (yield* Launch.SpectrumLaunchService).register({ + ...request, + colors: [ + { + label: "Blue", + selection: fast, + effort: "high", + fallbacks: [{ selection: fast, effort: "medium" }], + }, + { label: "Red", selection: fast }, + ], + white: { selection: fast, effort: "high" }, + }); + const projections = yield* Projection.ProjectionStoreV2; + const blue = yield* projections.getThread(state.participants[0]!.threadId); + assert.deepStrictEqual(blue.modelSelection.options, [ + { id: "serviceTier", value: "priority" }, + { id: "reasoningEffort", value: "high" }, + ]); + const white = yield* projections.getThread(state.white!.threadId); + assert.deepStrictEqual(white.modelSelection.options, blue.modelSelection.options); + const saved = Option.getOrThrow(yield* readSpectrum(state.threadId)); + assert.deepStrictEqual(saved.participants[0]?.fallbacks?.[0]?.options, [ + { id: "serviceTier", value: "priority" }, + { id: "reasoningEffort", value: "medium" }, + ]); + }).pipe(Effect.provide(runtime)), +); diff --git a/apps/server/src/spectrum/LaunchService.ts b/apps/server/src/spectrum/LaunchService.ts index 2944eab983..5f4bf75ac3 100644 --- a/apps/server/src/spectrum/LaunchService.ts +++ b/apps/server/src/spectrum/LaunchService.ts @@ -39,6 +39,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderAdapters from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as Prism from "../prism/PrismService.ts"; +import { colorModelSelection } from "../prism/modelOptions.ts"; import { resolveSchedulerRun, schedulerRunOpenGuard } from "../scheduledTaskChecks/handoff.ts"; import { spectrumTaskEvent, spectrumTaskId, spectrumTaskRow } from "./delegatedTask.ts"; import { spectrumRegistrationPlan } from "./registrationPlan.ts"; @@ -223,20 +224,15 @@ const make = Effect.gen(function* () { const provider = (yield* providers.getProviders).find( (provider) => provider.instanceId === instanceId, ); - const effortOption = - provider?.driver === "codex" || provider?.driver === "grok" - ? "reasoningEffort" - : provider?.driver === "opencode" - ? "variant" - : "effort"; - return { - ...base, - instanceId, - model: spec.model ?? base.model, - ...(spec.effort === undefined - ? {} - : { options: [{ id: effortOption, value: spec.effort }] }), - } satisfies ModelSelection; + return colorModelSelection( + { + ...base, + instanceId, + model: spec.model ?? base.model, + ...(spec.effort === undefined ? {} : { effort: spec.effort }), + }, + provider?.driver, + ); }); const resolveMember = (color: SpectrumColorSpec, threadId: ThreadId) => Effect.gen(function* () { diff --git a/apps/server/src/spectrum/settings.test.ts b/apps/server/src/spectrum/settings.test.ts index d45294ec6c..edd75e010a 100644 --- a/apps/server/src/spectrum/settings.test.ts +++ b/apps/server/src/spectrum/settings.test.ts @@ -134,3 +134,20 @@ it("names the separate White after the first label no Color uses, ignoring case" "White (moderator 3)", ); }); + +it("retains options on Spectrum primary, fallbacks and copied White", () => { + const fast = { ...primary, options: [{ id: "serviceTier", value: "priority" }] }; + const configured = { + ...settings, + slots: settings.slots.map((slot) => ({ ...slot, primary: fast, fallbacks: [fast] })), + white: { primary: fast, fallbacks: [fast] }, + }; + const result = resolveSpectrumStart(request, configured); + expect(result.colors[0]?.selection?.options).toEqual(fast.options); + expect(result.colors[0]?.fallbacks?.[0]?.selection?.options).toEqual(fast.options); + expect(result.white.selection?.options).toEqual(fast.options); + expect(result.white.fallbacks?.[0]?.selection?.options).toEqual(fast.options); + expect( + resolveSpectrumStart({ ...request, white: "Blue" }, configured).white.selection?.options, + ).toEqual(fast.options); +}); diff --git a/apps/server/src/spectrum/settings.ts b/apps/server/src/spectrum/settings.ts index ba9b3de0a8..60a6fa2b55 100644 --- a/apps/server/src/spectrum/settings.ts +++ b/apps/server/src/spectrum/settings.ts @@ -14,7 +14,11 @@ export class SpectrumStartValidationError extends Schema.TaggedError ({ - selection: { instanceId: color.instanceId, model: color.model }, + selection: { + instanceId: color.instanceId, + model: color.model, + ...(color.options === undefined ? {} : { options: color.options }), + }, ...(color.effort === undefined ? {} : { effort: color.effort }), }); diff --git a/apps/web/src/components/settings/ColorPicker.browser.test.tsx b/apps/web/src/components/settings/ColorPicker.browser.test.tsx new file mode 100644 index 0000000000..aecce020ca --- /dev/null +++ b/apps/web/src/components/settings/ColorPicker.browser.test.tsx @@ -0,0 +1,91 @@ +import { ProviderDriverKind, ProviderInstanceId, type Color } from "@t3tools/contracts"; +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { ColorList } from "./ColorList"; +import type { ColorProviderChoice } from "./ColorPicker.logic"; + +const standard: Color = { instanceId: ProviderInstanceId.make("codex"), model: "sol" }; +const codex: ColorProviderChoice = { + instanceId: standard.instanceId, + driverKind: ProviderDriverKind.make("codex"), + displayName: "Codex", + models: [ + { + slug: "sol", + name: "Sol", + efforts: [{ id: "high", label: "High" }], + options: [ + { + id: "serviceTier", + label: "Speed", + type: "select", + options: [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Fast" }, + ], + }, + { id: "enabled", label: "Enabled", type: "boolean" }, + ], + }, + ], +}; +let root: Root; +let container: HTMLDivElement; +let persisted: readonly Color[]; +const save = vi.fn(async (colors: readonly Color[]) => { + persisted = colors; +}); +function Settings() { + const [saved, setSaved] = useState(persisted); + return ( + { + await save(colors); + setSaved(colors); + }} + /> + ); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + persisted = [standard]; + save.mockClear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); +async function choose(label: string, option: string) { + await userEvent.click(page.getByRole("combobox", { name: label, exact: true })); + await userEvent.click(page.getByRole("option", { name: option, exact: true })); +} +it("saves Fast, retains it through effort edits and remount, and restores the speed default", async () => { + await act(async () => root.render()); + await choose("Reviewer preference 1 Speed", "Fast"); + const fast = { ...standard, options: [{ id: "serviceTier", value: "priority" }] }; + expect(save).toHaveBeenLastCalledWith([fast]); + await choose("Reviewer preference 1 effort", "High"); + expect(save).toHaveBeenLastCalledWith([{ ...fast, effort: "high" }]); + await act(async () => root.render()); + await expect + .element(page.getByRole("combobox", { name: "Reviewer preference 1 Speed", exact: true })) + .toHaveTextContent("Fast"); + await choose("Reviewer preference 1 Speed", "Default speed"); + expect(save).toHaveBeenLastCalledWith([{ ...standard, effort: "high" }]); + await choose("Reviewer preference 1 Enabled", "Off"); + expect(save).toHaveBeenLastCalledWith([ + { ...standard, effort: "high", options: [{ id: "enabled", value: false }] }, + ]); +}); diff --git a/apps/web/src/components/settings/ColorPicker.logic.test.ts b/apps/web/src/components/settings/ColorPicker.logic.test.ts index 5fa3d59b7e..4201686745 100644 --- a/apps/web/src/components/settings/ColorPicker.logic.test.ts +++ b/apps/web/src/components/settings/ColorPicker.logic.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { chooseColorEffort, chooseColorModel, + chooseColorOption, + colorKey, chooseColorProvider, colorEffortOptions, colorProviderChoices, @@ -173,3 +175,83 @@ describe("choosing a Color", () => { expect(chooseColorEffort(current, "low")).toEqual({ ...current, effort: "low" }); }); }); + +const speed = { + id: "serviceTier", + label: "Speed", + type: "select" as const, + options: [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Priority" }, + ], +}; +it("offers advertised speed with priority labeled Fast and leaves Grok Fast as a model", () => { + const choices = colorProviderChoices( + [ + { + ...entry(codex, []), + models: [ + { + slug: "sol", + name: "Sol", + isCustom: false, + capabilities: { optionDescriptors: [speed] }, + }, + ], + }, + { ...entry(claude, [{ slug: "grok-fast" }]), driverKind: ProviderDriverKind.make("grok") }, + ], + new Map([ + [codex, [{ slug: "sol", name: "Sol" }]], + [claude, [{ slug: "grok-fast", name: "Grok Fast" }]], + ]), + () => null, + ); + expect(choices[0]?.models[0]?.options?.[0]).toMatchObject({ + options: [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Fast" }, + ], + }); + expect(choices[1]?.models[0]).toEqual({ slug: "grok-fast", name: "Grok Fast", efforts: [] }); +}); +it("preserves other options when changing effort or clearing speed, and keeps only supported options on a new model", () => { + const fast = { + instanceId: codex, + model: "sol", + effort: "high", + options: [ + { id: "serviceTier", value: "priority" }, + { id: "enabled", value: false }, + ], + }; + expect(chooseColorEffort(fast, "low")).toEqual({ ...fast, effort: "low" }); + expect(chooseColorOption(fast, "serviceTier", undefined).options).toEqual([ + { id: "enabled", value: false }, + ]); + const available = [ + { + ...providers[0]!, + models: [ + { slug: "luna", name: "Luna", efforts: [{ id: "high", label: "High" }], options: [speed] }, + ], + }, + ]; + expect(chooseColorModel(available, fast, "luna")).toEqual({ + ...fast, + model: "luna", + options: [{ id: "serviceTier", value: "priority" }], + }); + expect( + chooseColorModel( + available, + { ...fast, options: [{ id: "serviceTier", value: "turbo" }] }, + "luna", + ).options, + ).toBeUndefined(); + expect(chooseColorProvider(providers, fast, claude).options).toBeUndefined(); + expect(colorKey(fast)).not.toBe( + colorKey({ ...fast, options: [{ id: "serviceTier", value: "default" }] }), + ); + expect(colorKey(fast)).toBe(colorKey({ ...fast, options: fast.options.toReversed() })); +}); diff --git a/apps/web/src/components/settings/ColorPicker.logic.ts b/apps/web/src/components/settings/ColorPicker.logic.ts index 13f6b5cc86..fbe84b420a 100644 --- a/apps/web/src/components/settings/ColorPicker.logic.ts +++ b/apps/web/src/components/settings/ColorPicker.logic.ts @@ -1,4 +1,10 @@ -import type { Color, ModelCapabilities, ProviderInstanceId } from "@t3tools/contracts"; +import type { + Color, + ModelCapabilities, + ProviderInstanceId, + ProviderOptionDescriptor, + ProviderOptionSelection, +} from "@t3tools/contracts"; import type { ProviderInstanceEntry } from "../../providerInstances"; import type { ModelEsque } from "../chat/providerIconUtils"; @@ -11,6 +17,7 @@ export interface ColorModelChoice { readonly slug: string; readonly name: string; readonly efforts: readonly ColorEffortChoice[]; + readonly options?: readonly ProviderOptionDescriptor[]; } /** A provider the scope allows, with the models it allows; the picker's only source of options. */ @@ -26,8 +33,15 @@ export interface ColorProviderChoice extends Pick< readonly models: readonly ColorModelChoice[]; } -export function colorKey(color: Pick) { - return JSON.stringify([color.instanceId, color.model]); +export function colorKey(color: Color) { + return JSON.stringify([ + color.instanceId, + color.model, + color.effort ?? null, + [...(color.options ?? [])] + .sort((a, b) => a.id.localeCompare(b.id)) + .map(({ id, value }) => [id, value]), + ]); } export function colorEffortOptions( @@ -63,13 +77,33 @@ export function colorProviderChoices( if (!entry.enabled || !entry.isAvailable) return []; const models = (options.get(entry.instanceId) ?? []) .filter((model) => !model.isUnavailable && !disabledReason(entry.instanceId, model.slug)) - .map((model) => ({ - slug: model.slug, - name: model.name, - efforts: colorEffortOptions( - entry.models.find((candidate) => candidate.slug === model.slug)?.capabilities, - ), - })); + .map((model) => { + const capabilities = entry.models.find( + (candidate) => candidate.slug === model.slug, + )?.capabilities; + const options = capabilities?.optionDescriptors + ?.filter((option) => !["effort", "reasoningEffort", "variant"].includes(option.id)) + .map((option) => + option.type === "select" + ? { + ...option, + options: option.options + .filter((choice) => !option.promptInjectedValues?.includes(choice.id)) + .map((choice) => + option.id === "serviceTier" && choice.id === "priority" + ? { ...choice, label: "Fast" } + : choice, + ), + } + : option, + ); + return { + slug: model.slug, + name: model.name, + efforts: colorEffortOptions(capabilities), + ...(options?.length ? { options } : {}), + }; + }); if (models.length === 0) return []; const { enabled: _enabled, isAvailable: _isAvailable, models: _models, ...provider } = entry; return [{ ...provider, models }]; @@ -117,10 +151,37 @@ export function chooseColorModel( const effort = model.efforts.some((option) => option.id === current.effort) ? current.effort : undefined; - return withEffort(current.instanceId, slug, effort); + const options = current.options?.filter((selection) => + model.options?.some( + (descriptor) => + descriptor.id === selection.id && + (descriptor.type === "boolean" + ? typeof selection.value === "boolean" + : descriptor.options.some((choice) => choice.id === selection.value)), + ), + ); + return { + ...withEffort(current.instanceId, slug, effort), + ...(options?.length ? { options } : {}), + }; } /** An empty effort means the provider default. */ export function chooseColorEffort(current: Color, effort: string): Color { - return withEffort(current.instanceId, current.model, effort || undefined); + const { effort: _effort, ...rest } = current; + return { ...rest, ...(effort ? { effort } : {}) }; +} + +/** Undefined removes an override so the provider uses its default. */ +export function chooseColorOption( + current: Color, + id: string, + value: ProviderOptionSelection["value"] | undefined, +): Color { + const { options: previous, ...rest } = current; + const options = [ + ...(previous ?? []).filter((option) => option.id !== id), + ...(value === undefined ? [] : [{ id, value }]), + ]; + return { ...rest, ...(options.length ? { options } : {}) }; } diff --git a/apps/web/src/components/settings/ColorPicker.tsx b/apps/web/src/components/settings/ColorPicker.tsx index d5caded263..0631784332 100644 --- a/apps/web/src/components/settings/ColorPicker.tsx +++ b/apps/web/src/components/settings/ColorPicker.tsx @@ -4,6 +4,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { chooseColorEffort, chooseColorModel, + chooseColorOption, chooseColorProvider, findColorChoice, type ColorProviderChoice, @@ -27,7 +28,7 @@ function ProviderLabel({ provider }: { provider: ColorProviderChoice }) { /** * Chooses a Color: a provider enabled for the scope, then one of its models, then that model's - * effort. A saved Color the scope no longer offers stays visible, marked unavailable. + * effort and advertised options. A saved Color the scope no longer offers stays visible, marked unavailable. */ export function ColorPicker({ value, @@ -125,6 +126,62 @@ export function ColorPicker({ ))} + {model?.options?.map((descriptor) => { + const selected = value.options?.find((option) => option.id === descriptor.id)?.value; + const choices = + descriptor.type === "boolean" + ? [ + { id: "true", label: "On" }, + { id: "false", label: "Off" }, + ] + : descriptor.options; + const selectedId = selected === undefined ? "" : String(selected); + const unknown = + selected !== undefined && !choices.some((choice) => choice.id === selectedId); + return ( + + ); + })} ); } diff --git a/apps/web/src/components/settings/PrismSettings.logic.test.ts b/apps/web/src/components/settings/PrismSettings.logic.test.ts index 409783361e..54b3051479 100644 --- a/apps/web/src/components/settings/PrismSettings.logic.test.ts +++ b/apps/web/src/components/settings/PrismSettings.logic.test.ts @@ -365,3 +365,21 @@ it("resets environment Prism roles without changing the Promachos launch kit", ( }), ).toBe(true); }); + +it("adopts a tier-only change and keeps distinct Color row identities across reordering", () => { + const standard = { + instanceId: ProviderInstanceId.make("codex"), + model: "sol", + options: [{ id: "serviceTier", value: "default" }], + }; + const fast = { ...standard, options: [{ id: "serviceTier", value: "priority" }] }; + const draft = [ + { ...standard, entryId: 1 }, + { ...fast, entryId: 2 }, + ]; + expect(syncPrismDraft(draft, [fast, standard])).toEqual([ + { ...fast, entryId: 2 }, + { ...standard, entryId: 1 }, + ]); + expect(syncPrismDraft([draft[0]!], [fast])[0]?.options).toEqual(fast.options); +}); diff --git a/apps/web/src/components/settings/PrismSettings.logic.ts b/apps/web/src/components/settings/PrismSettings.logic.ts index 6b57b5eb46..eb007128f6 100644 --- a/apps/web/src/components/settings/PrismSettings.logic.ts +++ b/apps/web/src/components/settings/PrismSettings.logic.ts @@ -31,7 +31,7 @@ export function movePrismPreference( /** A list row while editing; `entryId` keeps drag identity stable across saves. */ export type PrismDraftPreference = Color & { entryId: number }; -const samePreference = (a: Color, b: Color) => colorKey(a) === colorKey(b) && a.effort === b.effort; +const samePreference = (a: Color, b: Color) => colorKey(a) === colorKey(b); /** Row ids only need to be unique within their list. */ export const nextPrismEntryId = (draft: readonly PrismDraftPreference[]) => diff --git a/docs/fork-features.md b/docs/fork-features.md index bc1d99ba86..c48183db8e 100644 --- a/docs/fork-features.md +++ b/docs/fork-features.md @@ -1256,6 +1256,7 @@ Child creation paths are owned by child-threads and shared with thread-people. "purpose": "Keep scoped Prism kits for the delegated roles (Planner, Researcher, Designer, Worker lanes, Computer use, Roast, Reviewer, Judge) and the stored Promachos kit on its own page editable with ordered Color lists and saved settings replay. Removed role kits drop at decode; stored records that name a removed role read as its successor. Project overrides are partial role maps over the environment kits; each page resets only its own kits.", "issues": [ "https://github.com/toolboxmd/chromeria/issues/169", + "https://github.com/toolboxmd/chromeria/issues/625", "https://github.com/toolboxmd/chromeria/issues/395", "https://github.com/toolboxmd/chromeria/issues/397", "https://github.com/toolboxmd/chromeria/issues/399", @@ -1268,6 +1269,7 @@ Child creation paths are owned by child-threads and shared with thread-people. ], "newFiles": [ "apps/server/src/prism/PrismRoleOverrides.test.ts", + "apps/server/src/prism/modelOptions.ts", "apps/server/src/prism/PrismRoleOverrides.ts", "packages/shared/src/prismProjectOverrides.test.ts", "apps/web/src/components/settings/ColorList.tsx", @@ -1277,6 +1279,7 @@ Child creation paths are owned by child-threads and shared with thread-people. "apps/web/src/components/settings/usePrismSettingsSave.ts", "apps/web/src/routes/settings.promachos.tsx", "apps/web/src/components/settings/ColorPicker.logic.test.ts", + "apps/web/src/components/settings/ColorPicker.browser.test.tsx", "apps/web/src/components/settings/ColorPicker.logic.ts", "apps/web/src/components/settings/ColorPicker.tsx", "apps/web/src/components/settings/PrismSettings.list.test.tsx", diff --git a/docs/map.md b/docs/map.md index 88b33de208..ef3e12609b 100644 --- a/docs/map.md +++ b/docs/map.md @@ -53,7 +53,7 @@ flowchart TD T3M --> VCF --> PBI PZ["Prism routing"] - %% PZ: #92 #616 #579 #573 #568 #564 PR #556 #554 PR #408 PR #430 PR #479 PR #489 PR #512 PR #471 PR #476 PR #539 + %% PZ: #92 #625 #616 #579 #573 #568 #564 PR #556 #554 PR #408 PR #430 PR #479 PR #489 PR #512 PR #471 PR #476 PR #539 VT["Visible T3 threads"] %% VT: #10 PM["Promachos bubbles and persona"] diff --git a/packages/contracts/src/prism.test.ts b/packages/contracts/src/prism.test.ts index 4fcb8b5d4f..9171ed1c04 100644 --- a/packages/contracts/src/prism.test.ts +++ b/packages/contracts/src/prism.test.ts @@ -1,8 +1,13 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { OrchestratorMcpDelegateTaskInput, OrchestrationV2RemoteTaskLaunch } from "./index.ts"; import { + ProjectId, + OrchestratorMcpDelegateTaskInput, + OrchestrationV2RemoteTaskLaunch, +} from "./index.ts"; +import { + Color, admitDelegateRole, DEFAULT_PRISM_ROLE_KITS, PRISM_DELEGATE_ROLE_ERROR, @@ -303,3 +308,44 @@ describe("Prism role kits", () => { expect(decodeKitsPatch({ planner: { run: 1 } })).toEqual({ planner: { run: 1 } }); }); }); + +it("round-trips Color options in role, project, Promachos and Spectrum settings without changing old Colors", () => { + const legacy = { instanceId: "codex", model: "gpt-6.1-sol", effort: "high" }; + const fast = { + ...legacy, + options: [ + { id: "serviceTier", value: "priority" }, + { id: "enabled", value: false }, + ], + }; + const stored = decodeServerSettings({ + prismRoles: { reviewer: { models: [legacy, fast] }, promachos: { models: [fast] } }, + projectSettingsOverrides: { project: { prismRoles: { reviewer: { models: [fast] } } } }, + spectrum: { + slots: [ + { label: "Blue", primary: fast, fallbacks: [legacy] }, + { label: "Red", primary: legacy, fallbacks: [fast] }, + ], + white: { primary: fast, fallbacks: [legacy] }, + }, + }); + const restored = decodeServerSettings(encodeServerSettings(stored)); + expect(restored.prismRoles.reviewer.models).toEqual([legacy, fast]); + expect(restored.prismRoles.promachos.models).toEqual([fast]); + expect( + restored.projectSettingsOverrides[ProjectId.make("project")]?.prismRoles?.reviewer?.models, + ).toEqual([fast]); + expect(restored.spectrum.slots[0]?.primary).toEqual(fast); + expect(restored.spectrum.white?.primary).toEqual(fast); + expect(decodeKitsPatch({ reviewer: { models: [fast] } }).reviewer?.models).toEqual([fast]); +}); +const decodeColor = Schema.decodeUnknownSync(Color); +it("uses the existing model option decoder and rejects malformed Color option arrays", () => { + expect( + decodeColor({ instanceId: "codex", model: "sol", options: { serviceTier: "priority" } }) + .options, + ).toEqual([{ id: "serviceTier", value: "priority" }]); + expect(() => + decodeColor({ instanceId: "codex", model: "sol", options: [{ id: "serviceTier", value: 3 }] }), + ).toThrow(); +}); diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts index d8f7266042..6997fad673 100644 --- a/packages/contracts/src/prism.ts +++ b/packages/contracts/src/prism.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { PositiveInt, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { ProviderOptionSelections } from "./model.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind, @@ -113,15 +114,16 @@ export type PrismLane = typeof PrismLane.Type; export const DEFAULT_PRISM_LANE: PrismLane = "medium"; /** - * A Color: one provider instance, model and effort used together. Prism role + * A Color: one provider instance, model, effort and model options used together. Prism role * lists, the Promachos kit and Spectrum slots all store Colors; the same - * model at another effort is a separate Color. + * model at another effort or with different options is a separate Color. */ export const Color = Schema.Struct({ instanceId: ProviderInstanceId, model: TrimmedNonEmptyString, /** Claude effort, Codex/Grok reasoningEffort, OpenCode variant. */ effort: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(ProviderOptionSelections), }); export type Color = typeof Color.Type; From 37d80a5f9b9d2780300e94b224d3132834ffc064 Mon Sep 17 00:00:00 2001 From: Luke Maj Date: Mon, 26 Oct 2026 16:30:39 +0100 Subject: [PATCH 2/2] fix(spectrum): validate delayed fallback model options Validate each uncommitted Spectrum start with the shared advertised-option validator, including White and delayed backups. Keep unavailable candidates eligible for ordered fallback without hiding invalid options, and retain accepted-command replay. Written with GPT-6.1-Sol through the Codex harness in T3 Code. --- .../peer/PeerAuthority.workflow.fixture.ts | 3 +- apps/server/src/spectrum/RoundService.ts | 30 +++- .../delegatedTask.integration.test.ts | 168 ++++++++++++++++++ 3 files changed, 197 insertions(+), 4 deletions(-) diff --git a/apps/server/src/peer/PeerAuthority.workflow.fixture.ts b/apps/server/src/peer/PeerAuthority.workflow.fixture.ts index 9215f45bf9..f5e3a51934 100644 --- a/apps/server/src/peer/PeerAuthority.workflow.fixture.ts +++ b/apps/server/src/peer/PeerAuthority.workflow.fixture.ts @@ -2182,6 +2182,7 @@ export const makeNode = ( }; }); const restartSchedulerTransport = restartSchedulerTransportWith(attachmentRequests); + const launchValidation = yield* layerLaunchCatalog.pipe(Layer.build); return { ...peer, pendingRequests: Context.get(pendingRequests, PendingRequests.PendingRequests), @@ -2209,7 +2210,7 @@ export const makeNode = ( databasePath: Context.get(databaseConfig, Config.ServerConfig).dbPath, lifecycleRecovery, signals, - runtime, + runtime: Context.merge(runtime, launchValidation), requestContext, secretOwners, secretRequestClock, diff --git a/apps/server/src/spectrum/RoundService.ts b/apps/server/src/spectrum/RoundService.ts index f232a0880a..240fdec6e4 100644 --- a/apps/server/src/spectrum/RoundService.ts +++ b/apps/server/src/spectrum/RoundService.ts @@ -13,6 +13,9 @@ import { ForkDispatchPlans } from "../fork/ForkDispatchPlans.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as Threads from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProviderAdapters from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as Prism from "../prism/PrismService.ts"; import { bindRun, fallBack, nextRound } from "./barrier.ts"; import { whiteInstruction } from "./freeMode.ts"; import { spectrumLaunchAdmission } from "./launchAdmission.ts"; @@ -43,6 +46,8 @@ const make = Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const sink = yield* EventSink.EventSinkV2; const threads = yield* Threads.ThreadManagementService; + const adapters = yield* ProviderAdapters.ProviderAdapterRegistryV2; + const providers = yield* ProviderRegistry.ProviderRegistry; const read = (threadId: ThreadId) => readSpectrum(threadId).pipe( Effect.provideService(SqlClient.SqlClient, sql), @@ -53,8 +58,13 @@ const make = Effect.gen(function* () { ), ); /** Admission refused the start: nothing of it committed, and its command can never commit. */ - const refusedBeforeCommit = (commandId: CommandId, error: { readonly _tag: string }) => - error._tag === "ThreadManagementProviderInstanceDisabledError" + const refusedBeforeCommit = ( + commandId: CommandId, + error: { readonly _tag: string; readonly code?: string }, + ) => + error._tag === "ThreadManagementProviderInstanceDisabledError" || + (error._tag === "OrchestratorMcpFailure" && + (error.code === "provider_unavailable" || error.code === "model_unavailable")) ? Effect.succeed(true) : sql<{ readonly status: string }>` SELECT status FROM orchestration_command_receipts WHERE command_id = ${commandId} @@ -164,7 +174,21 @@ const make = Effect.gen(function* () { pending.commandId, ); // Fiber-local admission is rebuilt on every replay. No captured scope survives a restart. - const outcome = yield* threads.dispatch(pending).pipe( + const outcome = yield* Effect.gen(function* () { + // Replays bind an already accepted run; only a new start needs fresh validation. + if (!committed) { + if (pending.modelSelection === undefined) + return yield* new SpectrumRoundError({ + threadId, + cause: "Round command has no model selection", + }); + yield* Prism.validateLaunchSelection(pending.modelSelection).pipe( + Effect.provideService(ProviderAdapters.ProviderAdapterRegistryV2, adapters), + Effect.provideService(ProviderRegistry.ProviderRegistry, providers), + ); + } + return yield* threads.dispatch(pending); + }).pipe( Effect.provideService(PeerDispatchAuthority, owner?.id), Effect.provideService(ForkDispatchPlans, [ spectrumLaunchAdmission(threadId, current.generation, pending), diff --git a/apps/server/src/spectrum/delegatedTask.integration.test.ts b/apps/server/src/spectrum/delegatedTask.integration.test.ts index 60b242cfef..cbb264741f 100644 --- a/apps/server/src/spectrum/delegatedTask.integration.test.ts +++ b/apps/server/src/spectrum/delegatedTask.integration.test.ts @@ -13,12 +13,14 @@ import { ThreadId, type NodeId, type OrchestrationV2Run, + OrchestratorMcpFailure, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as Events from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; @@ -30,6 +32,7 @@ import { layer as serialization } from "../orchestration-v2/ThreadCommandExecuto import type { McpInvocationScope } from "../mcp/McpInvocationContext.ts"; import * as Mcp from "../mcp/OrchestratorMcpService.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; +import * as Providers from "../provider/ProviderRegistry.ts"; import { initializeRecoveryHistory } from "../prism/RecoveryHistory.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -468,6 +471,171 @@ it.effect( }).pipe(Effect.provide(runtime)), ); +const isControllerError = Schema.is(Controller.SpectrumControllerError); +const isRoundError = Schema.is(Round.SpectrumRoundError); +const isMcpFailure = Schema.is(OrchestratorMcpFailure); + +it.effect.each( + (["Color", "White"] as const).flatMap((member) => + [ + { id: "serviceTier", value: true, error: "must be one of" }, + { id: "serviceTier", value: "unsupported", error: "must be one of" }, + { id: "unknown", value: "priority", error: "Unknown option" }, + { id: "serviceTier", value: "priority", error: null }, + ].map((option) => ({ member, option, label: `${option.id}=${option.value}` })), + ), +)("a delayed $member fallback validates advertised options: $label", ({ member, option }) => + Effect.gen(function* () { + yield* setup; + yield* initializeRecoveryHistory; + const codex = ProviderInstanceId.make("codex"); + const primary = { instanceId: codex, model: "test-model" }; + const spare = { instanceId: spareInstanceId, model: "test-model" }; + const backup = { ...spare, options: [{ id: option.id, value: option.value }] }; + const unavailable = [ + { instanceId: ProviderInstanceId.make("missing"), model: "test-model" }, + { ...spare, model: "missing-model" }, + ]; + // Unavailable backups are admitted now and skipped later; malformed options stop the start + // even when another valid candidate remains. + const fallbacks = option.error === null ? [...unavailable, backup] : [backup, spare]; + const slot = { primary, fallbacks }; + const providers = yield* Providers.ProviderRegistry; + const catalog = Layer.succeed(Providers.ProviderRegistry, { + ...providers, + getProviders: providers.getProviders.pipe( + Effect.map((entries) => + entries.map((provider) => ({ + ...provider, + models: provider.models.map((model) => ({ + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: [ + { + id: "serviceTier", + label: "Speed", + type: "select" as const, + options: [ + { id: "standard", label: "Standard" }, + { id: "priority", label: "Fast" }, + ], + }, + ], + }, + })), + })), + ), + ), + }); + // Load saved Colors, including their unchecked backups, through the real settings seam. + const saved = Settings.layerTest({ + spectrum: { + slots: [ + { + label: "Blue", + ...(member === "Color" ? slot : { primary: spare, fallbacks: [] }), + }, + { label: "Red", primary: spare, fallbacks: [] }, + ], + white: member === "White" ? slot : { primary: spare, fallbacks: [] }, + }, + }); + const { colors: _colors, moderator: _moderator, ...request } = agentStart; + const state = yield* Effect.gen(function* () { + return yield* (yield* Launch.SpectrumLaunchService).register(request); + }).pipe( + Effect.provide(Layer.fresh(Launch.layer).pipe(Layer.provide(Layer.mergeAll(saved, catalog)))), + ); + const target = member === "White" ? state.white! : state.participants[0]!; + assert.deepStrictEqual(target.selection, primary); + assert.deepStrictEqual(target.fallbacks, fallbacks); + const projections = yield* Projection.ProjectionStoreV2; + const projectId = (yield* projections.getThread(state.threadId)).projectId; + const gate = Layer.fresh(Threads.layer).pipe( + Layer.provideMerge( + Settings.layerTest({ + providerInstances: { + [codex]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + [spareInstanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [codex]: { enabled: false } } }, + }, + }), + ), + ); + yield* Effect.gen(function* () { + const rounds = yield* Round.SpectrumRoundService; + const result = yield* Effect.gen(function* () { + if (member === "Color") { + const prepared = yield* rounds.prepare(state.threadId); + return yield* rounds.dispatch( + state.threadId, + prepared.outbox.find( + (command) => + command.type === "message.dispatch" && command.threadId === target.threadId, + )!.commandId, + ); + } + const controller = yield* Controller.SpectrumController; + let current = yield* controller.resume(state.threadId); + // Complete the real council rounds so White reaches its own dispatch seam. + for (let step = 0; step <= state.limit; step++) { + assert.strictEqual(current.round!.step, step); + for (const slot of current.round!.slots) { + const records = yield* projections.getThreadRecords(slot.threadId, ["runs"]); + yield* ended( + records.runs.find((run) => run.id === slot.runId)!, + "completed", + "reply", + ); + } + current = yield* controller.resume(state.threadId); + } + return current; + }).pipe(Effect.result); + const runs = (yield* projections.getThreadRecords(target.threadId, ["runs"])).runs; + const persisted = yield* read(state.threadId); + const selected = member === "White" ? persisted.white! : persisted.participants[0]!; + // Both cases must reach the delayed fallback, after the initially valid primary refuses. + assert.deepStrictEqual( + selected.attempted, + option.error === null ? [primary, ...unavailable] : [primary], + ); + if (option.error === null) { + assert.strictEqual(result._tag, "Success"); + assert.deepStrictEqual( + runs.map((run) => run.modelSelection), + [backup], + ); + } else { + assert.strictEqual(result._tag, "Failure"); + if (result._tag !== "Failure") return; + const error = + member === "White" && isControllerError(result.failure) + ? result.failure.cause + : result.failure; + assert.isTrue(isRoundError(error)); + if (!isRoundError(error)) return; + assert.isTrue(isMcpFailure(error.cause)); + if (!isMcpFailure(error.cause)) return; + assert.strictEqual(error.cause.code, "invalid_request"); + assert.include(error.cause.message, option.error); + assert.deepStrictEqual(runs, []); + } + }).pipe( + Effect.provide( + Layer.fresh(Controller.layer).pipe( + Layer.provideMerge( + Layer.fresh(Round.layer).pipe(Layer.provideMerge(Layer.mergeAll(gate, catalog))), + ), + ), + ), + ); + }).pipe(Effect.provide(runtime)), +); + it.effect("a Spectrum task of a scheduled run still holds that run until its report lands", () => Effect.gen(function* () { yield* setup;