diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 0faf43acb9dd5..b91a62f157031 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -66,7 +66,7 @@ import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; -import { resolveRuntimeMode } from "./delegatedTaskModes.ts"; +import { resolveCyberAccessProgram, resolveRuntimeMode } from "./delegatedTaskModes.ts"; import type { ProjectionRecordFilter } from "../orchestration-v2/ProjectionStore.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -3253,6 +3253,56 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); }); + + describe("resolveCyberAccessProgram", () => { + const codex = (options?: ReadonlyArray<{ id: string; value: string }>) => ({ + instanceId: codexInstanceId, + model: "gpt-6-sol", + ...(options === undefined ? {} : { options }), + }); + const requested = (value: string) => [{ id: "cyberAccessProgram", value }]; + + it.effect("refuses a cyber access program the parent thread did not choose", () => + Effect.gen(function* () { + const error = yield* resolveCyberAccessProgram( + codex(), + codex(requested("daybreakRed")), + ).pipe(Effect.flip); + assert.equal(error.code, "invalid_request"); + const switched = yield* resolveCyberAccessProgram( + codex(requested("daybreakBlue")), + codex(requested("daybreakRed")), + ).pipe(Effect.flip); + assert.equal(switched.code, "invalid_request"); + const duplicated = yield* resolveCyberAccessProgram( + codex(), + codex([...requested("default"), ...requested("daybreakBlue")]), + ).pipe(Effect.flip); + assert.equal(duplicated.code, "invalid_request"); + // The model may not offer the parent's program, so it does not carry over. + const otherModel = yield* resolveCyberAccessProgram(codex(requested("daybreakBlue")), { + ...codex(requested("daybreakBlue")), + model: "gpt-6.1-sol", + }).pipe(Effect.flip); + assert.equal(otherModel.code, "invalid_request"); + }), + ); + + it.effect("allows the parent's program, the default, or no request", () => + Effect.gen(function* () { + const parent = codex(requested("daybreakBlue")); + yield* resolveCyberAccessProgram(parent, codex(requested("daybreakBlue"))); + yield* resolveCyberAccessProgram(parent, { + ...codex(requested("default")), + model: "gpt-6.1-sol", + }); + yield* resolveCyberAccessProgram( + codex(), + codex([{ id: "reasoningEffort", value: "high" }]), + ); + }), + ); + }); }); describe("OrchestratorMcpService thread lookup", () => { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e53ed03a7c6fa..409b38d98c9b8 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -118,6 +118,7 @@ import { import * as Metrics from "../observability/Metrics.ts"; import { interactionModeRank, + resolveCyberAccessProgram, resolveInteractionMode, resolveRuntimeMode, runtimeModeRank, @@ -1443,6 +1444,11 @@ const make = Effect.gen(function* () { `Model ${model} on provider ${instanceId} rejected options: ${invalid.join(" ")}`, ); } + yield* resolveCyberAccessProgram(inheritedSelection, { + instanceId, + model, + options: requestedOptions, + }); } const inheritsOptions = diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 54d25bbb36e02..81adbf74c6837 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -5,8 +5,10 @@ import { type OrchestratorMcpInteractionMode, type OrchestratorMcpRuntimeMode, type ProviderInteractionMode, + type ProviderOptionSelection, type RuntimeMode, } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; /** How much a runtime mode allows, narrowest first. */ @@ -87,6 +89,41 @@ export function resolveInteractionMode( : Effect.succeed(resolved); } +/** + * A child may keep its parent's cyber access program on the same instance and model, but not + * choose another; only the user can. Codex grants programs per account and model. + */ +export function resolveCyberAccessProgram( + parentSelection: ModelSelection | undefined, + requested: Pick & { + readonly options?: ReadonlyArray | undefined; + }, +): Effect.Effect { + const sameModel = + parentSelection?.instanceId === requested.instanceId && + parentSelection.model === requested.model; + const parentValue = + (sameModel + ? getModelSelectionStringOptionValue(parentSelection, "cyberAccessProgram") + : undefined) ?? "default"; + // Check every entry: a later duplicate would win wherever options are merged. + const chosen = (requested.options ?? []).find( + (selection) => + selection.id === "cyberAccessProgram" && + selection.value !== "default" && + selection.value !== parentValue, + ); + return chosen === undefined + ? Effect.void + : Effect.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "The requested cyber access program is not valid for this thread. Omit it; the user chooses one in the composer's model options.", + }), + ); +} + /** The task as its child receives it, with the role the caller asked for. */ export function taskPrompt(input: OrchestratorMcpDelegateTaskInput): string { return input.role === undefined || input.role === "general" diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 602031290763d..5308a9f361308 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -525,6 +525,25 @@ it.effect("a client caller targets any thread within its ceiling and cannot act code: "runtime_mode_escalation_denied", }); + // The thread runs Daybreak Blue on gpt-6-sol: configure may keep it, not move it. + const configureCyber = (model: string) => + call( + "t3_thread_configure", + { + threadId: "other-project-thread", + modelSelection: { + instanceId: "codex", + model, + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }, + clientScope("auto"), + ); + expect((yield* configureCyber("gpt-6-sol")).isError).toBe(false); + expect(declaredFailure(yield* configureCyber("gpt-6.1-sol"))).toMatchObject({ + code: "invalid_request", + }); + const forked = yield* call( "t3_thread_transfer", { action: "fork", sourcePoint: { type: "latest_stable" } }, @@ -545,6 +564,12 @@ it.effect("a client caller targets any thread within its ceiling and cannot act thread: { id: ThreadId.make("other-project-thread"), projectId: "other-project", + providerInstanceId: "codex", + modelSelection: { + instanceId: "codex", + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, runtimeMode: "auto", interactionMode: "default", deletedAt: null, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 6721e89478550..2921b0b8be20e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -188,6 +188,7 @@ it.effect( readonly targeted?: false; readonly overrideModel?: ModelSelection; readonly hostedAppUrl?: string; + readonly modelSelection?: ModelSelection; readonly refusedByCeiling?: true; }, ) { @@ -248,6 +249,17 @@ it.effect( expect(escalated.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); expect(escalated.launched).toHaveLength(0); + const cyber = yield* launchWith("full-access", "full-access", { + title: "Fix", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }); + expect(cyber.result).toMatchObject({ code: "invalid_request" }); + expect(cyber.launched).toHaveLength(0); + const untargeted = yield* launchWith("full-access", "full-access", { title: "Fix", targeted: false, @@ -276,6 +288,31 @@ it.effect( }), ); +it.effect("a client cannot make a cyber access program a project's default", () => + Effect.gen(function* () { + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched: [], + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const result = yield* toolkit + .handle("t3_project_update", { + projectId, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(result.at(-1)?.result).toMatchObject({ code: "invalid_request" }); + }), +); + it.effect("a launch steps an inherited mode down to the provider's, and refuses one it lacks", () => Effect.gen(function* () { // Pi offers no Auto; a mode a provider lacks would run as Supervised. @@ -367,6 +404,23 @@ it.effect("a launch retried with the same clientRequestId replays the first one" expect(ids[2]).not.toEqual(ids[0]); // Without a key every launch is new. expect(ids[3]).not.toEqual(ids[4]); + + // A retry still cannot choose a cyber access program. + const retried = yield* toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + message: "Fix the bug", + clientRequestId: "fix-1", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(retried.at(-1)?.result).toMatchObject({ code: "invalid_request" }); + expect(launched).toHaveLength(5); }), ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index a14ee03daa470..0b2efa7d8b686 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -26,7 +26,7 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as DateTime from "effect/DateTime"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; +import { resolveCyberAccessProgram, resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; @@ -131,9 +131,9 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { Effect.mapError(unavailable), Effect.map(Option.getOrUndefined), ); - // A retry of an accepted launch replays it: it keeps the model it started - // with and skips the checks only a new launch needs, which the project - // may no longer pass, such as a base branch deleted since. + // A retry of an accepted launch replays it: without a modelSelection it keeps + // the launched model, and it skips the checks only a new launch needs, which + // the project may no longer pass, such as a base branch deleted since. const launched = input.clientRequestId === undefined ? null @@ -208,6 +208,9 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { message: "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", }); + // A retry's selection still reaches a new run, so replays are checked too. + if (input.modelSelection !== undefined) + yield* resolveCyberAccessProgram(caller?.modelSelection, input.modelSelection); let startedRuntimeMode = projectDefaults !== undefined && input.runtimeMode === undefined ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( @@ -345,6 +348,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ...(created.commitError === undefined ? {} : { commitError: created.commitError }), }; } + if (input.defaultModelSelection) + yield* resolveCyberAccessProgram(undefined, input.defaultModelSelection); const commandId = yield* newCommandId(); return yield* projects .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) @@ -357,6 +362,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_project_update: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { const projects = yield* Project.ProjectService; + const requested = input.defaultModelSelection; + if (requested) { + const current = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable)); + yield* resolveCyberAccessProgram( + Option.getOrUndefined(current)?.defaultModelSelection ?? undefined, + requested, + ); + } return yield* projects .update({ ...input, commandId: yield* newCommandId() }) .pipe(Effect.mapError(projectFailure)); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index f968312437518..fe79e96e787be 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -10,6 +10,7 @@ import { import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; +import { resolveCyberAccessProgram } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -218,6 +219,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { threads, projection: { thread }, } = yield* readThread(input.threadId); + yield* resolveCyberAccessProgram(thread.modelSelection, modelSelection); const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); const result = yield* threads .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection }) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 84c78b91453c2..190ae5c3e4498 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -255,6 +255,31 @@ describe("CodexAdapterV2 runtime policy", () => { assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]); }), ); + + it.effect("requests a cyber access program only when one is selected", () => + Effect.gen(function* () { + const build = (cyberAccessProgram: string | undefined) => + CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-cyber-access", + codexInput: [{ type: "text", text: "test" }], + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + ...(cyberAccessProgram === undefined + ? {} + : { options: [{ id: "cyberAccessProgram", value: cyberAccessProgram }] }), + }, + }); + + assert.notProperty(yield* build(undefined), "cyberAccessProgram"); + assert.notProperty(yield* build("default"), "cyberAccessProgram"); + assert.notProperty(yield* build("standard"), "cyberAccessProgram"); + assert.notProperty(yield* build("daybreakPurple"), "cyberAccessProgram"); + assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build("daybreakRed")).cyberAccessProgram, "daybreakRed"); + }), + ); }); describe("CodexAdapterV2 native protocol logging", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 07c4e4e72eca7..3d73ba2e65dc5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -696,19 +696,13 @@ const decodeTurnSandboxPolicy = Schema.decodeUnknownEffect( const decodeTurnReasoningEffort = Schema.decodeUnknownEffect( Schema.Union([CodexSchema.V2TurnStartParams__ReasoningEffort, Schema.Null]), ); +const isTurnCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); -const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( - Schema.fieldsAssign({ - collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), - additionalContext: Schema.optionalKey( - Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), - ), - }), -); type CodexTurnStartParamsWithCollaborationMode = - typeof CodexTurnStartParamsWithCollaborationMode.Type; + typeof CodexClient.V2TurnStartParamsWithCollaborationMode.Type; +// The client encodes with this schema, so decoding with it keeps only what reaches Codex. const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( - CodexTurnStartParamsWithCollaborationMode, + CodexClient.V2TurnStartParamsWithCollaborationMode, ); const isProviderAdapterRuntimeRequestResponseError = Schema.is( ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, @@ -786,6 +780,15 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); + const selectedCyberAccessProgram = getModelSelectionStringOptionValue( + input.modelSelection, + "cyberAccessProgram", + ); + const cyberAccessProgram = + isTurnCyberAccessProgram(selectedCyberAccessProgram) && + selectedCyberAccessProgram !== "standard" + ? selectedCyberAccessProgram + : undefined; // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -821,6 +824,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), ...(effort === undefined ? {} : { effort }), ...(serviceTier === undefined ? {} : { serviceTier }), + ...(cyberAccessProgram === undefined ? {} : { cyberAccessProgram }), // Codex supplies its own mode instructions; send both modes to clear Plan on exit. collaborationMode: { mode: input.runtimePolicy.interactionMode, diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 00b7b5d6f6963..5b9e39f856671 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -6,6 +6,7 @@ import { CommandId, EventId, MessageId, + type ModelSelection, NodeId, type OrchestrationV2Run, ProjectId, @@ -642,7 +643,10 @@ const parentScope: McpInvocationContext.McpInvocationScope = { }; /** The laptop's parent thread, mid-turn when its agent delegates. */ -const seedParent = (laptopEnv: Effect.Success>) => +const seedParent = ( + laptopEnv: Effect.Success>, + parentModelSelection: ModelSelection = modelSelection, +) => Effect.gen(function* () { const now = yield* DateTime.now; const providerThreadId = ProviderThreadId.make("provider-thread:laptop-parent"); @@ -654,7 +658,7 @@ const seedParent = (laptopEnv: Effect.Success>) => threadId: parentThreadId, projectId: ProjectId.make("project:laptop"), title: "Laptop parent", - modelSelection, + modelSelection: parentModelSelection, runtimeMode: "full-access", interactionMode: "default", branch: null, @@ -1111,6 +1115,50 @@ it.effect("a task named only by environment inherits the parent's model, within clientRequestId: "broader", }); expect(broader.isError).toBe(true); + + // Only the user picks a cyber access program; the parent here has none. + const cyber = yield* a.call("delegate_task", { + task: "Run the suite on the box.", + target: { + environmentId: box.environmentId, + options: { cyberAccessProgram: "daybreakRed" }, + }, + clientRequestId: "cyber", + }); + expect(cyber.isError).toBe(true); + expect(JSON.stringify(cyber.content)).toContain("stays on this machine"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a task there inherits the parent's model without its cyber access program", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a, { + ...modelSelection, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "cyberAccessProgram", value: "daybreakBlue" }, + ], + }); + + const result = yield* a.call("delegate_task", { + task: "Run the suite on the box.", + target: { environmentId: box.environmentId }, + }); + expect(result.isError, JSON.stringify(result.content)).toBe(false); + expect((yield* Ref.get(thread.launchedWith))?.modelSelection).toEqual({ + ...modelSelection, + options: [{ id: "reasoningEffort", value: "high" }], + }); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 89917026ff8f7..edca6b47a1a6a 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -231,11 +231,23 @@ const make = Effect.gen(function* () { message: "Pass target.model from orchestrator_capabilities with this environmentId.", }); } - const options = + // A cyber access program belongs to this machine's account; a task there runs with Automatic. + if ( + target.options?.some( + (selection) => selection.id === "cyberAccessProgram" && selection.value !== "default", + ) + ) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "A cyber access program stays on this machine. Omit cyberAccessProgram for a task in another environment.", + }); + const options = ( target.options ?? (instanceId === inherited.instanceId && model === inherited.model ? inherited.options - : undefined); + : undefined) + )?.filter((selection) => selection.id !== "cyberAccessProgram"); // Modes asked for are checked against this thread. Omitted ones are left // to the other side, which inherits this thread's modes capped by the // link's access, so a narrower link still takes the task. diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 30f63b21f318e..c4f002dfbe5b5 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + appendCustomCodexModels, + applyPreferredCodexDefaultModel, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -12,6 +16,7 @@ it("maps current Codex model capability fields", () => { id: "gpt-test", isDefault: true, model: "gpt-test", + availableAccessPrograms: { cyber: ["standard", "daybreakBlue", "daybreakRed"] }, defaultServiceTier: "flex", serviceTiers: [ { @@ -61,6 +66,30 @@ it("maps current Codex model capability fields", () => { ], currentValue: "flex", }, + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { + id: "default", + label: "Automatic", + description: "Codex's default treatment", + isDefault: true, + }, + { + id: "daybreakBlue", + label: "Daybreak Blue", + description: "Request Daybreak Blue on each turn", + }, + { + id: "daybreakRed", + label: "Daybreak Red", + description: "Request Daybreak Red on each turn", + }, + ], + currentValue: "default", + }, ]); }); @@ -75,6 +104,7 @@ it("uses standard routing when the catalog has no default service tier", () => { id: "gpt-test", isDefault: true, model: "gpt-test", + availableAccessPrograms: { cyber: ["standard"] }, serviceTiers: [ { id: "priority", @@ -153,3 +183,27 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("does not lend cyber access to custom models", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [{ description: "Medium", reasoningEffort: "medium" }], + }); + const [, custom] = appendCustomCodexModels( + [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }], + ["gpt-custom"], + ); + + assert.deepStrictEqual( + custom?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id), + ["reasoningEffort"], + ); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2d546b36a9b15..e1442138f03d8 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -113,6 +113,16 @@ const SERVICE_TIER_DESCRIPTIONS: Readonly> = { ultrafast: "Even faster, more expensive", }; +const DEFAULT_CYBER_ACCESS_PROGRAM_ID = "default"; + +const CYBER_ACCESS_PROGRAM_LABELS: Readonly< + Record +> = { + standard: "Standard", + daybreakBlue: "Daybreak Blue", + daybreakRed: "Daybreak Red", +}; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -241,6 +251,30 @@ export function mapCodexModelCapabilities( currentValue: defaultServiceTier, }); } + const cyberAccessPrograms = (model.availableAccessPrograms?.cyber ?? []).filter( + (program) => program !== "standard", + ); + if (cyberAccessPrograms.length > 0) { + optionDescriptors.push({ + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { + id: DEFAULT_CYBER_ACCESS_PROGRAM_ID, + label: "Automatic", + description: "Codex's default treatment", + isDefault: true, + }, + ...cyberAccessPrograms.map((program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program], + description: `Request ${CYBER_ACCESS_PROGRAM_LABELS[program]} on each turn`, + })), + ], + currentValue: DEFAULT_CYBER_ACCESS_PROGRAM_ID, + }); + } return createModelCapabilities({ optionDescriptors, @@ -313,14 +347,21 @@ function applyConfiguredCodexServiceTier( /** * Codex has no static default capability set, so a bare custom slug borrows - * the first built-in's descriptors; an entry with its own capabilities keeps - * them. + * the first built-in's descriptors, except cyber access, which Codex grants + * per catalog model; an entry with its own capabilities keeps them. */ -function appendCustomCodexModels( +export function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, ): ReadonlyArray { - const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; + const borrowed = models.find((model) => model.capabilities)?.capabilities; + const fallbackCapabilities = borrowed + ? createModelCapabilities({ + optionDescriptors: (borrowed.optionDescriptors ?? []).filter( + (descriptor) => descriptor.id !== "cyberAccessProgram", + ), + }) + : null; return providerModelsFromSettings(models, customModels, fallbackCapabilities); } diff --git a/docs/user/composer.md b/docs/user/composer.md index c236da3c82c60..f16945da47701 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -124,6 +124,9 @@ returns to the remembered selection. Leaving reasoning level or service tier unset uses the provider's own configuration. +Codex shows **Cyber Access** on models whose catalog lists a Daybreak program for your account. +Choosing a program requests it for the turn; OpenAI still decides whether the account may use it. + ## Quote an assistant response On web and desktop, select text within one assistant response and choose diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..2d0508368d059 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,52 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderOptionDescriptor, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildTraitsTriggerDisplay } from "./modelTraits.ts"; + +describe("buildTraitsTriggerDisplay", () => { + it("labels Codex cyber access only when a program is chosen", () => { + const display = (currentValue: string, reportedProgram?: string) => + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("codex"), + primarySelectDescriptorId: "reasoningEffort", + ultrathinkPromptControlled: false, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6-sol" }, + ...(reportedProgram === undefined + ? {} + : { + reportedModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: reportedProgram }], + }, + }), + descriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [{ id: "medium", label: "Medium", isDefault: true }], + currentValue: "medium", + }, + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "default", label: "Automatic", isDefault: true }, + { id: "daybreakBlue", label: "Daybreak Blue" }, + ], + currentValue, + }, + ] satisfies ReadonlyArray, + }).label; + + expect(display("default")).toBe("Medium"); + expect(display("daybreakBlue")).toBe("Medium · Daybreak Blue"); + expect(display("default", "daybreakBlue")).toBe("Medium · Daybreak Blue"); + }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index b05f9226defb3..17963cd035bff 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -155,6 +155,17 @@ export function buildTraitsTriggerDisplay(input: { continue; } } + if ( + input.provider === "codex" && + descriptor.id === "cyberAccessProgram" && + getProviderOptionCurrentValue( + descriptor, + input.modelSelection, + input.reportedModelSelection, + ) === "default" + ) { + continue; + } const label = input.ultrathinkPromptControlled && descriptor.id === input.primarySelectDescriptorId ? "Ultrathink" diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 56d9dbcebfae1..2df4356968e16 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -22,6 +23,15 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => ); const mockPeerArgs = (path: string) => [path]; +it("keeps a requested cyber access program when encoding turn/start", () => { + const encode = Schema.encodeUnknownSync(CodexClient.V2TurnStartParamsWithCollaborationMode); + const params = { threadId: "thread-1", input: [] }; + for (const program of ["daybreakBlue", "daybreakRed"] as const) { + assert.equal(encode({ ...params, cyberAccessProgram: program }).cyberAccessProgram, program); + } + assert.notProperty(encode(params), "cyberAccessProgram"); +}); + it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { const makeHandle = (env?: Record) => Effect.gen(function* () { diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 28f677e03ee0a..98e71bd266bb8 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -86,12 +86,18 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( +/** + * `turn/start` params as sent, with experimental fields the generated struct lacks. + * Encoding drops unknown keys, so callers build params with this schema too. + */ +export const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + // Codex still enforces authorization for the requested program. + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), );