diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index ae6a4775100..3d427ac444a 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1036,6 +1036,11 @@ ocx agent roles suggest ocx agent roles suggest --model xai/grok-4.5 --apply ``` +The CLI's `--apply` accepts up to 50 changed roles in one history batch. A larger operation is +refused before any role update; preview it and apply individual choices with `ocx agent roles set`. +An interrupted operation reports confirmed writes and the unconfirmed request, and leaves earlier +writes applied. See the [CLI apply and recovery contract](/reference/cli/agents/) before retrying. + Under the table, **Change history** lists the newest changes a save, an auto-assign apply or `ocx agent roles set` wrote, newest first, in whole batches and never more than 50, so an Apply all is listed with every role it wrote or not at all, with each role's model and effort before and after. One Apply all is one entry. **Restore** writes the earlier values back through the same save, so a restore is listed too. A save never removes a model pin, so a role that had none keeps its current model and gets only its effort back. The history lives in `integrations/codex-agent-roles.jsonl` under the opencodex config directory, which retains at most 256 KiB and 1000 rows by dropping its oldest whole batches. Compaction targets 128 KiB and preserves a Restore together with its complete source batch when they fit both limits, making room by dropping unrelated older batches; history can still expire as later writes arrive. The writer captures the bounded history before appending so a Restore does not evict its own source at the retention boundary; the omo card's Restore center above tracks only `~/.omo/agent/models.json`. Each change records the Codex home it was made in, and only the current home's changes are listed or restored, so after pointing `CODEX_HOME` elsewhere the list starts empty and a Restore from another home's history is refused. If the history file cannot be read, the section says so in place of the list, and the role table keeps working. ## Kilo diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index 78a542c29b8..e34ff7db555 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -245,6 +245,8 @@ LazyCodex が検出されると、omo タブの **omo (Codex / LazyCodex)** セ **自動割り当て** は、必要な能力と推論負荷を評価したロールのうち、選んだモデルの推論負荷の段階リストが空でないすべてのロールに推論負荷を提案します。**適用**、**すべて適用**、または `ocx agent roles suggest --apply` は、そのロールの `model_reasoning_effort` を追加または置き換えます。推論負荷は選んだモデル自身の段階から、最も低い段階、既定の段階、既定より 1 つ上の段階、最も高い段階のいずれかになります。段階リストが空なら推論負荷を提案せず(`proposedEffort: null`)、適用時は `effort` を送らないため既存の固定値はそのまま残ります。`null` の提案が固定値を消すことはありません。 +CLI の `ocx agent roles suggest --apply` は、1 つの履歴バッチで最大 **50 件の変更対象ロール**を適用します。設定が既に一致するロール、評価されていないロール、割当先のないロールは数えません。50 件を超える場合は、ロールの更新を一件も送信せず終了コード 5 で拒否します。`--apply` なしでプレビューし、個別の変更には `ocx agent roles set [--effort ]` を使ってください。途中で失敗した場合の確認済み・未確認の更新と再試行の注意点は、[CLI の適用と復旧の契約](/reference/cli/agents/)を参照してください。 + ## Kilo Kilo CLI、VS Code、JetBrains は同じグローバル設定を共有します。この統合は `~/.config/kilo` 内の `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json`、`config.json` のうち最初に存在するファイルに `provider.opencodex` を書き込みます。`XDG_CONFIG_HOME` でこのディレクトリを変更できます。候補がなければ `kilo.jsonc` を作成します。プロジェクト設定には書き込みません。 diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index ba1a77a7ea9..db7efc81b07 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -255,6 +255,8 @@ LazyCodex가 감지되면 omo 탭의 **omo (Codex / LazyCodex)** 섹션에서 `$ **자동 배정**은 필요한 능력과 추론 강도를 판정한 역할 가운데 선택된 모델의 강도 단계 목록이 비어 있지 않은 모든 역할에 추론 강도를 제안하며, **적용**, **모두 적용** 또는 `ocx agent roles suggest --apply`는 그 역할의 `model_reasoning_effort` 값을 추가하거나 바꿉니다. 강도는 선택된 모델 자체의 단계에서 정하며, 가장 낮은 단계, 기본 단계, 기본보다 한 단계 높은 단계, 가장 높은 단계 중 하나입니다. 단계 목록이 비어 있으면 강도를 제안하지 않고(`proposedEffort: null`), 적용할 때 `effort`를 보내지 않아 기존 강도 고정을 그대로 둡니다. `null` 제안은 고정값을 지우지 않습니다. +CLI의 `ocx agent roles suggest --apply`는 한 이력 묶음에 **변경할 역할을 최대 50개** 적용합니다. 이미 같은 값으로 설정됐거나 평가·배정되지 않은 역할은 세지 않습니다. 50개를 넘으면 역할 업데이트를 하나도 보내지 않고 종료 코드 5로 거부합니다. `--apply` 없이 미리 확인한 뒤, 개별 변경에는 `ocx agent roles set [--effort ]`을 사용하세요. 도중에 실패했을 때 확인된 쓰기와 미확인 요청을 구분하고 재시도하는 방법은 [CLI 적용·복구 규칙](/reference/cli/agents/)을 참고하세요. + ## Kilo Kilo CLI, VS Code, JetBrains는 전역 설정을 공유합니다. 이 연동은 `~/.config/kilo` 아래의 `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, `config.json` 중 먼저 존재하는 파일에 `provider.opencodex`를 씁니다. `XDG_CONFIG_HOME`로 이 디렉터리를 옮길 수 있습니다. 후보 파일이 없으면 `kilo.jsonc`를 만듭니다. 프로젝트 설정은 수정하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index bce042afced..11b7f0269cf 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -34,6 +34,23 @@ prints a proposed model and effort per role without writing anything. `--apply` through the same write as `set`, skipping and naming the roles whose model and effort already match. See [Auto-assign](/guides/integrations/#auto-assign). +One `--apply` accepts at most **50 changed roles**, so its recorded changes fit one complete +history batch and can be restored together in the dashboard's **Change history**. Already-set, +unsized and unassigned roles do not count toward this limit. If more than 50 roles would change, +the command exits 5 before sending any role update. Preview without `--apply`, then use +`ocx agent roles set [--effort ]` for individual changes. + +Updates run sequentially. If a request fails, earlier confirmed writes remain applied, later +roles are not attempted, and the command keeps the original nonzero error exit. The receipt names +confirmed writes, the role whose write was not confirmed, roles not attempted, and the server-issued +history batch when known. A lost response can follow a saved write, including the first write before +its batch id is received; inspect `ocx agent roles --json` and the dashboard history before retrying. +No automatic retry or rollback occurs. History remains subject to its existing availability and +retention limits; a batch id does not guarantee that every write was recorded or is still restorable. +With `--json`, an interrupted apply adds `complete: false`, `unconfirmedRole` and `unattemptedRoles` +beside the existing `applied`, `skipped` and `omoJsoncNotWritten` arrays. A preflight refusal instead +adds `refusal` with code `too_many_role_changes`, the limit and the proposed change count. + Human-readable suggestion output displays terminal control characters as visible escapes. Use `--json` when you need the original suggestion values without presentation escaping. diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index 47299f9e003..6afaa08e162 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -579,6 +579,8 @@ OpenCodex требует явного `--overwrite-conflict`. Отключени **Автоназначение** предлагает уровень для каждой оценённой роли, у выбранной модели которой непустой список уровней, а **Применить**, **Применить все** или `ocx agent roles suggest --apply` добавляют или заменяют её значение `model_reasoning_effort`. Уровень выбирается из собственных уровней модели: самый низкий, уровень по умолчанию, на один выше уровня по умолчанию или самый высокий. Для пустого списка уровень не предлагается (`proposedEffort: null`); **Применить** тогда не отправляет `effort` и оставляет закреплённый уровень как есть. Предложение `null` никогда не сбрасывает закреплённый уровень. +Команда CLI `ocx agent roles suggest --apply` применяет не более **50 изменяемых ролей** в одном пакете истории. Роли с уже совпадающими настройками, без оценки или без назначения не учитываются. При превышении лимита команда завершается с кодом 5 до отправки любого обновления роли. Просмотрите предложения без `--apply`, затем применяйте отдельные изменения через `ocx agent roles set [--effort ]`. Подтверждённые записи, запросы с неподтверждённым результатом и порядок повторной попытки описаны в [контракте применения и восстановления CLI](/reference/cli/agents/). + ## Kilo Kilo CLI, VS Code и JetBrains используют общую глобальную конфигурацию. Интеграция записывает `provider.opencodex` в первый существующий файл среди `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json` и `config.json` в `~/.config/kilo`. Переменная `XDG_CONFIG_HOME` переносит этот каталог. Если файлов нет, создаётся `kilo.jsonc`. Конфигурация проекта не изменяется. diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index 41c8042427e..6b8c37a9f81 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -243,6 +243,8 @@ Undo 会恢复**两个原始字节串**,包括原本不存在的文件。操 **自动分配** 会为每个已评估、且所选模型的推理强度级别列表不为空的角色提出推理强度;**应用**、**全部应用** 或 `ocx agent roles suggest --apply` 会插入或替换该角色的 `model_reasoning_effort`。推理强度取自所选模型自身的级别:最低级别、默认级别、比默认高一级,或最高级别。级别列表为空时不提出推理强度(`proposedEffort: null`);此时 **应用** 会省略 `effort`,保留已有的固定强度。`null` 提案永远不会清除固定的推理强度。 +CLI 命令 `ocx agent roles suggest --apply` 在一个历史批次中最多应用 **50 个需要更改的角色**。设置已一致、未完成评估或未分配的角色不计入限额。超过 50 个时,命令会在发送任何角色更新之前以退出码 5 拒绝操作。请先不带 `--apply` 预览,再用 `ocx agent roles set [--effort ]` 逐个应用更改。中途中断后如何区分已确认的写入、结果未确认的请求以及安全重试,请参阅 [CLI 应用与恢复约定](/reference/cli/agents/)。 + ## Kilo Kilo CLI、VS Code 和 JetBrains 共用一份全局配置。此集成只在 `~/.config/kilo` 下最先存在的 `kilo.jsonc`、`kilo.json`、`opencode.jsonc`、`opencode.json` 或 `config.json` 中写入 `provider.opencodex`。`XDG_CONFIG_HOME` 可以迁移该目录;如果候选文件均不存在,则创建 `kilo.jsonc`。不会修改项目配置。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2442a618c9c..707b7c3c28f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -499,7 +499,7 @@ "cli-dto-fidelity.test.ts": "cli", "cli-export-command.test.ts": "cli", "cli-head.test.ts": "cli", - "cli-headless-parity.test.ts": "cli", + "cli-headless-parity.test.ts": "cli", "cli-role-suggestion-history.test.ts": "cli", "cli-help.test.ts": "cli", "cli-help-paths.test.ts": "cli", "cli-help-navigation.test.ts": "cli", diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 8f0f7eecca3..48928cacd8a 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -1,5 +1,6 @@ import { CliUsageError, + RuntimeApiError, csv, desktopSwitchApplyReason, printData, @@ -345,27 +346,48 @@ async function suggestRoles(args: string[], wantsJson: boolean, deps: RuntimeApi ); const proposals = result.proposals ?? []; const applied: Array<{ role: string; model: string; effort?: string }> = []; - const skipped: string[] = []; + const skipped = apply ? proposals.filter(p => p.status === "proposed" && p.proposedModel && proposalAlreadySet(p)).map(p => p.role) : []; + const changes = proposals.filter((p): p is CodexRoleProposal & { proposedModel: string } => + p.status === "proposed" && !!p.proposedModel && !proposalAlreadySet(p)); + const { ROLE_HISTORY_LIMIT } = await import("../codex/role-model-history"); const omoJsoncNotWritten: Array<{ role: string; status: string }> = []; + let batch: string | undefined; + let failure: { cause: unknown; unconfirmedRole: string; unattemptedRoles: string[] } | undefined; if (apply) { - for (const proposal of proposals) { - if (proposal.status !== "proposed" || !proposal.proposedModel) continue; - if (proposalAlreadySet(proposal)) { - skipped.push(proposal.role); - continue; + // Splitting into several batches would hide earlier changes from the bounded Restore list. + if (changes.length > ROLE_HISTORY_LIMIT) { + const message = `${changes.length} changed proposals exceed the ${ROLE_HISTORY_LIMIT}-role batch limit. No role files were written. Preview without --apply and use 'ocx agent roles set' for individual changes.`; + printData({ ...result, applied, skipped, omoJsoncNotWritten, complete: false, + refusal: { code: "too_many_role_changes", limit: ROLE_HISTORY_LIMIT, count: changes.length }, + unattemptedRoles: changes.map(p => p.role) }, wantsJson, [message]); + throw new RuntimeApiError(message, 409, null); + } + for (const [index, proposal] of changes.entries()) { + const body = { + model: proposal.proposedModel, + ...(proposal.proposedEffort ? { effort: proposal.proposedEffort } : {}), + ...(batch ? { batch } : {}), + }; + let written: { batch?: string; omoJsonc?: { status?: string } } | null; + try { + written = await runtimeRequest( + `/api/codex-agent-roles/${encodeURIComponent(proposal.role)}`, + { method: "PUT", body: JSON.stringify(body) }, + deps, + ); + } catch (cause) { + // A lost response can follow a persisted write: report uncertainty and do not replay it. + failure = { cause, unconfirmedRole: proposal.role, unattemptedRoles: changes.slice(index + 1).map(p => p.role) }; + break; } - const body = { model: proposal.proposedModel, ...(proposal.proposedEffort ? { effort: proposal.proposedEffort } : {}) }; - const written = await runtimeRequest<{ omoJsonc?: { status?: string } } | null>( - `/api/codex-agent-roles/${encodeURIComponent(proposal.role)}`, - { method: "PUT", body: JSON.stringify(body) }, - deps, - ); - applied.push({ role: proposal.role, ...body }); + batch = written?.batch ?? batch; + applied.push({ role: proposal.role, model: body.model, ...(proposal.proposedEffort ? { effort: proposal.proposedEffort } : {}) }); const omoStatus = written?.omoJsonc?.status; if (omoStatus && OMO_JSONC_NOT_WRITTEN.has(omoStatus)) omoJsoncNotWritten.push({ role: proposal.role, status: omoStatus }); } } - printData(apply ? { ...result, applied, skipped, omoJsoncNotWritten } : result, wantsJson, [ + printData(apply ? { ...result, applied, skipped, omoJsoncNotWritten, ...(batch ? { batch } : {}), + ...(failure ? { complete: false, unconfirmedRole: failure.unconfirmedRole, unattemptedRoles: failure.unattemptedRoles } : {}) } : result, wantsJson, [ `Sized with ${result.sizingModel ?? "unknown"}.`, ...proposals.map(p => p.status === "unsized" ? `${p.role}: not sized (${p.reason ?? "unknown"})` @@ -374,9 +396,16 @@ async function suggestRoles(args: string[], wantsJson: boolean, deps: RuntimeApi : `${p.role}: ${p.model ?? "(no pin)"} -> ${p.proposedModel}${p.proposedEffort ? ` (${p.proposedEffort})` : ""} [${p.tier}/${p.effortIntent}] ${p.rationale ?? ""}`), apply ? `Applied ${applied.length} of ${proposals.length} roles.${skipped.length > 0 ? ` Skipped ${skipped.length} already set: ${skipped.join(", ")}.` : ""}` - : "Nothing was written; rerun with --apply to write every proposal that differs from the role's current pin.", + : `Nothing was written; --apply accepts up to ${ROLE_HISTORY_LIMIT} changed roles in one batch. Use 'ocx agent roles set' for individual changes.`, ...omoJsoncNotWritten.map(entry => `${entry.role}: omo.jsonc not written (${entry.status}); the role TOML was updated.`), + ...(batch ? [`Recorded history batch: ${batch}. Restore uses the retained changes in the dashboard's Change history.`] : []), + ...(failure ? [ + `Confirmed writes: ${applied.map(p => p.role).join(", ") || "none"}.`, + `Write not confirmed for ${failure.unconfirmedRole}; it may have been saved. Not attempted: ${failure.unattemptedRoles.join(", ") || "none"}.`, + "Earlier writes remain applied. Inspect 'ocx agent roles --json' before retrying.", + ] : []), ]); + if (failure) throw failure.cause; } async function roles(argv: string[], deps: RuntimeApiDeps): Promise { diff --git a/structure/cli-management.md b/structure/cli-management.md index cd1294ef63c..08bc4b38609 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -107,6 +107,8 @@ live and latest durable rows are captured under the same mutation lock as deleti `src/cli/agent-settings.ts` writes strict full memory/compaction override blocks via their canonical schemas. Empty memory blocks and null clears restore existing/default routing rather than disabling pipelines. `src/cli/system-settings-parity.ts` projects new settings observations; ultraFastTier is read back because its mutation receipt omits it. `src/cli/agent-runtime-settings.ts` keeps supported injection/guidance and sidecar field ownership, including web reasoning persistence despite reply omission. The actual window spellings and sidecar limits come from their owners, not similar flag names. +`src/cli/agent.ts` preflights `roles suggest --apply` against `ROLE_HISTORY_LIMIT` in `src/codex/role-model-history.ts`, counting only proposed changes that differ from the current pin. More than 50 changes refuse with exit 5 before any role PUT; accepted operations reuse the server-issued batch so recorded changes fit the existing whole-batch Restore listing. An interrupted apply prints confirmed writes, the unconfirmed role, never-attempted roles and the known batch id before preserving the original error exit. A lost response can follow a persisted write, so the CLI never retries or rolls back automatically. History availability, retention and server-side restore validation remain authoritative. `tests/cli/cli-role-suggestion-history.test.ts` exercises the real handlers and files at the 50/51 boundary, complete restoration, partial refusal and lost acknowledgements. + `src/cli/settings-result.ts` handles the settings pending boolean and shared native apply report without private detail; it never treats that flag as a full catalog disposition. Missing confirmation stays unverified/nonzero, while the accepted state remains visible. Existing no-new-option settings output remains compatible. Shared static usage text retains authored line breaks while each displayed line is terminal-safe. `src/cli/v2-input.ts` parses target/output/acknowledgment before effects and protects literal hint text after the terminator. `src/cli/v2-runtime.ts` has no parent-local-writer import and validates the management state/advisory/catalog contract. `src/cli/v2-local-output.ts` reads actual local state and validates unknown injected sync results before projection. Local mode/keep and changed feature toggles retain their sync call even without a discovered port; other verbs do not gain new synchronization. Live failure never invokes local writers, and partial native state never becomes an implicit rollback claim. diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 3ab33edae87..1595da1f9b4 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -25,6 +25,8 @@ import { firstPartyDesired } from "../../src/claude/first-party-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { resolveCurrentWindowsPrincipal } from "../../src/lib/windows-user-principal"; let root = ""; let lifecycleLock: ReturnType; @@ -72,10 +74,23 @@ beforeEach(() => { process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; process.env.CLAUDE_CONFIG_DIR = claudeDir; writeFileSync(join(root, "config.json"), JSON.stringify(config())); + // The local-CA ACL inspection spawns PowerShell under the shared 30 s principal-lookup budget, + // which a loaded hosted Windows runner can exhaust (local_ca_path_unsafe). This file tests + // first-party apply; claude-intercept-local-ca.test.ts runs the native inspection. Report the + // private ACL for the current SID, which production resolves (and caches) before this runner. + if (process.platform === "win32") { + setLocalCaWindowsAclRunnerForTests((_path, timeoutMs) => { + const sid = resolveCurrentWindowsPrincipal(timeoutMs).replace(/^\*/, ""); + return { success: true, exitCode: 0, timedOut: false, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) }; + }); + } }); afterEach(async () => { - await flushConfigDirHardeningAndReaps(root); + try { await flushConfigDirHardeningAndReaps(root); } + finally { setLocalCaWindowsAclRunnerForTests(null); } lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index c304144e182..a7c6a5faa6a 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1078,7 +1078,7 @@ describe("headless GUI parity CLI", () => { { role: "vague", model: null, status: "unsized", reason: "no JSON" }, ], }; - const runtime = fakeRuntime(req => req.method === "POST" ? proposals : { ok: true }); + const runtime = fakeRuntime(req => req.method === "POST" ? proposals : { ok: true, batch: "suggest-batch" }); const logSpy = spyOn(console, "log").mockImplementation(() => {}); let output = ""; try { @@ -1094,7 +1094,7 @@ describe("headless GUI parity CLI", () => { { path: "/api/codex-agent-roles/auto-assign", method: "POST", body: { model: "a/sizer" } }, { path: "/api/codex-agent-roles/auto-assign", method: "POST", body: {} }, { path: "/api/codex-agent-roles/explorer", method: "PUT", body: { model: "a/small", effort: "low" } }, - { path: "/api/codex-agent-roles/worker", method: "PUT", body: { model: "a/mid" } }, + { path: "/api/codex-agent-roles/worker", method: "PUT", body: { model: "a/mid", batch: "suggest-batch" } }, ]); expect(output).not.toMatch(/[\x07\x1b]/); expect(output).toContain("Use \\x1b]52;c;cG9pc29uZWQ=\\x07 carefully."); diff --git a/tests/cli/cli-role-suggestion-history.test.ts b/tests/cli/cli-role-suggestion-history.test.ts new file mode 100644 index 00000000000..10e76e8153a --- /dev/null +++ b/tests/cli/cli-role-suggestion-history.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleAgentCommand } from "../../src/cli/agent"; +import type { RuntimeApiDeps } from "../../src/cli/runtime-api"; +import type { RoleModelChange } from "../../src/codex/role-model-history"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { STORE_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, + HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +const config = { port: 10100, providers: {}, defaultProvider: "openai" } as OcxConfig; +let root: string; +let codexHome: string; +let mirrorPath: string; +let historyPath: string; + +interface Proposal { + role: string; + model: string | null; + effort: string | null; + status: "proposed" | "unsized" | "unassigned"; + proposedModel: string | null; + proposedEffort: string | null; +} + +beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-cli-role-history-"))); + codexHome = join(root, "codex"); + const userHome = join(root, "home"); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.HOME = process.env.USERPROFILE = userHome; + mkdirSync(join(codexHome, "agents"), { recursive: true }); + mkdirSync(join(userHome, ".omo"), { recursive: true }); + const plugin = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); + writeFileSync(join(codexHome, "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); + mirrorPath = join(userHome, ".omo", "omo.jsonc"); + historyPath = join(root, "ocx", "integrations", "codex-agent-roles.jsonl"); +}); + +afterEach(() => { + for (const key of Object.keys(saved) as Array) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + removeTreeWithRetry(root); +}); + +const rolePath = (role: string) => join(codexHome, "agents", `${role}.toml`); +const roleText = (role: string) => readFileSync(rolePath(role), "utf8"); +const original = '# Keep the role instructions.\nmodel = "a/before"\nmodel_reasoning_effort = "high"\n'; + +function seed(count: number): Proposal[] { + const proposals = Array.from({ length: count }, (_, i): Proposal => ({ + role: `role-${i}`, model: "a/before", effort: "high", status: "proposed", + proposedModel: "a/after", proposedEffort: "low", + })); + for (const proposal of proposals) writeFileSync(rolePath(proposal.role), original); + writeFileSync(mirrorPath, JSON.stringify({ "[codex]": { agents: Object.fromEntries( + proposals.map(p => [p.role, { model: "a/before", reasoning: "high" }]), + ) } })); + return proposals; +} + +async function management(path: string, init?: RequestInit): Promise { + const request = new ManagementRequest(`http://127.0.0.1:10100${path}`, init); + const response = await handleManagementAPI(request, new URL(request.url), config); + if (!response) throw new Error(`fixture route not handled: ${path}`); + return response; +} + +async function listedHistory(): Promise { + const response = await management("/api/codex-agent-roles"); + expect(response.status).toBe(200); + return (await response.json() as { history: RoleModelChange[] }).history; +} + +interface WriteCall { role: string; body: Record; status?: number; result?: Record } +function runtime(proposals: Proposal[], hooks: { before?: (role: string) => void; after?: (role: string) => void } = {}) { + const writes: WriteCall[] = []; + const deps: RuntimeApiDeps = { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: (async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/api/codex-agent-roles/auto-assign") return Response.json({ sizingModel: "fixture", proposals }); + const role = decodeURIComponent(path.split("/").at(-1)!); + const call: WriteCall = { role, body: JSON.parse(String(init?.body)) }; + writes.push(call); + hooks.before?.(role); + const response = await management(path, init); + call.status = response.status; + call.result = await response.clone().json() as Record; + hooks.after?.(role); + return response; + }) as typeof fetch, + }; + return { deps, writes }; +} + +async function invoke(deps: RuntimeApiDeps, json = true, apply = true) { + const out = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + try { + const exit = await handleAgentCommand(["roles", "suggest", ...(apply ? ["--apply"] : []), ...(json ? ["--json"] : [])], deps); + const stdout = out.mock.calls.flat().join("\n"); + return { exit, stdout, stderr: err.mock.calls.flat().join("\n"), receipt: json && stdout ? JSON.parse(stdout) : undefined }; + } finally { + out.mockRestore(); + err.mockRestore(); + } +} + +async function restoreBatch(changes: RoleModelChange[]): Promise { + let batch: string | undefined; + for (const change of changes) { + const response = await management(`/api/codex-agent-roles/${encodeURIComponent(change.role)}`, { + method: "PUT", + body: JSON.stringify({ model: change.before.model ?? undefined, effort: change.before.effort, + restores: change.batch, ...(batch ? { batch } : {}) }), + }); + expect(response.status).toBe(200); + const written = await response.json() as { batch: string }; + if (batch) expect(written.batch).toBe(batch); + batch = written.batch; + } + return batch!; +} + +describe("CLI role suggestions preserve bounded whole-batch history", () => { + test("50 real writes share one listed batch and every role restores after the source leaves the listing", async () => { + const proposals = seed(50); + const mirrorBefore = JSON.parse(readFileSync(mirrorPath, "utf8")); + const transport = runtime(proposals); + const { exit, receipt } = await invoke(transport.deps); + expect(exit).toBe(0); + expect(receipt.applied).toHaveLength(50); + expect(receipt.applied[0]).toEqual({ role: "role-0", model: "a/after", effort: "low" }); + expect(transport.writes).toHaveLength(50); + expect(transport.writes[0]!.body).not.toHaveProperty("batch"); + expect(new Set(transport.writes.slice(1).map(w => w.body.batch))).toEqual(new Set([receipt.batch])); + const changes = await listedHistory(); + expect(changes).toHaveLength(50); + expect(new Set(changes.map(c => c.batch))).toEqual(new Set([receipt.batch])); + const stored = readFileSync(historyPath, "utf8").trim().split("\n").map(line => JSON.parse(line)); + expect(stored.map(row => row.n)).toEqual(Array.from({ length: 50 }, (_, i) => i + 1)); + expect(proposals.every(p => roleText(p.role).includes('model = "a/after"'))).toBe(true); + const restored = await restoreBatch(changes); + expect(proposals.map(p => roleText(p.role))).toEqual(Array(50).fill(original)); + expect(JSON.parse(readFileSync(mirrorPath, "utf8"))).toEqual(mirrorBefore); + const latest = await listedHistory(); + expect(latest).toHaveLength(50); + expect(new Set(latest.map(c => c.batch))).toEqual(new Set([restored])); + }, { timeout: STORE_BUDGET_MS }); + + test.each([false, true])("51 changed roles are refused before any PUT or file change (json=%s)", async json => { + const proposals = seed(51); + const mirrorBefore = readFileSync(mirrorPath, "utf8"); + const transport = runtime(proposals); + const result = await invoke(transport.deps, json); + expect(result.exit).toBe(5); + expect(transport.writes).toEqual([]); + expect(proposals.map(p => roleText(p.role))).toEqual(Array(51).fill(original)); + expect(readFileSync(mirrorPath, "utf8")).toBe(mirrorBefore); + expect(existsSync(historyPath)).toBe(false); + expect(result.stderr).toContain("51 changed proposals exceed the 50-role batch limit. No role files were written."); + if (json) { + expect(result.receipt).toMatchObject({ applied: [], complete: false, + refusal: { code: "too_many_role_changes", limit: 50, count: 51 }, unattemptedRoles: proposals.map(p => p.role) }); + } else expect(result.stdout).toContain("Preview without --apply"); + }); + + test("already-set, unsized and unassigned roles do not consume the 50-change budget", async () => { + const proposals = seed(53); + Object.assign(proposals[50]!, { proposedModel: "a/before", proposedEffort: "high" }); + Object.assign(proposals[51]!, { status: "unsized", proposedModel: null, proposedEffort: null }); + Object.assign(proposals[52]!, { status: "unassigned", proposedModel: null, proposedEffort: null }); + const transport = runtime(proposals); + const { exit, receipt } = await invoke(transport.deps); + expect(exit).toBe(0); + expect(receipt.skipped).toEqual(["role-50"]); + expect(receipt.applied).toHaveLength(50); + expect(transport.writes).toHaveLength(50); + expect(proposals.slice(50).map(p => roleText(p.role))).toEqual(Array(3).fill(original)); + expect(await listedHistory()).toHaveLength(50); + }, { timeout: STORE_BUDGET_MS }); + + test("preview remains available above the apply limit", async () => { + const proposals = seed(51); + const transport = runtime(proposals); + const { exit, receipt } = await invoke(transport.deps, true, false); + expect(exit).toBe(0); + expect(receipt).toEqual({ sizingModel: "fixture", proposals }); + const preview = await invoke(transport.deps, false, false); + expect(preview.exit).toBe(0); + expect(preview.stdout).toContain("--apply accepts up to 50 changed roles in one batch"); + expect(transport.writes).toEqual([]); + expect(existsSync(historyPath)).toBe(false); + }); + + test.each([false, true])("a real role refusal reports and preserves the restorable prefix (json=%s)", async json => { + const proposals = seed(3); + writeFileSync(mirrorPath, '{\n // keep this comment\n "[codex]": {"agents": {}}\n}\n'); + const transport = runtime(proposals, { before: role => { if (role === "role-1") unlinkSync(rolePath(role)); } }); + const result = await invoke(transport.deps, json); + expect(result.exit).toBe(4); + expect(transport.writes.map(w => w.status)).toEqual([200, 404]); + expect(result.stderr).toBe(`Error: ${transport.writes[1]!.result!.error}`); + expect(roleText("role-0")).toContain('model = "a/after"'); + expect(roleText("role-2")).toBe(original); + const changes = await listedHistory(); + expect(changes).toHaveLength(1); + if (json) { + expect(result.receipt).toMatchObject({ complete: false, batch: changes[0]!.batch, + applied: [{ role: "role-0", model: "a/after", effort: "low" }], unconfirmedRole: "role-1", + unattemptedRoles: ["role-2"], omoJsoncNotWritten: [{ role: "role-0", status: "skipped_comments" }] }); + } else { + expect(result.stdout).toContain("Confirmed writes: role-0."); + expect(result.stdout).toContain("Write not confirmed for role-1; it may have been saved. Not attempted: role-2."); + expect(result.stdout).toContain(`Recorded history batch: ${changes[0]!.batch}`); + expect(result.stdout).toContain("role-0: omo.jsonc not written (skipped_comments)"); + } + await restoreBatch(changes); + expect(roleText("role-0")).toBe(original); + }); + + test.each([0, 1])("a response lost after real write %s is unconfirmed, never retried or rolled back", async lostIndex => { + const proposals = seed(3); + const lostRole = `role-${lostIndex}`; + const transport = runtime(proposals, { after: role => { if (role === lostRole) throw new TypeError("fixture response lost"); } }); + const { exit, receipt, stderr } = await invoke(transport.deps); + expect(exit).toBe(1); + expect(stderr).toBe("Error: Management API is unreachable: fixture response lost"); + expect(transport.writes).toHaveLength(lostIndex + 1); + expect(receipt.applied.map((p: { role: string }) => p.role)).toEqual(proposals.slice(0, lostIndex).map(p => p.role)); + expect(receipt.unconfirmedRole).toBe(lostRole); + expect(receipt.unattemptedRoles).toEqual(proposals.slice(lostIndex + 1).map(p => p.role)); + expect(receipt.complete).toBe(false); + expect(roleText(lostRole)).toContain('model = "a/after"'); + expect(proposals.slice(lostIndex + 1).map(p => roleText(p.role))).toEqual(Array(2 - lostIndex).fill(original)); + const changes = await listedHistory(); + expect(changes).toHaveLength(lostIndex + 1); + expect(new Set(changes.map(c => c.batch)).size).toBe(1); + if (lostIndex === 0) expect(receipt).not.toHaveProperty("batch"); + else expect(receipt.batch).toBe(changes[0]!.batch); + // Reading the real history recovers even the unacknowledged write's server-issued batch. + await restoreBatch(changes); + expect(proposals.map(p => roleText(p.role))).toEqual(Array(3).fill(original)); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7939d5f9243..b7965ae8040 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -521,7 +521,7 @@ "cli-dispatch.test.ts": "cli", "cli-client-start-fence.test.ts": "cli", "cli-ensure-client-state.test.ts": "cli", "cli-client-start-owner-e2e.test.ts": "cli", "cli-dto-fidelity.test.ts": "cli", "cli-export-command.test.ts": "cli", - "cli-head.test.ts": "cli", "cli-headless-parity.test.ts": "cli", + "cli-head.test.ts": "cli", "cli-headless-parity.test.ts": "cli", "cli-role-suggestion-history.test.ts": "cli", "cli-help.test.ts": "cli", "cli-help-paths.test.ts": "cli", "cli-help-navigation.test.ts": "cli", "cli-help-recovery.test.ts": "cli", "cli-integer-options.test.ts": "cli", "cli-init-port.test.ts": "cli",