diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 139c9be408..9e8ac4a894 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -186,9 +186,11 @@ ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 ocx agent fallback set gpt-5.4-mini,xai/grok-4.5 --poll-ms 60000 -ocx agent effort set --subagent max +ocx effort set --subagent max ``` +The top-level `ocx effort` command is the canonical entry point for effort inspection and caps (e.g. `ocx effort high`, `ocx effort status`, `ocx effort clear`), with `ocx agent effort` preserved as a backward-compatible path. Note that `ocx effort clear` removes active main-agent and sub-agent caps while leaving delegation `injectionEffort` untouched (use `ocx effort set --injection -` or `ocx agent injection set --effort -` to clear injection effort). + Pass `-` to clear a nullable `ocx agent injection` value, or use the relevant `clear` action for a roster or fallback list. See the [CLI reference](/reference/cli/) for all command families. diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b6e094c54a..48a1f44be3 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -698,6 +698,10 @@ const commandRunners: Record = { return await handleRoutePolicyCommand(deps.args.slice(2)); } }, + effort: async deps => { + const { handleEffortCommand } = await import("./effort"); + return await handleEffortCommand(deps.args.slice(1), { findLiveProxy: deps.findLiveProxy }); + }, agent: async deps => { const { handleAgentCommand } = await import("./agent"); return await handleAgentCommand(deps.args.slice(1)); diff --git a/src/cli/effort.ts b/src/cli/effort.ts new file mode 100644 index 0000000000..0e4ea89d72 --- /dev/null +++ b/src/cli/effort.ts @@ -0,0 +1,372 @@ +import { loadConfig, saveConfig } from "../config"; +import { + CODEX_REASONING_LEVELS, + configuredReasoningEfforts, + isDeclaredReasoningEffort, + mapReasoningEffort, + reasoningEffortMapFor, +} from "../reasoning-effort"; +import { findLiveProxy } from "../server/proxy-liveness"; +import { modelInList, type OcxConfig } from "../types"; +import { + CliUsageError, + printData, + rejectArgs, + runCliAction, + runtimeRequest, + takeFlag, + takeOption, + type RuntimeApiDeps, +} from "./runtime-api"; + +export const EFFORT_USAGE = `Usage: + ocx effort [status] [--json] + ocx effort [--json] + ocx effort set [--main ] [--subagent ] [--injection ] [--json] + ocx effort clear [--json] + ocx effort model [--json] + +Note: 'ocx effort clear' resets main and subagent caps but keeps delegation +injection effort. Use 'ocx effort set --injection -' to clear injection effort.`; + +function clearable(value: string | undefined): string | null | undefined { + return value === "-" ? null : value; +} + +function validateEffortLevel(level: string | null | undefined, label: string): string | null | undefined { + if (level === undefined || level === null) return level; + const trimmed = level.trim(); + if (trimmed === "-" || trimmed === "") return null; + if (!isDeclaredReasoningEffort(trimmed)) { + throw new CliUsageError( + `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}, none, minimal, -)`, + EFFORT_USAGE, + ); + } + return trimmed; +} + +interface EffortCapsResponse { + effortCap: string | null; + subagentEffortCap: string | null; + efforts: string[]; +} + +interface InjectionResponse { + effort?: string | null; +} + +async function getLiveStatus(deps: RuntimeApiDeps): Promise<{ + effortCap: string | null; + subagentEffortCap: string | null; + injectionEffort: string | null; + efforts: string[]; + source: "runtime"; +}> { + // Propagate API failures once live mode is selected — do not swallow 401/500 into null (#3528 review). + const [caps, injection] = await Promise.all([ + runtimeRequest("/api/effort-caps", {}, deps), + runtimeRequest("/api/injection-model", {}, deps), + ]); + return { + effortCap: caps.effortCap ?? null, + subagentEffortCap: caps.subagentEffortCap ?? null, + injectionEffort: injection?.effort ?? null, + efforts: caps.efforts ?? CODEX_REASONING_LEVELS.map(l => l.effort), + source: "runtime", + }; +} + +function getOfflineStatus(): { + effortCap: string | null; + subagentEffortCap: string | null; + injectionEffort: string | null; + efforts: string[]; + source: "config"; +} { + const config = loadConfig(); + return { + effortCap: config.effortCap ?? null, + subagentEffortCap: config.subagentEffortCap ?? null, + injectionEffort: config.injectionEffort ?? null, + efforts: CODEX_REASONING_LEVELS.map(l => l.effort), + source: "config", + }; +} + +async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise { + let data: { + effortCap: string | null; + subagentEffortCap: string | null; + injectionEffort: string | null; + efforts: string[]; + source: "runtime" | "config"; + }; + + const liveFinder = deps.findLiveProxy ?? findLiveProxy; + const live = await liveFinder().catch(() => null); + const isLive = Boolean(live || deps.baseUrl); + + if (isLive) { + // Once live proxy / baseUrl is selected, propagate API failures; do not silently substitute local config (#3528 review). + data = await getLiveStatus(deps); + } else { + data = getOfflineStatus(); + } + + const lines = [ + `Reasoning effort status (${data.source === "runtime" ? "live proxy" : "offline config"}):`, + ` Main agent effort cap: ${data.effortCap ?? "(unset — no cap)"}`, + ` Subagent effort cap: ${data.subagentEffortCap ?? "(unset — no cap)"}`, + ` Subagent injection effort: ${data.injectionEffort ?? "(unset — inherits parent session)"}`, + "", + "Supported Codex reasoning effort ladder:", + ...CODEX_REASONING_LEVELS.map(l => ` - ${l.effort.padEnd(8)} ${l.description}`), + ]; + + printData(data, wantsJson, lines); +} + +async function setEffort( + options: { + main?: string | null; + subagent?: string | null; + injection?: string | null; + }, + wantsJson: boolean, + deps: RuntimeApiDeps, +): Promise { + const validatedMain = validateEffortLevel(options.main, "--main"); + const validatedSubagent = validateEffortLevel(options.subagent, "--subagent"); + const validatedInjection = validateEffortLevel(options.injection, "--injection"); + + if (validatedMain === undefined && validatedSubagent === undefined && validatedInjection === undefined) { + throw new CliUsageError("at least one effort option (--main, --subagent, or --injection) is required", EFFORT_USAGE); + } + + // Probe live proxy BEFORE any mutation attempt + const liveFinder = deps.findLiveProxy ?? findLiveProxy; + const live = await liveFinder().catch(() => null); + const isLive = Boolean(live || deps.baseUrl); + + if (isLive) { + // Live update path: once live proxy / baseUrl is selected, HTTP 4xx/5xx or transport + // errors must never fall through to silent offline config writes (#3528 review). + const capsBody: Record = {}; + if (validatedMain !== undefined) capsBody.effortCap = validatedMain; + if (validatedSubagent !== undefined) capsBody.subagentEffortCap = validatedSubagent; + + let capsCommitted = false; + let injectionCommitted = false; + + if (Object.keys(capsBody).length > 0) { + await runtimeRequest("/api/effort-caps", { + method: "PUT", + body: JSON.stringify(capsBody), + }, deps); + capsCommitted = true; + } + + if (validatedInjection !== undefined) { + try { + await runtimeRequest("/api/injection-model", { + method: "PUT", + body: JSON.stringify({ effort: validatedInjection }), + }, deps); + injectionCommitted = true; + } catch (err) { + if (capsCommitted) { + const errMsg = err instanceof Error ? err.message : String(err); + throw new Error(`effort caps were updated on live proxy, but injection effort failed: ${errMsg}`); + } + throw err; + } + } + + // Accurately reflect live state: query fresh live status so unchanged caps are never serialized as null. + // If the read fails after a successful PUT, wrap with explicit partial-application error (#3528 review). + let finalStatus: { effortCap: string | null; subagentEffortCap: string | null; injectionEffort: string | null; efforts: string[] }; + try { + finalStatus = await getLiveStatus(deps); + } catch (err) { + if (capsCommitted || injectionCommitted) { + const errMsg = err instanceof Error ? err.message : String(err); + throw new Error(`live state was updated, but verifying live status failed: ${errMsg}`); + } + throw err; + } + + const result = { + ok: true, + effortCap: finalStatus.effortCap, + subagentEffortCap: finalStatus.subagentEffortCap, + injectionEffort: finalStatus.injectionEffort, + source: "runtime" as const, + }; + + printData(result, wantsJson, [ + "Effort caps updated on live proxy:", + ...(validatedMain !== undefined ? [` Main agent effort cap: ${validatedMain ?? "(cleared)"}`] : []), + ...(validatedSubagent !== undefined ? [` Subagent effort cap: ${validatedSubagent ?? "(cleared)"}`] : []), + ...(validatedInjection !== undefined ? [` Subagent injection effort: ${validatedInjection ?? "(cleared)"}`] : []), + ]); + return; + } + + // Offline persistence path: only reached when no live proxy was found before mutation + const config = loadConfig(); + if (validatedMain !== undefined) { + if (validatedMain === null) delete config.effortCap; + else config.effortCap = validatedMain; + } + if (validatedSubagent !== undefined) { + if (validatedSubagent === null) delete config.subagentEffortCap; + else config.subagentEffortCap = validatedSubagent; + } + if (validatedInjection !== undefined) { + if (validatedInjection === null) delete config.injectionEffort; + else config.injectionEffort = validatedInjection; + } + saveConfig(config); + + const result = { + ok: true, + effortCap: config.effortCap ?? null, + subagentEffortCap: config.subagentEffortCap ?? null, + injectionEffort: config.injectionEffort ?? null, + source: "config" as const, + }; + + printData(result, wantsJson, [ + "[offline] Effort caps updated in config.json (proxy is not running; changes will take effect on next start):", + ...(validatedMain !== undefined ? [` Main agent effort cap: ${validatedMain ?? "(cleared)"}`] : []), + ...(validatedSubagent !== undefined ? [` Subagent effort cap: ${validatedSubagent ?? "(cleared)"}`] : []), + ...(validatedInjection !== undefined ? [` Subagent injection effort: ${validatedInjection ?? "(cleared)"}`] : []), + ]); +} + +function inspectModelEffort(modelTarget: string, wantsJson: boolean): void { + // Reject leading or trailing slash selectors before lookup (#3528 review) + if (modelTarget.startsWith("/") || modelTarget.endsWith("/")) { + throw new CliUsageError( + `invalid model selector "${modelTarget}" (must be or without leading or trailing slashes)`, + EFFORT_USAGE, + ); + } + + const config = loadConfig(); + let providerName = ""; + let modelId = modelTarget; + + const slashIndex = modelTarget.indexOf("/"); + if (slashIndex > 0) { + providerName = modelTarget.slice(0, slashIndex); + modelId = modelTarget.slice(slashIndex + 1); + } else { + providerName = config.defaultProvider || "openai"; + } + + const provider = config.providers[providerName]; + if (!provider) { + throw new CliUsageError( + `Provider "${providerName}" is not configured. Configured providers: ${Object.keys(config.providers).join(", ")}`, + EFFORT_USAGE, + ); + } + + const isReasoningDisabled = modelInList(provider.noReasoningModels, modelId); + const efforts = configuredReasoningEfforts(provider, modelId); + const wireMap = reasoningEffortMapFor(provider, modelId); + + // Derive sample ladder directly from canonical CODEX_REASONING_LEVELS (#3528 review) + const mappedExamples: Record = {}; + for (const { effort } of CODEX_REASONING_LEVELS) { + mappedExamples[effort] = mapReasoningEffort(provider, modelId, effort); + } + + const result = { + provider: providerName, + model: modelId, + reasoningDisabled: isReasoningDisabled, + supportedEfforts: efforts ?? null, + wireMap: wireMap ?? null, + mappedTiers: mappedExamples, + }; + + const lines = [ + `Reasoning effort configuration for ${providerName}/${modelId}:`, + ` Reasoning disabled: ${isReasoningDisabled ? "yes (noReasoningModels)" : "no"}`, + ` Supported ladder: ${efforts ? efforts.join(", ") : "(default / unconstrained)"}`, + ` Wire mapping overrides: ${wireMap ? JSON.stringify(wireMap) : "(standard provider mapping)"}`, + " Sample wire translations:", + ...Object.entries(mappedExamples).map(([req, wire]) => ` ${req.padEnd(8)} -> ${wire ?? "(omitted/unsupported)"}`), + ]; + + printData(result, wantsJson, lines); +} + +export async function handleEffortCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + + if (args.length === 0) { + await status(wantsJson, deps); + return; + } + + const rawFirst = args[0]!; + const first = rawFirst.toLowerCase(); + + if (first === "status") { + args.shift(); + rejectArgs(args, EFFORT_USAGE); + await status(wantsJson, deps); + return; + } + + if (first === "clear" || first === "unset") { + args.shift(); + rejectArgs(args, EFFORT_USAGE); + await setEffort({ main: null, subagent: null }, wantsJson, deps); + return; + } + + if (first === "set") { + args.shift(); + const main = clearable(takeOption(args, "--main")); + const subagent = clearable(takeOption(args, "--subagent")); + const injection = clearable(takeOption(args, "--injection")); + rejectArgs(args, EFFORT_USAGE); + await setEffort({ main, subagent, injection }, wantsJson, deps); + return; + } + + if (first === "model") { + args.shift(); + const target = args.shift(); + if (!target) throw new CliUsageError("model identifier ( or ) is required", EFFORT_USAGE); + rejectArgs(args, EFFORT_USAGE); + inspectModelEffort(target, wantsJson); + return; + } + + // Shorthand: check if first argument is an effort level or "-" + if (first === "-" || isDeclaredReasoningEffort(first)) { + args.shift(); + rejectArgs(args, EFFORT_USAGE); + await setEffort({ main: first === "-" ? null : first }, wantsJson, deps); + return; + } + + // Shorthand model inspection: preserve raw casing and check slash boundaries (#3528 review) + if (rawFirst.includes("/")) { + args.shift(); + rejectArgs(args, EFFORT_USAGE); + inspectModelEffort(rawFirst, wantsJson); + return; + } + + throw new CliUsageError(`unknown effort command or level "${rawFirst}"`, EFFORT_USAGE); + }); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index e03cdd903e..0b3652ab59 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -66,6 +66,7 @@ Usage: ocx alias Short names for providers and models (list, set, rm, defaults) ocx combo Combo routing strategies and failover ocx agent Subagents, injection, effort caps, and sidecars + ocx effort [sub] Inspect and configure reasoning effort caps and defaults ocx observe Logs, usage, storage, memory, and debug data ocx inspect Effective config, catalog, analytics, pacing, client-config ocx route Routing features (combo, policy) diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 32d7481606..00ff25ed52 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -228,6 +228,19 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx route combo ...", summary: "Manage routing features; combo is currently the supported routing resource.", }, + { + name: "effort", + usage: "ocx effort [status||set|clear|model] [--main ] [--subagent ] [--injection ] [--json]", + summary: "Inspect and configure reasoning effort caps and defaults.", + details: [ + "With no arguments or `status`, displays effective effort caps, injection effort, and supported rungs.", + "`ocx effort ` (or `set --main `) sets the global/main-agent reasoning ceiling.", + "`--subagent ` sets the hard ceiling for delegated sub-agent turns.", + "`ocx effort clear` (or `set --main - --subagent -`) removes main and subagent caps but preserves injection effort; use `ocx effort set --injection -` to clear it.", + "`ocx effort model ` inspects a model's configured ladder, disabled status, and wire mappings.", + "Works both online (via live proxy API) and offline (modifies persisted config safely with atomic writes).", + ], + }, { name: "agent", usage: "ocx agent ...", diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index 15c5b037d6..f6d7353280 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -9,7 +9,7 @@ * - 다른 대안 대신 이 방식을 선택한 이유: GUI/CLI의 검증 규칙이 갈라지지 않고 fallback port도 안전하게 찾는다. * - 장점, 단점 및 영향: 동작 일관성이 높아지는 대신 live 관리 명령은 실행 중인 proxy가 필요하다. */ -import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; +import { findLiveProxy, probeHostname, type LivenessIo, type LiveProxy } from "../server/proxy-liveness"; import { runningProxyUpdateHeaders } from "../oauth/login-cli"; export type CliStdin = NodeJS.ReadableStream & { isTTY?: boolean; readableEnded?: boolean }; @@ -20,6 +20,8 @@ export interface RuntimeApiDeps { /** Test injection for commands that read a secret from stdin instead of argv. */ stdinImpl?: CliStdin; stdinTimeoutMs?: number; + /** Optional proxy liveness probe injection for commands that check or fall back around live runtime state. */ + findLiveProxy?: (io?: LivenessIo) => Promise; } export class CliUsageError extends Error { diff --git a/tests/cli/cli-effort.test.ts b/tests/cli/cli-effort.test.ts new file mode 100644 index 0000000000..6e8079183b --- /dev/null +++ b/tests/cli/cli-effort.test.ts @@ -0,0 +1,428 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleEffortCommand } from "../../src/cli/effort"; +import { dispatchCommand } from "../../src/cli/dispatch"; +import type { CliDispatchDeps } from "../../src/cli/dispatch"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; + +let tempHome: string | null = null; +const savedHome = process.env.OPENCODEX_HOME; +let logOrig = console.log; +let errorOrig = console.error; + +beforeEach(() => { + logOrig = console.log; + errorOrig = console.error; + tempHome = mkdtempSync(join(tmpdir(), "ocx-effort-test-")); + process.env.OPENCODEX_HOME = tempHome; + const initialConfig: OcxConfig = { + port: 10100, + defaultProvider: "anthropic", + providers: { + anthropic: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "oauth", + models: ["claude-sonnet-5", "claude-haiku-4-5"], + modelReasoningEfforts: { + "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], + }, + }, + MyProvider: { + adapter: "openai-chat", + baseUrl: "https://my.test/v1", + models: ["model-1"], + }, + }, + } as unknown as OcxConfig; + writeFileSync(join(tempHome, "config.json"), JSON.stringify(initialConfig, null, 2), "utf8"); +}); + +afterEach(() => { + console.log = logOrig; + console.error = errorOrig; + if (savedHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = savedHome; + if (tempHome) { + removeTreeWithRetry(tempHome); + tempHome = null; + } +}); + +function readTestConfig(): OcxConfig { + return JSON.parse(readFileSync(join(tempHome!, "config.json"), "utf8")) as OcxConfig; +} + +function fakeDeps(args: string[] = []): { + deps: CliDispatchDeps; + logs: string[]; + errors: string[]; +} { + const logs: string[] = []; + const errors: string[] = []; + console.log = (...a: unknown[]) => logs.push(a.map(String).join(" ")); + console.error = (...a: unknown[]) => errors.push(a.map(String).join(" ")); + + const deps: CliDispatchDeps = { + args, + command: "effort", + head: { kind: "command", command: "effort", args }, + loadConfig: () => readTestConfig(), + findLiveProxy: async () => null, + probeHostname: () => "127.0.0.1", + waitForProxy: async () => null, + startArgv: () => [], + spawnDetached: () => {}, + handleStart: async () => {}, + handleStop: async () => true, + handleEnsure: async () => true, + handleTrayProxyStart: async () => true, + handleTrayProxyRestart: async () => {}, + handleRestartStartWhenStopped: async () => true, + handleProxyRestart: async () => true, + handleUninstall: async () => {}, + handleStatus: async () => {}, + handleRecoverHistory: async () => {}, + handleReady: async () => 0, + serviceCommand: async () => {}, + }; + + return { deps, logs, errors }; +} + +describe("ocx effort offline config operations", () => { + test("ocx effort (bare) prints offline status", async () => { + const { deps, logs } = fakeDeps([]); + const code = await handleEffortCommand([], deps); + expect(code).toBe(0); + expect(logs.join("\n")).toContain("Reasoning effort status (offline config)"); + expect(logs.join("\n")).toContain("Main agent effort cap: (unset — no cap)"); + }); + + test("ocx effort status --json returns JSON envelope", async () => { + const { deps, logs } = fakeDeps(["status", "--json"]); + const code = await handleEffortCommand(["status", "--json"], deps); + expect(code).toBe(0); + const parsed = JSON.parse(logs.join("\n")); + expect(parsed.source).toBe("config"); + expect(parsed.effortCap).toBeNull(); + expect(parsed.subagentEffortCap).toBeNull(); + expect(parsed.efforts).toContain("low"); + expect(parsed.efforts).toContain("ultra"); + }); + + test("ocx effort sets main effort cap offline", async () => { + const { deps, logs } = fakeDeps(["high"]); + const code = await handleEffortCommand(["high"], deps); + expect(code).toBe(0); + expect(logs.join("\n")).toContain("Main agent effort cap: high"); + expect(readTestConfig().effortCap).toBe("high"); + }); + + test("ocx effort - clears main effort cap offline", async () => { + const conf = readTestConfig(); + conf.effortCap = "high"; + writeFileSync(join(tempHome!, "config.json"), JSON.stringify(conf, null, 2), "utf8"); + + const { deps } = fakeDeps(["-"]); + const code = await handleEffortCommand(["-"], deps); + expect(code).toBe(0); + expect(readTestConfig().effortCap).toBeUndefined(); + }); + + test("ocx effort set --main and --subagent sets both caps", async () => { + const { deps } = fakeDeps(["set", "--main", "max", "--subagent", "medium"]); + const code = await handleEffortCommand(["set", "--main", "max", "--subagent", "medium"], deps); + expect(code).toBe(0); + const updated = readTestConfig(); + expect(updated.effortCap).toBe("max"); + expect(updated.subagentEffortCap).toBe("medium"); + }); + + test("ocx effort clear unsets both caps but preserves injection effort", async () => { + const conf = readTestConfig(); + conf.effortCap = "high"; + conf.subagentEffortCap = "low"; + conf.injectionEffort = "max"; + writeFileSync(join(tempHome!, "config.json"), JSON.stringify(conf, null, 2), "utf8"); + + const { deps } = fakeDeps(["clear"]); + const code = await handleEffortCommand(["clear"], deps); + expect(code).toBe(0); + const updated = readTestConfig(); + expect(updated.effortCap).toBeUndefined(); + expect(updated.subagentEffortCap).toBeUndefined(); + expect(updated.injectionEffort).toBe("max"); + }); + + test("ocx effort set --injection - clears injection without changing caps", async () => { + const conf = readTestConfig(); + conf.effortCap = "high"; + conf.subagentEffortCap = "low"; + conf.injectionEffort = "max"; + writeFileSync(join(tempHome!, "config.json"), JSON.stringify(conf, null, 2), "utf8"); + + const { deps } = fakeDeps(["set", "--injection", "-"]); + const code = await handleEffortCommand(["set", "--injection", "-"], deps); + expect(code).toBe(0); + const updated = readTestConfig(); + expect(updated.effortCap).toBe("high"); + expect(updated.subagentEffortCap).toBe("low"); + expect(updated.injectionEffort).toBeUndefined(); + }); + + test("ocx effort rejects unknown effort level with usage error 2", async () => { + const { deps, errors } = fakeDeps(["super-hyper-max"]); + const code = await handleEffortCommand(["super-hyper-max"], deps); + expect(code).toBe(2); + expect(errors.join("\n")).toContain('unknown effort command or level "super-hyper-max"'); + }); + + test("ocx effort model inspects configured model reasoning metadata", async () => { + const { deps, logs } = fakeDeps(["model", "anthropic/claude-sonnet-5"]); + const code = await handleEffortCommand(["model", "anthropic/claude-sonnet-5"], deps); + expect(code).toBe(0); + expect(logs.join("\n")).toContain("Reasoning effort configuration for anthropic/claude-sonnet-5"); + expect(logs.join("\n")).toContain("Supported ladder: low, medium, high, xhigh, max"); + }); + + test("selector regression: malformed leading or trailing slash selectors are rejected with usage error 2", async () => { + const { deps: deps1, errors: errors1 } = fakeDeps(["/claude-sonnet-5"]); + const code1 = await handleEffortCommand(["/claude-sonnet-5"], deps1); + expect(code1).toBe(2); + expect(errors1.join("\n")).toContain("invalid model selector"); + + const { deps: deps2, errors: errors2 } = fakeDeps(["anthropic/"]); + const code2 = await handleEffortCommand(["anthropic/"], deps2); + expect(code2).toBe(2); + expect(errors2.join("\n")).toContain("invalid model selector"); + }); + + test("shorthand selector regression: mixed-case provider key is preserved in shorthand", async () => { + const { deps, logs } = fakeDeps(["MyProvider/model-1"]); + const code = await handleEffortCommand(["MyProvider/model-1"], deps); + expect(code).toBe(0); + expect(logs.join("\n")).toContain("Reasoning effort configuration for MyProvider/model-1"); + }); +}); + +describe("ocx effort online live-proxy integration & negative regressions", () => { + test("live status read failures never substitute offline config", async () => { + const { logs, errors } = fakeDeps(["status", "--json"]); + const configBefore = readTestConfig(); + const code = await handleEffortCommand(["status", "--json"], { + baseUrl: "http://127.0.0.1:10100", + findLiveProxy: async () => null, + fetchImpl: async () => new Response(JSON.stringify({ error: "permission_denied" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }), + }); + expect(code).not.toBe(0); + expect(logs).toEqual([]); + expect(errors.join("\n")).toContain("permission_denied"); + expect(readTestConfig()).toEqual(configBefore); + }); + + test("ocx effort uses live management API when proxy is active", async () => { + const requests: Array<{ path: string; method?: string; body?: unknown }> = []; + const runtimeDeps = { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request, init?: RequestInit) => { + const u = new URL(url.toString()); + requests.push({ + path: u.pathname, + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(init.body as string) : undefined, + }); + if (u.pathname === "/api/effort-caps") { + return new Response(JSON.stringify({ + effortCap: "xhigh", + subagentEffortCap: "medium", + efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (u.pathname === "/api/injection-model") { + return new Response(JSON.stringify({ effort: "high" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }, + }; + + const code = await handleEffortCommand(["status", "--json"], runtimeDeps); + expect(code).toBe(0); + expect(requests.some(r => r.path === "/api/effort-caps")).toBe(true); + }); + + test("ocx effort set communicates mutation to live management API", async () => { + let liveCaps: { effortCap: string | null; subagentEffortCap: string | null } = { + effortCap: null, + subagentEffortCap: null, + }; + const runtimeDeps = { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request, init?: RequestInit) => { + const u = new URL(url.toString()); + if (u.pathname === "/api/effort-caps" && init?.method === "PUT") { + const body = JSON.parse(init.body as string); + liveCaps.effortCap = body.effortCap ?? null; + liveCaps.subagentEffortCap = body.subagentEffortCap ?? null; + return new Response(JSON.stringify({ + ok: true, + effortCap: liveCaps.effortCap, + subagentEffortCap: liveCaps.subagentEffortCap, + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (u.pathname === "/api/effort-caps" && (init?.method === "GET" || !init?.method)) { + return new Response(JSON.stringify({ + effortCap: liveCaps.effortCap, + subagentEffortCap: liveCaps.subagentEffortCap, + efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (u.pathname === "/api/injection-model") { + return new Response(JSON.stringify({ effort: null }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); + }, + }; + + const code = await handleEffortCommand(["set", "--main", "high", "--subagent", "low", "--json"], runtimeDeps); + expect(code).toBe(0); + expect(liveCaps.effortCap).toBe("high"); + expect(liveCaps.subagentEffortCap).toBe("low"); + }); + + test("negative regression 1: live 4xx/5xx fails non-zero and never falls through to saveConfig", async () => { + const configBefore = readTestConfig(); + const runtimeDeps = { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async () => { + return new Response(JSON.stringify({ error: "permission_denied: invalid admin token" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + }, + }; + + const code = await handleEffortCommand(["set", "--main", "high"], runtimeDeps); + expect(code).not.toBe(0); + // Persisted config must NOT have changed under a live failure + expect(readTestConfig().effortCap).toBe(configBefore.effortCap); + }); + + test("negative regression 2: failure after caps PUT succeeds identifies partial application and fails non-zero", async () => { + let capsCommitted = false; + const runtimeDeps = { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request) => { + const u = new URL(url.toString()); + if (u.pathname === "/api/effort-caps") { + capsCommitted = true; + return new Response(JSON.stringify({ ok: true, effortCap: "high" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (u.pathname === "/api/injection-model") { + return new Response(JSON.stringify({ error: "subagent injection template unwriteable" }), { status: 500, headers: { "Content-Type": "application/json" } }); + } + return new Response("{}", { status: 200 }); + }, + }; + + const errors: string[] = []; + console.error = (...a: unknown[]) => errors.push(a.map(String).join(" ")); + + const code = await handleEffortCommand(["set", "--main", "high", "--injection", "medium"], runtimeDeps); + expect(code).not.toBe(0); + expect(capsCommitted).toBe(true); + expect(errors.join("\n")).toContain("effort caps were updated on live proxy, but injection effort failed"); + }); + + test("negative regression 3: successful PUT followed by failed status GET wraps with explicit verification error and fails non-zero", async () => { + let capsCommitted = false; + const runtimeDeps = { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request, init?: RequestInit) => { + const u = new URL(url.toString()); + if (u.pathname === "/api/effort-caps" && init?.method === "PUT") { + capsCommitted = true; + return new Response(JSON.stringify({ ok: true, effortCap: "high" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (u.pathname === "/api/effort-caps" && (init?.method === "GET" || !init?.method)) { + // Status verification GET fails with 500 + return new Response(JSON.stringify({ error: "internal telemetry failure" }), { status: 500, headers: { "Content-Type": "application/json" } }); + } + return new Response("{}", { status: 200 }); + }, + }; + + const errors: string[] = []; + console.error = (...a: unknown[]) => errors.push(a.map(String).join(" ")); + + const code = await handleEffortCommand(["set", "--main", "high"], runtimeDeps); + expect(code).not.toBe(0); + expect(capsCommitted).toBe(true); + expect(errors.join("\n")).toContain("live state was updated, but verifying live status failed"); + }); + + test("negative regression 4: unreachable-before-mutation offline fallback when live proxy probe throws or returns null", async () => { + const { deps, logs } = fakeDeps(["high"]); + deps.findLiveProxy = async () => { + throw new Error("daemon socket closed"); + }; + + const code = await handleEffortCommand(["high"], deps); + expect(code).toBe(0); + expect(logs.join("\n")).toContain("[offline] Effort caps updated in config.json"); + expect(readTestConfig().effortCap).toBe("high"); + }); + + test("negative regression 5: injection-only update preserves existing caps without fabricating null", async () => { + let recordedInjection = ""; + const runtimeDeps = { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request, init?: RequestInit) => { + const u = new URL(url.toString()); + if (u.pathname === "/api/injection-model" && init?.method === "PUT") { + const body = JSON.parse(init.body as string); + recordedInjection = body.effort; + return new Response(JSON.stringify({ ok: true, effort: body.effort }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (u.pathname === "/api/effort-caps") { + return new Response(JSON.stringify({ + effortCap: "high", + subagentEffortCap: "medium", + efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (u.pathname === "/api/injection-model") { + return new Response(JSON.stringify({ effort: recordedInjection || "low" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("{}", { status: 200 }); + }, + }; + + const logs: string[] = []; + console.log = (...a: unknown[]) => logs.push(a.map(String).join(" ")); + + const code = await handleEffortCommand(["set", "--injection", "max", "--json"], runtimeDeps); + expect(code).toBe(0); + const parsed = JSON.parse(logs.join("\n")); + expect(parsed.effortCap).toBe("high"); + expect(parsed.subagentEffortCap).toBe("medium"); + expect(parsed.injectionEffort).toBe("max"); + }); + + test("ocx effort dispatches through top-level dispatchCommand", async () => { + const argv = ["effort", "medium"]; + const { deps } = fakeDeps(argv); + const code = await dispatchCommand({ kind: "command", command: "effort", args: argv }, deps); + expect(code).toBe(0); + expect(readTestConfig().effortCap).toBe("medium"); + }); +});