diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/010_roadmap.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/010_roadmap.md new file mode 100644 index 00000000000..5e5ac7e883d --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/010_roadmap.md @@ -0,0 +1,63 @@ +# Sub-agent surface: v1 as the install default, with an approval guard on base and v2 + +## Why this unit exists + +The v2 collaboration surface cannot deliver a task from a ChatGPT-native parent to a +routed child. Codex mints that child task as backend `encrypted_content`, and only the +ChatGPT backend holds the key, so an external provider receives ciphertext it cannot +read. OpenCodex already fails closed on it (`unreadable_encrypted_agent_task`) and +documents the limitation as issue #92, but the product still ships `base` as the install +default, and `base` pins Sol and Terra to v2. A new user who delegates from Sol to Grok +therefore meets the failure on their first attempt, with no warning that the mode they +never chose is the reason. + +This unit makes v1 the default, and makes base and v2 a choice the operator confirms +after reading what it costs. + +## Locked decisions + +**D1 — an absent `multiAgentMode` key still means base.** Roughly fifteen call sites read +`config.multiAgentMode ?? "default"`, and the management API deletes the key when the +operator selects base. Re-pointing that fallback to `"v1"` would make the key's absence +ambiguous: it could no longer distinguish "never configured" from "explicitly base". So +what changes is the written default — `getDefaultConfig()` now emits an explicit +`multiAgentMode: "v1"`, exactly the shape `subagentModels` already uses. The resolver is +untouched, and the merge that could push a default into an existing document runs only +on the schema-repair path. + +**D2 — an existing operator is asked, not flipped.** Rewriting a stored +`multiAgentMode: "v2"` during an update would change a setting its owner deliberately +made, on a machine they are not looking at. Instead the runtime computes an advisory, +the GUI raises it once, and only the operator's answer writes. That is also what makes +the two buttons meaningful: 계속하기 keeps the current mode, v1으로 바꾸기 applies v1. + +**D3 — the advisory is a version counter, not a boolean.** `multiAgentSurfaceAdvisoryVersion` +mirrors `subagentModelsVersion`. When the encryption limitation is fixed upstream, or when +a later release needs to say something different about the same setting, bumping the +constant re-raises the notice for everyone without adding another key. + +**D4 — one dialog component, two triggers.** The approval dialog shown when the operator +selects base or v2 and the post-update advisory differ only in their heading and body +text. They offer the same two actions and link to the same guide, so they are one +component with a `reason` discriminator rather than two that drift apart. + +**D5 — the guide is a real published page, not an anchor.** The dialog links to +`https://opencodex.me/guides/subagent-v1-default/`, a new docs-site page whose job is to +make the failure legible: an inline SVG showing the same delegation succeeding under v1 +and dying at the provider boundary under v2. + +## Work phases + +| Phase | Outcome | +| --- | --- | +| wp1 | This roadmap, with the decisions above locked before any code moves. | +| wp2 | Runtime: `getDefaultConfig()` emits v1, the advisory state and its version constant exist, and `GET/PUT /api/v2` carry them. | +| wp3 | GUI: the approval dialog, both triggers wired, and translations for all nine locales. | +| wp4 | The docs-site guide and its SVG, plus the link from the dialog and the existing surface guide. | +| wp5 | Focused tests, typecheck, PR to `dev` with a UI screenshot, exact-head CI, merge. | + +## Out of scope + +Fixing the encryption limitation itself. `agentTaskRecovery` and `plaintextV2AgentMessages` +remain experimental and default-off; this unit changes which surface an operator lands +on by default, not what v2 can carry. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/020_runtime_default_and_advisory.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/020_runtime_default_and_advisory.md new file mode 100644 index 00000000000..28a4a55e507 --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/020_runtime_default_and_advisory.md @@ -0,0 +1,63 @@ +# wp2 — Runtime: v1 default and the advisory state + +## What changes + +`src/config/multi-agent-surface.ts` (new) owns three things: + +- `MULTI_AGENT_SURFACE_ADVISORY_VERSION = 1`. +- `resolveMultiAgentMode(config)`, the single reader that turns a stored value into + `"v1" | "default" | "v2"`. It keeps the existing rule that an absent key means + `"default"`; it exists so the advisory and the catalog agree on one answer. +- `multiAgentSurfaceAdvisoryRequired(config)`, true when the resolved mode is not `"v1"` + and the stored `multiAgentSurfaceAdvisoryVersion` is below the constant. + +`getDefaultConfig()` gains `multiAgentMode: "v1"` and +`multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION`. A fresh install +has nothing to advise about, so it starts acknowledged. + +`src/types/config.ts` gains the optional `multiAgentSurfaceAdvisoryVersion?: number`, and +`configSchema` in `src/config.ts` accepts it as a positive integer with `.catch(undefined)` +so a hand-edited value cannot fail the parse. + +## Management API + +`GET /api/v2` adds one response-only object: + +```json +{ + "multiAgentSurfaceAdvisory": { + "required": true, + "mode": "v2", + "recommended": "v1", + "version": 1, + "docsUrl": "https://opencodex.me/guides/subagent-v1-default/" + } +} +``` + +`PUT /api/v2` accepts `multiAgentSurfaceAdvisoryAcknowledged: true`, which stores the +current constant and is idempotent. It composes with an existing `multiAgentMode` write in +the same body, because `v1으로 바꾸기` sends both in one request and must not leave the +advisory raised if the mode write succeeded. + +It stays a boolean in, number out. A client that could post an arbitrary version could +silence a future advisory it has never seen. + +## What deliberately does not change + +The catalog stamping in `src/codex/catalog/sync.ts`, the `keepNativeChatGptOnV1` hybrid, +and every `?? "default"` fallback. This phase moves the written default and adds one +piece of state; it does not renegotiate what the three modes mean. + +## Tests + +New tests go into existing files. `tests/test-layout.test.ts` requires every new test file to +be registered in both `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`, and a file named after this route does not resolve +through the regex seeds. Extending files that already own these subsystems is the smaller +change and keeps the guard quiet. + +`tests/server/config.test.ts` — a fresh config resolves to v1 and starts acknowledged. +`tests/codex-integration/codex-v2-gate.test.ts`, in its existing management-API parity +describe block — the advisory is required for a v2 config and for a config with no key, not +required after acknowledgement, and a combined mode plus acknowledgement write persists both. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/030_gui_approval_dialog.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/030_gui_approval_dialog.md new file mode 100644 index 00000000000..71c36d74353 --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/030_gui_approval_dialog.md @@ -0,0 +1,45 @@ +# wp3 — GUI: the approval dialog and its two triggers + +## Component + +`gui/src/components/SubagentSurfaceWarningModal.tsx`, modelled on +`OAuthTosWarningModal.tsx`: a native `` opened with `showModal()` so focus +trapping, the backdrop and Escape come from the platform. + +Props: + +| Prop | Meaning | +| --- | --- | +| `reason` | `"selection"` when the operator just clicked base or v2, `"advisory"` for the one-time post-update notice. | +| `mode` | The mode being selected, or the mode currently stored, depending on `reason`. | +| `docsUrl` | The published guide. | +| `onContinue` | Keep or apply the non-v1 mode. | +| `onChooseV1` | Apply v1. | + +Both actions are real buttons in `.modal-actions`. `v1으로 바꾸기` is the primary, because +it is the recommended answer; `계속하기` is the ghost. Escape and the backdrop resolve to +the same outcome as `계속하기` for the advisory (the stored mode is kept) and to a no-op +for a selection (the click is abandoned and the segmented control does not move). + +## Triggers + +**Selection.** `Models.tsx` `setMultiAgentMode` and `use-dashboard-data.ts` `switchMaMode` +stop writing directly for `"default"` and `"v2"`. They stage the pending mode, render the +dialog, and write only from `onContinue`. Selecting `"v1"` stays immediate: confirming a +move toward the safe default would be noise. + +**Advisory.** The dashboard reads `multiAgentSurfaceAdvisory.required` from the `/api/v2` +poll it already runs and opens the dialog once per load while it is required. `계속하기` +sends the acknowledgement alone; `v1으로 바꾸기` sends the mode and the acknowledgement +together. + +## Translations + +Nine locales live in `gui/src/i18n/`: en, ko, ja, zh, zh-TW, fr, de, ru, tr. Keys: + +- `subagentSurface.selectionTitle`, `subagentSurface.selectionBody` +- `subagentSurface.advisoryTitle`, `subagentSurface.advisoryBody` +- `subagentSurface.continue`, `subagentSurface.switchToV1`, `subagentSurface.learnMore` + +Korean carries the literal `계속하기` and `v1으로 바꾸기` the user asked for; the other +locales carry their natural equivalents. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/040_docs_guide_and_visual.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/040_docs_guide_and_visual.md new file mode 100644 index 00000000000..7b192fb54ec --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/040_docs_guide_and_visual.md @@ -0,0 +1,42 @@ +# wp4 — The guide and its visual explanation + +`docs-site/src/content/docs/guides/subagent-v1-default.md` answers one question: why does +a v2 delegation from a ChatGPT model to Grok or Claude fail, and why is v1 the default +until it does not. + +Structure, answer first: + +1. What you will see — the exact `unreadable_encrypted_agent_task` failure and the HTTP 400. +2. Why it happens — an inline SVG, described below. +3. What each mode does now, and why v1 is the shipped default. +4. What to do if you want v2 anyway, including `keepNativeChatGptOnV1`. +5. When this page goes away — the condition under which the default moves back. + +## The SVG + +Two stacked lanes over the same delegation, so the reader compares one variable. + +The v1 lane: parent (ChatGPT model) emits a plaintext `spawn_agent` task, OpenCodex routes +it, the routed child reads it, and the lane ends green. + +The v2 lane: the parent emits `encrypted_content` minted by the ChatGPT backend. The lane +crosses the provider boundary, drawn as a vertical rule, and the routed child holds +ciphertext with no key. The lane ends at a stop marker labelled with the real error code. +A short caption states the asymmetry in words: the key never leaves the backend that +minted it, so the failure is structural rather than a misconfiguration. + +Accessibility is part of the artifact, not a later pass: `role="img"`, a `` and a +`<desc>`, meaning carried by labels rather than colour alone, and text that stays legible +at the width Starlight gives a guide body. The prose immediately below repeats what the +diagram says, so the page still works with images off. + +## Wiring + +The page needs an explicit sidebar entry in `docs-site/astro.config.mjs`, next to the +`guides/sub-agent-surface` entry. Registration is per-page there; nothing is autogenerated +from the directory, so a page without an entry ships invisible. + +`guides/sub-agent-surface.md` gains a pointer at the mode table and its `Not sure?` tip +changes to recommend v1. The GUI dialog links to the published URL. The i18n mirrors under +`docs-site/src/content/docs/<locale>/` are not translated in this unit; the English guide +is the source and the existing locales keep their current surface page. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/050_verification_and_delivery.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/050_verification_and_delivery.md new file mode 100644 index 00000000000..ba9cc0ca25b --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/050_verification_and_delivery.md @@ -0,0 +1,25 @@ +# wp5 — Verification and delivery + +## Local checks + +`bun run typecheck`, `bun run lint:gui`, and the focused files touched by wp2 and wp3. +The full suite is the hosted gate, not a local one: CI runs typecheck and the whole suite +on Linux, Windows and macOS, and that run is the evidence the merge decision uses. + +## Screenshot + +`enforce-target` rejects a PR that mentions `gui` without a screenshot of the UI change. +That is a real gate and this PR is squarely a GUI change, so the dialog is captured from a +running dashboard, not mocked in a drawing tool, and attached to the description. + +## PR + +Target `dev`. Fill Summary, Verification and Checklist from +`.github/PULL_REQUEST_TEMPLATE.md`. Reference issue #92 as context rather than `Closes`, +because this unit does not fix the encryption limitation. + +## Merge + +Maintainer integration through the PR, under the `dev` policy in `MAINTAINERS.md`: record +the decision and CI evidence at the exact final head. A green aggregate on an earlier +commit is not evidence for the head being merged. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/060_upstream_evidence.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/060_upstream_evidence.md new file mode 100644 index 00000000000..ed9da1c22f0 --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/060_upstream_evidence.md @@ -0,0 +1,43 @@ +# Evidence: is the v2 encrypted-task limitation fixed anywhere? + +Collected 2026-09-13 with the Aside browser agent against public GitHub, to check that +this unit is not shipping a warning about something already solved. + +## Upstream (openai/codex) + +| Item | State | What it actually covers | +| --- | --- | --- | +| #35845 Support plaintext collaboration tool messages | merged, 0.147.0-alpha.1 | The receiving half only. It handles plaintext that was already produced, gated on the `encrypted_function_args: []` marker. It does not make an OpenAI parent emit plaintext. | +| #36892 Support leaf models in multi-agent v2 | merged | Fixed the Sol/Terra plus Luna case. Does not touch cross-provider delivery. | +| #36376 OpenAI parent still sends an encrypted v2 task to a non-OpenAI child | open | Reproduced across CLI 0.146 to 0.151 on Windows, macOS and Linux. | +| #37197 Plaintext support does not complete cross-provider delivery | open | The clearest statement that a sending-side policy is the missing piece; the reporter built and validated a local `message_delivery = "plaintext"` policy. | +| #32031 spawn_agent hides model overrides | open | Adjacent; no maintainer response. | + +None of the open issues has an official maintainer response or an ETA. So the sending side +is unfixed, and it is the side that matters here. + +## Why a proxy cannot paper over it + +With an OpenAI parent the value in `encrypted_content` is real Fernet-shaped ciphertext +(`gAAAA...`), not a plaintext assignment behind a marker. A proxy that rewrites the field +has nothing to rewrite it from. The fix has to come from the parent side emitting the +plaintext marker, which is exactly the path #35845 does not open for OpenAI parents. + +## Scope of the failure + +Affected: v2 with a ChatGPT-native parent and a routed child. Observed with Sol to Grok, +Astra to Grok, Luna and Astra to Claude, DeepSeek, GLM and others. It is not model +specific; no routed child can read backend ciphertext. + +Unaffected: v1 in any topology, routed parent to routed child under v2, and native parent +to native child under v2 (the backend can decrypt what it minted). + +Already handled here: the mid-thread native-to-routed model switch, fixed by #4135, and +the opt-in `agentTaskRecovery` path, which recovers most fresh spawns but still loses +message-type follow-ups and multipart envelopes (#3661). + +## Conclusion for this unit + +The warning is accurate and the default change is justified. v1 is the surface maintainers +and users both name as the reliable one for cross-provider delegation, and there is no +upstream commitment that would make this advisory short-lived by accident. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/070_audit_followups.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/070_audit_followups.md new file mode 100644 index 00000000000..55db0b3d0bb --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/070_audit_followups.md @@ -0,0 +1,33 @@ +# Audit follow-ups + +An independent reviewer audited the roadmap against the branch tip before any code moved. +Verdict: near-pass. No factual claim in D1 through D5 failed; three cautions are resolved +here. + +## 1. The advisory fires for most existing installs, and that is the point + +The reviewer noted that a config written before this change usually has no `multiAgentMode` +key at all, so the advisory raises for the majority of upgrading users rather than a rare +corner. That is intended. Those are precisely the operators running base, which pins Sol and +Terra to v2, and who have never been told what that costs them when they delegate to a +routed model. It fires once per install and both answers end it. + +## 2. New test files would trip the layout guard + +Folded into 020: the new assertions extend `tests/server/config.test.ts` and the existing +management-API describe block in `tests/codex-integration/codex-v2-gate.test.ts` rather than +creating files that would need registering in two manifests. + +## 3. The guide needs a sidebar entry + +Folded into 040. + +## Carried into the build + +`src/config/multi-agent-surface.ts` lands in an existing documented directory, so +`structure:check` has nothing new to claim. `structure/gui-and-management-api.md` documents +the `/api/v2` contract and must be updated in the same change, as must the mode table in +`structure/subagents.md`, which still calls base the install default. + +GUI locale parity is enforced by `Record<TKey, string>` at GUI build time, so a missing key +in any of the nine catalogs fails `bun run build:gui` rather than slipping through. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/080_outcome.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/080_outcome.md new file mode 100644 index 00000000000..211bfebe378 --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/080_outcome.md @@ -0,0 +1,46 @@ +# Outcome + +Landed as [#4462](https://github.com/lidge-jun/opencodex/pull/4462), squashed onto `dev`. + +## What shipped + +All five work phases, as planned. v1 is the written install default; an absent `multiAgentMode` +still means base; existing installs are asked once instead of being rewritten; the approval dialog +guards all three places the mode switch is rendered; and the guide is published with a diagram. + +## What the plan got wrong + +Two things, both caught by review rather than by writing the plan more carefully. + +**The repair path.** D1 argued that `getDefaultConfig()` reaches an existing config only on the +schema-repair path, and treated that as safe. It is not: a config missing an unrelated field — +`defaultProvider` is the common one — takes that path, and the defaults were spread *underneath* +the stored document. A base operator would have been repaired into v1 with the advisory +pre-answered, which is exactly the silent flip D2 forbids. Both merges now pin the two keys to the +stored document. The roadmap audit missed this; the implementation audit found it by enumerating +the merge paths instead of reasoning about them. + +**The third switch.** 030 described two mode switches. There are three — the Subagents page renders +one too, and it wrote straight through, which made the dialog decoration on that page. The lesson is +cheap to state and easy to miss: when a control is duplicated, count the copies before gating one. + +## Review findings, by who found what + +| Reviewer | Finding | Severity | +| --- | --- | --- | +| Implementation audit | Repair path flips an existing operator's surface | blocking | +| Implementation audit | Ordering comment claimed a transaction the code does not have | blocking | +| GUI audit (second pass) | Subagents page bypasses the dialog | blocking | +| GUI audit (second pass) | Continuing to base or v2 left the notice raised, so the poll re-asked | real | +| GUI audit (second pass) | Locale test grepped source, so a key in a comment would pass | real | +| Codex | Acknowledgement gated on a projection that goes stale | P2 | +| CodeRabbit | Staged selections not scoped to the endpoint that staged them | major | +| CodeRabbit | Escape and backdrop dismissible while a write is in flight | minor | +| CodeRabbit | Unsupported stored mode echoed raw from the API | minor | +| CodeRabbit | Warning text overstated the failure on base; Korean, French and Russian copy | minor | + +## Still true after this lands + +The upstream limitation is unfixed. When a ChatGPT-native parent can emit a routed child's task as +plaintext, bump `MULTI_AGENT_SURFACE_ADVISORY_VERSION`, move the default back to base, and the same +machinery tells everyone who was asked the first time. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/README.md b/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/README.md new file mode 100644 index 00000000000..23b1b4afc36 --- /dev/null +++ b/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/README.md @@ -0,0 +1,16 @@ +# Evidence: the dialog as the operator sees it + +Captured from a real dashboard on 2026-09-13. A throwaway OpenCodex home with +`multiAgentMode: "v2"` and no stored acknowledgement, served on a spare port, read with a +headless browser. Not a mockup. + +`dashboard-advisory-en.png` — the one-time advisory an existing v2 install raises after +updating. The sub-agent switch behind it still reads v2, because nothing was changed for +that operator: the notice is asking, not reporting. + +`dashboard-advisory-ko.png` — the same dialog in Korean, showing the two answers the +request specified: 계속하기 and v1으로 바꾸기. + +The selection dialog is the same component with `reason="selection"`; its heading and body +differ and its two answers do not. `gui/tests/subagent-surface-warning.test.tsx` covers that +variant, the backdrop dismissal, and the gate on all three mode switches. diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/dashboard-advisory-en.png b/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/dashboard-advisory-en.png new file mode 100644 index 00000000000..20f20fa3a16 Binary files /dev/null and b/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/dashboard-advisory-en.png differ diff --git a/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/dashboard-advisory-ko.png b/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/dashboard-advisory-ko.png new file mode 100644 index 00000000000..c79692f4e52 Binary files /dev/null and b/devlog/_fin/260913_subagent_v1_default_encryption_guard/evidence/dashboard-advisory-ko.png differ diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index b25586f7a65..ec102f71c95 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -106,6 +106,7 @@ export default defineConfig({ { label: "Video Bridge", translations: { fr: "Pont vidéo", ko: "비디오 브릿지", "zh-CN": "视频桥接", "zh-TW": "影片橋接", ru: "Video Bridge", ja: "動画ブリッジ", tr: "Video Bridge" }, slug: "guides/video-bridge" }, { label: "Web Dashboard", translations: { fr: "Tableau de bord web", ko: "웹 대시보드", "zh-CN": "网页控制台", "zh-TW": "網頁儀表板", ru: "Веб-дашборд", ja: "ウェブダッシュボード", tr: "Web Kontrol Paneli" }, slug: "guides/web-dashboard" }, { label: "Sub-agent Surface", translations: { fr: "Interface des sous-agents", ko: "서브에이전트 서피스", "zh-CN": "子代理界面", "zh-TW": "子代理介面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス", tr: "Alt Ajan Arayüzü" }, slug: "guides/sub-agent-surface" }, + { label: "Why v1 Is the Default", translations: { fr: "Pourquoi v1 est la valeur par défaut", ko: "v1이 기본값인 이유", "zh-CN": "为什么默认是 v1", "zh-TW": "為什麼預設是 v1", ru: "Почему v1 по умолчанию", ja: "v1 がデフォルトである理由", tr: "Neden varsayılan v1" }, slug: "guides/subagent-v1-default" }, ], }, { diff --git a/docs-site/src/assets/subagent-v2-encrypted-task.svg b/docs-site/src/assets/subagent-v2-encrypted-task.svg new file mode 100644 index 00000000000..6fec585e217 --- /dev/null +++ b/docs-site/src/assets/subagent-v2-encrypted-task.svg @@ -0,0 +1,57 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 500" width="980" height="500" role="img" aria-labelledby="t d" font-family="ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif"> + <title id="t">Why a v2 sub-agent task fails across providers + Two lanes compare the same delegation. On v1 a ChatGPT parent sends a plaintext task through OpenCodex, it crosses the provider boundary, and the routed child reads it. On v2 the parent sends encrypted_content minted by the ChatGPT backend; OpenCodex has no key, so the task stops at the provider boundary and the request fails with HTTP 400 unreadable_encrypted_agent_task, and the routed child never receives a readable task. + + + + + + + + + + + + + provider boundary + + v1 — classic sub-agent surface + + ChatGPT model + parent + + plaintext task + + OpenCodex + reads it, routes it + + forwarded as text + + Grok, Claude, GLM + child reads the task + Delegation across providers works. Nothing in the path needs a key. + + + + v2 — concurrent sub-agent surface + + ChatGPT model + parent + + encrypted + gAAAA… + + OpenCodex + has no key + + + stops here + + Grok, Claude, GLM + never gets a readable task + OpenCodex fails closed instead of forwarding ciphertext: HTTP 400 unreadable_encrypted_agent_task. + + + The ChatGPT backend mints that key and keeps it. + A proxy cannot decrypt what it was never given, so this is structural, not a misconfiguration. + 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 912268325fb..939d93b4ab2 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -16,8 +16,8 @@ Choose the mode for **new sessions**. Existing sessions keep the surface they st | Mode | What Codex gets | Who should pick it | | --- | --- | --- | -| **v1** | Classic namespaced `spawn_agent`, `send_input`, `resume_agent`, and `close_agent` tools. A spawn can select another model directly. | Beginners who need reliable delegation across different providers, especially native-to-routed children. | -| **base** (default) | Upstream model pins: GPT-5.6 Sol/Terra use v2, Luna uses v1, and unpinned models follow Codex's `multi_agent_v2` feature flag. | Most users. It follows Codex's intended surface for each model without forcing one globally. | +| **v1** (default) | Classic namespaced `spawn_agent`, `send_input`, `resume_agent`, and `close_agent` tools. A spawn can select another model directly. | Anyone who delegates across providers, especially native-to-routed children. This is what a fresh install ships with. | +| **base** | Upstream model pins: GPT-5.6 Sol/Terra use v2, Luna uses v1, and unpinned models follow Codex's `multi_agent_v2` feature flag. | Operators who want Codex's per-model pins and whose parent and child models sit on the same side of the provider boundary. Note that its pins put Sol and Terra on v2. | | **v2** | Flat `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent`, and agent-list tools, with concurrent sessions. | Users who want the newer concurrent workflow and understand model inheritance and the encrypted-task limitation below. | On **v2**, an optional **Keep ChatGPT on v1** switch (`keepNativeChatGptOnV1`) leaves Sol/Terra @@ -28,8 +28,10 @@ Codex applies that override before per-model catalog pins. This is a switch *ins fourth catalog mode. :::tip[Not sure?] -Start with **base**. Choose **v1** when cross-provider delegation must work predictably. Force **v2** -only when you specifically want its newer session model across every catalog entry. +Stay on **v1**, the shipped default. Choose **base** or **v2** only when your parent and child models +sit on the same side of the provider boundary — on both, a task handed from a ChatGPT model to a +routed one arrives encrypted and fails. The dashboard asks before either, and links to +[Why v1 is the default](/guides/subagent-v1-default/). ::: ## External task input diff --git a/docs-site/src/content/docs/guides/subagent-v1-default.md b/docs-site/src/content/docs/guides/subagent-v1-default.md new file mode 100644 index 00000000000..6c6c69d55e0 --- /dev/null +++ b/docs-site/src/content/docs/guides/subagent-v1-default.md @@ -0,0 +1,131 @@ +--- +title: Why v1 is the default sub-agent surface +description: What the v2 encrypted-task limitation breaks, why OpenCodex now ships v1, and what to do if you still want v2. +--- + +OpenCodex installs with the sub-agent surface set to **v1**. The Dashboard, Models and Subagents +pages all ask you to confirm before moving to **base** or **v2**, and this page is what that +confirmation links to. The CLI does not prompt. + +The reason is narrow and specific: on v2, a task handed from a ChatGPT-native model to a routed +model cannot be read by the routed model. That is the single most common way people delegate — +a GPT parent spawning a Grok, Claude or GLM child — and on v2 it fails every time. + +## What you see when it happens + +The spawn is rejected rather than silently producing an empty child task: + +```json +{ + "error": { + "code": "unreadable_encrypted_agent_task", + "message": "Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model." + } +} +``` + +HTTP 400, and the ciphertext is never echoed back. Failing closed is deliberate: forwarding an +unreadable payload would give the child an empty instruction and a confidently wrong answer. + +## Why it happens + +![Two lanes compare the same delegation. On v1 a ChatGPT parent sends a plaintext task through OpenCodex, it crosses the provider boundary, and the routed child reads it. On v2 the parent sends encrypted_content minted by the ChatGPT backend; OpenCodex cannot decrypt it, so the task stops at the provider boundary and the request fails with unreadable_encrypted_agent_task.](../../../assets/subagent-v2-encrypted-task.svg) + +On v1 the parent emits the child's task as plain text. OpenCodex reads it, routes it, and the +routed child receives something it can act on. + +On v2 the parent emits the task as `encrypted_content`, minted by the ChatGPT backend. The key +stays in that backend. OpenCodex never had it, so there is nothing for a proxy to decrypt and +nothing to rewrite — the value really is ciphertext, not plaintext behind a flag. That is why +this is structural rather than a misconfiguration, and why no proxy-side setting can fix it. + +Three topologies are unaffected, which is worth stating because it explains the shape of the +failure rather than the failure being mysterious: + +| Topology | v1 | v2 | +| --- | --- | --- | +| ChatGPT parent to routed child | works | **fails** | +| Routed parent to routed child | works | works | +| ChatGPT parent to ChatGPT child | works | works — the backend can decrypt what it minted | + +The backend can always read its own ciphertext. Only the boundary crossing breaks. + +## Is it fixed upstream? + +Not yet, and not for the half that matters. Upstream merged +[openai/codex#35845](https://github.com/openai/codex/pull/35845), which added support for +plaintext collaboration messages — but that is the *receiving* side. It handles plaintext that +was already produced; it does not make an OpenAI parent produce it. + +The sending side is still open: +[#36376](https://github.com/openai/codex/issues/36376), reproduced across CLI 0.146 to 0.151 on +Windows, macOS and Linux, and [#37197](https://github.com/openai/codex/issues/37197), which +states the missing piece directly — a sending-side delivery policy. Neither has a maintainer +commitment or an ETA. + +OpenCodex recorded the consequence as [#92](https://github.com/lidge-jun/opencodex/issues/92), +closed as not planned: nothing in this repository can fix it, so the issue is a pointer to the +upstream work rather than a task waiting on a maintainer here. + +## What the three modes do now + +| Mode | Surface | When to choose it | +| --- | --- | --- | +| **v1** (default) | Every model advertises the classic namespaced spawn tools. A spawn can name another model directly. | Anyone who delegates across providers. This is the shipped default. | +| **base** | Upstream model pins: Sol and Terra use v2, Luna uses v1, unpinned models follow Codex's own flag. | You want Codex's intended per-model surface and you only delegate within one provider. | +| **v2** | Every model advertises the flat concurrent tools. | You want the newer concurrent session model and your parent and child are on the same side of the boundary. | + +base is listed second rather than first because its pins put Sol and Terra — the two models most +people delegate *from* — on v2. base is not a middle setting for this problem; for a +ChatGPT-to-routed spawn it behaves like v2. + +## If you already chose base or v2 + +Nothing was changed for you. Upgrading to a release that ships this default does not rewrite an +existing setting; the dashboard raises the notice once and waits for an answer. + +- **Continue** keeps the mode you are on and stops asking. +- **Switch to v1** applies v1 and stops asking. + +Either answer is recorded, and the notice does not come back. Dismissing it without answering +leaves it for the next time you open the dashboard. + +Mode changes apply to **new** Codex sessions. Start a new session after choosing; if a long-running +App host still shows the old surface, run `ocx sync` and restart that surface. + +## If you want v2 anyway + +Four routes, in the order most people should try them: + +1. **Keep ChatGPT on v1.** Inside v2, the `keepNativeChatGptOnV1` switch leaves Sol and Terra on + the v1 surface so they can still spawn Grok or Claude, while routed parents get v2. This is the + closest thing to having both. +2. **Delegate within one provider.** A routed parent spawning a routed child is plaintext on v2 + and works normally. +3. **Trust a direct key-auth Responses relay.** A provider you explicitly mark with + `allowEncryptedV2AgentTasks: true` receives the opaque payload instead of the 400. Only do this + for a destination you know can consume it. +4. **Enable `agentTaskRecovery`.** Experimental and off by default. It recovers most fresh spawns + through the ChatGPT backend, at the cost of quota, latency and a dependency on undocumented + behavior, and it still loses message-type follow-ups and multipart envelopes. + +See [Sub-agent Surface](/guides/sub-agent-surface/) for the full mechanics of each, and +[Agent configuration](/reference/configuration/agents/) for the settings themselves. + +## When this page goes away + +When an upstream release makes a ChatGPT-native parent emit a routed child's task as plaintext, +the reason for the default disappears with it. At that point the default moves back to base, the +confirmation stops appearing, and this page becomes history rather than advice. + +## Changing the mode + +Dashboard, Models and Subagents all carry the same v1/base/v2 switch, and all three ask before +base or v2. From the CLI: + +```bash +ocx v2 status +ocx v2 mode v1 +``` + +The CLI does not prompt. It is the same setting, so choose it knowing what this page describes. diff --git a/gui/src/components/SubagentSurfaceWarningModal.tsx b/gui/src/components/SubagentSurfaceWarningModal.tsx new file mode 100644 index 00000000000..a5edfffffff --- /dev/null +++ b/gui/src/components/SubagentSurfaceWarningModal.tsx @@ -0,0 +1,94 @@ +/** + * Approval dialog for the sub-agent surface. + * + * Two triggers, one component: the operator selecting base or v2, and the one-time advisory + * an install that predates the v1 default raises after an update. They say different things + * and offer the same two answers, so keeping them apart would only let the wording drift. + */ +import { useCallback, useEffect, useId, useRef } from "react"; +import { useT } from "../i18n/shared"; +import { IconAlert } from "../icons"; +import { type SubagentSurfaceMode, subagentSurfaceLabel } from "../subagent-surface"; + +export default function SubagentSurfaceWarningModal({ + reason, + mode, + docsUrl, + busy = false, + onContinue, + onChooseV1, + onDismiss, +}: { + /** `selection` is a mode the operator just clicked; `advisory` is the stored mode. */ + reason: "selection" | "advisory"; + mode: SubagentSurfaceMode; + docsUrl: string; + busy?: boolean; + /** Keep or apply the non-v1 mode. */ + onContinue: () => void; + /** Apply v1, the recommended answer. */ + onChooseV1: () => void; + /** Escape or backdrop: abandon a selection, keep the stored mode for an advisory. */ + onDismiss: () => void; +}) { + const t = useT(); + const titleId = useId(); + const bodyId = useId(); + const dialogRef = useRef(null); + + // Open as a native modal dialog — focus trapping and the backdrop come from the platform. + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + // The dashboard keeps this mounted until its write settles, so Escape or the backdrop + // could otherwise close the dialog over a request that then lands anyway. + if (busy) return; + onDismiss(); + }, [busy, onDismiss]); + + const label = subagentSurfaceLabel(mode); + const advisory = reason === "advisory"; + + return ( + + + + + + + ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4a7833a0b44..11633d6dfec 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -461,6 +461,13 @@ export const de: Record = { "oauthTos.saferPath": "Sicherere Option: Hinterlege stattdessen einen API-Schlüssel in OpenCodex.", "oauthTos.acknowledge": "Ich verstehe das Risiko und möchte trotzdem mit OAuth fortfahren.", "oauthTos.continue": "Mit OAuth fortfahren", + "subagentSurface.selectionTitle": "Sub-Agent-Oberfläche auf {mode} umstellen?", + "subagentSurface.selectionBody": "Auf {mode} übergeben ChatGPT-Modelle, die die v2-Oberfläche nutzen — Sol und Terra auf base, alle Modelle auf v2 —, ihre Aufgabe an ein geroutetes Modell wie Grok oder Claude, verschlüsselt für das ChatGPT-Backend, und das geroutete Modell kann sie nicht lesen. Diese Delegation schlägt mit unreadable_encrypted_agent_task fehl, bis das Problem upstream behoben ist. v1 delegiert zuverlässig über Anbieter hinweg.", + "subagentSurface.advisoryTitle": "v1 ist jetzt die Standard-Sub-Agent-Oberfläche", + "subagentSurface.advisoryBody": "Diese Installation nutzt {mode}; ein ChatGPT-Modell auf der v2-Oberfläche übergibt dabei einem gerouteten Modell eine verschlüsselte Aufgabe, die es nicht lesen kann, und die Delegation über Anbieter hinweg bricht. Wir empfehlen v1, bis das Problem upstream behoben ist. Deine aktuelle Einstellung bleibt unverändert, bis du etwas wählst.", + "subagentSurface.continue": "Fortsetzen", + "subagentSurface.switchToV1": "Zu v1 wechseln", + "subagentSurface.learnMore": "Warum das fehlschlägt", "prov.logoutOk": "Von {provider} abgemeldet.", "prov.logoutFail": "Abmeldung von {provider} fehlgeschlagen. Der Kontostatus bleibt unverändert.", "prov.removed": "\"{name}\" entfernt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 96e30de5579..127082e5586 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -484,6 +484,13 @@ export const en = { "oauthTos.saferPath": "Safer option: configure an API key in OpenCodex instead.", "oauthTos.acknowledge": "I understand the risk and want to continue with OAuth anyway.", "oauthTos.continue": "Continue with OAuth", + "subagentSurface.selectionTitle": "Switch the sub-agent surface to {mode}?", + "subagentSurface.selectionBody": "On {mode}, ChatGPT models that use the v2 surface — Sol and Terra on base, every model on v2 — hand their task to a routed model such as Grok or Claude encrypted for the ChatGPT backend, and the routed model cannot read it. That delegation fails with unreadable_encrypted_agent_task until it is fixed upstream. v1 delegates across providers reliably.", + "subagentSurface.advisoryTitle": "v1 is now the default sub-agent surface", + "subagentSurface.advisoryBody": "This install runs {mode}, where a ChatGPT model on the v2 surface hands a routed model an encrypted task it cannot read, so cross-provider delegation fails. We recommend v1 until that is fixed upstream. Your current setting is unchanged until you choose.", + "subagentSurface.continue": "Continue", + "subagentSurface.switchToV1": "Switch to v1", + "subagentSurface.learnMore": "Why this fails", "prov.logoutOk": "Logged out of {provider}.", "prov.logoutFail": "Could not log out of {provider}. Your account state is unchanged.", "prov.removed": "Removed \"{name}\".", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 0dc86dbaae0..943205d96c9 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -471,6 +471,13 @@ export const fr: Record = { "oauthTos.saferPath": "Option plus sûre : configurez plutôt une clé API dans OpenCodex.", "oauthTos.acknowledge": "Je comprends le risque et souhaite tout de même continuer avec OAuth.", "oauthTos.continue": "Continuer avec OAuth", + "subagentSurface.selectionTitle": "Passer l’interface de sous-agents à {mode} ?", + "subagentSurface.selectionBody": "Sur {mode}, les modèles ChatGPT qui utilisent l’interface v2 (Sol et Terra sur base, tous les modèles sur v2) confient leur tâche, chiffrée pour le backend ChatGPT, à un modèle routé tel que Grok ou Claude, et le modèle routé ne peut pas la lire. Cette délégation échoue avec unreadable_encrypted_agent_task tant que ce n’est pas corrigé en amont. v1 délègue de manière fiable entre fournisseurs.", + "subagentSurface.advisoryTitle": "v1 est désormais l’interface de sous-agents par défaut", + "subagentSurface.advisoryBody": "Cette installation utilise {mode}, où un modèle ChatGPT sur l’interface v2 confie à un modèle routé une tâche chiffrée qu’il ne peut pas lire, si bien que la délégation entre fournisseurs échoue. Nous recommandons v1 tant que ce n’est pas corrigé en amont. Votre paramètre actuel reste inchangé jusqu’à ce que vous fassiez un choix.", + "subagentSurface.continue": "Continuer", + "subagentSurface.switchToV1": "Passer à v1", + "subagentSurface.learnMore": "Pourquoi cet échec", "prov.logoutOk": "Déconnexion de {provider} réussie.", "prov.logoutFail": "Impossible de se déconnecter de {provider}. L’état de votre compte n’a pas changé.", "prov.removed": "« {name} » supprimé.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 97b5dff2819..307d9e3c0c5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -467,6 +467,13 @@ export const ja: Record = { "oauthTos.saferPath": "より安全な選択肢: 代わりに OpenCodex で API キーを設定してください。", "oauthTos.acknowledge": "リスクを理解した上で、OAuth を続行します。", "oauthTos.continue": "OAuth で続行", + "subagentSurface.selectionTitle": "サブエージェントサーフェスを {mode} に切り替えますか?", + "subagentSurface.selectionBody": "{mode} では、v2 サーフェスを使う ChatGPT モデル(base では Sol と Terra、v2 ではすべてのモデル)が、タスクを ChatGPT バックエンド向けに暗号化したまま Grok や Claude などの routed モデルに渡し、routed モデルはそれを読めません。上流で修正されるまで、この委任は unreadable_encrypted_agent_task で失敗します。v1 ならプロバイダーをまたいで確実に委任できます。", + "subagentSurface.advisoryTitle": "v1 がデフォルトのサブエージェントサーフェスになりました", + "subagentSurface.advisoryBody": "このインストールは {mode} で動作しており、v2 サーフェス上の ChatGPT モデルが、routed モデルに読めない暗号化タスクを渡すため、プロバイダーをまたぐ委任が壊れます。上流で修正されるまで v1 を推奨します。選ぶまで現在の設定は変わりません。", + "subagentSurface.continue": "続行", + "subagentSurface.switchToV1": "v1 に切り替え", + "subagentSurface.learnMore": "失敗する理由", "prov.logoutOk": "{provider} からログアウトしました。", "prov.logoutFail": "{provider} からログアウトできませんでした。アカウント状態は変更されていません。", "prov.removed": "\"{name}\" を削除しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 0957274d6b8..57411169e85 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -470,6 +470,13 @@ export const ko: Record = { "oauthTos.saferPath": "더 안전한 방법: OpenCodex에 API 키를 대신 설정하세요.", "oauthTos.acknowledge": "위험을 이해했으며 OAuth로 계속 진행합니다.", "oauthTos.continue": "OAuth로 계속", + "subagentSurface.selectionTitle": "서브에이전트 서피스를 {mode}로 바꿀까요?", + "subagentSurface.selectionBody": "{mode}에서는 v2 서피스를 쓰는 ChatGPT 모델(base에서는 Sol과 Terra, v2에서는 모든 모델)이 작업을 Grok이나 Claude 같은 routed 모델에 ChatGPT 백엔드용으로 암호화된 채 넘기고, routed 모델은 읽지 못합니다. 업스트림에서 고쳐질 때까지 이 위임은 unreadable_encrypted_agent_task로 실패합니다. v1은 프로바이더가 달라도 안정적으로 위임합니다.", + "subagentSurface.advisoryTitle": "v1이 이제 기본 서브에이전트 서피스입니다", + "subagentSurface.advisoryBody": "이 설치는 {mode}로 동작하며, v2 서피스의 ChatGPT 모델이 routed 모델에 읽지 못하는 암호화된 작업을 넘기기 때문에 프로바이더가 다른 위임은 깨집니다. 업스트림에서 고쳐질 때까지는 v1을 권합니다. 직접 바꾸기 전까지 현재 설정은 그대로 유지됩니다.", + "subagentSurface.continue": "계속하기", + "subagentSurface.switchToV1": "v1으로 바꾸기", + "subagentSurface.learnMore": "실패하는 이유", "prov.logoutOk": "{provider} 에서 로그아웃했습니다.", "prov.logoutFail": "{provider}에서 로그아웃하지 못했습니다. 계정 상태는 그대로입니다.", "prov.removed": "\"{name}\" 을(를) 삭제했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 464c71d7ac3..fddf2b52620 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -472,6 +472,13 @@ export const ru: Record = { "oauthTos.saferPath": "Более безопасный вариант: вместо этого настройте API-ключ в OpenCodex.", "oauthTos.acknowledge": "Я понимаю риск и всё равно хочу продолжить с OAuth.", "oauthTos.continue": "Продолжить с OAuth", + "subagentSurface.selectionTitle": "Переключить поверхность подагентов на {mode}?", + "subagentSurface.selectionBody": "В режиме {mode} модели ChatGPT, использующие поверхность v2 (Sol и Terra на base, все модели на v2), передают свою задачу, зашифрованную для бэкенда ChatGPT, маршрутизируемой модели вроде Grok или Claude, и та не может её прочитать. Это делегирование завершается ошибкой unreadable_encrypted_agent_task, пока это не исправлено в вышестоящем коде. v1 надёжно делегирует между провайдерами.", + "subagentSurface.advisoryTitle": "v1 теперь поверхность подагентов по умолчанию", + "subagentSurface.advisoryBody": "В этой установке используется режим {mode}: модель ChatGPT на поверхности v2 передаёт маршрутизируемой модели зашифрованную задачу, которую та не может прочитать, поэтому делегирование между провайдерами ломается. Мы рекомендуем v1, пока это не исправлено выше. Текущая настройка не изменится, пока вы не выберете.", + "subagentSurface.continue": "Продолжить", + "subagentSurface.switchToV1": "Переключиться на v1", + "subagentSurface.learnMore": "Почему возникает сбой", "prov.logoutOk": "Выполнен выход из {provider}.", "prov.logoutFail": "Не удалось выйти из {provider}. Состояние аккаунта не изменилось.", "prov.removed": "Провайдер \"{name}\" удалён.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3e3c12358f9..b8c0aaacaba 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -485,6 +485,13 @@ export const tr: Record = { "oauthTos.saferPath": "Daha güvenli seçenek: OpenCodex'te bir API anahtarı yapılandırın.", "oauthTos.acknowledge": "Riski anlıyorum ve OAuth ile devam etmek istiyorum.", "oauthTos.continue": "OAuth ile devam et", + "subagentSurface.selectionTitle": "Alt ajan yüzeyi {mode} olarak değiştirilsin mi?", + "subagentSurface.selectionBody": "{mode} üzerinde, v2 yüzeyini kullanan ChatGPT modelleri (base üzerinde Sol ve Terra, v2 üzerinde tüm modeller) görevlerini Grok veya Claude gibi yönlendirilmiş bir modele ChatGPT arka ucu için şifrelenmiş olarak devreder ve yönlendirilmiş model bunu okuyamaz. Yukarı akışta düzeltilene kadar bu devir unreadable_encrypted_agent_task hatasıyla başarısız olur. v1 sağlayıcılar arası devri güvenilir biçimde yapar.", + "subagentSurface.advisoryTitle": "v1 artık varsayılan alt ajan yüzeyi", + "subagentSurface.advisoryBody": "Bu kurulum {mode} ile çalışıyor; v2 yüzeyindeki bir ChatGPT modeli, yönlendirilmiş bir modele okuyamayacağı şifreli bir görev aktardığı için sağlayıcılar arası devri bozuyor. Yukarı akışta düzeltilene kadar v1 önerilir. Seçim yapılana kadar mevcut ayarınız değişmez.", + "subagentSurface.continue": "Devam", + "subagentSurface.switchToV1": "v1'e geç", + "subagentSurface.learnMore": "Neden başarısız oluyor", "prov.logoutOk": "{provider} çıkışı yapıldı.", "prov.logoutFail": "{provider} çıkışı yapılamadı.", "prov.removed": "\"{name}\" kaldırıldı.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a0d0767dc38..637ec6d5be2 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -366,6 +366,13 @@ export const zhTW: Record = { "oauthTos.saferPath": "更安全的做法:改為在 OpenCodex 中配置 API 金鑰。", "oauthTos.acknowledge": "我瞭解風險,仍要繼續使用 OAuth。", "oauthTos.continue": "繼續使用 OAuth", + "subagentSurface.selectionTitle": "要將子代理介面切換到 {mode}嗎?", + "subagentSurface.selectionBody": "在 {mode} 下,使用 v2 介面的 ChatGPT 模型(base 上是 Sol 和 Terra,v2 上是所有模型)會把任務以 ChatGPT 後端的加密形式交給 Grok、Claude 等路由模型,路由模型無法讀取。在上游修正之前,這種委派會以 unreadable_encrypted_agent_task 失敗。v1 能可靠地跨供應商委派。", + "subagentSurface.advisoryTitle": "v1 現在是預設子代理介面", + "subagentSurface.advisoryBody": "此安裝目前使用 {mode},v2 介面上的 ChatGPT 模型會把無法讀取的加密任務交給路由模型,跨供應商委派因此失敗。在上游修正之前建議使用 v1。在你選擇之前,目前的設定維持不變。", + "subagentSurface.continue": "繼續", + "subagentSurface.switchToV1": "切換到 v1", + "subagentSurface.learnMore": "為什麼會失敗", "prov.logoutOk": "已登出 {provider}。", "prov.logoutFail": "無法登出 {provider}。帳號狀態保持不變。", "prov.removed": "已移除 \"{name}\"。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index da66e4f8da6..aac09968457 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -467,6 +467,13 @@ export const zh: Record = { "oauthTos.saferPath": "更安全的做法:改为在 OpenCodex 中配置 API 密钥。", "oauthTos.acknowledge": "我了解风险,仍要继续使用 OAuth。", "oauthTos.continue": "继续使用 OAuth", + "subagentSurface.selectionTitle": "将子代理界面切换到 {mode}?", + "subagentSurface.selectionBody": "在 {mode} 下,使用 v2 界面的 ChatGPT 模型(base 上是 Sol 和 Terra,v2 上是所有模型)会把任务以 ChatGPT 后端的加密形式交给 Grok、Claude 等路由模型,路由模型无法读取。在上游修复之前,这种委托会以 unreadable_encrypted_agent_task 失败。v1 可以可靠地跨提供方委托。", + "subagentSurface.advisoryTitle": "v1 现在是默认子代理界面", + "subagentSurface.advisoryBody": "此安装当前使用 {mode},v2 界面上的 ChatGPT 模型会把无法读取的加密任务交给路由模型,跨提供方委托因此失败。在上游修复之前建议使用 v1。在你做出选择之前,当前设置保持不变。", + "subagentSurface.continue": "继续", + "subagentSurface.switchToV1": "切换到 v1", + "subagentSurface.learnMore": "为什么失败", "prov.logoutOk": "已退出 {provider}。", "prov.logoutFail": "无法退出 {provider}。账户状态保持不变。", "prov.removed": "已移除 \"{name}\"。", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 0150cd29b0f..710c6cab1d8 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -77,6 +77,8 @@ import { type V2Status, } from "./models-shared"; import { DiscoveryDependencyHint, EmptyProviderHint } from "./models-provider-hints"; +import SubagentSurfaceWarningModal from "../components/SubagentSurfaceWarningModal"; +import { SUBAGENT_SURFACE_GUIDE_URL, readSubagentSurfaceAdvisory } from "../subagent-surface"; import { shadowCallModelOptions } from "./dashboard-shared"; import { shadowSourceModelBadge, shadowSourceModelLabel } from "./shadow-call-source"; @@ -344,6 +346,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [threadsCustom, setThreadsCustom] = useState(""); const [showThreadsCustom, setShowThreadsCustom] = useState(false); const [v2HelpOpen, setV2HelpOpen] = useState(false); + /** A base/v2 selection waiting on the approval dialog. Null while nothing is pending. */ + const [pendingSurface, setPendingSurface] = useState<"default" | "v2" | null>(null); const [customModalOpen, setCustomModalOpen] = useState(false); const [displayNameModel, setDisplayNameModel] = useState(null); const [priceModel, setPriceModel] = useState(null); @@ -1150,7 +1154,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const setMultiAgentMode = async (mode: "v1" | "default" | "v2") => { if (!v2 || v2.multiAgentMode === mode) return; - await putV2Setting({ multiAgentMode: mode }); + // v1 applies immediately: confirming a move toward the safe default would be noise. + // base and v2 both put ChatGPT-native parents on the v2 surface, where a task handed + // to a routed child is undeliverable ciphertext, so those wait for an answer. + if (mode === "v1") { await putV2Setting({ multiAgentMode: "v1" }); return; } + setPendingSurface(mode); }; @@ -2020,6 +2028,17 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )} + {pendingSurface && ( + { const next = pendingSurface; setPendingSurface(null); void putV2Setting({ multiAgentMode: next, multiAgentSurfaceAdvisoryAcknowledged: true }); }} + onChooseV1={() => { setPendingSurface(null); if (v2?.multiAgentMode !== "v1") void putV2Setting({ multiAgentMode: "v1", multiAgentSurfaceAdvisoryAcknowledged: true }); }} + onDismiss={() => setPendingSurface(null)} + /> + )} {v2 && (v2.enabled || v2.agentsMaxThreadsConflict || v2Note) && ( diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index 94a8df7086d..b29f2157daf 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -6,6 +6,8 @@ import SubagentsWorkspace, { FEATURED_MAX } from "../components/subagents-worksp import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; +import SubagentSurfaceWarningModal from "../components/SubagentSurfaceWarningModal"; +import { SUBAGENT_SURFACE_GUIDE_URL } from "../subagent-surface"; import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; type CachedSubagents = { available: string[]; chosen: string[]; fallback?: string[]; pollMs?: number; fallbackAvailable?: string[] }; @@ -49,6 +51,13 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const ultraModeCurrent = ultraState?.apiBase === apiBase; const ultraMode = ultraModeCurrent ? ultraState.mode : UNLOADED_ULTRA_MODE; const [ultraSaving, setUltraSaving] = useState(false); + /** A base/v2 selection from this page waiting on the approval dialog. */ + /** + * A base/v2 selection waiting on the approval dialog, tagged with the endpoint it was staged + * for. Switching endpoints with the dialog open must not apply one proxy's answer to another, + * and tagging beats clearing it from an effect, which would be a cascading render. + */ + const [pendingSurface, setPendingSurface] = useState<{ mode: "default" | "v2"; apiBase: string } | null>(null); const [ultraLoadFailed, setUltraLoadFailed] = useState(false); const ultraLoadGeneration = useRef(0); const currentUltraApiBase = useRef(apiBase); @@ -361,11 +370,38 @@ export default function Subagents({ apiBase }: { apiBase: string }) { onSave: patch => { void delegation.save(patch); }, ultraMode, ultraSaving: ultraSaving || !ultraModeCurrent, - onUltraModeSave: patch => { void saveUltraMode(patch); }, + // This page carries the same v1/base/v2 switch as Models and the Dashboard, so it + // needs the same gate: base and v2 wait for an answer, everything else writes. + onUltraModeSave: patch => { + if (patch.multiAgentMode === "default" || patch.multiAgentMode === "v2") { + setPendingSurface({ mode: patch.multiAgentMode, apiBase }); + return; + } + void saveUltraMode(patch); + }, ultraLoadFailed, onUltraModeRetry: () => { void retryUltraMode(); }, }} /> + {pendingSurface && pendingSurface.apiBase === apiBase && ( + { + const next = pendingSurface.mode; + setPendingSurface(null); + // Answer the advisory too: this operator has just read the same warning. + void saveUltraMode({ multiAgentMode: next, multiAgentSurfaceAdvisoryAcknowledged: true }); + }} + onChooseV1={() => { + setPendingSurface(null); + if (ultraMode.multiAgentMode !== "v1") void saveUltraMode({ multiAgentMode: "v1", multiAgentSurfaceAdvisoryAcknowledged: true }); + }} + onDismiss={() => setPendingSurface(null)} + /> + )} ); } diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index 1b1e98461ef..232b8afcff5 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -1,4 +1,5 @@ import { readJsonIfOk } from "../fetch-json"; +import { readSubagentSurfaceAdvisory, type SubagentSurfaceAdvisory } from "../subagent-surface"; import { beginPollEpoch, settingsPollMayCommit, @@ -79,6 +80,8 @@ export type DashboardSettingsPoll = { export type DashboardMaModePoll = { maMode: "v1" | "default" | "v2"; + /** Null when the runtime predates the advisory or the call failed. */ + advisory?: SubagentSurfaceAdvisory | null; }; export type DashboardEpochRefs = { @@ -238,11 +241,12 @@ export async function fetchDashboardMaMode( try { const v2Res = await fetch(`${apiBase}/api/v2`, { signal }); if (!v2Res.ok) return { maMode: "default" }; - const v2Data = await v2Res.json() as { multiAgentMode?: unknown }; + const v2Data = await v2Res.json() as { multiAgentMode?: unknown; multiAgentSurfaceAdvisory?: unknown }; + const advisory = readSubagentSurfaceAdvisory(v2Data.multiAgentSurfaceAdvisory); if (v2Data.multiAgentMode === "v1" || v2Data.multiAgentMode === "v2") { - return { maMode: v2Data.multiAgentMode }; + return { maMode: v2Data.multiAgentMode, advisory }; } - return { maMode: "default" }; + return { maMode: "default", advisory }; } catch (error) { if (isAbortError(error, signal)) throw error; return { maMode: "default" }; diff --git a/gui/src/pages/dashboard-dialogs.tsx b/gui/src/pages/dashboard-dialogs.tsx index 7a53bb0ff86..4b96651d0fa 100644 --- a/gui/src/pages/dashboard-dialogs.tsx +++ b/gui/src/pages/dashboard-dialogs.tsx @@ -6,6 +6,8 @@ import { } from "./dashboard-shared"; import type { useDashboardData } from "./use-dashboard-data"; import { shadowSourceModelLabel } from "./shadow-call-source"; +import SubagentSurfaceWarningModal from "../components/SubagentSurfaceWarningModal"; +import { SUBAGENT_SURFACE_GUIDE_URL } from "../subagent-surface"; type Dash = ReturnType; @@ -19,6 +21,7 @@ export function DashboardDialogs(d: Dash) { effortCapHelpOpen, setEffortCapHelpOpen, effortCapHelpDialogRef, shadowCallHelpOpen, setShadowCallHelpOpen, shadowCallHelpDialogRef, shadowCall, + maAdvisory, maAdvisoryOpen, pendingMaMode, maBusy, keepMaMode, chooseMaV1, dismissMaSurfaceDialog, } = d; return ( @@ -208,6 +211,17 @@ export function DashboardDialogs(d: Dash) { + {(pendingMaMode || maAdvisoryOpen) && ( + { void keepMaMode(); }} + onChooseV1={() => { void chooseMaV1(); }} + onDismiss={dismissMaSurfaceDialog} + /> + )} ); } diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 8285c02d76f..acc694e6bea 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -141,6 +141,8 @@ export interface V2Status { maxConcurrentThreadsPerSession?: number | null; multiAgentMode?: "v1" | "default" | "v2"; keepNativeChatGptOnV1?: boolean; + /** Response-only; absent on a runtime older than the v1-default advisory. */ + multiAgentSurfaceAdvisory?: unknown; } export interface ShadowCallData { diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index abcb5be9ff5..cf7efb98ef4 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -25,6 +25,7 @@ import { type DashboardEpochRefs, } from "./dashboard-core-poll"; import { usageSummary30dResourceKey } from "../usage-summary-resource"; +import type { SubagentSurfaceAdvisory } from "../subagent-surface"; import { type DashboardSection, type HealthData, @@ -180,8 +181,20 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { const [modelsLoading, setModelsLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [maMode, setMaMode] = useState(() => cachedMaMode ?? "default"); - const [maBusy, setMaBusy] = useState(false); +const [maBusy, setMaBusy] = useState(false); const [maError, setMaError] = useState(null); + /** The runtime's one-time advisory, and whether this page load has answered it. */ + /** + * The runtime's one-time advisory and any staged base/v2 selection, each tagged with the + * endpoint it came from. This hook stays mounted across an endpoint switch, and clearing the + * state from an effect would be a cascading render, so the tag is what scopes them. + */ + const [maAdvisoryState, setMaAdvisoryState] = useState<{ advisory: SubagentSurfaceAdvisory | null; apiBase: string } | null>(null); + const [maAdvisoryAnsweredFor, setMaAdvisoryAnsweredFor] = useState(null); + const [pendingMaModeState, setPendingMaModeState] = useState<{ mode: "default" | "v2"; apiBase: string } | null>(null); + const maAdvisory = maAdvisoryState?.apiBase === apiBase ? maAdvisoryState.advisory : null; + const maAdvisoryAnswered = maAdvisoryAnsweredFor === apiBase; + const pendingMaMode = pendingMaModeState?.apiBase === apiBase ? pendingMaModeState.mode : null; const [maHelpOpen, setMaHelpOpen] = useState(false); const [effortCapHelpOpen, setEffortCapHelpOpen] = useState(false); const [shadowCallHelpOpen, setShadowCallHelpOpen] = useState(false); @@ -378,6 +391,7 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { useEffect(() => { if (maModePoll.data === undefined) return; setMaMode(maModePoll.data.maMode); + setMaAdvisoryState({ advisory: maModePoll.data.advisory ?? null, apiBase }); writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, maModePoll.data.maMode); }, [maModePoll.data, apiBase]); @@ -605,19 +619,22 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { } } - const switchMaMode = async (mode: "v1" | "default" | "v2") => { - if (maBusy || maMode === mode) return; - setMaBusy(true); + const writeMaMode = async (mode: "v1" | "default" | "v2", acknowledgeAdvisory = false) => { + setMaBusy(true); setMaError(null); - try { - const r = await fetch(`${apiBase}/api/v2`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ multiAgentMode: mode }), - }); - if (r.ok) { - setMaMode(mode); - writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, mode); + try { + const payload: { multiAgentMode: "v1" | "default" | "v2"; multiAgentSurfaceAdvisoryAcknowledged?: true } = { multiAgentMode: mode }; + // One request, so the recommended answer cannot leave the notice raised on a mode it applied. + if (acknowledgeAdvisory) payload.multiAgentSurfaceAdvisoryAcknowledged = true; + const r = await fetch(`${apiBase}/api/v2`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (r.ok) { + setMaMode(mode); + if (acknowledgeAdvisory) setMaAdvisoryAnsweredFor(apiBase); + writeSessionListCache(`${MA_MODE_CACHE_PREFIX}${apiBase}`, mode); } else { let message = t("dash.maSwitchFailed", { status: String(r.status) }); try { @@ -625,12 +642,67 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { message = (typeof body.error === "string" && body.error) || (typeof body.message === "string" && body.message) || message; } catch { /* non-JSON error body */ } setMaError(message); - } + } + } catch (e) { + setMaError(e instanceof Error ? e.message : t("dash.maNetworkError")); + } + finally { setMaBusy(false); } + }; + + const switchMaMode = async (mode: "v1" | "default" | "v2") => { + if (maBusy || maMode === mode) return; + // v1 applies immediately: confirming a move toward the safe default would be noise. base + // and v2 both put ChatGPT-native parents on the surface whose task a routed child cannot + // read, so they wait for an answer. + if (mode !== "v1") { setPendingMaModeState({ mode, apiBase }); return; } + await writeMaMode("v1"); + }; + + /** Answer the advisory without moving the mode. Failure just means it asks again. */ + const acknowledgeMaAdvisory = async () => { + // Answered for this page load either way: the operator did answer. If the write did not + // land the runtime raises the notice again next load, so a failure costs one more prompt + // rather than a lost setting — but say so instead of swallowing it. + setMaAdvisoryAnsweredFor(apiBase); + try { + const r = await fetch(`${apiBase}/api/v2`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ multiAgentSurfaceAdvisoryAcknowledged: true }), + }); + if (!r.ok) setMaError(t("dash.maSwitchFailed", { status: String(r.status) })); } catch (e) { setMaError(e instanceof Error ? e.message : t("dash.maNetworkError")); } - finally { setMaBusy(false); } - }; + }; + + /** The ghost button: apply the mode that was selected, or keep the stored one. */ + const keepMaMode = async () => { + const pending = pendingMaMode; + setPendingMaModeState(null); + // A selection answers the advisory too. Without that, continuing to base or v2 leaves the + // notice raised and the next poll asks the same question the operator just answered. + // Unconditionally, not gated on the poll's current `required`: that projection goes false + // while the mode is v1 without the version having been stored, so a later confirmed base or + // v2 would skip the acknowledgement and be asked all over again. + if (pending) { await writeMaMode(pending, true); return; } + await acknowledgeMaAdvisory(); + }; + + /** The primary button: v1, and the advisory answered in the same request when it is raised. */ + const chooseMaV1 = async () => { + setPendingMaModeState(null); + if (maMode === "v1") { await acknowledgeMaAdvisory(); return; } + await writeMaMode("v1", true); + }; + + /** Escape or backdrop: abandon a selection, or leave the advisory unanswered for next load. */ + const dismissMaSurfaceDialog = () => { + if (pendingMaMode) { setPendingMaModeState(null); return; } + setMaAdvisoryAnsweredFor(apiBase); + }; + + const maAdvisoryOpen = !pendingMaMode && !maAdvisoryAnswered && maAdvisory?.required === true; const saveInjection = async (patch: { multiAgentGuidanceEnabled?: boolean; @@ -844,8 +916,9 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { usageLoading: usagePoll.loading && !usage30d, healthLoading: overviewPoll.loading && !health, sidecarSaving, shadowCallSaving, modelsLoading, settingsSaving, syncing, - maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen, - maError, +maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen, + maError, + maAdvisory, maAdvisoryOpen, pendingMaMode, keepMaMode, chooseMaV1, dismissMaSurfaceDialog, effortCapHelpOpen, setEffortCapHelpOpen, shadowCallHelpOpen, setShadowCallHelpOpen, injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving, multiAgentGuidanceEnabled, syncCodexSubagentDefaults, saveInjection, diff --git a/gui/src/pages/use-subagent-delegation.ts b/gui/src/pages/use-subagent-delegation.ts index 1a7829dc515..cea33a8508d 100644 --- a/gui/src/pages/use-subagent-delegation.ts +++ b/gui/src/pages/use-subagent-delegation.ts @@ -40,6 +40,8 @@ export type UltraModePatch = { multiAgentModeHintText?: string | null; /** The v1/base/v2 switch. Models owns the catalog-side copy; this is the delegation-side one. */ multiAgentMode?: "v1" | "default" | "v2"; + /** Answers the one-time surface advisory in the same request as a mode change. */ + multiAgentSurfaceAdvisoryAcknowledged?: true; }; type DelegationResponse = { diff --git a/gui/src/subagent-surface.ts b/gui/src/subagent-surface.ts new file mode 100644 index 00000000000..b6b405c693f --- /dev/null +++ b/gui/src/subagent-surface.ts @@ -0,0 +1,39 @@ +/** + * The sub-agent surface advisory, on the dashboard side. + * + * The runtime sends `docsUrl` with every `/api/v2` response, so this constant is only the + * fallback for a runtime older than the advisory. `gui/tests/subagent-surface-warning.test.tsx` + * pins it to `src/config/multi-agent-surface.ts` so the two cannot drift. + */ +export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent-v1-default/"; + +export type SubagentSurfaceMode = "v1" | "default" | "v2"; + +export type SubagentSurfaceAdvisory = { + required: boolean; + mode: SubagentSurfaceMode; + recommended: "v1"; + version: number; + docsUrl: string; +}; + +/** Narrow an unknown `/api/v2` field; a runtime that does not send it yields null. */ +export function readSubagentSurfaceAdvisory(value: unknown): SubagentSurfaceAdvisory | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const mode = raw.mode; + if (mode !== "v1" && mode !== "default" && mode !== "v2") return null; + if (typeof raw.required !== "boolean") return null; + return { + required: raw.required, + mode, + recommended: "v1", + version: typeof raw.version === "number" ? raw.version : 0, + docsUrl: typeof raw.docsUrl === "string" && raw.docsUrl ? raw.docsUrl : SUBAGENT_SURFACE_GUIDE_URL, + }; +} + +/** What the operator sees for a mode. The stored `"default"` is called base in the UI. */ +export function subagentSurfaceLabel(mode: SubagentSurfaceMode): string { + return mode === "default" ? "base" : mode; +} diff --git a/gui/tests/models-keep-native-v1-placement.test.ts b/gui/tests/models-keep-native-v1-placement.test.ts index 4e3969df583..7f9cfa60bb4 100644 --- a/gui/tests/models-keep-native-v1-placement.test.ts +++ b/gui/tests/models-keep-native-v1-placement.test.ts @@ -74,7 +74,17 @@ test("both v2 surface setters route through the shared writer", () => { modelsSource.indexOf("const putV2Threads"), ); - expect(setters).toContain("putV2Setting({ multiAgentMode: mode })"); + // v1 still writes straight through the shared writer. base and v2 are staged for the + // approval dialog, whose Continue handler calls the same writer — the invariant here is + // that neither path falls back to a refetch. + expect(setters).toContain('putV2Setting({ multiAgentMode: "v1" })'); + expect(setters).toContain("setPendingSurface(mode)"); expect(setters).toContain("putV2Setting({ keepNativeChatGptOnV1: next })"); expect(setters).not.toContain("void loadV2()"); + + const dialog = modelsSource.slice(modelsSource.indexOf("")); + // The Continue handler also answers the surface advisory, so match the call, not the body. + expect(props).toContain("putV2Setting({ multiAgentMode: next,"); + expect(props).not.toContain("void loadV2()"); }); diff --git a/gui/tests/subagent-surface-warning.test.tsx b/gui/tests/subagent-surface-warning.test.tsx new file mode 100644 index 00000000000..ac785cab480 --- /dev/null +++ b/gui/tests/subagent-surface-warning.test.tsx @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { DICTS, LOCALES } from "../src/i18n/shared"; +import SubagentSurfaceWarningModal from "../src/components/SubagentSurfaceWarningModal"; +import { SUBAGENT_SURFACE_GUIDE_URL, readSubagentSurfaceAdvisory, subagentSurfaceLabel } from "../src/subagent-surface"; + +const KEYS = [ + "subagentSurface.selectionTitle", + "subagentSurface.selectionBody", + "subagentSurface.advisoryTitle", + "subagentSurface.advisoryBody", + "subagentSurface.continue", + "subagentSurface.switchToV1", + "subagentSurface.learnMore", +]; + +/** + * The GUI keeps its own copy of the guide URL as a fallback for a runtime that predates the + * advisory. Two copies of a URL is exactly the thing that rots, so pin them to each other. + */ +test("the fallback guide URL matches the runtime constant", async () => { + const runtime = await Bun.file(new URL("../../src/config/multi-agent-surface.ts", import.meta.url)).text(); + + expect(runtime).toContain(`SUBAGENT_SURFACE_GUIDE_URL = "${SUBAGENT_SURFACE_GUIDE_URL}"`); + expect(SUBAGENT_SURFACE_GUIDE_URL).toStartWith("https://opencodex.me/guides/"); +}); + +test("every locale defines the dialog keys, and Korean uses the wording that was asked for", () => { + // Read the loaded catalogs, not the source text: a key mentioned only in a comment would + // satisfy a grep and still render the raw key id to the operator. + expect(LOCALES.length).toBe(9); + for (const { code } of LOCALES) { + for (const key of KEYS) { + const value = DICTS[code][key as keyof (typeof DICTS)[typeof code]]; + expect(typeof value).toBe("string"); + expect(value.trim().length).toBeGreaterThan(0); + expect(value).not.toBe(key); + } + } + + expect(DICTS.ko["subagentSurface.continue"]).toBe("계속하기"); + expect(DICTS.ko["subagentSurface.switchToV1"]).toBe("v1으로 바꾸기"); +}); + +test("a runtime that does not send the advisory yields null instead of a raised notice", () => { + expect(readSubagentSurfaceAdvisory(undefined)).toBeNull(); + expect(readSubagentSurfaceAdvisory({})).toBeNull(); + expect(readSubagentSurfaceAdvisory({ required: true })).toBeNull(); + expect(readSubagentSurfaceAdvisory({ required: "yes", mode: "v2" })).toBeNull(); + + expect(readSubagentSurfaceAdvisory({ required: true, mode: "v2" })).toEqual({ + required: true, mode: "v2", recommended: "v1", version: 0, docsUrl: SUBAGENT_SURFACE_GUIDE_URL, + }); + expect(readSubagentSurfaceAdvisory({ + required: false, mode: "default", version: 3, docsUrl: "https://example.test/guide/", + })).toEqual({ + required: false, mode: "default", recommended: "v1", version: 3, docsUrl: "https://example.test/guide/", + }); +}); + +test("the stored default mode is called base in the UI", () => { + expect(subagentSurfaceLabel("default")).toBe("base"); + expect(subagentSurfaceLabel("v1")).toBe("v1"); + expect(subagentSurfaceLabel("v2")).toBe("v2"); +}); + +/** + * The same v1/base/v2 switch is rendered on three pages. Every one of them must stage a base + * or v2 click instead of writing it, or the dialog is decoration on whichever page was missed. + * v1 stays immediate: asking an operator to confirm a move toward the safe default is noise. + */ +test("all three mode switches gate base and v2 but let v1 through", async () => { + const models = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + const gate = models.slice(models.indexOf("const setMultiAgentMode")); + const body = gate.slice(0, gate.indexOf("};")); + + expect(body).toContain('if (mode === "v1") { await putV2Setting({ multiAgentMode: "v1" }); return; }'); + expect(body).toContain("setPendingSurface(mode);"); + expect(body).not.toContain("await putV2Setting({ multiAgentMode: mode })"); + + const dash = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text(); + const switcher = dash.slice(dash.indexOf("const switchMaMode")); + const switcherBody = switcher.slice(0, switcher.indexOf("};")); + + expect(switcherBody).toContain('if (mode !== "v1") { setPendingMaModeState({ mode, apiBase }); return; }'); + expect(switcherBody).toContain('await writeMaMode("v1")'); + + // The Subagents page carries a third copy of the switch and used to PUT straight through. + const subagents = await Bun.file(new URL("../src/pages/Subagents.tsx", import.meta.url)).text(); + const handler = subagents.slice(subagents.indexOf("onUltraModeSave: patch =>")); + const handlerBody = handler.slice(0, handler.indexOf("},")); + + expect(handlerBody).toContain('patch.multiAgentMode === "default" || patch.multiAgentMode === "v2"'); + expect(handlerBody).toContain("setPendingSurface({ mode: patch.multiAgentMode, apiBase });"); + expect(subagents).toContain(" { + const dash = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text(); + const choose = dash.slice(dash.indexOf("const chooseMaV1")); + const body = choose.slice(0, choose.indexOf("};")); + + // Unconditional: the poll's `required` goes false while the mode is v1 without the version + // having been stored, so gating on it would skip the acknowledgement and ask all over again. + expect(body).toContain('await writeMaMode("v1", true)'); + expect(body).not.toContain("maAdvisory?.required"); + + const writer = dash.slice(dash.indexOf("const writeMaMode")); + expect(writer.slice(0, writer.indexOf("const switchMaMode"))).toContain( + "if (acknowledgeAdvisory) payload.multiAgentSurfaceAdvisoryAcknowledged = true;", + ); + + // Continuing to base or v2 also answers the notice. Otherwise the next poll asks again. + const keep = dash.slice(dash.indexOf("const keepMaMode")); + expect(keep.slice(0, keep.indexOf("};"))).toContain("await writeMaMode(pending, true)"); +}); + +const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + // happy-dom has no native modal dialog; the component only needs the call to be safe. + const dialogProto = (testWindow as unknown as { HTMLDialogElement?: { prototype: Record } }).HTMLDialogElement?.prototype; + if (dialogProto && typeof dialogProto.showModal !== "function") dialogProto.showModal = function showModal() { (this as { open?: boolean }).open = true; }; + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); + root = null; +}); + +afterEach(() => { + if (root) act(() => root!.unmount()); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } +}); + +function render(node: React.ReactElement): void { + root = createRoot(container); + act(() => root!.render({node})); +} + +test("the advisory offers both answers and a link to the guide, and each button calls its own handler", () => { + const calls: string[] = []; + render( + calls.push("continue")} + onChooseV1={() => calls.push("v1")} + onDismiss={() => calls.push("dismiss")} + />, + ); + + const dialog = container.querySelector("dialog"); + expect(dialog?.getAttribute("data-subagent-surface-reason")).toBe("advisory"); + + const link = container.querySelector('a[href="https://example.test/guide/"]'); + expect(link).not.toBeNull(); + expect(link?.getAttribute("rel")).toBe("noreferrer"); + + const actions = Array.from(container.querySelectorAll(".modal-actions button")) as HTMLButtonElement[]; + expect(actions).toHaveLength(2); + expect(actions[1]!.className).toContain("btn-primary"); + + act(() => actions[0]!.click()); + act(() => actions[1]!.click()); + expect(calls).toEqual(["continue", "v1"]); +}); + +test("a selection names the mode being selected, using the UI label for base", () => { + render( + {}} + onChooseV1={() => {}} + onDismiss={() => {}} + />, + ); + + const heading = container.querySelector("h3")?.textContent ?? ""; + expect(heading).toContain("base"); + expect(heading).not.toContain("{mode}"); + expect(container.querySelector("dialog")?.getAttribute("data-subagent-surface-reason")).toBe("selection"); +}); + +test("the backdrop dismisses without answering either way", () => { + const calls: string[] = []; + render( + calls.push("continue")} + onChooseV1={() => calls.push("v1")} + onDismiss={() => calls.push("dismiss")} + />, + ); + + const backdrop = container.querySelector(".modal-backdrop-dismiss") as HTMLButtonElement | null; + expect(backdrop).not.toBeNull(); + act(() => backdrop!.click()); + + expect(calls).toEqual(["dismiss"]); +}); + +test("a dialog with a write in flight cannot be dismissed out from under it", () => { + const calls: string[] = []; + render( + calls.push("continue")} + onChooseV1={() => calls.push("v1")} + onDismiss={() => calls.push("dismiss")} + />, + ); + + const backdrop = container.querySelector(".modal-backdrop-dismiss") as HTMLButtonElement | null; + act(() => backdrop!.click()); + const actions = Array.from(container.querySelectorAll(".modal-actions button")) as HTMLButtonElement[]; + act(() => actions[0]!.click()); + + expect(calls).toEqual([]); +}); diff --git a/src/config.ts b/src/config.ts index 1b513bf2d4d..edb52fc0f78 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; import { isValidProviderName, hasOwnProvider } from "./config/provider-name"; +import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./config/multi-agent-surface"; import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./config/subagent-models"; export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models"; import { @@ -1265,6 +1266,9 @@ const configSchema = z.object({ googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined), subagentModelsVersion: z.number().int().positive().optional().catch(undefined), subagentModels: z.array(z.string().min(1)).optional().catch(undefined), + // A hand-edited advisory version must not cost the operator their providers; a bad + // value degrades to undefined, which simply raises the notice again. + multiAgentSurfaceAdvisoryVersion: z.number().int().nonnegative().optional().catch(undefined), clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), // A malformed profile policy must not fall back to legacy all-profile activation. asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }), @@ -2465,7 +2469,18 @@ export function loadConfig(): OcxConfig { // discarding it entirely, so pool accounts and providers survive a missing // field like defaultProvider. const defaults = getDefaultConfig(); - const merged = { ...defaults, ...parsed, subagentModelsVersion: parsed.subagentModelsVersion }; + // Pin the keys whose ABSENCE is meaningful. Spreading defaults underneath means any + // key the stored document lacks is inherited, which is right for additive defaults and + // wrong for a behavioral mode: a config that reaches this path only because it lost + // `defaultProvider` would be repaired into v1 sub-agents and a pre-answered advisory, + // silently changing a setting its operator never touched. + const merged = { + ...defaults, + ...parsed, + subagentModelsVersion: parsed.subagentModelsVersion, + multiAgentMode: parsed.multiAgentMode, + multiAgentSurfaceAdvisoryVersion: parsed.multiAgentSurfaceAdvisoryVersion, + }; // Ensure providers from both sides survive if (parsed.providers && defaults.providers) { merged.providers = { ...defaults.providers, ...parsed.providers }; @@ -2687,7 +2702,14 @@ function mergeConfigDefaults(parsed: unknown): unknown { if (!parsed || typeof parsed !== "object") return parsed; const defaults = getDefaultConfig(); const raw = parsed as Record; - const merged: Record = { ...defaults, ...raw, subagentModelsVersion: raw.subagentModelsVersion }; + // Same absence-is-meaningful pin as the repair merge above. + const merged: Record = { + ...defaults, + ...raw, + subagentModelsVersion: raw.subagentModelsVersion, + multiAgentMode: raw.multiAgentMode, + multiAgentSurfaceAdvisoryVersion: raw.multiAgentSurfaceAdvisoryVersion, + }; if (raw.providers && typeof raw.providers === "object" && defaults.providers) { merged.providers = { ...defaults.providers, ...(raw.providers as Record) }; } @@ -4105,6 +4127,12 @@ export function getDefaultConfig(): OcxConfig { defaultProvider: "openai", subagentModels: [...DEFAULT_SUBAGENT_MODELS], subagentModelsVersion: SUBAGENT_MODELS_VERSION, + // v1 is the shipped surface while a v2 native-to-routed task is undeliverable + // ciphertext. Written explicitly rather than left absent, because an absent key + // means base everywhere else. A fresh install starts already acknowledged: there is + // nothing to advise an operator who is on the recommended surface. + multiAgentMode: "v1", + multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION, multiAgentGuidanceEnabled: true, websockets: false, codexAutoStart: true, diff --git a/src/config/multi-agent-surface.ts b/src/config/multi-agent-surface.ts new file mode 100644 index 00000000000..eefd7f1a077 --- /dev/null +++ b/src/config/multi-agent-surface.ts @@ -0,0 +1,61 @@ +import type { MultiAgentMode } from "../codex/catalog/parsing"; +import type { OcxConfig } from "../types"; + +/** + * Bump to raise the sub-agent surface advisory again for installs that already answered + * the previous one. A version rather than a boolean, so that the day the upstream + * encrypted-task limitation is fixed, the notice saying so reaches the same operators + * without a second config key. + */ +export const MULTI_AGENT_SURFACE_ADVISORY_VERSION = 1; + +/** The published explanation both advisory surfaces link to. */ +export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent-v1-default/"; + +/** + * The surface an operator effectively runs. An absent key still means base: the key is + * deleted when base is selected, so its absence cannot be read as "never configured". + * What changed in this release is the written default, not this resolution. + */ +export function resolveMultiAgentMode(config: Pick): MultiAgentMode { + return config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; +} + +type AdvisoryConfig = Pick; + +/** + * True when this install should be told once that v1 is now the default surface. + * + * A v1 install is silent because it has nothing to decide. A fresh install is silent + * because `getDefaultConfig()` writes the current version alongside the v1 default. What + * is left is the case this exists for: an install that predates the change and is running + * base or v2, where a ChatGPT-native parent cannot hand a task to a routed child. + */ +export function multiAgentSurfaceAdvisoryRequired(config: AdvisoryConfig): boolean { + if (resolveMultiAgentMode(config) === "v1") return false; + return (config.multiAgentSurfaceAdvisoryVersion ?? 0) < MULTI_AGENT_SURFACE_ADVISORY_VERSION; +} + +export type MultiAgentSurfaceAdvisory = { + /** Whether the dashboard should raise the notice. */ + required: boolean; + /** The surface stored today. */ + mode: MultiAgentMode; + /** What the notice recommends. */ + recommended: "v1"; + /** The advisory revision a client acknowledges. */ + version: number; + /** Where the notice sends a reader for the explanation. */ + docsUrl: string; +}; + +/** Response-only projection; none of this is a writable config field. */ +export function multiAgentSurfaceAdvisory(config: AdvisoryConfig): MultiAgentSurfaceAdvisory { + return { + required: multiAgentSurfaceAdvisoryRequired(config), + mode: resolveMultiAgentMode(config), + recommended: "v1", + version: MULTI_AGENT_SURFACE_ADVISORY_VERSION, + docsUrl: SUBAGENT_SURFACE_GUIDE_URL, + }; +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d9757d5b112..ef553702087 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError } from "../../config/provider-validation"; +import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../../config/multi-agent-surface"; import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance"; import { DEFAULT_SUBAGENT_MODELS, @@ -243,7 +244,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise enabled, agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(), maxConcurrentThreadsPerSession: getLogicalMaxThreads(), - multiAgentMode: config.multiAgentMode ?? "default", + // Resolved, not raw: a hand-edited unsupported value survives config parsing, and the + // advisory below already reports the resolved surface. Two answers would disagree. + multiAgentMode: resolveMultiAgentMode(config), + multiAgentSurfaceAdvisory: multiAgentSurfaceAdvisory(config), keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, agentsEnabled: getAgentsEnabled(), agentsMaxDepth: getAgentsMaxDepth(), @@ -265,6 +269,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise agentsMaxDepth?: unknown; subagentDeveloperInstructions?: unknown; multiAgentModeHintText?: unknown; + multiAgentSurfaceAdvisoryAcknowledged?: unknown; }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } const wantsFlag = body.enabled !== undefined; @@ -275,8 +280,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const wantsMaxDepth = body.agentsMaxDepth !== undefined; const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined; const wantsModeHintText = body.multiAgentModeHintText !== undefined; - if (!wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText) { - return jsonResponse({ error: "body must set enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, and/or multiAgentModeHintText" }, 400); + const wantsAdvisoryAck = body.multiAgentSurfaceAdvisoryAcknowledged !== undefined; + if (!wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText && !wantsAdvisoryAck) { + return jsonResponse({ error: "body must set enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, multiAgentModeHintText, and/or multiAgentSurfaceAdvisoryAcknowledged" }, 400); } if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400); if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") { @@ -311,6 +317,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise && (typeof body.multiAgentModeHintText !== "string" || body.multiAgentModeHintText.trim().length === 0)) { return jsonResponse({ error: "body.multiAgentModeHintText must be a non-empty string or null" }, 400); } + // A boolean, and only `true` acknowledges. `false` is an explicit no-op so a client + // that always sends the field cannot un-answer an advisory it already dismissed. + if (wantsAdvisoryAck && typeof body.multiAgentSurfaceAdvisoryAcknowledged !== "boolean") { + return jsonResponse({ error: "body.multiAgentSurfaceAdvisoryAcknowledged must be a boolean" }, 400); + } const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined; const effectiveMode = mode ?? config.multiAgentMode ?? "default"; const effectiveKeepNative = wantsKeepNative @@ -378,6 +389,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise : "keepNativeChatGptOnV1 is stored but inactive until multi-agent mode is v2. Applies to new sessions.") : "ChatGPT-native models follow the selected v1/v2/base surface. Applies to new sessions."); } + // Written after the mode, so a landed acknowledgement implies the mode write it was + // sent with already landed. The converse does not hold: this writer throws rather than + // reporting, exactly as the mode write above it does, so a failure here surfaces the + // mode change with the advisory still raised. That is the benign direction — the + // operator is asked again — and it is why this is not claimed as a transaction. + if (wantsAdvisoryAck && body.multiAgentSurfaceAdvisoryAcknowledged === true) { + config.multiAgentSurfaceAdvisoryVersion = MULTI_AGENT_SURFACE_ADVISORY_VERSION; + saveConfigPreservingClaudeCode(config); + } // New-key scalar writes: each writer is individually atomic, so apply them in // sequence after the transition. A failure here is a persistence failure (the // writers' ok:false result or a throw from the underlying atomic write helper), @@ -417,7 +437,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise enabled, agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(), maxConcurrentThreadsPerSession: getLogicalMaxThreads(), - multiAgentMode: config.multiAgentMode ?? "default", + multiAgentMode: resolveMultiAgentMode(config), + multiAgentSurfaceAdvisory: multiAgentSurfaceAdvisory(config), keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, agentsEnabled: getAgentsEnabled(), agentsMaxDepth: getAgentsMaxDepth(), diff --git a/src/types/config.ts b/src/types/config.ts index 372c5b82d03..b1e814f644f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -639,6 +639,11 @@ export interface OcxConfig { * - "v2": force ALL models to v2 surface (override upstream pins) */ multiAgentMode?: "v1" | "default" | "v2"; + /** + * Which revision of the sub-agent surface advisory this install has answered. + * Absent means it has answered none. Written by the dashboard, never by a mode change. + */ + multiAgentSurfaceAdvisoryVersion?: number; /** * When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1. * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 47bf09b6432..5db4ff66dd3 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -123,7 +123,7 @@ this document owns is which module holds which area and what invariant that area | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | -| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. | +| V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | diff --git a/structure/subagents.md b/structure/subagents.md index cea31404e52..3d58b30799e 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -32,7 +32,7 @@ encryption marker when the upstream omitted it or returned a nonempty marker. | Mode | Behavior | | --- | --- | | `"v1"` | Force ALL entries to `multi_agent_version = "v1"` — overrides upstream pins (sol/terra included). | -| `"default"` (install default) | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | +| `"default"` | Respect upstream model pins (sol/terra=v2, luna=v1, others=null → codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | | `"v2"` | Force ALL entries to `multi_agent_version = "v2"` — overrides upstream pins (luna included). | The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and @@ -40,6 +40,18 @@ The override is applied as a final pass in both `buildCatalogEntries` (live `/v1 ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does not clobber the forced value. +`getDefaultConfig()` (`src/config.ts`) writes `multiAgentMode: "v1"` explicitly, using the version +constant from `src/config/multi-agent-surface.ts`, so v1 is the install default while a v2 +native-to-routed child task is undeliverable ciphertext. The repair and salvage merges in +`src/config.ts` pin `multiAgentMode` and `multiAgentSurfaceAdvisoryVersion` to the stored +document, because spreading the defaults underneath would repair an unrelated missing field +into a surface change its operator never made. +An absent key still means `"default"`, because selecting base deletes the key — absence cannot be +read as "never configured". An install that predates that change is therefore not rewritten; it is +asked once. `multiAgentSurfaceAdvisoryRequired()` is true while the resolved mode is not v1 and +the stored `multiAgentSurfaceAdvisoryVersion` is below `MULTI_AGENT_SURFACE_ADVISORY_VERSION`, and +only the operator answering the dashboard notice writes that version. + CLI: `ocx v2 mode v1|default|v2`. GUI: segmented control on the Models page. API: `GET/PUT /api/v2` with `multiAgentMode` field. diff --git a/tests/codex-integration/multi-agent-keep-native-v1.test.ts b/tests/codex-integration/multi-agent-keep-native-v1.test.ts index e7ae3308b3d..31dd690558a 100644 --- a/tests/codex-integration/multi-agent-keep-native-v1.test.ts +++ b/tests/codex-integration/multi-agent-keep-native-v1.test.ts @@ -11,6 +11,7 @@ import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "../../src/codex/catalog/kinds"; import { buildCatalogEntriesFromObservedState } from "../../src/codex/catalog/sync"; import { cmdV2 } from "../../src/cli/v2"; import { loadConfig, saveConfig } from "../../src/config"; +import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, SUBAGENT_SURFACE_GUIDE_URL } from "../../src/config/multi-agent-surface"; import { isMultiAgentV2Enabled } from "../../src/codex/features"; import { handleManagementAPI } from "../../src/server/management-api"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; @@ -443,3 +444,86 @@ describe("/api/v2 keepNativeChatGptOnV1", () => { expect(converges).toBe(3); }); }); + +describe("/api/v2 sub-agent surface advisory", () => { + const url = () => new URL("http://localhost/api/v2"); + const deps = () => ({ + createManagementConvergeCodex: catalogConvergenceFactory(), + toggleCodexMultiAgentV2: () => {}, + }); + const liveConfig = (extra: Partial = {}): OcxConfig => ({ + providers: {}, hostname: "127.0.0.1", port: 10100, defaultProvider: "openai", ...extra, + } as OcxConfig); + + test("base raises it, and the recommended answer writes the mode and the acknowledgement together", async () => { + isolateHomes(); + writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = false\n"); + const config = liveConfig(); + const d = deps(); + + const raised = await handleManagementAPI(getV2(), url(), config, d); + expect(await raised?.json()).toMatchObject({ + multiAgentMode: "default", + multiAgentSurfaceAdvisory: { + required: true, + mode: "default", + recommended: "v1", + version: MULTI_AGENT_SURFACE_ADVISORY_VERSION, + docsUrl: SUBAGENT_SURFACE_GUIDE_URL, + }, + }); + + const switched = await handleManagementAPI( + putV2({ multiAgentMode: "v1", multiAgentSurfaceAdvisoryAcknowledged: true }), + url(), config, d, + ); + expect(switched?.status).toBe(200); + expect(await switched?.json()).toMatchObject({ + multiAgentMode: "v1", + multiAgentSurfaceAdvisory: { required: false, mode: "v1" }, + }); + expect(loadConfig().multiAgentMode).toBe("v1"); + expect(loadConfig().multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION); + + const again = await handleManagementAPI(getV2(), url(), config, d); + expect(await again?.json()).toMatchObject({ multiAgentSurfaceAdvisory: { required: false } }); + }); + + test("keeping v2 answers the advisory and leaves the mode alone", async () => { + isolateHomes(); + writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = true\n"); + const config = liveConfig({ multiAgentMode: "v2" }); + const d = deps(); + + const kept = await handleManagementAPI(putV2({ multiAgentSurfaceAdvisoryAcknowledged: true }), url(), config, d); + expect(kept?.status).toBe(200); + expect(await kept?.json()).toMatchObject({ + multiAgentMode: "v2", + multiAgentSurfaceAdvisory: { required: false, mode: "v2" }, + }); + expect(loadConfig().multiAgentMode).toBe("v2"); + expect(loadConfig().multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION); + }); + + test("false is an explicit no-op, so a client that always sends the field cannot un-answer it", async () => { + isolateHomes(); + writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = true\n"); + const config = liveConfig({ multiAgentMode: "v2", multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION }); + const d = deps(); + + const noop = await handleManagementAPI(putV2({ multiAgentSurfaceAdvisoryAcknowledged: false }), url(), config, d); + expect(noop?.status).toBe(200); + expect(await noop?.json()).toMatchObject({ multiAgentSurfaceAdvisory: { required: false } }); + expect(config.multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION); + }); + + test("a non-boolean acknowledgement is rejected before anything is written", async () => { + isolateHomes(); + writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), "[features.multi_agent_v2]\nenabled = false\n"); + const config = liveConfig(); + const bad = await handleManagementAPI(putV2({ multiAgentSurfaceAdvisoryAcknowledged: 1 }), url(), config, deps()); + expect(bad?.status).toBe(400); + expect(await bad?.json()).toEqual({ error: "body.multiAgentSurfaceAdvisoryAcknowledged must be a boolean" }); + expect(config.multiAgentSurfaceAdvisoryVersion).toBeUndefined(); + }); +}); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 8dbc4824032..b4b1bc9e73b 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -38,6 +38,13 @@ import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, ha import { nextAtomicTempSequence } from "../../src/config/atomic-write"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { DEFAULT_SUBAGENT_MODELS, migrateSubagentModels } from "../../src/config/subagent-models"; +import { + MULTI_AGENT_SURFACE_ADVISORY_VERSION, + SUBAGENT_SURFACE_GUIDE_URL, + multiAgentSurfaceAdvisory, + multiAgentSurfaceAdvisoryRequired, + resolveMultiAgentMode, +} from "../../src/config/multi-agent-surface"; import { migrateStartupSubagentModels } from "../../src/server/subagent-models-startup"; import { migrateXaiResponsesDefault } from "../../src/providers/xai-responses-opt-in"; import { migrateStartupXaiResponses } from "../../src/server/xai-responses-startup"; @@ -84,6 +91,102 @@ function backupNames(): string[] { return readdirSync(testDir).filter(name => name.startsWith("config.json.invalid-")); } +describe("sub-agent surface default and advisory", () => { + test("a fresh config ships v1 and has nothing to advise", () => { + const config = getDefaultConfig(); + expect(config.multiAgentMode).toBe("v1"); + expect(resolveMultiAgentMode(config)).toBe("v1"); + expect(config.multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION); + expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(false); + }); + + test("an absent key still resolves to base, which is what raises the advisory", () => { + const config = getDefaultConfig(); + delete config.multiAgentMode; + delete config.multiAgentSurfaceAdvisoryVersion; + expect(resolveMultiAgentMode(config)).toBe("default"); + expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(true); + }); + + test.each(["default", "v2"] as const)("an unanswered %s install is advised", mode => { + const config = { ...getDefaultConfig(), multiAgentMode: mode }; + delete config.multiAgentSurfaceAdvisoryVersion; + expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(true); + expect(multiAgentSurfaceAdvisory(config)).toEqual({ + required: true, + mode, + recommended: "v1", + version: MULTI_AGENT_SURFACE_ADVISORY_VERSION, + docsUrl: SUBAGENT_SURFACE_GUIDE_URL, + }); + }); + + test("answering it silences the notice without moving the mode", () => { + const config = { + ...getDefaultConfig(), + multiAgentMode: "v2" as const, + multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION, + }; + expect(multiAgentSurfaceAdvisoryRequired(config)).toBe(false); + expect(multiAgentSurfaceAdvisory(config).mode).toBe("v2"); + }); + + test("a stale acknowledgement is advised again", () => { + expect(multiAgentSurfaceAdvisoryRequired({ + multiAgentMode: "v2", + multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION - 1, + })).toBe(true); + }); + + test("v1 is silent whatever the stored acknowledgement", () => { + expect(multiAgentSurfaceAdvisoryRequired({ multiAgentMode: "v1" })).toBe(false); + expect(multiAgentSurfaceAdvisoryRequired({ multiAgentMode: "v1", multiAgentSurfaceAdvisoryVersion: 0 })).toBe(false); + }); + + test("the fresh default survives save and load", () => { + saveConfig(getDefaultConfig()); + const loaded = loadConfig(); + expect(loaded.multiAgentMode).toBe("v1"); + expect(loaded.multiAgentSurfaceAdvisoryVersion).toBe(MULTI_AGENT_SURFACE_ADVISORY_VERSION); + }); + + test("a hand-edited version degrades to an unanswered advisory, keeping providers", () => { + writeConfig({ ...getDefaultConfig(), multiAgentMode: "v2", multiAgentSurfaceAdvisoryVersion: "soon" }); + const loaded = loadConfig(); + expect(loaded.providers.openai).toEqual(getDefaultConfig().providers.openai); + expect(loaded.multiAgentSurfaceAdvisoryVersion).toBeUndefined(); + expect(multiAgentSurfaceAdvisoryRequired(loaded)).toBe(true); + }); + + test("a repaired config keeps the surface its operator chose", () => { + // A config that reaches the repair path only because it lost an unrelated field must + // not be handed the new v1 default underneath: that would change a setting silently. + const stored = { ...getDefaultConfig() } as Record; + delete stored.defaultProvider; + delete stored.multiAgentMode; + delete stored.multiAgentSurfaceAdvisoryVersion; + writeConfig(stored); + + const loaded = loadConfig(); + expect(loaded.defaultProvider).toBe("openai"); + expect(loaded.multiAgentMode).toBeUndefined(); + expect(resolveMultiAgentMode(loaded)).toBe("default"); + expect(loaded.multiAgentSurfaceAdvisoryVersion).toBeUndefined(); + expect(multiAgentSurfaceAdvisoryRequired(loaded)).toBe(true); + }); + + test("a repaired v2 config is still v2", () => { + const stored = { ...getDefaultConfig(), multiAgentMode: "v2" } as Record; + delete stored.defaultProvider; + delete stored.multiAgentSurfaceAdvisoryVersion; + writeConfig(stored); + + const loaded = loadConfig(); + expect(loaded.multiAgentMode).toBe("v2"); + expect(multiAgentSurfaceAdvisoryRequired(loaded)).toBe(true); + }); +}); + describe("Astra-first subagent upgrade", () => { const defaults = ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"];