From a26f8bfe143142d299ffe1709f98ceafff5ba3d6 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:12:30 +0900 Subject: [PATCH] fix(cli): reject unsupported caps and report ignored legacy values --- .../content/docs/ko/reference/cli/agents.md | 20 +++ .../src/content/docs/reference/cli/agents.md | 20 +++ src/cli/effort.ts | 32 +++-- structure/03_catalog-and-subagents.md | 5 + tests/cli/cli-effort.test.ts | 126 ++++++++++++++++++ 5 files changed, 195 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 3624a2a803..e7eca07b9c 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -16,6 +16,26 @@ description: 멀티 에이전트, 콤보, 관측성, 접근, 통합, 시스템, ocx agent subagents set ark/model-a,openai/gpt-5.5 ``` +### `ocx effort [status|set|clear]` + +실행 중인 프록시를 통해 메인·서브에이전트의 reasoning-effort 상한을 조회하거나 변경하며, +프록시가 없으면 로컬 설정을 사용합니다. 상한은 `low`, `medium`, `high`, `xhigh`, `max`, +`ultra`이고, `-`는 해당 상한을 해제합니다. `none`과 `minimal`은 상한 단계가 아니므로 같은 +명령의 다른 옵션이 유효하더라도 프록시 탐색이나 설정 변경 요청 전에 거부됩니다. + +```bash +ocx effort status --json +ocx effort set --main high --subagent low +ocx effort set --subagent - +``` + +상태 조회는 저장값 또는 런타임 상한 원문을 보존하고, 지원하지 않는 값은 `warnings`에 표시합니다 +(모두 지원되는 값이면 빈 배열). 일반 출력에도 같은 경고가 나오며, 무시되는 필드와 수정 명령을 +안내합니다. 상태 조회가 기존 값을 자동으로 복구하거나 덮어쓰지는 않습니다. 서브에이전트 필드가 +무시되더라도 유효한 메인 상한이 사라지는 것은 아닙니다. `ocx effort clear`는 별도의 injection-effort +설정을 유지하면서 두 상한을 해제합니다. 상한이 적용되는 요청 surface는 +[Sub-agent surfaces](/ko/guides/sub-agent-surface/)를 참고하세요. + ### `ocx v2 |threads >` Codex `multi_agent_v2` 기능 플래그와 세 상태 멀티 에이전트 surface mode를 관리합니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 4b95d1bcd2..c7ca95e764 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -33,6 +33,26 @@ ocx agent sidecar web --list ocx agent sidecar web --model gpt-5.6-luna ``` +### `ocx effort [status|set|clear]` + +Inspect or change main and subagent reasoning-effort caps through the live proxy, or the local +configuration when no proxy is available. Cap values are `low`, `medium`, `high`, `xhigh`, `max`, +and `ultra`; `-` clears the selected cap. `none` and `minimal` are not cap levels and are rejected +before probing the proxy or submitting an update, including when another option in the same command is valid. + +```bash +ocx effort status --json +ocx effort set --main high --subagent low +ocx effort set --subagent - +``` + +Status preserves existing stored/runtime cap values and reports unsupported values in `warnings` +(an empty array when none are unsupported). The same warnings appear in human output and name the +field that is ignored with a correction command. Status never repairs or rewrites those values. +An ignored subagent field does not remove a valid main cap. `ocx effort clear` clears both caps +while retaining the separate injection-effort setting. See [Sub-agent surfaces](/guides/sub-agent-surface/) +for the request surfaces where caps apply. + ### `ocx v2 |keep-native-v1 |threads |mode-hint >` Manage the Codex `multi_agent_v2` feature flag and the three-state multi-agent surface mode. diff --git a/src/cli/effort.ts b/src/cli/effort.ts index 0e4ea89d72..1daed4c679 100644 --- a/src/cli/effort.ts +++ b/src/cli/effort.ts @@ -2,6 +2,7 @@ import { loadConfig, saveConfig } from "../config"; import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, + isCodexReasoningEffort, isDeclaredReasoningEffort, mapReasoningEffort, reasoningEffortMapFor, @@ -21,7 +22,7 @@ import { export const EFFORT_USAGE = `Usage: ocx effort [status] [--json] - ocx effort [--json] + ocx effort [--json] ocx effort set [--main ] [--subagent ] [--injection ] [--json] ocx effort clear [--json] ocx effort model [--json] @@ -33,13 +34,18 @@ function clearable(value: string | undefined): string | null | undefined { return value === "-" ? null : value; } -function validateEffortLevel(level: string | null | undefined, label: string): string | null | undefined { +function validateEffortLevel( + level: string | null | undefined, + label: string, + kind: "cap" | "injection", +): string | null | undefined { if (level === undefined || level === null) return level; const trimmed = level.trim(); if (trimmed === "-" || trimmed === "") return null; - if (!isDeclaredReasoningEffort(trimmed)) { + const valid = kind === "cap" ? isCodexReasoningEffort(trimmed) : isDeclaredReasoningEffort(trimmed); + if (!valid) { throw new CliUsageError( - `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}, none, minimal, -)`, + `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}${kind === "injection" ? ", none, minimal" : ""}, -)`, EFFORT_USAGE, ); } @@ -114,6 +120,15 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise { data = getOfflineStatus(); } + // Report the stored/runtime value exactly as the enforcement layer evaluates it. + // An ignored subagent field does not disable a valid main cap on that child. + const warnings = ([ ["effortCap", "--main"], ["subagentEffortCap", "--subagent"] ] as const) + .flatMap(([key, flag]) => { + const value = data[key]; + if (value === null || isCodexReasoningEffort(value)) return []; + return [`${key}=${JSON.stringify(value)} is invalid and is not applied. Use: ocx effort set ${flag} <${CODEX_REASONING_LEVELS.map(l => l.effort).join("|")}|->.`]; + }); + const lines = [ `Reasoning effort status (${data.source === "runtime" ? "live proxy" : "offline config"}):`, ` Main agent effort cap: ${data.effortCap ?? "(unset — no cap)"}`, @@ -122,9 +137,10 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise { "", "Supported Codex reasoning effort ladder:", ...CODEX_REASONING_LEVELS.map(l => ` - ${l.effort.padEnd(8)} ${l.description}`), + ...(warnings.length ? ["", "Warnings:", ...warnings.map(warning => ` ${warning}`)] : []), ]; - printData(data, wantsJson, lines); + printData({ ...data, warnings }, wantsJson, lines); } async function setEffort( @@ -136,9 +152,9 @@ async function setEffort( wantsJson: boolean, deps: RuntimeApiDeps, ): Promise { - const validatedMain = validateEffortLevel(options.main, "--main"); - const validatedSubagent = validateEffortLevel(options.subagent, "--subagent"); - const validatedInjection = validateEffortLevel(options.injection, "--injection"); + const validatedMain = validateEffortLevel(options.main, "--main", "cap"); + const validatedSubagent = validateEffortLevel(options.subagent, "--subagent", "cap"); + const validatedInjection = validateEffortLevel(options.injection, "--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); diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index d3f20de401..49a571e493 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -346,6 +346,11 @@ wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhig (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. +The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. +Its status output preserves unsupported legacy cap values and reports that those fields are ignored; +the read does not normalize or migrate them, and an ignored subagent field does not disable a valid +main cap. Injection-effort input remains a separate contract. + Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root `modelPinnedEfforts` resolve before applicable effort caps at the final destination. Provider model pins precede provider-wide pins, then global selector/destination pins. diff --git a/tests/cli/cli-effort.test.ts b/tests/cli/cli-effort.test.ts index 6e8079183b..b80028fd02 100644 --- a/tests/cli/cli-effort.test.ts +++ b/tests/cli/cli-effort.test.ts @@ -112,6 +112,112 @@ describe("ocx effort offline config operations", () => { expect(parsed.subagentEffortCap).toBeNull(); expect(parsed.efforts).toContain("low"); expect(parsed.efforts).toContain("ultra"); + expect(parsed.warnings).toEqual([]); + }); + + for (const value of ["none", "minimal"]) { + for (const target of ["shorthand", "--main", "--subagent"]) { + test(`rejects unsupported cap ${value} through ${target} before probing or saving`, async () => { + const args = target === "shorthand" ? [value] : ["set", target, value]; + const { deps, logs, errors } = fakeDeps(args); + const configBefore = readFileSync(join(tempHome!, "config.json"), "utf8"); + let probes = 0; + deps.findLiveProxy = async () => { probes += 1; return null; }; + expect(await handleEffortCommand(args, deps)).toBe(2); + expect(errors.join("\n")).toContain('unknown reasoning effort "' + value + '"'); + expect(errors.join("\n")).toContain("allowed: low, medium, high, xhigh, max, ultra, -"); + expect(probes).toBe(0); + expect(logs).toEqual([]); + expect(readFileSync(join(tempHome!, "config.json"), "utf8")).toBe(configBefore); + }); + } + + test(`offline injection still accepts ${value} without treating it as a cap`, async () => { + const { deps } = fakeDeps(); + expect(await handleEffortCommand(["set", "--injection", value], deps)).toBe(0); + expect(readTestConfig().injectionEffort).toBe(value); + expect(readTestConfig().effortCap).toBeUndefined(); + expect(readTestConfig().subagentEffortCap).toBeUndefined(); + }); + } + + test("rejects unsupported cap spelling without advertising sentinel cap values", async () => { + const { deps, errors } = fakeDeps(); + expect(await handleEffortCommand(["set", "--main", "bogus"], deps)).toBe(2); + expect(errors.join("\n")).toContain("allowed: low, medium, high, xhigh, max, ultra, -"); + expect(errors.join("\n")).not.toContain("ultra, none, minimal"); + }); + + for (const source of ["config", "runtime"] as const) { + for (const wantsJson of [false, true]) { + test(`legacy unsupported cap diagnostics preserve ${source} values (${wantsJson ? "json" : "human"})`, async () => { + const conf = { ...readTestConfig(), effortCap: "none", subagentEffortCap: "minimal", injectionEffort: "none" }; + const configPath = join(tempHome!, "config.json"); + writeFileSync(configPath, JSON.stringify(conf, null, 2), "utf8"); + const configBefore = readFileSync(configPath, "utf8"); + const { deps, logs } = fakeDeps(); + const methods: string[] = []; + const main = source === "config" ? "none" : "minimal"; + const subagent = source === "config" ? "minimal" : "none"; + const runtime = source === "runtime" ? { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request, init?: RequestInit) => { + methods.push(init?.method ?? "GET"); + const body = new URL(url.toString()).pathname === "/api/effort-caps" + ? { effortCap: main, subagentEffortCap: subagent } + : { effort: "none" }; + return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); + }, + } : {}; + const args = wantsJson ? ["status", "--json"] : ["status"]; + expect(await handleEffortCommand(args, { ...deps, ...runtime })).toBe(0); + let warnings: string[]; + if (wantsJson) { + const result = JSON.parse(logs.join("\n")); + expect(result.source).toBe(source); + expect(result.effortCap).toBe(main); + expect(result.subagentEffortCap).toBe(subagent); + expect(result.injectionEffort).toBe("none"); + expect(result.warnings).toHaveLength(2); + warnings = result.warnings; + } else { + expect(logs.join("\n")).toContain(`Main agent effort cap: ${main}`); + warnings = logs; + } + expect(warnings.join("\n")).toContain(`effortCap="${main}" is invalid and is not applied`); + expect(warnings.join("\n")).toContain(`subagentEffortCap="${subagent}" is invalid and is not applied`); + expect(warnings.join("\n")).toContain("ocx effort set --main"); + expect(warnings.join("\n")).toContain("ocx effort set --subagent"); + expect(methods).toEqual(source === "runtime" ? ["GET", "GET"] : []); + expect(readFileSync(configPath, "utf8")).toBe(configBefore); + }); + } + } + + test("invalid legacy cap diagnostics do not normalize stored whitespace or casing", async () => { + const conf = { ...readTestConfig(), effortCap: " high ", subagentEffortCap: "HIGH" }; + const configPath = join(tempHome!, "config.json"); + writeFileSync(configPath, JSON.stringify(conf, null, 2), "utf8"); + const before = readFileSync(configPath, "utf8"); + const { deps, logs } = fakeDeps(); + expect(await handleEffortCommand(["status", "--json"], deps)).toBe(0); + const result = JSON.parse(logs.join("\n")); + expect(result.effortCap).toBe(" high "); + expect(result.subagentEffortCap).toBe("HIGH"); + expect(result.warnings).toHaveLength(2); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + test("an ignored subagent cap warning preserves the valid main cap", async () => { + const conf = { ...readTestConfig(), effortCap: "high", subagentEffortCap: "minimal" }; + writeFileSync(join(tempHome!, "config.json"), JSON.stringify(conf, null, 2), "utf8"); + const { deps, logs } = fakeDeps(); + expect(await handleEffortCommand(["status", "--json"], deps)).toBe(0); + const result = JSON.parse(logs.join("\n")); + expect(result.effortCap).toBe("high"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].startsWith('subagentEffortCap="minimal"')).toBe(true); + expect(readTestConfig()).toEqual(conf); }); test("ocx effort sets main effort cap offline", async () => { @@ -210,6 +316,26 @@ describe("ocx effort offline config operations", () => { }); describe("ocx effort online live-proxy integration & negative regressions", () => { + for (const value of ["none", "minimal"]) { + test(`invalid cap values reject a mixed live update before any request (${value})`, async () => { + const { deps, logs } = fakeDeps(); + const before = readFileSync(join(tempHome!, "config.json"), "utf8"); + let requests = 0; + let probes = 0; + const code = await handleEffortCommand(["set", "--main", "high", "--subagent", value, "--injection", "medium"], { + ...deps, + baseUrl: "http://127.0.0.1:10100", + findLiveProxy: async () => { probes += 1; return null; }, + fetchImpl: async () => { requests += 1; return new Response("{}"); }, + }); + expect(code).toBe(2); + expect(probes).toBe(0); + expect(requests).toBe(0); + expect(logs).toEqual([]); + expect(readFileSync(join(tempHome!, "config.json"), "utf8")).toBe(before); + }); + } + test("live status read failures never substitute offline config", async () => { const { logs, errors } = fakeDeps(["status", "--json"]); const configBefore = readTestConfig();