Merge pull request #1941: Save per-project Context and Effort

Save Context and Effort independently for Main and each project, retain running actors' choices through Resume, and let primary effort move independently of helper bounds.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Anton Razzhigaev 2026-10-17 03:54:56 +03:00 • committed by GitHub
commit 4758123795
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1464 additions and 508 deletions

View file

@ -439,10 +439,10 @@ Every settings writer (Settings, setup, owner controls, language, desktop backgr
**Owner controls.** Each saves one setting through the same writer:
- `POST /api/owner/runtime-mode` `{mode}`: `light`, `advanced`, `pro` or `cyber_pro`; applies when the core next starts.
- `POST /api/owner/context-mode` `{mode}`: `nano`, `low` or `max`. Lowering it while work is queued or running answers 409, except in `cyber_pro`.
- `POST /api/owner/context-mode` `{mode, project_id?}`: `nano`, `low` or `max`; applies to new participants in that room, including while others run. Omitted `project_id` means Main.
- `POST /api/owner/safety-mode` `{mode}`: `full`, `light` or `off`.
- `POST /api/owner/auto-grant` `{enabled}`.
- `POST /api/owner/effort-range` `{min, recommended, max}`, each a level of the effort scale (`none` to `ultra`) with min ≤ recommended ≤ max.
- `POST /api/owner/effort-range` `{min, recommended, max, project_id?}`: effort tiers (`none` to `ultra`), min ≤ max; primary `recommended` is independent. Omitted `project_id` means Main. Both endpoints return `project_id` and the saved `cognition` (`context_mode`, `effort_range`); project summaries carry the same object. Main state fields remain Main's.
`POST /api/owner/capability-ack` (`CapabilityAckRequest`) vouches for one exact route's maximum response or context window. It writes acknowledgement evidence, never settings, and a repeat overwrites it.

View file

@ -335,28 +335,27 @@ Enter as a line break.
### Composer owner controls
Two controls in the composer's pill row write a global owner setting rather than a
fact about the next message: the context mode and the **effort range**. Both save at
once through their owner endpoint, show the server's refusal as a toast and read the
one saved value back from `/api/state`, so Main and every Project composer agree;
Settings has no effort section.
Each composer saves its room's **Context** and **Effort** through the owner endpoints.
Main supplies a one-time copy to new Projects; later changes stay in their room.
Readback comes from Main state or the named Project, never another room. Saves apply
to new participants; running work retains its choices through Resume. A refusal
keeps the saved value and says why. No scope selector or extra settings row.
Each is a neutral **capsule** of the pills' height — ring, name, value: `Context Max`,
`Effort X-High`; the name in the secondary ink at 500, the value in the meta ink at
600, and the only colour the ring's lit arc (crimson; Low amber, Nano the Project's).
Until `/api/state` reports the range the ring stays unlit and the value secondary,
never a faded capsule. Closed, Effort names only the recommended level; its ring (a 300°
arc: band minimum..maximum, dot recommended) and the button's name carry the range.
`Effort X-High`; the name in secondary ink at 500, the value in meta ink at 600,
and only the ring's lit arc coloured (crimson; Low amber, Nano the Project's).
Until the room's values arrive the ring stays unlit and the value secondary,
never a faded capsule. Closed, Effort names only the primary level; its ring (a 300°
arc: band minimum..maximum, dot primary) and the button's name carry the range.
Context's ring is three arcs, the chosen one lit: one of three modes, never a fill
level or capacity. A capsule opens **inline**, never as a dropdown, one at a time;
its value yields to the strip. Context's is Nano · Low · Max under a glass thumb; a
choice posts at once and the strip waits for the answer (lowering only while idle,
outside Cyber Pro). Effort's: seven levels, two brackets, the recommended pill. A
choice posts at once and the strip waits for the answer. Effort's seven levels carry
a primary pill and two helper bounds; the primary may lie outside them. A
segment is its word plus `--capsule-seg-pad` a side; the pill covers its segment;
band, pill and brackets share one vertical inset. Every handle pushes the ones it
meets, a pushed bracket stays, the brackets never cross. A tap inside the range sets
the recommended level, outside it the nearer bracket; a drag keeps the grab offset
on whole levels. A stored `minimal` shows at Low until its handle moves.
band, pill and brackets share one vertical inset. The primary and helper bounds never
move each other; the brackets cannot cross. A level tap sets primary; each bracket
has its own handle. A drag keeps its grab offset on whole levels. A stored `minimal` shows at Low until its handle moves.
**A mark keeps from a rounded end the clearance it keeps from the straight edges.**
A pill (the thumb, the recommended pill) is concentric with the capsule, so the band
@ -378,7 +377,7 @@ or the chosen mode; arrows step a handle (PageUp/PageDown too) or choose the nex
mode, Home/End reach the ends; Esc closes and returns focus to the head; an outside
press closes. Handles are sliders naming their level; closed, they leave the tab
order. An effort change saves when the gesture ends, saves run in order (last wins),
and Send waits for a pending one so the next root starts at that level; a refused
and Send waits for both pending Context and Effort saves; a refused
save keeps the draft, says why and returns to the saved value — unsaved values are
never shown as saved.

View file

@ -19,7 +19,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
| D09 | Cancellation, owner control & process custody | 23 | 0 |
| D10 | Git, update & release machinery | 33 | 0 |
| D11 | Gateway, server & Web UI | 81 | 0 |
| D12 | Settings & configuration | 20 | 0 |
| D12 | Settings & configuration | 21 | 0 |
| D13 | Safety, guards & runtime mode | 9 | 0 |
| D14 | Skills & extensions | 58 | 0 |
| D15 | Memory, knowledge, consciousness & self-evolution | 29 | 0 |
@ -28,7 +28,7 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
| D18 | Launcher, packaging, platform & shared substrate | 25 | 0 |
| D19 | Frozen contracts (ABI) | 11 | 0 |
| D20 | Presence | 13 | 0 |
| **total** | | **745** | **0** |
| **total** | | **746** | **0** |
## Dependency direction matrix (strict, pinned)
@ -52,7 +52,7 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
| **D14** | · | ✓ | · | ✓ | ✓ | ✓ | · | · | ✓ | ✓ | ✓ | ✓ | ✓ | · | · | ✓ | · | ✓ | ✓ | · |
| **D15** | ✓ | · | ✓ | ✓ | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | ✓ | ✓ | · |
| **D16** | · | ✓ | · | · | · | ✓ | · | · | · | · | ✓ | ✓ | · | ✓ | · | · | ✓ | ✓ | · | · |
| **D17** | ✓ | · | · | ✓ | · | ✓ | · | ✓ | · | · | ✓ | · | · | · | ✓ | ✓ | · | ✓ | ✓ | · |
| **D17** | ✓ | · | · | ✓ | · | ✓ | · | ✓ | · | · | ✓ | ✓ | · | · | ✓ | ✓ | · | ✓ | ✓ | · |
| **D18** | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | · | · | · | · | · | · | · | · |
| **D19** | · | · | · | · | · | · | · | · | · | · | · | · | · | ✓ | · | · | · | ✓ | · | · |
| **D20** | · | · | · | ✓ | ✓ | · | · | · | · | · | · | · | · | ✓ | ✓ | · | ✓ | ✓ | ✓ | · |
@ -65,7 +65,7 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
## Hidden coupling (classified out of the strict graph)
- lazy-only cross-domain pairs: **121**
- lazy-only cross-domain pairs: **120**
- D01->D10
- D01->D11
- D02->D05
@ -166,7 +166,6 @@ Rows may import columns (`[graph].allowed`). `·` = forbidden direction.
- D17->D05
- D17->D07
- D17->D09
- D17->D12
- D17->D13
- D17->D20
- D18->D01
@ -758,6 +757,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne
### D12 — Settings & configuration
- `ouroboros/colab_bootstrap.py`
- `ouroboros/composer_cognition.py`
- `ouroboros/config.py`
- `ouroboros/i18n_memory.py`
- `ouroboros/launcher_onboarding.py`

View file

@ -79,7 +79,7 @@ Host task facts attach to its card by producer-stamped `card_row` (`timeline`/`r
#### Composer, attachments and delivered media
Main's composer has Swarm, Nano/Low/Max, effort, attachments and Send. Swarm stamps `force_plan` once then disarms, never inferring keywords. Without a model call the host admits one managed root in the sending room, retaining verbatim words/origin/surface/attachments; `ensure_project_scope` selects its Project (§6). `composer_owner_controls.js` owns Context/Effort (DESIGN "Composer owner controls"): `/api/owner/context-mode` saves immediately; `/api/owner/effort-range` saves `{min, recommended, max}` on gesture end, serialized, with Send waiting. `/api/state` feeds every composer; a derived Low fit changes neither owner intent nor review authority. Layout fits before paint on the docking page's resize or local text edits; row/strip feedback stays deferred, avoiding resize loops. Project panels omit global Restart, Panic, evolution, consciousness, review and budget controls.
Main and Project composers own Context/Effort (§7), attachments and Send. Swarm stamps `force_plan` on one message, then disarms; the host admits a root in that room retaining words, origin and attachments, and `ensure_project_scope` selects its Project (§6). `composer_owner_controls.js` saves Context immediately and effort on gesture end through the owner endpoints with `project_id`; Send waits for both. Primary effort moves independently of ordered helper brackets. State serves Main values and project `cognition`; an addressed lookup serves rooms outside its bounded sidebar list. Layout fits before paint on page resize or local text edits; row/strip feedback stays deferred to avoid resize loops. Project panels omit global Restart, Panic, evolution, consciousness, review and budget controls. Derived Low changes neither owner choice nor review authority.
Paperclip/paste/drop stage attachments without count/size rejection; Send uploads, offline behavior follows §4. In the ingress critical section the canonical writer claims uploads and stores measured refs; response/echo/history share `chat_uploads.attachment_view` (DESIGN "Chat attachments"). Documents retain immutable task-owned bytes, verified `file_ref` and canonical download URL; history rebuilds without base64, and files over `core_artifacts._MAX_DOCUMENT_FILE_BYTES` stay file-backed. Photo/video base64 is live-only; canonical task artifacts and content-addressed URLs restore replay. `links` rows allow at most `MAX_LINK_ACTIONS` revalidated HTTP(S) buttons. Supervisor transport owns durable copies; persistence failure yields an honest caption, never terminalizes a card.

View file

@ -56,7 +56,7 @@ Routing context offers registry folders and bounded typed recent/live root facts
#### Explicit starting effort of a root
`settings_scales.choose_effort` decides every effort: a level in the model name (`route_spec.model_named_effort`) wins in every mode; else an owner pin; else Ouroboros's request, clamped into the owner's range (§7) outside Cyber Pro (there a request also beats a pin); else the role default: recommended, or the range's top for Auto reviewers, evolution tasks and wakes. A root's first round (`agent_dispatch._initial_effort_for`): the named level, else the stored `reasoning_effort`, else that default. Owner ingress pins (`POST /api/tasks`, `ouroboros run --reasoning-effort`, owner schedules, Continue) pass `settings_scales.requested_effort` and ride the queue and admission rows. `promote_chat_to_task`/`route_to_project` and `schedule_followup` apply `reasoning_effort` in Cyber Pro only and say so; `switch_model(effort)` moves a child, an evolution task or a wake inside the range; a Main focus outside Cyber Pro keeps its level, the reply pointing to `schedule_subagent(effort=…)`.
`settings_scales.choose_effort`: model-named level wins, then owner pin, then requested effort within the helper bounds; Cyber accepts the request over a pin. Auto children default to primary clamped into those bounds; Auto reviewers, evolution and wakes start at their top. Main/root primary is independent (§7). `_initial_effort_for` prefers model-named, then stored `reasoning_effort`, then the role default. API/CLI/owner schedules and Continue carry validated owner pins. Promotion/routing/follow-up effort applies only in Cyber. `switch_model` moves a child, evolution or wake within its captured bounds; ordinary Main keeps its level. New children, delegates and review waves read the room's current values; existing actors keep theirs through cold Resume. `owner_wait.continuation_state.cognition` retains only composer choices, separately from actual rendered mode and route; legacy checkpoints keep their prior fallback.
### Tool capability and execution

View file

@ -10,9 +10,9 @@ Each path constant takes its `OUROBOROS_*` override, else derives from `APP_ROOT
### Reading and writing the settings document
A task runs on the settings it started with (`agent.handle_task` binds a `settings_integrity.TaskSettingsSnapshot`): concurrent tasks never share models, keys or review settings, absent and empty stay distinct, explicit task route overrides win. Immediate-effect keys, writers and a skill's granted keys read the current document, a key cleared since too (`skill_settings`: a credential-free extension child rereading it, operator variables in its payload); Access keeps its boot pin. A failed reload loudly keeps the prior environment, disclosing the document-only values it cannot answer.
Each task keeps its entry settings (`settings_integrity.TaskSettingsSnapshot`): models, keys and review settings stay private; absent/empty differ and explicit route overrides win. Room Context/Effort overlays never enter process env. `RoomResolver` follows conversion/lineage for new participants; Resume restores the actor's choice before dispatch/context assembly. `composer_cognition.py` copies Main once on project creation/recovery and freezes old rows before Main changes. Primary effort is independent of ordered helper bounds; old documents retain their effective range; internal `_effort_range_format=1` marks independent Main triples. Immediate keys, writers and granted skill keys read current disk, including cleared keys (`skill_settings`: credential-free children reread it; payloads carry operator variables). Access stays boot-pinned; reload failure discloses prior-env fallback and unavailable document-only values.
A document on disk was written by the release the owner last used, so every read translates it first. `normalize_settings_raw()` is the only translation (steps and their load-bearing order: its docstring; a dropped spelling is never promoted), applied by every reader before defaults merge: `load_settings()`, `_owner_read_settings_raw()`, `build_colab_settings()`. The review-lane keys (`REVIEW_POOL_MIGRATED_SETTING_KEYS`) are migrated, not dropped: `review_pool_migration.apply_at_read_seam` turns every seat the lanes effectively ran into a reviewer row of `OUROBOROS_SUBAGENTS` (Review pool), a refused value stays in the document untouched. Retired comma-lists and keys (`settings_defaults.RETIRED_SETTING_KEYS`/`RETIRED_COMMA_LIST_SETTING_KEYS`) are dropped with an owner notice, so a comma-list-only install, like one without lanes, gets the factory rows; no path reads review keys from the process environment; the boot says so once (`server_maintenance._startup_environment_review_notice`). The function is pure and idempotent, so a read stays a read; every in-process reader shares `settings_integrity.read_settings_json_verified`, so a changed pinned snapshot refuses them all alike. It normalizes vocabulary only; the loader proposes unauthored slots (§2 Provider defaulting) and `server_runtime.apply_runtime_provider_defaults` only respells, unpersisted. `apply_settings_to_env` echoes the slot and catalog values it projects in the internal `OUROBOROS_PROJECTED_PROPOSALS` (no setting or lever), inherited by children: a read tells it from an operator variable.
`normalize_settings_raw()` is the only translation before every reader merges defaults (`load_settings()`, `_owner_read_settings_raw()`, `build_colab_settings()`); a dropped spelling is never promoted. Review-lane keys (`REVIEW_POOL_MIGRATED_SETTING_KEYS`) migrate, not drop: `review_pool_migration.apply_at_read_seam` maps every effectively run lane seat to an `OUROBOROS_SUBAGENTS` reviewer row (Review pool); refused values stay untouched in the document. Retired comma-lists and keys (`settings_defaults.RETIRED_SETTING_KEYS`/`RETIRED_COMMA_LIST_SETTING_KEYS`) drop with an owner notice; comma-list-only and lane-free installs get factory rows; no path reads review keys from the process environment; the boot says so once (`server_maintenance._startup_environment_review_notice`). The pure, idempotent function keeps reads read-only; every in-process reader uses `settings_integrity.read_settings_json_verified` and refuses a changed pinned snapshot. The loader proposes unauthored slots (§2); `server_runtime.apply_runtime_provider_defaults` only respells, unpersisted. `apply_settings_to_env` echoes projected slot/catalog values in internal `OUROBOROS_PROJECTED_PROPOSALS` (no setting or lever), inherited by children and distinguished from operator variables on read.
Optional bounds. `OUROBOROS_MAX_ROUNDS` and `OUROBOROS_TASK_ABS_CEILING_SEC` take a positive integer or `unlimited` (`settings_scales.parse_positive_or_unlimited`, also the review-cycle cap's vocabulary) and ship `unlimited`: a fresh install is bounded by money, deadlines, Stop/Panic and the idle rail, not by age or round count. A document an earlier release saved without the key keeps the finite value it ran under (`settings_scales.OPTIONAL_BOUND_LEGACY`: 200 rounds / 21600 s), and a blank, zero, negative or malformed value takes that value too, with a log line, never "no bound"; `upgrade_notices.py` tells the owner once which finite limit applies. Readers (`runtime_limits.get_max_rounds`, `get_task_abs_ceiling_sec`) return `None` for no bound. An operation inheriting the task lifetime takes `operation_window_sec()`, so it is never unbounded and never a transport bound.
@ -159,7 +159,7 @@ A registry of `config.SETTINGS_DEFAULTS` (defaults canonical in `settings_defaul
| OUROBOROS_PREFLIGHT_TEST_WORKERS | unset | Env-only: xdist workers for the parallel pass (floor 2, else `os.cpu_count()`); scrubbed from the candidate |
| OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS | true | Auto-grant manifest-declared permissions to cleanly reviewed skills (hash-bound; blocking findings never grant) |
| OUROBOROS_TRUST_NATIVE_SEEDED_SKILLS | true | Launcher seed/resync writes hash-pinned `native_seed` verdicts; no runtime grant endpoint |
| OUROBOROS_CONTEXT_MODE | max | Context mode `nano`/`low`/`max` for Ouroboros's own working window, owner-selected outside Cyber Pro; the review wave's retrieving seats run in every mode (BIBLE P1; §6 Context fitting, retry, and compaction) |
| OUROBOROS_CONTEXT_MODE | max | Main's `nano`/`low`/`max` and initial project copy; owner-selected outside Cyber. Review remains independent (§6 Context fitting) |
| OUROBOROS_CONTEXT_MODE_AUTO_LOW | false | Provenance tombstone, not a toggle: normalization writes `false`; a persisted `low` is honoured only beside it (`context_mode_compat.py`) |
| OUROBOROS_RUNTIME_MODE | advanced | Effective Access light/advanced/pro/cyber_pro, persisted as the next-boot value (§6 Safety and runtime mode) |
| OUROBOROS_SKILLS_REPO_PATH | "" | Extra skills checkout path (expanded at read time, never cloned or pulled) |
@ -169,10 +169,10 @@ A registry of `config.SETTINGS_DEFAULTS` (defaults canonical in `settings_defaul
| OUROBOROS_HUB_CATALOG_URL | `https://raw.githubusercontent.com/razzant/OuroborosHub/main/catalog.json` | OuroborosHub catalog URL (automatic fetch limited to catalog JSON; installs verify SHA-256) |
| OUROBOROS_CLAWHUB_REGISTRY_URL | `https://clawhub.ai/api/v1` | ClawHub registry URL |
| OUROBOROS_PROMPT_CACHE_TTL | 1h | Prompt-cache tier default/5m/1h for Anthropic-family cache markers, applied at the send boundary and recorded in usage |
| OUROBOROS_EFFORT_MIN | low | Floor of the effort range (`settings_scales.effort_range`), which only the composer edits |
| OUROBOROS_EFFORT_TASK | medium | The recommended level: Main's, an ordinary root's (§6), an Auto child's |
| OUROBOROS_EFFORT_MAX | high | Top of the range: caps Ouroboros's requests outside Cyber Pro; Auto reviewers, evolution tasks and wakes start here |
| OUROBOROS_EFFORT_REVIEW | (migrated) | Retired surface default: at load it becomes the effort of each triad seat's reviewer row that had none; afterwards the row's `effort` is the only effort; env inert |
| OUROBOROS_EFFORT_MIN | low | Main helper floor; project composers save their own copy (`settings_scales.effort_range`) |
| OUROBOROS_EFFORT_TASK | medium | Main primary; project copy sets its roots' primary; Auto helpers clamp it into their bounds |
| OUROBOROS_EFFORT_MAX | high | Main helper ceiling; Auto reviewers, evolution and wakes start here; project copies are independent |
| OUROBOROS_EFFORT_REVIEW | (migrated) | Retired surface default: fills missing triad-seat reviewer effort at load; afterwards row `effort` is the only effort; env inert |
| OUROBOROS_EFFORT_SCOPE_REVIEW | (migrated) | Retired surface default: consumed into the scope seats' reviewer rows at load; env inert |
| OUROBOROS_EFFORT_DEEP_SELF_REVIEW | (migrated) | Retired surface default: consumed into the deep-review helper row at load; env inert |
| OUROBOROS_RETURN_REASONING | true | Ask OpenRouter to return reasoning; direct/local request copies strip OpenRouter-only fields |

View file

@ -158,8 +158,8 @@ Enforcement: `tests/test_cancel_intents_phase_a.py`, `tests/test_cancel_cascade_
ARCHITECTURE §2 owns wizard steps, completion and install-time proofs; §3 owns Settings, accounts and shared controls. Copy and wizard shape are review-only. The imperatives:
- Current tasks read their entry settings: a next-task save never changes an overlapping direct actor's Supervisor, Review, model or key; owner writers, grant classification and skill configuration read current disk state. Cyber selects context, review scope/enforcement, models and Supervisor configuration via the settings writer under effective Access; permission is not a review verdict.
- One wizard host, the `GET /onboarding` page, serves subscriptions, API keys and mixed installs; its frame keeps the `allow-popups allow-popups-to-escape-sandbox` sandbox tokens, without which the sign-in click is blocked silently. Completion is one transaction, `GET /api/onboarding` and its `/bootstrap` JSON never persist, and the client completes only on the exact success envelope (`ok`/`runtime_mode`/`restart_required`), because a silent success discards the restart receipt. Only declared raw-model sources supply Main, never agent-only connections; "Without an API key" never means free (ARCHITECTURE §2).
- Keep entry models, keys, Supervisor, Review and room cognition private; never publish project overrides to process env. New participants resolve their current room; running actors retain their choice through Resume (`test_project_cognition_runtime`). Writers, grants and skill settings read current disk. Cyber configures under effective Access; permission is not a verdict.
- One wizard host, the `GET /onboarding` page, serves subscriptions, API keys and mixed installs; its frame keeps the `allow-popups allow-popups-to-escape-sandbox` sandbox tokens, without which the sign-in click is blocked silently. Completion is one transaction, `GET /api/onboarding` and its `/bootstrap` JSON never persist, and the client completes only on the exact success envelope (`ok`/`runtime_mode`/`restart_required`), because a silent success discards the restart receipt. Only declared raw-model sources supply Main, never agent-only connections; "Without an API key" never means free.
- Settings validates the complete draft before any Save: never drop an invalid custom-key row and save the remainder; a failed save/refresh preserves edits; leaving a dirty draft asks first. Saved intent, generated drafts and live status are different axes: a status/catalog failure annotates, never erases, a loaded row; only an Agents edit, the wizard or Colab authors a generated or factory catalog; a late preview never absorbs owner edits.
- Model-role and actor/reviewer adapters use `model_chooser.js`, owning only suggestions/keyboard/position, never route identity or entitlement. Models, actors and reviewers share source/model/account controls: exact pins survive save/reload and catalog failure; a source's credential harness comes from its metadata, never an assumed equal name; delivery follows the row's surface and reference, not its model or account: a session row retrieves, an API row keeps its saved `delivery` (native by default, `review_native_episode.py`), and model/account edits never silently turn it into a packet.
- One capability, one section: task actors live in Agents → Available subagents (`web/modules/subagents_settings.js`), editing one canonical `OUROBOROS_SUBAGENTS` object of at most `MAX_CONFIGURED_SUBAGENTS` rows. The review pool is the same list: the Reviewer mark (`review_eligible`) and an API row's `delivery` are row fields, never a second editor; `GET /api/review-pool` reads it. Name a row by its route-derived handle, never a stored label or ordinal; never render two controls for one settings key (`OUROBOROS_MAX_WORKERS` stays in Advanced). Routes serialize `api_model` + `credential_profile_id` via the neutral primitives (`route_editor_primitives.js`); an empty managed pin means engine rotation; saved-but-undiscovered choices stay visible and editable; a compound effort slug plus a conflicting separate effort is a validation error. Owner switches expose semantic choices: `OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS` shows Off / Auto / On, Auto being the unset default, saved empty (`config.get_allow_mutative_subagents`).

View file

@ -2,7 +2,7 @@
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 593-697; UTF-8 SHA-256 `9463540ee3d38b84fc00d59500f91e93484dd05f9ab43c2f9022898ffe1a38ff`.
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 593-697; UTF-8 SHA-256 `e6b5007a3de34233e47fde9157b58e5deb1b99188a30a9f21b4e401e49d9df17`.
- entries: **94** (code-ref: 87, repo-dir: 6, repo-path: 1)

View file

@ -2,7 +2,7 @@
AST-derived inventory of compatibility facades, regenerated by `python scripts/regenerate_inventories.py`. Do not edit. A facade row is any runtime module whose top-level `from <population module> import ...` statements carry the `noqa: F401` re-export marker — the codebase's declared "this binding exists for its binding, not for this module's own use" convention (reference FACADE_CONSUMERS method). Leaf domains come from `ouroboros/domains.toml`; a leaf outside the facade's domain is marked ✗ (that edge also appears in the manifest's pinned direction matrix). `tests/test_generated_inventories.py` pins byte-identity, so any re-export surface change must regenerate this file.
- facade modules: **66**; marked re-export bindings: **2351**; cross-domain facade→leaf pairs: **131**
- facade modules: **66**; marked re-export bindings: **2354**; cross-domain facade→leaf pairs: **131**
| facade | domain | bindings | leaves |
|---|---|---:|---|
@ -10,7 +10,7 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
| `ouroboros/agent.py` | D01 | 33 | `ouroboros/agent_dispatch.py` (15)<br>`ouroboros/agent_startup_checks.py` (4)<br>`ouroboros/config.py` (2 ✗D12)<br>`ouroboros/subagent_dispatch_notes.py` (4 ✗D07)<br>`ouroboros/subagents.py` (8 ✗D07) |
| `ouroboros/agent_task_pipeline.py` | D01 | 22 | `ouroboros/dialogue_provenance.py` (2 ✗D15)<br>`ouroboros/post_task_synthesis.py` (9)<br>`ouroboros/synthesis_cost_text.py` (1)<br>`ouroboros/task_finalization.py` (10) |
| `ouroboros/budget_pause.py` | D01 | 10 | `ouroboros/external_runs.py` (10) |
| `ouroboros/config.py` | D12 | 147 | `ouroboros/model_slots.py` (16)<br>`ouroboros/provider_models.py` (6 ✗D02)<br>`ouroboros/review_model_routes.py` (4)<br>`ouroboros/runtime_limits.py` (73)<br>`ouroboros/settings_defaults.py` (24)<br>`ouroboros/settings_integrity.py` (4)<br>`ouroboros/settings_scales.py` (18)<br>`ouroboros/update_channels.py` (2) |
| `ouroboros/config.py` | D12 | 150 | `ouroboros/model_slots.py` (16)<br>`ouroboros/provider_models.py` (6 ✗D02)<br>`ouroboros/review_model_routes.py` (4)<br>`ouroboros/runtime_limits.py` (73)<br>`ouroboros/settings_defaults.py` (24)<br>`ouroboros/settings_integrity.py` (4)<br>`ouroboros/settings_scales.py` (21)<br>`ouroboros/update_channels.py` (2) |
| `ouroboros/context.py` | D03 | 8 | `ouroboros/context_runtime_facts.py` (8) |
| `ouroboros/context_compaction.py` | D03 | 1 | `ouroboros/context_source_view.py` (1) |
| `ouroboros/context_fit.py` | D03 | 1 | `ouroboros/tool_result_delivery.py` (1 ✗D01) |

View file

@ -153,13 +153,8 @@ def _settle_start_origin(task: Dict[str, Any]) -> float:
return float(continuation.get("started_at") or time.time())
def _restore_saved_clocks(ctx: Any, task: Dict[str, Any]) -> None:
"""Bind this start's continuation carriers and restore the clocks they saved.
Runtime/ContextFit must disclose the original ceiling, so the saved state is
read here once: an owner-wait handoff, an exact pause grant (#1196), or a
same/new-ID retry continuing a frozen working checkpoint (#1563).
"""
def _load_start_continuation(ctx: Any, task: Dict[str, Any]) -> dict:
"""Read the existing validated continuation before dispatch or context assembly."""
ctx.owner_wait_resume = task.get("_owner_wait_resume")
ctx.budget_pause_resume = task.get("_budget_pause_resume")
ctx.working_recovery = task.get("_working_recovery")
@ -168,20 +163,35 @@ def _restore_saved_clocks(ctx: Any, task: Dict[str, Any]) -> None:
if not saved_wait and ctx.budget_pause_resume:
from ouroboros.budget_pause import load_budget_pause
saved_wait = load_budget_pause(ctx) # same-ID budget continuation (#1196)
recovered = False
if not saved_wait and isinstance(ctx.working_recovery, dict):
from ouroboros.working_checkpoint import load_recovery
try:
saved_wait = load_recovery(ctx) # same/new-ID recovery continues its clocks
recovered = True
except Exception:
saved_wait = {} # the loop discloses the unreadable source itself
return saved_wait
def _restore_saved_clocks(ctx: Any, task: Dict[str, Any], saved_wait: Optional[dict] = None) -> None:
"""Restore clocks from the same start capture; the loop later revalidates its grant."""
if saved_wait is None:
saved_wait = _load_start_continuation(ctx, task)
else:
ctx.owner_wait_resume = task.get("_owner_wait_resume")
ctx.budget_pause_resume = task.get("_budget_pause_resume")
ctx.working_recovery = task.get("_working_recovery")
if saved_wait:
from ouroboros.settings_integrity import restore_cognition
from ouroboros.task_pacing import restore_cost_ceiling
restore_cognition(saved_wait.get("cognition"))
ctx._cost_ceiling = restore_cost_ceiling(ctx, saved_wait.get("cost_ceiling"))
if saved_wait and ctx.model_wait_context is not None:
from ouroboros.model_wait import adopt_handoff_clock, native_sleep_started_at
model_state = saved_wait.get("model_wait") or {}
# Both native waits and working recovery carry confirmed post-checkpoint clocks.
model_state = adopt_handoff_clock(model_state, ctx.working_recovery if recovered else ctx.owner_wait_resume)
model_state = adopt_handoff_clock(model_state, ctx.working_recovery if saved_wait.get("_working_handoff") else ctx.owner_wait_resume)
if (saved_wait.get("_working_handoff") or {}).get("source_task_id", ctx.task_id) != ctx.task_id:
# A new execution keeps the cumulative clock, without importing
# the old task owner's model-choice/control overrides.
@ -628,9 +638,15 @@ class OuroborosAgent:
return None, [], {"authority_source_unavailable": authority_refusal}
drive_logs = self.env.drive_path("logs")
task = attach_task_contract(apply_consciousness_authority(task))
# THE resolution, before anything durable is written about this run: the
# RUNNING record below is the single atomic write that states model, effort,
# route, profile, effective executor and the one `capability_delta` together.
from types import SimpleNamespace
from ouroboros.settings_integrity import restore_cognition
saved_start = _load_start_continuation(SimpleNamespace(
task_id=str(task.get("id") or ""), task_attempt=task.get("_attempt"),
drive_root=self.env.drive_root, budget_drive_root=task.get("budget_drive_root"),
task_metadata=task.get("metadata") or {}, task_contract=task.get("task_contract") or {},
), task)
restore_cognition(saved_start.get("cognition"))
# Resolve once, before the atomic RUNNING record stores all axes and capability_delta.
dispatch = resolve_dispatch_axes(task)
_record_executor_resolution(drive_logs, task, dispatch)
sanitized_task = sanitize_task_for_event(task, drive_logs)
@ -640,9 +656,7 @@ class OuroborosAgent:
**{key: str(task[key]) for key in ("root_task_id", "parent_task_id") if task.get(key)},
"activity_emitted_at": getattr(self, "_activity_emitted_at", None)})
self._persist_running_record(task)
# Durable record first, live mirror second: the supervisor's RUNNING copy
# (and therefore the queue snapshot) learns the same resolution the record
# just persisted, across the process boundary.
# After persistence, mirror the same resolution to the supervisor/queue.
emit_dispatch_resolution(self._event_queue, task, dispatch)
self._emit_live_log(
"context_building_started",
@ -666,7 +680,6 @@ class OuroborosAgent:
})
except Exception:
log.debug("Failed to emit subagent running progress", exc_info=True)
task_metadata = dict(task.get("metadata") or {}) if isinstance(task.get("metadata"), dict) else {}
for key in (
"parent_task_id", "root_task_id", "session_id", "actor_id", "delegation_role", "role",
@ -714,7 +727,6 @@ class OuroborosAgent:
task_metadata["write_surface"] = str(_tc_meta["surface"])
with self._owner_message_admission_lock:
self._current_task_metadata = dict(task_metadata)
from ouroboros.project_facts import resolve_project_id
# Project scope flows to tools via ctx.project_id and to context build via
@ -781,7 +793,7 @@ class OuroborosAgent:
ctx.task_started_at = self._task_started_ts
ctx.owner_wait_callback = getattr(self, "owner_wait_callback", None)
ctx.review_wait_callback = getattr(self, "review_wait_callback", None) # Presence review park (#1536)
_restore_saved_clocks(ctx, task)
_restore_saved_clocks(ctx, task, saved_start)
if self._event_queue is not None:
# Optional runtime seam consumed by loop.py. Unit/direct contexts
@ -944,6 +956,10 @@ class OuroborosAgent:
# restart; a failed reload is disclosed loudly, not swallowed (#285).
settings_snapshot = subagent_runtime.apply_task_start_settings_or_disclose(
str(task.get("id") or ""), self._emit_live_log)
from ouroboros.settings_integrity import actor_settings_snapshot
settings_snapshot = actor_settings_snapshot(
settings_snapshot, task, getattr(self.env, "budget_drive_root", None) or self.env.drive_root)
from ouroboros.usage_accounting import UsageScope, usage_scope
from ouroboros.model_wait import task_model_wait_scope

View file

@ -352,8 +352,8 @@ def _initial_effort_for(task: Dict[str, Any], task_type: str) -> str:
"""The effort a task starts on.
For a delegated child this is what ``resolve_subagent_dispatch`` derived and
wrote onto the record moments ago, which is ``resolve_effort(task_type)`` — read
back rather than recomputed so the loop runs the effort the record states. For
wrote onto the record moments ago — read back rather than recomputed so the
loop runs the effort the record states. For
everything else, and for an unrecognized STORED value (durable data outlives the
schema that wrote it), it is the task-type default directly — or, when the task
names a model ROLE in its metadata (a consciousness wake-up), that role's effort

View file

@ -17,6 +17,7 @@ from typing import Any, Callable, Dict, Optional
from ouroboros.config import (
SETTINGS_DEFAULTS, defaults_for_settings_document, normalize_settings_raw, serialize_settings,
)
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, normalize_effort_document
from ouroboros.update_channels import get_managed_update_fetch_timeout_sec, normalize_update_channel
from ouroboros.utils import atomic_write_json, write_text_atomic
@ -163,8 +164,8 @@ def build_colab_settings(
# renamed key is already present as its default and the customization under the
# former key is lost, then written back to Drive as the owner's choice. Private
# sentinel keys stay out of the document.
loaded = {k: v for k, v in normalize_settings_raw(document).items() if not str(k).startswith("_")} if document else {}
settings.update(loaded)
loaded = {k: v for k, v in normalize_settings_raw(document).items() if not str(k).startswith("_") or k == EFFORT_FORMAT_KEY} if document else {}
settings.update(normalize_effort_document(loaded))
if github_repo:
settings["GITHUB_REPO"] = github_repo
settings["TOTAL_BUDGET"] = float(total_budget)

View file

@ -0,0 +1,131 @@
"""Saved Context and Effort of Main and each registered Project room.
Projects copy Main once, at creation or migration. Reads stay read-only; every
settings writer freezes legacy rooms before changing Main. Registry writers take
their Main snapshot before the registry lock, never acquiring settings under it.
Actor lifetimes and room resolution belong to the task-settings owner.
"""
from __future__ import annotations
import copy
import os
from pathlib import Path
from typing import Any, Mapping
from ouroboros.context_mode_compat import normalize_context_mode, owner_declared_low
from ouroboros.settings_scales import EFFORT_RANGE_KEYS, effort_range
COGNITION_KEYS = ("OUROBOROS_CONTEXT_MODE", *EFFORT_RANGE_KEYS)
def cognition_from_settings(settings: Mapping[str, Any]) -> dict:
return {"context_mode": normalize_context_mode(settings.get("OUROBOROS_CONTEXT_MODE", "max")),
"effort_range": effort_range(settings)}
def cognition_overrides(cognition: Mapping[str, Any]) -> dict:
"""The four non-secret values an actor privately overlays on its settings."""
effort = cognition["effort_range"]
return {"OUROBOROS_CONTEXT_MODE": cognition["context_mode"],
**{key: effort[field] for key, field in zip(EFFORT_RANGE_KEYS, ("min", "recommended", "max"))}}
def main_cognition(settings: Mapping[str, Any] | None = None) -> dict:
from ouroboros import config
from ouroboros.settings_integrity import SETTINGS_ENV_LOCK
settings = config.load_settings() if settings is None else settings
with SETTINGS_ENV_LOCK:
projected = dict(os.environ)
config.apply_settings_to_env(settings, environ=projected)
cognition = cognition_from_settings(projected)
# A retired auto-Low view is not an owner choice to copy into a new room.
if cognition["context_mode"] == "low" and not owner_declared_low(projected.get("OUROBOROS_CONTEXT_MODE_AUTO_LOW")):
cognition["context_mode"] = "max"
return cognition
def project_cognition(project: Mapping[str, Any], baseline: Mapping[str, Any]) -> dict:
"""Legacy rows use the pre-migration baseline; saved choices never follow Main."""
saved = project.get("cognition")
return (cognition_from_settings(cognition_overrides(saved)) if isinstance(saved, Mapping)
else copy.deepcopy(dict(baseline)))
def read_cognition(drive_root: Any, project_id: str = "", *, settings: Mapping[str, Any] | None = None) -> dict:
baseline = main_cognition(settings) # settings before registry: the write order, too
if not project_id:
return baseline
from ouroboros.projects_registry import get_project
project = get_project(drive_root, project_id, strict=True)
if project is None:
raise ValueError(f"Project {project_id!r} is unavailable")
return project_cognition(project, baseline)
def _seed_rows(data: dict, baseline: Mapping[str, Any]) -> bool:
changed = False
for row in data["projects"]:
if "cognition" not in row:
row["cognition"] = copy.deepcopy(dict(baseline))
changed = True
return changed
def ensure_project_cognition(drive_root: Any, *, baseline: Mapping[str, Any] | None = None) -> None:
"""Freeze existing rooms before Main changes, or during ordinary boot reconcile."""
from ouroboros import projects_registry as registry
path = registry._registry_path(drive_root)
if not path.exists():
return
baseline = main_cognition() if baseline is None else baseline
with registry._file_write_lock(path):
data = registry._load(drive_root, strict=True, project_id="")
if _seed_rows(data, baseline):
registry._save(drive_root, data)
def preserve_project_cognition(settings: Mapping[str, Any], settings_path: Path) -> None:
"""Settings-write prologue; the caller already owns the settings file lock."""
from ouroboros import config
current = main_cognition(config.load_settings_lock_held())
if current != cognition_from_settings(settings):
# The file's parent is its actual data root, including isolated readers
# that replace SETTINGS_PATH without replacing the module's DATA_DIR.
ensure_project_cognition(settings_path.parent, baseline=current)
def update_project_cognition(drive_root: Any, project_id: str, *, context_mode: str | None = None,
effort_range: Mapping[str, str] | None = None) -> tuple[dict, dict]:
"""Update just this room's decision under the registry's existing write lock."""
from ouroboros import projects_registry as registry
baseline = main_cognition()
with registry._file_write_lock(registry._registry_path(drive_root)):
data = registry._load(drive_root, strict=True, project_id=project_id)
project = next((row for row in data["projects"] if row["id"] == project_id
and row.get("lifecycle") == registry.PROJECT_ACTIVE), None)
if project is None:
raise ValueError(f"Project {project_id!r} is unavailable")
previous = project_cognition(project, baseline)
current = copy.deepcopy(previous)
if context_mode is not None:
current["context_mode"] = context_mode
if effort_range is not None:
current["effort_range"] = dict(effort_range)
project["cognition"] = current
registry._save(drive_root, data)
return previous, current
def request_project_id(body: Mapping[str, Any]) -> str:
"""Exact optional room identity; never sanitize a different id into this one."""
from ouroboros.project_facts import explicit_project_id_ok
value = body.get("project_id", "")
if not isinstance(value, str) or (value and not explicit_project_id_ok(value)):
raise ValueError("project_id must name an existing Project, or be empty for Main")
return value

View file

@ -40,7 +40,7 @@ from ouroboros.settings_defaults import (
settings_env_keys, # noqa: F401
)
from ouroboros.settings_scales import (
EFFORT_SCALE, OPTIONAL_BOUND_LEGACY, UNLIMITED, # noqa: F401
EFFORT_SCALE, EFFORT_RANGE_KEYS, EFFORT_FORMAT_KEY, normalize_effort_document, OPTIONAL_BOUND_LEGACY, UNLIMITED, # noqa: F401
PROMPT_CACHE_TTL_SCALE, defaults_for_settings_document, optional_bound_value, # noqa: F401
REVIEW_POOL_DEFAULT_EFFORT, # noqa: F401
VALID_RUNTIME_MODES, # noqa: F401
@ -502,6 +502,8 @@ def prepare_settings_for_persist(settings: dict, *, authored_keys: Sequence[str]
for key in (MODEL_ACCOUNTS_KEY, MODEL_CONTEXT_WINDOWS_KEY, MODEL_PROCESSING_PREFERENCES_KEY):
if key in prepared:
prepared[key] = normalize_model_role_options(key, prepared[key])[1]
from ouroboros.composer_cognition import preserve_project_cognition
preserve_project_cognition(prepared, SETTINGS_PATH)
return strip_masked_secrets(prepared, known_setting_keys=SETTINGS_DEFAULTS)
@ -700,9 +702,7 @@ def verify_settings_integrity() -> str | None:
log = logging.getLogger(__name__)
# Dropped-retired-key sets this process has already reported (the notice in
# ``normalize_settings_raw``). Keyed by the exact set so a document carrying a
# second ghost still gets its own line.
# Retired-key combinations reported by normalize_settings_raw each get one notice.
_RETIREMENT_NOTICE_SEEN: set[tuple[str, ...]] = set()
@ -852,7 +852,9 @@ def load_settings_lock_held(*, _settings_lock_held: bool = True) -> dict:
continue
settings[key] = _coerce_setting_value(key, raw_env)
env_authored.add(key)
# Unauthored model slots follow the configured access; a never-configured install (§1.5) mints its factory catalog here.
settings.update(normalize_effort_document({EFFORT_FORMAT_KEY: loaded.get(EFFORT_FORMAT_KEY),
**{key: settings[key] for key in EFFORT_RANGE_KEYS if key in loaded or key in env_authored}}))
# Unauthored slots and the never-configured catalog follow the configured access.
propose_unauthored_model_slots(settings, authored_model_slots(loaded) | env_authored)
apply_at_read_seam(settings, merged=True)
return settings

View file

@ -137,6 +137,7 @@ D20 = "Presence"
"ouroboros/colab_bootstrap.py" = "D12"
"ouroboros/commit_admission.py" = "D06"
"ouroboros/config.py" = "D12"
"ouroboros/composer_cognition.py" = "D12"
"ouroboros/confined_files.py" = "D18"
"ouroboros/model_slots.py" = "D12"
"ouroboros/search_routes.py" = "D12"
@ -993,6 +994,7 @@ allowed = [
"D17->D06",
"D17->D08",
"D17->D11",
"D17->D12",
"D17->D15",
"D17->D16",
"D17->D18",
@ -1117,7 +1119,6 @@ lazy_only = [
"D17->D05",
"D17->D07",
"D17->D09",
"D17->D12",
"D17->D13",
"D17->D20",
"D18->D01",

View file

@ -264,10 +264,17 @@ class OwnerRuntimeModeRequest(TypedDict):
class OwnerContextModeRequest(TypedDict):
"""``POST /api/owner/context-mode``. Lowering it while work is queued or running answers 409
(nothing saved) unless the runtime mode is cyber_pro."""
"""Save the room's context for future actors; existing actors retain theirs."""
mode: Literal["nano", "low", "max"]
project_id: NotRequired[str]
class OwnerEffortRangeRequest(TypedDict):
min: str
recommended: str
max: str
project_id: NotRequired[str]
class OwnerSafetyModeRequest(TypedDict):
@ -534,7 +541,7 @@ __all__ = [
"SettingsReadResponse", "SettingsWriteRefusal",
"AuthLoginRequest", "AuthLoginResponse",
"ContractBody", "ContractHttpEntry", "ContractWsEntry", "ContractWs", "GatewayContractResponse",
"OwnerRuntimeModeRequest", "OwnerContextModeRequest", "OwnerSafetyModeRequest", "OwnerEnabledRequest",
"OwnerRuntimeModeRequest", "OwnerContextModeRequest", "OwnerEffortRangeRequest", "OwnerSafetyModeRequest", "OwnerEnabledRequest",
"SettingsSecretRequest", "SettingsSecretResponse", "ChatUploadDeleteRequest", "ChatUploadDeleteResponse",
"ProjectListResponse", "ProjectCreateResponse", "ProjectUpdateRequest", "ProjectUpdateResponse",
"ProjectFromTaskRequest", "ProjectTaskBinding", "ProjectFromTaskResponse",

View file

@ -627,6 +627,7 @@ class ProjectEntry(TypedDict, total=False):
name: str
chat_id: int
working_dir: str
cognition: ProjectCognition
provenance: str
clone_url: str
trusted_at: str
@ -768,15 +769,22 @@ class ActiveChatActivity(ActiveDirectTurn):
can_resume: NotRequired[bool]
cancelable: NotRequired[bool]
class EffortRange(TypedDict): # min ≤ recommended ≤ max, each an EFFORT_SCALE tier (the tolerant read)
class EffortRange(TypedDict): # helper min ≤ max; primary recommended is independent
min: str
recommended: str
max: str
class ProjectCognition(TypedDict):
context_mode: str
effort_range: EffortRange
class OwnerEffortRangeResponse(TypedDict):
ok: bool
effort_range: EffortRange
project_id: str
cognition: ProjectCognition
class StateResponse(TypedDict):
@ -916,6 +924,8 @@ class OwnerAutoGrantResponse(TypedDict):
class OwnerContextModeResponse(TypedDict):
ok: bool
context_mode: str
project_id: str
cognition: ProjectCognition
class OwnerSafetyModeResponse(TypedDict):
@ -1526,25 +1536,17 @@ __all__ = [
"UpdateApplySuccessResponse", "UpdateApplyErrorResponse",
"UpdateStatusReadyOutbound",
"UpdateProgressChangedOutbound",
"ProjectCreateRequest",
"ProjectEntry",
"ProjectDeleteResponse",
"ProjectCreateRequest", "ProjectEntry", "ProjectDeleteResponse", "ProjectCognition",
"FsDirsEntry", "FsDirsResponse",
"TaskNamedOutbound",
"ErrorResponse",
"StatusResponse",
"HealthResponse",
"StateResponse",
"ErrorResponse", "StatusResponse", "HealthResponse", "StateResponse",
"ActiveDirectTurn", "ActiveChatActivity",
"EvolutionStateSnapshot",
"SettingsNetworkMeta",
"AvailableSubagentsSettingsMeta",
"SettingsPolicyAxis", "SettingsPolicyState", "SettingsMeta",
"SettingsSaveResponse",
"OwnerRuntimeModeResponse",
"OwnerAutoGrantResponse",
"OwnerContextModeResponse",
"OwnerSafetyModeResponse",
"OwnerRuntimeModeResponse", "OwnerAutoGrantResponse", "OwnerContextModeResponse", "OwnerSafetyModeResponse",
"OwnerSkillPresenceRuntimeRequest", "OwnerSkillPresenceRuntimeResponse",
"OnboardingCompleteRequest", "OnboardingCompleteResponse",
"OnboardingSubagentsPreviewResponse",
@ -1557,9 +1559,7 @@ __all__ = [
"EvolutionDataResponse",
"ScheduledTasksResponse", "ScheduleUpsertResponse", "ScheduleDeleteResponse", "ScheduleActionResponse",
"UploadResponse",
"ExtensionsIndexResponse",
"ExtensionLiveSnapshot",
"WidgetTab",
"ExtensionsIndexResponse", "ExtensionLiveSnapshot", "WidgetTab",
"WidgetsResponse",
"SkillPublishPreflightResponse",
"SkillLifecycleQueueResponse",

View file

@ -232,9 +232,9 @@ HTTP_SHAPES: dict[str, Shape] = {
"POST /api/desktop/background": Shape(f"200:DesktopAutostartResponse {_SETTINGS_REFUSAL}", "OwnerEnabledRequest"),
"POST /api/owner/runtime-mode": Shape(f"200:OwnerRuntimeModeResponse {_SETTINGS_REFUSAL}", "OwnerRuntimeModeRequest"),
"POST /api/owner/auto-grant": Shape(f"200:OwnerAutoGrantResponse {_SETTINGS_REFUSAL}", "OwnerEnabledRequest"),
"POST /api/owner/context-mode": Shape(f"200:OwnerContextModeResponse {_SETTINGS_REFUSAL}", "OwnerContextModeRequest"),
"POST /api/owner/effort-range": Shape(f"200:OwnerEffortRangeResponse {_SETTINGS_REFUSAL}", "EffortRange",
note="min <= recommended <= max on the effort scale"),
"POST /api/owner/context-mode": Shape(f"200:OwnerContextModeResponse {_SETTINGS_REFUSAL} 404:SettingsWriteRefusal", "OwnerContextModeRequest"),
"POST /api/owner/effort-range": Shape(f"200:OwnerEffortRangeResponse {_SETTINGS_REFUSAL} 404:SettingsWriteRefusal", "OwnerEffortRangeRequest",
note="helper min <= max; primary independent; optional project_id, otherwise Main"),
"POST /api/owner/safety-mode": Shape(f"200:OwnerSafetyModeResponse {_SETTINGS_REFUSAL}", "OwnerSafetyModeRequest"),
"POST /api/owner/capability-ack": Shape(
"200:ResponseLimitAckResponse|ContextWindowAckResponse 400:ErrorResponse", "CapabilityAckRequest",

View file

@ -1,11 +1,4 @@
"""``POST /api/owner/effort-range``: the owner's effort range from the chat composer.
The one writer of the three range keys (``settings_scales.EFFORT_RANGE_KEYS``): a complete
ordered triple of tiers, written atomically under the owner write seam, projected into the
process environment under the same lock and audited — the contour of the context-mode
endpoint without its idle rule, because the range must be movable while work runs (a
running participant keeps the range it started with; the next one reads this).
"""
"""Save the composer's primary effort and independent helper bounds for its room."""
from __future__ import annotations
@ -15,24 +8,24 @@ from typing import Any, Dict
from starlette.requests import Request
from starlette.responses import JSONResponse
from ouroboros.composer_cognition import main_cognition, request_project_id, update_project_cognition
from ouroboros.gateway._helpers import request_drive_root
from ouroboros.gateway.owner_settings import (
_owner_audit,
_owner_read_settings_raw,
_owner_update_settings,
owner_write_guard,
settings_document_digest,
settings_document_mutation,
unsaved_error,
)
from ouroboros.settings_scales import EFFORT_RANGE_KEYS, EFFORT_SCALE, effort_range, effort_rank
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, EFFORT_RANGE_KEYS, EFFORT_SCALE, effort_range, effort_rank
# The body's fields, in the order of ``EFFORT_RANGE_KEYS`` (min, recommended = TASK, max).
RANGE_FIELDS = ("min", "recommended", "max")
def effort_range_refusal(body: Any) -> str:
"""'' when ``body`` is a complete ordered triple of ``EFFORT_SCALE`` tiers, else the sentence
the 400 carries (every tier is accepted, ``minimal`` included; order is min ≤ recommended ≤ max)."""
"""A complete triple of tiers with min ≤ max; primary recommended is independent."""
if not isinstance(body, dict):
return "JSON body must be an object with min, recommended and max."
tiers: Dict[str, str] = {}
@ -41,8 +34,8 @@ def effort_range_refusal(body: Any) -> str:
if value not in EFFORT_SCALE:
return f"'{field}' must be one of: {', '.join(EFFORT_SCALE)}."
tiers[field] = value
if not (effort_rank(tiers["min"]) <= effort_rank(tiers["recommended"]) <= effort_rank(tiers["max"])):
return "The range must be ordered min ≤ recommended ≤ max."
if effort_rank(tiers["min"]) > effort_rank(tiers["max"]):
return "The helper range must be ordered min ≤ max."
return ""
@ -60,24 +53,34 @@ def _api_owner_effort_range_sync(request: Request, body: Any) -> JSONResponse:
refusal = effort_range_refusal(body)
if refusal:
return unsaved_error(refusal, 400, code="effort_range_invalid")
try:
project_id = request_project_id(body)
except ValueError as exc:
return unsaved_error(str(exc), 400, code="effort_range_invalid")
values = {key: str(body[field]).strip().lower() for key, field in zip(EFFORT_RANGE_KEYS, RANGE_FIELDS)}
current = effort_range(values)
def _set_range(current: Dict[str, Any]) -> Dict[str, Any]:
current.update(values)
current[EFFORT_FORMAT_KEY] = 1
return current
with settings_document_mutation():
# The previous range is read for the audit trail only; the digest binds that read
# to the document the locked update replaces.
digest = settings_document_digest()
previous = effort_range(_owner_read_settings_raw())
_owner_update_settings(_set_range, digest, authored_keys=EFFORT_RANGE_KEYS)
# Same-lock projection: the server process reads the range from its environment.
for key, value in values.items():
os.environ[key] = value
current = effort_range(values)
_owner_audit(request, "effort_range", {"effort_range": current, "previous_effort_range": previous})
return JSONResponse({"ok": True, "effort_range": current})
if project_id:
try:
old, cognition = update_project_cognition(request_drive_root(request), project_id, effort_range=current)
except ValueError as exc:
return unsaved_error(str(exc), 404, code="project_unavailable")
previous = old["effort_range"]
else:
previous = main_cognition()["effort_range"]
digest = settings_document_digest()
_owner_update_settings(_set_range, digest, authored_keys=EFFORT_RANGE_KEYS)
for key, value in values.items():
os.environ[key] = value
cognition = main_cognition()
_owner_audit(request, "effort_range", {"project_id": project_id, "effort_range": current, "previous_effort_range": previous})
return JSONResponse({"ok": True, "project_id": project_id, "effort_range": current, "cognition": cognition})
__all__ = ["RANGE_FIELDS", "api_owner_effort_range", "effort_range_refusal"]

View file

@ -267,13 +267,13 @@ def _owner_read_settings_raw(document_out: Optional[Dict[str, Any]] = None) -> D
raw, settings_path=_config.SETTINGS_PATH, warn_ambiguous=True,
)
document = _config.normalize_settings_raw(raw)
merged.update(document)
except SettingsIntegrityError:
raise
except Exception:
log.debug("Failed to read raw owner settings; using defaults", exc_info=True)
if document_out is not None:
document_out.update(document)
merged.update(_config.normalize_effort_document(document))
return merged

View file

@ -794,23 +794,20 @@ async def api_owner_context_mode(request: Request) -> JSONResponse:
def _api_owner_context_mode_sync(request: Request, body: Any) -> JSONResponse:
from ouroboros import config as _config
from ouroboros.runtime_mode_policy import runtime_mode_at_least
from ouroboros.composer_cognition import main_cognition, request_project_id, update_project_cognition
if not isinstance(body, dict):
return unsaved_error("JSON body must be an object.", 400)
raw_mode = str((body or {}).get("mode") or "").strip().lower()
from ouroboros.context_mode_compat import VALID_CONTEXT_MODES
if raw_mode not in set(VALID_CONTEXT_MODES):
return unsaved_error("'mode' must be one of: " + ", ".join(VALID_CONTEXT_MODES), 400)
next_mode = _config.normalize_context_mode(raw_mode)
digest = settings_document_digest()
previous_mode = _config.get_owner_context_mode()
cyber = runtime_mode_at_least(_config.get_runtime_mode(), "cyber_pro")
if not cyber and VALID_CONTEXT_MODES.index(next_mode) < VALID_CONTEXT_MODES.index(previous_mode) and _has_running_agent_tasks():
return unsaved_error(
"Context mode can only be lowered while Ouroboros is idle. "
"Wait until no queued or running work remains, then choose the working context.",
409,
)
try:
project_id = request_project_id(body)
except ValueError as exc:
return unsaved_error(str(exc), 400)
def _set_context_mode(current: Dict[str, Any]) -> Dict[str, Any]:
current["OUROBOROS_CONTEXT_MODE"] = next_mode
@ -821,30 +818,26 @@ def _api_owner_context_mode_sync(request: Request, body: Any) -> JSONResponse:
return current
with settings_document_mutation():
# Re-prove the ordinary-mode idle policy under the lock: either the queue
# or the previous mode may have changed while this writer waited. The
# digest alone cannot attest idleness. Cyber can choose the next mode
# during work; existing task snapshots retain their original settings.
previous_mode = _config.get_owner_context_mode()
if not cyber and VALID_CONTEXT_MODES.index(next_mode) < VALID_CONTEXT_MODES.index(previous_mode) and _has_running_agent_tasks():
return unsaved_error(
"Context mode can only be lowered while Ouroboros is idle. "
"Wait until no queued or running work remains, then choose the working context.",
409,
)
# This endpoint IS the author of both keys, so they persist even at the shipped default.
# The digest binds the idle refusal above to the document this write replaces.
_owner_update_settings(_set_context_mode, digest,
authored_keys=_CONTEXT_MODE_KEYS, allow_context_lowering=True)
# Same-lock projection: see api_owner_auto_grant.
os.environ["OUROBOROS_CONTEXT_MODE"] = next_mode
os.environ["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = "false"
if project_id:
try:
old, cognition = update_project_cognition(request_drive_root(request), project_id, context_mode=next_mode)
except ValueError as exc:
return unsaved_error(str(exc), 404, code="project_unavailable")
previous_mode = old["context_mode"]
else:
previous_mode = main_cognition()["context_mode"]
digest = settings_document_digest()
_owner_update_settings(_set_context_mode, digest,
authored_keys=_CONTEXT_MODE_KEYS, allow_context_lowering=True)
os.environ["OUROBOROS_CONTEXT_MODE"] = next_mode
os.environ["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = "false"
cognition = main_cognition()
_owner_audit(
request,
"context_mode",
{"context_mode": next_mode, "previous_context_mode": previous_mode},
{"project_id": project_id, "context_mode": next_mode, "previous_context_mode": previous_mode},
)
return JSONResponse({"ok": True, "context_mode": next_mode})
return JSONResponse({"ok": True, "project_id": project_id, "context_mode": next_mode, "cognition": cognition})
@owner_write_guard

View file

@ -164,6 +164,8 @@ def continuation_state(ctx: Any, messages: list, trace: dict, usage: dict,
cost_ceiling = getattr(ctx, "_cost_ceiling", None)
model_wait = getattr(ctx, "model_wait_context", None)
model_state = model_wait.continuation_state() if model_wait is not None else {}
from ouroboros.settings_integrity import captured_cognition
return {
"task_id": ctx.task_id, "task_attempt": int(getattr(ctx, "task_attempt", None) or 1),
"started_at": getattr(ctx, "task_started_at", None),
@ -175,6 +177,7 @@ def continuation_state(ctx: Any, messages: list, trace: dict, usage: dict,
"usage": without_finalization_timing(usage),
"cost_ceiling": asdict(cost_ceiling) if cost_ceiling is not None else None,
"model_wait": model_state,
"cognition": captured_cognition(),
"nanny_activity": {key: getattr(ctx, key, None) for key in (
"_nanny_metered_progress", "_nanny_delegate_baseline",
)},
@ -942,8 +945,10 @@ def restore_continuation_state(tools: Any, state: dict, messages: list, trace: d
from ouroboros.loop_delivery import DeliveryCandidate
from ouroboros.model_wait import adopt_handoff_clock, budget_paused_seconds
from ouroboros.settings_integrity import restore_cognition
ctx = tools._ctx
restore_cognition(state.get("cognition"))
# Missing old observations are unknown, not this new process's startup seed.
# None is the known fresh state; an empty mark has no historical anchor.
for key in ("_nanny_metered_progress", "_nanny_delegate_baseline"):
@ -977,8 +982,8 @@ def restore_continuation_state(tools: Any, state: dict, messages: list, trace: d
def rebind_restored_route(tools: Any, state: dict, messages: list) -> tuple:
"""Rebind the restored route's context-fit plan; returns ``(plan, mode)``.
The new capture starts in the mode the task ran in (restored onto ``ctx``), never above the
owner's current one: a restore, like any route, never raises a mode."""
The actor's captured owner choice survives restart, separately from the physically
achieved mode. Legacy sources without that choice keep the previous fallback."""
from ouroboros.loop import _rebind_context_fit_plan, get_context_mode
from ouroboros.memory_floor import MODES
from ouroboros.model_slots import task_model_binding

View file

@ -14,6 +14,7 @@ from contextlib import contextmanager, nullcontext
from typing import Any, Dict, Iterator, List, Optional
from ouroboros.contracts.chat_id_policy import project_chat_id
from ouroboros.composer_cognition import ensure_project_cognition, main_cognition, project_cognition
from ouroboros.contracts.schema_versions import with_schema_version
from ouroboros.project_facts import sanitize_project_id
from ouroboros.store import open_store
@ -45,10 +46,7 @@ PROJECT_LIFECYCLES = frozenset({PROJECT_ACTIVE, PROJECT_DELETING, PROJECT_TOMBST
def _file_write_lock(target_path: pathlib.Path, *, timeout_sec: float = 4.0) -> Iterator[None]:
"""Server create/bind and worker journal share a sidecar flock for load→modify→atomic-write;
nested _LOCK serializes threads. A process-local lock alone permits lost updates."""
from ouroboros.platform_layer import (
acquire_exclusive_file_lock,
release_exclusive_file_lock,
)
from ouroboros.platform_layer import acquire_exclusive_file_lock, release_exclusive_file_lock
target_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = target_path.with_name(target_path.name + ".lock")
@ -1000,15 +998,13 @@ def create_project(
origin: str = "owner",
admission_basis: Optional[dict] = None,
) -> Dict[str, Any]:
"""Register or return an active room; its optional folder may stay empty.
Chat identity derives deterministically from the id. The returned, unpersisted
``created`` flag is true only for THIS registration, so announcements can
distinguish a newly created room from an idempotent replay.
"""
"""Register an active room with deterministic chat id and an optional folder.
The unpersisted ``created`` flag distinguishes this registration from replay."""
pid = sanitize_project_id(project_id)
if not pid:
raise ValueError(f"unusable project id: {project_id!r}")
cognition = main_cognition()
ensure_project_cognition(drive_root, baseline=cognition)
with _file_write_lock(_registry_path(drive_root)), \
project_admission_guard(drive_root, admission_basis) if admission_basis is not None else nullcontext():
data = _load(drive_root, strict=True, project_id=pid)
@ -1025,6 +1021,7 @@ def create_project(
"name": _validated_name(name, pid),
"chat_id": project_chat_id(pid),
"working_dir": str(working_dir or "").strip(),
"cognition": cognition,
"origin": str(origin or "owner"),
"created_at": utc_now_iso(),
"last_active_at": utc_now_iso(),
@ -1237,6 +1234,8 @@ def reconcile_projects(drive_root: Any) -> int:
"""
added = 0
try:
ensure_project_cognition(drive_root)
cognition = main_cognition()
if _registry_path(drive_root).is_file():
_mark_registry_committed(drive_root) # a registry saved before its witness existed
projects_root = pathlib.Path(drive_root) / "projects"
@ -1277,13 +1276,14 @@ def reconcile_projects(drive_root: Any) -> int:
if not store.is_dir():
continue
marker = _read_store_marker(store)
if marker.get("id") != row.get("id") or marker.get("name") != row.get("name"):
if any(marker.get(key) != row.get(key) for key in ("id", "name", "cognition")):
try:
atomic_write_json(store / _PROJECT_MARKER_NAME, {
"id": row.get("id"),
"name": row.get("name"),
"origin": row.get("origin"),
"created_at": row.get("created_at"),
"cognition": row.get("cognition"),
})
except OSError:
# One unwritable store must not abort the whole reconcile:
@ -1318,8 +1318,8 @@ def reconcile_projects(drive_root: Any) -> int:
# proj_*-named project passed through create_project and is
# already in ``known``, so it never reaches this guard.
row_name, row_origin, row_created = pid, "reconcile", ""
marker = _read_store_marker(entry)
if pid.startswith("proj_") and pid not in bound:
marker = _read_store_marker(entry)
if marker.get("id") != pid:
continue # unbound, unmarked: a workspace facts store
# Recovery with the room's REAL identity: the pre-guard
@ -1333,6 +1333,7 @@ def reconcile_projects(drive_root: Any) -> int:
"name": row_name,
"chat_id": project_chat_id(pid),
"working_dir": "",
"cognition": project_cognition(marker, cognition),
"origin": row_origin,
"created_at": row_created or utc_now_iso(),
"last_active_at": utc_now_iso(),
@ -1479,6 +1480,7 @@ def projects_summary(drive_root: Any, *, limit: int = 50, chat_id: Any = None) -
"""Sidebar list or strict addressed room, independent of sidebar caps/activity.
Missing/inactive yields no room; unreadable/ambiguous authority raises."""
out: List[Dict[str, Any]] = []
cognition = main_cognition()
bindings = _load_bindings(drive_root).get("bindings", {})
if chat_id is not None:
address = int(chat_id)
@ -1490,15 +1492,10 @@ def projects_summary(drive_root: Any, *, limit: int = 50, chat_id: Any = None) -
projects = list_sidebar_projects(drive_root)[: max(1, int(limit))]
def _has_thread_activity(project: Dict[str, Any]) -> bool:
# Registry-facts derivation ONLY — the GET path never scans logs and
# never writes. Micro-delta vs the retired per-request log scan
# (disclosed): a project whose thread carries ONLY telemetry rows (no
# owner-visible canonical row, no binding) reads inactive until its
# first visible row — exactly the junk-row shape this filter exists
# to hide. Legacy projects whose activity predates the
# visible_revision counter are covered by the write-once
# `thread_activity_seen` flag seeded at boot reconcile
# (_backfill_thread_activity).
# Registry facts only: GET never scans logs or writes. Telemetry-only
# rooms (no visible canonical row or binding) remain inactive until a
# visible row arrives, hiding junk rooms. Boot reconcile's write-once
# thread_activity_seen covers activity predating visible_revision.
pid = str(project.get("id") or "")
# v6.59.0: a project the OWNER explicitly created in the UI is always shown —
# the activity filter exists to hide junk reconcile rows, not a fresh project
@ -1517,6 +1514,7 @@ def projects_summary(drive_root: Any, *, limit: int = 50, chat_id: Any = None) -
"name": project.get("name"),
"chat_id": project.get("chat_id"),
"working_dir": project.get("working_dir") or "",
"cognition": project_cognition(project, cognition),
"provenance": project.get("provenance") or "",
"last_active_at": project.get("last_active_at") or "",
"lifecycle": project.get("lifecycle") or PROJECT_ACTIVE,

View file

@ -299,8 +299,8 @@ SETTINGS_DEFAULTS = {**UPDATE_SETTINGS_DEFAULTS,
# writes at the documented 2x-vs-1.25x ratio). Non-Anthropic wire formats are a NO-OP by construction
# (Gemini documents no ttl field — the v5.30.0 outage class).
"OUROBOROS_PROMPT_CACHE_TTL": "1h",
# The owner's effort range (settings_scales.effort_range): EFFORT_SCALE tiers, read tolerantly as
# MIN <= TASK (the recommended level: Main's) <= MAX; a reviewer row's effort is the row's own.
# Main's primary TASK is independent of the helper corridor MIN <= MAX.
# Projects copy these once; explicit reviewer-row pins retain their precedence.
"OUROBOROS_EFFORT_MIN": "low",
"OUROBOROS_EFFORT_TASK": "medium",
"OUROBOROS_EFFORT_MAX": "high",

View file

@ -18,7 +18,7 @@ import contextlib
import contextvars
import copy
import threading
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from types import MappingProxyType
from typing import Mapping
@ -38,6 +38,7 @@ class TaskSettingsSnapshot:
settings: Mapping
environ: Mapping
actor_scope: tuple[Path, Mapping] | None = None
def _next_task_setting(key: str) -> bool:
@ -185,15 +186,99 @@ def runtime_environ() -> dict[str, str]:
return env
def _actor_project(scope: tuple[Path, Mapping]) -> str:
"""The actor's current room, including descendants of a converted task.
RoomResolver owns lineage; the admitted caller supplies the facts a new
direct turn has not stored yet. A working directory is not a room.
"""
from ouroboros.project_admission import reserved_project_for_chat
from ouroboros.projects_registry import RoomResolver, get_reserved_project
root, actor = scope
if actor.get("presence"):
return ""
chat = RoomResolver(root, strict=True)(actor.get("id"), actor.get("parent_task_id"), actor.get("root_task_id"))
if chat:
return reserved_project_for_chat(root, chat)["id"]
project = get_reserved_project(root, actor.get("project_id"), strict=True)
if project:
return project["id"]
return reserved_project_for_chat(root, actor["chat_id"]).get("id", "") if actor.get("chat_id") else ""
def _with_cognition(snapshot: TaskSettingsSnapshot, cognition: Mapping) -> TaskSettingsSnapshot:
from ouroboros.composer_cognition import cognition_overrides
from ouroboros.context_mode_compat import VALID_CONTEXT_MODES
from ouroboros.settings_scales import EFFORT_SCALE, effort_rank
effort = cognition.get("effort_range")
if (cognition.get("context_mode") not in VALID_CONTEXT_MODES or not isinstance(effort, Mapping)
or any(effort.get(key) not in EFFORT_SCALE for key in ("min", "recommended", "max"))
or effort_rank(effort["min"]) > effort_rank(effort["max"])):
raise ValueError("Saved actor cognition is invalid")
values = cognition_overrides(cognition)
values["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] = cognition.get("context_mode_auto_low", "false")
return replace(snapshot, settings=MappingProxyType({**snapshot.settings, **values}),
environ=MappingProxyType({**snapshot.environ, **values}))
def actor_settings_snapshot(snapshot: TaskSettingsSnapshot, task: Mapping, drive_root: Path) -> TaskSettingsSnapshot:
"""Capture this room privately; a new subordinate resolves its room again."""
from ouroboros.composer_cognition import read_cognition
from ouroboros.dialogue_provenance import presence_metadata_binding
metadata = task.get("metadata") if isinstance(task.get("metadata"), Mapping) else {}
actor = {key: task.get(key) or metadata.get(key) for key in (
"id", "parent_task_id", "root_task_id", "project_id", "chat_id")}
actor["presence"] = bool(task.get("_presence_turn") or presence_metadata_binding(metadata) is not None)
scope = (Path(task.get("budget_drive_root") or metadata.get("budget_drive_root") or drive_root),
MappingProxyType(actor))
snapshot = replace(snapshot, actor_scope=scope)
project_id = _actor_project(scope)
if not project_id:
return snapshot # preserve Main's projected values and legacy Low provenance exactly
return _with_cognition(snapshot, read_cognition(scope[0], project_id, settings=snapshot.environ))
def captured_cognition() -> dict | None:
"""Composer values and any legacy Low provenance, never credentials."""
from ouroboros.composer_cognition import cognition_from_settings
from ouroboros.context_mode_compat import owner_declared_low
snapshot = _TASK_SETTINGS.get()
if snapshot is None:
return None
cognition = cognition_from_settings(snapshot.environ)
marker = snapshot.environ.get("OUROBOROS_CONTEXT_MODE_AUTO_LOW", "")
if cognition["context_mode"] == "low" and not owner_declared_low(marker):
cognition["context_mode_auto_low"] = marker
return cognition
def restore_cognition(cognition: Mapping | None) -> None:
"""Replace the bound private view before rebuilding a returning actor's request.
Legacy checkpoints have no choice recorded and retain their existing fallback.
The enclosing task_settings_scope still resets the context at task exit.
"""
snapshot = _TASK_SETTINGS.get()
if cognition is not None and snapshot is not None:
_TASK_SETTINGS.set(_with_cognition(snapshot, cognition))
def live_effort_range() -> dict[str, str]:
"""The owner's CURRENT effort range (``settings_scales.effort_range`` over the settings
document as it reads now), for a participant started INSIDE a running task — a review
wave, a direct ``delegate_start``: it reads this once at its start while the task that
started it keeps the range of its own snapshot (``runtime_setting``)."""
"""Current own-room bounds for a NEW review/delegate; the caller keeps its snapshot."""
from ouroboros import config
from ouroboros.composer_cognition import read_cognition
from ouroboros.settings_scales import effort_range
return effort_range(config.load_settings())
settings = config.load_settings()
snapshot = _TASK_SETTINGS.get()
if snapshot is None or snapshot.actor_scope is None:
return effort_range(settings)
root, _actor = snapshot.actor_scope
return read_cognition(root, _actor_project(snapshot.actor_scope), settings=settings)["effort_range"]
def read_environ() -> Mapping:

View file

@ -20,8 +20,9 @@ from ouroboros.settings_integrity import runtime_setting
# vendor tier above `max`; above-ceiling tiers adapt per route (API wire recovery / delegated).
EFFORT_SCALE: tuple[str, ...] = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
# The owner's effort range: three flat keys read as one ordered triple (``effort_range``).
# The primary effort and independently ordered helper bounds (``effort_range``).
EFFORT_RANGE_KEYS: tuple[str, ...] = ("OUROBOROS_EFFORT_MIN", "OUROBOROS_EFFORT_TASK", "OUROBOROS_EFFORT_MAX")
EFFORT_FORMAT_KEY = "_effort_range_format" # disk format, never an environment setting or owner knob
# The tiers the owner's range control offers; `minimal` stays a runtime tier a stored value may name.
OWNER_EFFORT_TIERS: tuple[str, ...] = tuple(tier for tier in EFFORT_SCALE if tier != "minimal")
# Who decided one actor's effort (``choose_effort``); task records and chat frames carry it verbatim.
@ -69,25 +70,42 @@ def _tier(value: Any) -> str:
return text if text in EFFORT_SCALE else ""
def effort_range(settings: Optional[Mapping[str, Any]] = None) -> dict[str, str]:
"""The owner's effort range ``{min, recommended, max}`` — THE tolerant read.
def normalize_legacy_effort_settings(settings: Mapping[str, Any], *, legacy_order: bool = False) -> dict[str, Any]:
"""Materialize the old effective bounds BEFORE defaults erase their absence.
``recommended`` is ``OUROBOROS_EFFORT_TASK``, the level Main works at. An unknown or
blank value takes its key's shipped default; ``min`` is never above and ``max`` never
below ``recommended`` (a document that carries only TASK=high reads low/high/high).
Snapshot-aware through ``runtime_setting`` (a running task keeps the range it started
with) unless a settings document is given (``settings_integrity.live_effort_range``
reads the owner's current one for a participant starting inside a running task). A
read never rewrites the document.
Partial choices widened their bounds around Main. Canonical complete triples
are independent; the document reader alone opts into the old ordering.
"""
def read(key: str) -> str:
default = str(SETTINGS_DEFAULTS[key])
raw = settings.get(key, default) if settings is not None else runtime_setting(key, default)
return _tier(raw) or default
result = dict(settings)
if legacy_order or (any(key in settings for key in EFFORT_RANGE_KEYS) and not all(
_tier(settings.get(key)) for key in (EFFORT_RANGE_KEYS[0], EFFORT_RANGE_KEYS[2]))):
low, primary, high = (_tier(settings.get(key)) or str(SETTINGS_DEFAULTS[key]) for key in EFFORT_RANGE_KEYS)
result.update(zip(EFFORT_RANGE_KEYS, (min(low, primary, key=effort_rank), primary,
max(high, primary, key=effort_rank))))
return result
low, recommended, high = (read(key) for key in EFFORT_RANGE_KEYS)
return {"min": min(low, recommended, key=effort_rank), "recommended": recommended,
"max": max(high, recommended, key=effort_rank)}
def normalize_effort_document(settings: Mapping[str, Any]) -> dict[str, Any]:
"""Keep pre-independent effective bounds, even after old writers filled defaults.
Call after selecting authored sources, before defaults hide absence. The returned
format marker survives ordinary saves; canonical actor/project triples need none.
"""
return {**normalize_legacy_effort_settings(settings, legacy_order=settings.get(EFFORT_FORMAT_KEY) != 1),
EFFORT_FORMAT_KEY: 1}
def effort_range(settings: Optional[Mapping[str, Any]] = None) -> dict[str, str]:
"""Snapshot-aware primary level and helper bounds; only the bounds are ordered.
Missing legacy bounds retain their former effective values. Unknown tiers
take their defaults. Reversed helper bounds sort without moving the primary.
"""
raw = settings if settings is not None else {key: runtime_setting(key) for key in EFFORT_RANGE_KEYS
if runtime_setting(key) is not None}
values = normalize_legacy_effort_settings(raw)
low, primary, high = (_tier(values.get(key)) or str(SETTINGS_DEFAULTS[key]) for key in EFFORT_RANGE_KEYS)
return {"min": min(low, high, key=effort_rank), "recommended": primary, "max": max(low, high, key=effort_rank)}
def clamp_effort_into(value: str, rng: Mapping[str, str]) -> str:
@ -113,8 +131,8 @@ def choose_effort(
an explicit root effort — wins over ``requested`` while the range binds; in Cyber Pro an
explicit request beats the pin and silence still sits on it. Otherwise the request is
clamped into the range while it binds (``auto``) and applied as asked in Cyber Pro
(``cyber``); with no request the role default applies: the range's ``recommended``, or
its ``max`` for ``default_top`` roles (reviewers, evolution, consciousness). Blank or
(``cyber``); with no request the helper starts nearest the primary within its bounds,
or at ``max`` for ``default_top`` roles (reviewers, evolution, consciousness). Blank or
unknown tiers read as absent; callers refuse an unknown request on their own surface.
``binds`` defaults to the runtime mode (``runtime_mode_policy.effort_range_binds``);
``rng`` to the task's snapshot read.
@ -132,7 +150,7 @@ def choose_effort(
rng = dict(rng) if rng is not None else effort_range()
if requested:
return (clamp_effort_into(requested, rng), "auto") if binding else (requested, "cyber")
return (rng["max"] if default_top else rng["recommended"]), "auto"
return (rng["max"] if default_top else clamp_effort_into(rng["recommended"], rng)), "auto"
def effort_fact(requested: Any, level: str, source: str) -> dict[str, str]:

View file

@ -143,8 +143,8 @@ def model_visible_subagent_catalog(settings: Mapping[str, Any]) -> dict[str, Any
continue
session = row.route.is_session
handle = handles[row.subagent_id]
# The row's effort as the mind may act on it: ``auto`` (the range decides, recommended by
# default), the owner's pin, or the level its model name carries (``effort_source``).
# The row's effort: ``auto`` (primary clamped into room helper bounds), the owner's pin,
# or the level its model name carries (``effort_source``).
named = model_named_effort(row.route)
projected: dict[str, Any] = {
"subagent_id": handle,

View file

@ -11,7 +11,7 @@ the applied one comes back on the engine receipt.
Effort follows ONE rule (``settings_scales.choose_effort``): a level encoded in
the row's model name, else the owner's pin (the row's ``effort``), else the
parent's request clamped into the owner's effort range (Cyber Pro: unclamped,
and a request beats a pin), else the range's recommended level. The decision is
and a request beats a pin), else this room's primary clamped into its helper bounds. The decision is
taken at dispatch, recorded as ``{requested, applied, source}`` on the child
record (``SubagentDispatch.effort_fact``) and disclosed to the child, the parent
and the chat; for a session row it is the delegated LEAF's level, while the
@ -1021,7 +1021,7 @@ def resolve_subagent_dispatch(
from ouroboros.runtime_mode_policy import effort_range_binds
from ouroboros.settings_scales import choose_effort, effort_fact
# A legacy (row-less) child: the parent's request inside the owner's range, else recommended.
# A row-less child: request or primary, clamped into this room's helper bounds.
derived_effort, effort_source = choose_effort(
task.get("requested_effort"), binds=effort_range_binds(task.get("metadata")))

View file

@ -118,8 +118,8 @@ def schedule_subagent_properties() -> Dict[str, Any]:
"type": "string", "enum": ["auto", *EFFORT_SCALE], "default": "auto",
# `auto` is the provider-safe spelling of omission (a strict schema may fill it).
"description": (
"This child's reasoning effort, inside my human's effort range: auto/omitted = the "
"recommended level; a request outside the range runs at its nearest bound. A row my "
"This child's reasoning effort: auto/omitted = this room's primary clamped into its "
"helper bounds; a request outside those bounds runs at the nearest bound. A row my "
"human pinned or a level in its model name keeps that level outside Cyber Pro; the "
"result says so. For a session row this is the delegated run's level."
),

View file

@ -1364,8 +1364,8 @@ def get_tools() -> List[ToolEntry]:
"Lower native access to readonly or workspace_write; omit to inherit the captured profile "
"(new mutating sessions default full). Explicit readonly authority wins. Omit on retry_of."},
"effort": {"type": "string", "enum": ["auto", *EFFORT_SCALE], "default": "auto", "description":
"The run's reasoning effort inside my human's effort range: auto/omitted = the recommended "
"level; outside the range it runs at the nearest bound. A row my human pinned or a level in "
"The run's reasoning effort: auto/omitted = this room's primary clamped into its helper "
"bounds; outside those bounds it runs at the nearest bound. A row my human pinned or a level in "
"its model name keeps that level outside Cyber Pro; the receipt says so. Omit on retry_of."},
"root": {"type": "string", "enum": ["active_workspace", "skill_payload"],
"default": "active_workspace", "description":

View file

@ -89,9 +89,9 @@ set, as facts: the host neither ranks rows nor substitutes actors. I choose by
my human's words in `recommended_use` plus the route facts. Agent-session rows
ride my human's subscriptions (shared quota, no API dollars); API rows on a
provider key bill per token; weighing that is mine. An Auto row's effort is
mine to choose inside my human's effort range (`schedule_subagent(effort=…)`;
default recommended); a pinned row or a level in a model name keeps its own.
Deeper thinking than my recommended level I delegate to a child at a higher
mine to choose inside this room's current helper range (`schedule_subagent(effort=…)`;
default primary clamped into it); a pin or a level in a model name keeps its own.
My primary level is independent of that range; I can delegate at another
level. A row's `subagent_id` is its handle: its route plus the facets it really
runs with, defaults omitted. An unavailable row refuses typed and I choose the
next action; an absent block means no actor: I invent no id. Saved settings key

View file

@ -1,6 +1,7 @@
"""The real worker start seam for #939 clock tests (no loop, no model call)."""
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
@ -23,15 +24,17 @@ def agent_start(row, root, monkeypatch):
from ouroboros import agent as agent_module
from ouroboros.agent import Env, OuroborosAgent
from ouroboros.settings_integrity import task_settings_snapshot
real_restore = agent_module._restore_saved_clocks
def restore_then_stop(ctx, task):
real_restore(ctx, task)
def restore_then_stop(ctx, task, *args, **kwargs):
real_restore(ctx, task, *args, **kwargs)
raise _Started(ctx)
monkeypatch.setattr(agent_module, "_restore_saved_clocks", restore_then_stop)
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose", lambda *_a: None)
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose",
lambda *_a: task_settings_snapshot({}, os.environ))
monkeypatch.setattr(agent_module, "attach_task_contract", lambda task: task)
monkeypatch.setattr(agent_module, "apply_consciousness_authority", lambda task: task)
monkeypatch.setattr(agent_module, "resolve_dispatch_axes", lambda task: {})

View file

@ -10,6 +10,7 @@ only on positive contention. No provider, engine or live data is reached.
from __future__ import annotations
import os
import queue as queue_mod
import time
from contextlib import contextmanager, nullcontext
@ -18,6 +19,7 @@ from types import SimpleNamespace
import pytest
from ouroboros import usage_accounting as ua
from ouroboros.settings_integrity import task_settings_snapshot
from tests._store_shared import unreadable_result
from tests._usage_store_testing import ledger_rows
from tests.test_billing_group import _scope, _spend
@ -69,7 +71,8 @@ def test_a_startup_read_failure_binds_no_stand_in_and_every_send_rereads(data_ro
return real_read(*args, **kwargs)
monkeypatch.setattr(task_results, "load_task_result", reader)
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose", lambda *_a, **_k: None)
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose",
lambda *_a, **_k: task_settings_snapshot({}, os.environ))
monkeypatch.setattr("ouroboros.model_wait.task_model_wait_scope", lambda **_k: nullcontext())
outcomes = []

View file

@ -314,10 +314,11 @@ class TestWakeRootCeiling:
def _scope(self, tmp_path, monkeypatch, *, metadata=None, per_task_cap="50"):
"""The scope `handle_task` actually binds for one task."""
from ouroboros.agent import OuroborosAgent
from ouroboros.settings_integrity import task_settings_snapshot
from ouroboros.usage_accounting import current_usage_scope
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose",
lambda *_a, **_k: None)
lambda *_a, **_k: task_settings_snapshot({}, os.environ))
monkeypatch.setattr("ouroboros.model_wait.task_model_wait_scope", lambda **_k: nullcontext())
monkeypatch.setenv("TOTAL_BUDGET", "1000")
monkeypatch.setenv("OUROBOROS_PER_TASK_COST_USD", per_task_cap)

View file

@ -4,12 +4,14 @@ from concurrent.futures import ThreadPoolExecutor
from contextlib import nullcontext
from datetime import datetime, timedelta, timezone
import json
import os
import threading
from types import SimpleNamespace
import pytest
from ouroboros import task_pacing as pacing, usage_accounting as accounting
from ouroboros.settings_integrity import task_settings_snapshot
from tests._usage_store_testing import ledger_rows
@ -75,7 +77,8 @@ def test_request_override_does_not_inherit_another_limits_provenance(root):
def _run_task_scoped(root, monkeypatch, scoped):
from ouroboros.agent import OuroborosAgent
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose", lambda *_: None)
monkeypatch.setattr("ouroboros.subagent_runtime.apply_task_start_settings_or_disclose",
lambda *_: task_settings_snapshot({}, os.environ))
monkeypatch.setattr("ouroboros.model_wait.task_model_wait_scope", lambda **_: nullcontext())
host = SimpleNamespace(env=SimpleNamespace(drive_root=root), _emit_live_log=lambda *_: None,
_event_queue=None, _handle_task_scoped=scoped)

View file

@ -707,18 +707,19 @@ def test_right_to_left_strips_that_scroll_keep_the_moved_mode_and_bracket_in_vie
assert at_end["maxWhole"] and at_end["ultraWhole"] and at_end["scroll"] == "end" and "to left" in at_end["fade"], seen
assert at_end["max"][0] - at_end["shellLeft"] >= at_end["inset"] - 0.5, seen
# A press lands on the level under it: back at the start, a press on None (outside the
# range, on the min side) carries the min bracket there.
# helper range) sets primary there without moving the min bracket.
page.evaluate(f"() => {{ document.querySelector('{EFFORT} .chat-effort-strip').scrollLeft = 0; }}")
page.wait_for_function(f"() => document.querySelector('{EFFORT} .chat-effort-strip').dataset.scroll === 'start'")
none = page.evaluate(MARKS)["none"]
box = page.locator(f'{EFFORT} [data-effort-seg="none"]').bounding_box()
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as saved:
page.mouse.click(none[0] + (none[1] - none[0]) * 0.75, box["y"] + box["height"] / 2)
assert json.loads(saved.value.request.post_data)["min"] == "none", saved.value.request.post_data
assert json.loads(saved.value.request.post_data)["recommended"] == "none", saved.value.request.post_data
assert json.loads(saved.value.request.post_data)["min"] == "low", saved.value.request.post_data
page.wait_for_timeout(300)
seen["effort-press"] = pressed = page.evaluate(MARKS)
# The scale's outer edge is two whole-pixel offsets (as Ultra's is left to right): within a pixel.
assert abs(pressed["band"][1] - pressed["none"][1]) <= 1 and abs(pressed["min"][1] - pressed["band"][1]) <= 0.6, seen
assert abs(pressed["pill"][1] - pressed["none"][1]) <= 1 and abs(pressed["min"][1] - pressed["band"][1]) <= 0.6, seen
_shot(page, tmp_path, f"capsules-rtl-effort-press-{engine}-320")
finally:
_record(tmp_path, f"capsules-rtl-{engine}-320", seen)

View file

@ -41,6 +41,9 @@ _ADDED_OWNERS = {
# The owner's effort range: its keys, the owner-facing tiers, the tolerant read, the Cyber
# Pro predicate, the clamp, the one effort decision and its recorded fact.
"EFFORT_RANGE_KEYS": settings_scales,
"EFFORT_FORMAT_KEY": settings_scales,
"normalize_effort_document": settings_scales,
"normalize_legacy_effort_settings": settings_scales,
"OWNER_EFFORT_TIERS": settings_scales,
"EFFORT_SOURCES": settings_scales,
"_tier": settings_scales,

View file

@ -411,7 +411,10 @@ def test_handle_task_ledger_category_comes_from_usage_category(monkeypatch, tmp_
monkeypatch.setattr(usage_accounting, "usage_scope", fake_scope)
monkeypatch.setattr(model_wait, "task_model_wait_scope", lambda **kw: contextlib.nullcontext())
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose", lambda *a, **k: None)
from ouroboros.settings_integrity import task_settings_snapshot
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose",
lambda *a, **k: task_settings_snapshot({}, os.environ))
monkeypatch.setattr(config, "task_settings_scope", lambda snapshot: contextlib.nullcontext())
monkeypatch.setattr(agent_module.OuroborosAgent, "_handle_task_scoped", lambda self, task: [])
agent = object.__new__(agent_module.OuroborosAgent)

View file

@ -48,7 +48,7 @@ def test_unknown_values_take_their_key_default_and_minimal_round_trips(monkeypat
_env(monkeypatch, OUROBOROS_EFFORT_MIN="bogus", OUROBOROS_EFFORT_TASK="", OUROBOROS_EFFORT_MAX="HIGH")
assert effort_range() == {"min": "low", "recommended": "medium", "max": "high"}
_env(monkeypatch, OUROBOROS_EFFORT_MIN="minimal", OUROBOROS_EFFORT_TASK="minimal", OUROBOROS_EFFORT_MAX="none")
assert effort_range() == {"min": "minimal", "recommended": "minimal", "max": "minimal"}
assert effort_range() == {"min": "none", "recommended": "minimal", "max": "minimal"}
# A document read bypasses the environment; a live read opens the owner's current document.
assert effort_range({"OUROBOROS_EFFORT_MAX": "ultra"}) == {"min": "low", "recommended": "medium", "max": "ultra"}
@ -789,7 +789,10 @@ def test_the_owner_endpoint_writes_the_triple_atomically_projects_it_and_audits(
ok = client.post("/api/owner/effort-range", json={"min": "none", "recommended": "medium", "max": "ultra"})
assert ok.status_code == 200, ok.text
assert ok.json() == {"ok": True, "effort_range": {"min": "none", "recommended": "medium", "max": "ultra"}}
assert ok.json() == {"ok": True, "project_id": "",
"effort_range": {"min": "none", "recommended": "medium", "max": "ultra"},
"cognition": {"context_mode": "max", "effort_range": {
"min": "none", "recommended": "medium", "max": "ultra"}}}
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert (stored["OUROBOROS_EFFORT_MIN"], stored["OUROBOROS_EFFORT_TASK"], stored["OUROBOROS_EFFORT_MAX"]) == ("none", "medium", "ultra")
assert stored["OUROBOROS_MODEL"] == "openai/x" # the rest of the document is untouched
@ -807,8 +810,7 @@ def test_the_owner_endpoint_writes_the_triple_atomically_projects_it_and_audits(
@pytest.mark.parametrize("body, fragment", [
({"min": "low", "recommended": "turbo", "max": "high"}, "'recommended' must be one of"),
({"min": "high", "recommended": "medium", "max": "ultra"}, "ordered min"),
({"min": "low", "recommended": "high", "max": "medium"}, "ordered min"),
({"min": "high", "recommended": "medium", "max": "low"}, "ordered min"),
({"recommended": "medium"}, "'min' must be one of"),
(["not", "an", "object"], "JSON body must be an object"),
])
@ -861,12 +863,12 @@ def test_the_generic_save_accepts_a_tier_per_key_and_refuses_anything_else(monke
refused = client.post("/api/settings", json={"OUROBOROS_EFFORT_TASK": "turbo"})
assert refused.status_code == 400 and "OUROBOROS_EFFORT_TASK must be one of" in refused.json()["error"]
assert not settings_path.exists()
# The order is not judged here (the read is tolerant): TASK above MAX saves, and reads as a widened top.
# The primary is independent: TASK above the helper MAX saves without moving either.
saved = client.post("/api/settings", json={"OUROBOROS_EFFORT_TASK": " ULTRA ", "OUROBOROS_EFFORT_MAX": "high"})
assert saved.status_code == 200, saved.text
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert (stored["OUROBOROS_EFFORT_TASK"], stored["OUROBOROS_EFFORT_MAX"]) == ("ultra", "high")
assert effort_range(stored) == {"min": "low", "recommended": "ultra", "max": "ultra"}
assert effort_range(stored) == {"min": "low", "recommended": "ultra", "max": "high"}
def test_the_endpoint_is_indexed_mirrored_and_owner_only_for_the_browser():

View file

@ -8,8 +8,8 @@ and returns focus, an outside press closes. The open strip never takes a second
inside its row: beside the others on a wide screen, alone while they step aside on a 390 px phone
and in a ~440 px Project pane, scrolling inside itself on a 320 px phone; the pill covers its word
and the band meets the brackets. A drag and the keyboard save the full triple through
the owner endpoint, the pill pushes a bracket and the bracket stays pushed, and every composer
reads the one value back from /api/state; a change made just before Send lands before the
the owner endpoint, with primary independent from helper bounds. Each composer
reads only its room; a change made just before Send lands before the
message; Settings → Behavior has no effort section and ends with Startup & background, whose
host fact reads as one plain note; Settings → Agents says Auto or the level in the model name.
"""
@ -154,7 +154,7 @@ def test_desktop_hover_opens_press_pins_escape_and_outside_close(direct_server_w
assert (closed["name"], closed["value"], closed["headText"]) == ("Effort", "Medium", "EffortMedium"), closed
assert abs(closed["top"] - closed["pillsTop"]) <= 2, closed
assert closed["recTabIndex"] == -1, "closed handles are out of the tab order"
assert closed["title"] == "Effort: Medium, range Low–High. Applies to new work.", closed
assert closed["title"] == "Primary effort: Medium; helpers Low–High. Applies to new participants in this room.", closed
assert closed["extras"] == 0, "no title word of the strip's own, no separator element, no reset"
_shot(page, tmp_path, f"composer-closed-{engine}-dark")
# Hover opens after ~160 ms; at this width the strip stays beside the pills.
@ -306,7 +306,7 @@ def test_phone_press_opens_alone_on_the_line_while_the_pills_step_aside(direct_s
browser.close()
def test_project_pane_strip_stands_alone_and_shows_the_same_value(direct_server_with_data, tmp_path): # noqa: F811
def test_project_pane_strip_stands_alone_and_keeps_its_own_value(direct_server_with_data, tmp_path): # noqa: F811
pytest.importorskip("playwright.sync_api", reason="Playwright is not installed")
from playwright.sync_api import sync_playwright
@ -347,13 +347,17 @@ def test_project_pane_strip_stands_alone_and_shows_the_same_value(direct_server_
assert opened["top"] < opened["inputTop"], opened
_assert_strip_geometry(opened)
_shot(page, tmp_path, "composer-project-pane-open-chromium")
# A change in the pane reaches Main through /api/state.
# A change in the pane remains there after authoritative readback.
page.focus(f"{panel} .chat-effort-max")
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range") and r.status == 200, timeout=30_000):
page.keyboard.press("ArrowRight")
_wait(page, "el.getAttribute('aria-valuetext') !== 'High'", f"{panel} .chat-effort-max")
page.wait_for_function(f"() => document.querySelector('{CONTROL} .chat-effort-max').getAttribute('aria-valuetext') === 'X-High'",
timeout=30_000)
page.reload(wait_until="domcontentloaded")
page.wait_for_function(f"() => document.querySelector('{CONTROL}')?.dataset.known === 'true'")
assert _facts(page)["max"] == "High"
page.click('.nav-project-row[data-project-id="pane"]')
page.wait_for_function(f"() => document.querySelector('{panel}')?.dataset.known === 'true'")
assert _facts(page, panel)["max"] == "X-High"
finally:
browser.close()
@ -387,7 +391,7 @@ def test_drag_and_keyboard_save_the_full_triple_and_reload_reads_it_back(direct_
_wait(page, "(el.dataset.saving || 'false') === 'false'")
after = _facts(page)
assert (after["min"], after["rec"], after["max"]) == ("Low", "Medium", "Ultra"), after
# A tap inside the range moves the recommended level; outside, the nearest bracket.
# A level tap sets primary both inside and outside the helper range.
x, y = _segment_center(page, "xhigh")
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
page.mouse.click(x, y)
@ -395,45 +399,43 @@ def test_drag_and_keyboard_save_the_full_triple_and_reload_reads_it_back(direct_
x, y = _segment_center(page, "none")
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
page.mouse.click(x, y)
assert json.loads(waited.value.request.post_data) == {"min": "none", "recommended": "xhigh", "max": "ultra"}
# Keyboard: the recommended handle steps, min and max push it.
assert json.loads(waited.value.request.post_data) == {"min": "low", "recommended": "none", "max": "ultra"}
# Keyboard: primary and helper bounds move independently.
page.focus(f"{CONTROL} .chat-effort-rec")
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
page.keyboard.press("ArrowLeft")
assert json.loads(waited.value.request.post_data) == {"min": "none", "recommended": "high", "max": "ultra"}
page.keyboard.press("ArrowRight")
assert json.loads(waited.value.request.post_data) == {"min": "low", "recommended": "low", "max": "ultra"}
page.focus(f"{CONTROL} .chat-effort-min")
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
page.keyboard.press("End")
assert json.loads(waited.value.request.post_data) == {"min": "ultra", "recommended": "ultra", "max": "ultra"}
assert json.loads(waited.value.request.post_data) == {"min": "ultra", "recommended": "low", "max": "ultra"}
_shot(page, tmp_path, "composer-open-changed-chromium")
# The pill pushes the bracket in its way, and a pushed bracket stays when the pointer
# comes back within the same drag: Ultra · Ultra · Ultra → drag the pill to None and
# back to Medium → None · Medium · Ultra.
# Moving primary below the helper interval leaves both brackets at Ultra.
pill = page.locator(f"{CONTROL} .chat-effort-rec").bounding_box()
page.mouse.move(pill["x"] + pill["width"] / 2, pill["y"] + pill["height"] / 2)
page.mouse.down()
x, y = _segment_center(page, "none")
page.mouse.move(x, y, steps=10)
_wait(page, "el.querySelector('.chat-effort-min').getAttribute('aria-valuetext') === 'None'")
_wait(page, "el.querySelector('.chat-effort-min').getAttribute('aria-valuetext') === 'Ultra'")
x, y = _segment_center(page, "medium")
page.mouse.move(x, y, steps=6)
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
page.mouse.up()
assert json.loads(waited.value.request.post_data) == {"min": "none", "recommended": "medium", "max": "ultra"}
assert json.loads(waited.value.request.post_data) == {"min": "ultra", "recommended": "medium", "max": "ultra"}
_wait(page, "(el.dataset.saving || 'false') === 'false'")
_assert_strip_geometry(_facts(page))
# A reload reads the saved value back from /api/state.
page.reload(wait_until="domcontentloaded")
page.wait_for_function(f"() => document.querySelector('{CONTROL}')?.dataset.known === 'true'", timeout=30_000)
reloaded = _facts(page)
assert (reloaded["min"], reloaded["rec"], reloaded["max"]) == ("None", "Medium", "Ultra"), reloaded
assert (reloaded["min"], reloaded["rec"], reloaded["max"]) == ("Ultra", "Medium", "Ultra"), reloaded
page.click(HEAD)
_wait(page, "el.dataset.open === 'true'")
# A refused save: the server's sentence, the control back at the server's value.
page.route("**/api/owner/effort-range", lambda route: route.fulfill(
status=400, content_type="application/json",
body=json.dumps({"error": "Effort range refused by the test.", "saved": False, "code": "effort_range_invalid"})))
page.focus(f"{CONTROL} .chat-effort-max")
page.focus(f"{CONTROL} .chat-effort-min")
page.keyboard.press("ArrowLeft")
page.wait_for_selector(".toast", timeout=10_000)
assert "Effort range refused by the test." in page.locator(".toast").first.inner_text()
@ -465,13 +467,14 @@ def test_a_320_px_phone_scrolls_the_strip_inside_the_control(direct_server_with_
assert opened["headWidth"] > 20, "the ring stays"
_assert_strip_geometry(opened)
_shot(page, tmp_path, f"composer-phone-320-open-{engine}")
# The hidden end is reached by scrolling the strip; a tap there moves the bracket.
# The hidden end is reached by scrolling; a tap sets primary without moving bounds.
page.evaluate(f"() => {{ const s = document.querySelector('{CONTROL} .chat-effort-strip'); s.scrollLeft = s.scrollWidth; }}")
_wait(page, "el.querySelector('.chat-effort-strip').dataset.scroll === 'end'")
x, y = _segment_center(page, "ultra")
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range"), timeout=30_000) as waited:
page.touchscreen.tap(x, y)
assert json.loads(waited.value.request.post_data)["max"] == "ultra"
assert json.loads(waited.value.request.post_data)["recommended"] == "ultra"
assert json.loads(waited.value.request.post_data)["max"] == "high"
finally:
browser.close()
@ -607,3 +610,72 @@ def test_agents_rows_say_auto_or_the_level_in_the_model_name(role_ui, tmp_path,
_shot(page, tmp_path, f"settings-agents-effort-{width}")
session_named.locator("[data-subagent-effort-named]").scroll_into_view_if_needed()
_shot(page, tmp_path, f"settings-agents-effort-named-{width}")
@pytest.mark.parametrize("engine,touch", [("chromium", False), ("webkit", True)])
def test_room_cognition_is_independent_and_survives_project_switch(direct_server_with_data, tmp_path, engine, touch): # noqa: F811 - fixture import
"""Main and two Projects keep their own values; primary can sit below both helper bounds."""
import urllib.request
from playwright.sync_api import sync_playwright
url = direct_server_with_data["url"]
def request(path, body=None):
req = urllib.request.Request(url + path, data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as response:
return json.loads(response.read())
original = {"min": "high", "recommended": "max", "max": "ultra"}
request("/api/owner/effort-range", original)
alpha = request("/api/projects", {"id": "cognition-alpha", "name": "Cognition Alpha"})["project"]
beta = request("/api/projects", {"id": "cognition-beta", "name": "Cognition Beta"})["project"]
request("/api/owner/effort-range", {"min": "low", "recommended": "medium", "max": "high"})
# Hide these rooms from the bounded state projection: the addressed read remains authoritative.
with sync_playwright() as pw:
browser, page = _launch(pw, engine, viewport={"width": 1440, "height": 900}, has_touch=touch)
try:
page.route("**/api/state", lambda route: route.fulfill(json={
**route.fetch().json(), "projects": [],
}))
_open_chat(page, url, width=1440)
def open_room(project):
page.evaluate("project => window.dispatchEvent(new CustomEvent('ouro:open-project', {detail: {project}}))", project)
selector = f"#panel-pchat-{project['id']}"
page.wait_for_function("sel => document.querySelector(sel + ' .chat-effort-range')?.dataset.known === 'true'", arg=selector)
return selector
panel = open_room(alpha)
if touch:
page.set_viewport_size({"width": 390, "height": 844})
context = f"{panel} .chat-context-mode"
page.locator(f"{context} .chat-capsule-head").tap() if touch else page.click(f"{context} .chat-capsule-head")
with page.expect_response(lambda r: r.url.endswith("/api/owner/context-mode")) as changed:
page.locator(f'{context} [data-mode="low"]').tap() if touch else page.click(f'{context} [data-mode="low"]')
assert changed.value.status == 200, changed.value.text()
assert json.loads(changed.value.request.post_data) == {"mode": "low", "project_id": alpha["id"]}
page.keyboard.press("Escape")
effort = f"{panel} .chat-effort-range"
page.focus(f"{effort} .chat-effort-head")
page.keyboard.press("Enter")
page.focus(f"{effort} .chat-effort-rec")
with page.expect_response(lambda r: r.url.endswith("/api/owner/effort-range")) as changed:
page.keyboard.press("Home")
assert changed.value.status == 200, changed.value.text()
body = json.loads(changed.value.request.post_data)
assert body == {**original, "recommended": "none", "project_id": alpha["id"]}
_wait(page, "el.dataset.saving === 'false'", effort)
assert (_facts(page, effort)["min"], _facts(page, effort)["rec"], _facts(page, effort)["max"]) == ("High", "None", "Ultra")
_shot(page, tmp_path, f"cognition-primary-outside-range-{engine}")
second = open_room(beta)
assert _facts(page, f"{second} .chat-effort-range")["rec"] == "Max"
assert page.locator(f"{second} .chat-context-mode").get_attribute("data-context-mode") == "max"
panel = open_room(alpha)
assert _facts(page, f"{panel} .chat-effort-range")["rec"] == "None"
assert page.locator(f"{panel} .chat-context-mode").get_attribute("data-context-mode") == "low"
main = request("/api/state")
assert main["context_mode"] == "max" and main["effort_range"]["recommended"] == "medium"
assert request(f"/api/projects?chat_id={beta['chat_id']}")["projects"][0]["cognition"]["effort_range"] == original
finally:
browser.close()

View file

@ -30,7 +30,7 @@ def test_task_received_carries_only_supplied_top_level_identity(tmp_path, monkey
monkeypatch.setattr(module, 'resolve_dispatch_axes', lambda task: {})
monkeypatch.setattr(module, '_record_executor_resolution', lambda *args: None)
monkeypatch.setattr(module, 'sanitize_task_for_event', lambda task, logs: task)
host = SimpleNamespace(env=SimpleNamespace(drive_path=lambda name: tmp_path / name),
host = SimpleNamespace(env=SimpleNamespace(drive_root=tmp_path, drive_path=lambda name: tmp_path / name),
_persist_running_record=stop)
with pytest.raises(StopAfterReceived):
module.OuroborosAgent._prepare_task_context(host, {'id': 'child', **lineage}, authority_refusal={})

View file

@ -1,6 +1,7 @@
"""Finalization observations must preserve the answer's existing delivery path."""
import json
import os
import queue
import threading
from collections import deque
@ -609,6 +610,7 @@ def test_late_phase_checkpoint_omits_clocks_but_live_delivery_keeps_them(
def test_agent_scope_keeps_post_loop_child_lookups_and_resets_reused_workers(tmp_path, monkeypatch):
from ouroboros import agent as agent_module, observability as obs
from ouroboros.settings_integrity import task_settings_snapshot
from ouroboros.task_status import find_child_tasks
class LLM:
@ -634,7 +636,7 @@ def test_agent_scope_keeps_post_loop_child_lookups_and_resets_reused_workers(tmp
monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off")
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose",
lambda *args: None)
lambda *args: task_settings_snapshot({}, os.environ))
host = SimpleNamespace(env=SimpleNamespace(drive_root=tmp_path), _event_queue=None,
_emit_live_log=lambda *args, **kwargs: None, _handle_task_scoped=task_body)
first = agent_module.OuroborosAgent.handle_task(host, {"id": "one", "type": "task"})

View file

@ -44,7 +44,8 @@ def test_owner_can_enable_max_without_main_route_capability_ack(tmp_path, monkey
response = TestClient(app).post("/api/owner/context-mode", json={"mode": "max"})
assert response.status_code == 200, response.text
assert response.json() == {"ok": True, "context_mode": "max"}
assert response.json()["ok"] is True and response.json()["context_mode"] == "max"
assert response.json()["project_id"] == "" and response.json()["cognition"]["context_mode"] == "max"
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert stored["OUROBOROS_CONTEXT_MODE"] == "max"
assert stored["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] == "false"
@ -52,8 +53,8 @@ def test_owner_can_enable_max_without_main_route_capability_ack(tmp_path, monkey
assert os.environ["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] == "false"
def test_bare_env_low_cannot_author_owner_low_while_busy(tmp_path, monkeypatch):
"""Sizing-only env Low must not bypass the owner-intent idle guard."""
def test_owner_can_author_low_during_work_despite_bare_env_low(tmp_path, monkeypatch):
"""The owner writes the next actor's choice; active actors keep their snapshots."""
from ouroboros import config as cfg
from ouroboros.gateway import settings as gateway_settings
@ -77,9 +78,9 @@ def test_bare_env_low_cannot_author_owner_low_while_busy(tmp_path, monkeypatch):
app.state.drive_root = tmp_path
response = TestClient(app).post("/api/owner/context-mode", json={"mode": "low"})
assert response.status_code == 409, response.text
assert not settings_path.exists()
assert cfg.get_owner_context_mode() == "max"
assert response.status_code == 200, response.text
assert json.loads(settings_path.read_text(encoding="utf-8"))["OUROBOROS_CONTEXT_MODE"] == "low"
assert cfg.get_owner_context_mode() == "low"
def test_main_route_resolution_remains_available_to_dispatch_fit():

View file

@ -3,6 +3,7 @@
import asyncio
from copy import deepcopy
import json
import os
import pathlib
import queue
from types import SimpleNamespace
@ -11,6 +12,7 @@ import pytest
from ouroboros import cancel_intents, loop, model_wait, owner_mailbox, usage_accounting as ua
from ouroboros.model_slots import MODEL_ACCOUNTS_KEY
from ouroboros.settings_integrity import task_settings_snapshot
from ouroboros.task_results import STATUS_RUNNING, load_task_result, write_task_result
from tests.test_llm_claudexor import MODEL, result, setup as gateway_fixture
from tests.test_model_wait import live_wait as wait_fixture
@ -476,7 +478,8 @@ def test_hard_cancel_returns_empty_events_to_real_worker_loop_and_keeps_queue_ow
monkeypatch.setattr(actual, "_prepare_task_context", prepare)
monkeypatch.setattr(actual, "_start_task_heartbeat_loop", lambda *_args: None)
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose", lambda *_args: None)
monkeypatch.setattr(subagent_runtime, "apply_task_start_settings_or_disclose",
lambda *_args: task_settings_snapshot({}, os.environ))
monkeypatch.setattr(agent_module, "make_agent", lambda **_kwargs: actual)
monkeypatch.setattr(worker_process, "_bind_worker_repo_root", lambda *_args: None)
monkeypatch.setattr(worker_process, "_prepare_worker_task_runtime", lambda: None)

View file

@ -135,8 +135,14 @@ def onboarding(monkeypatch, tmp_path):
return payload
monkeypatch.setattr(gw_onboarding, "_read_harness_snapshot", _snapshot)
monkeypatch.setattr(config, "apply_settings_to_env",
lambda settings: calls["env"].append(dict(settings)))
original_apply_settings = config.apply_settings_to_env
def apply_settings(settings, *, environ=None):
if environ is not None:
return original_apply_settings(settings, environ=environ)
calls["env"].append(dict(settings))
monkeypatch.setattr(config, "apply_settings_to_env", apply_settings)
def _supervisor(_request, _settings):
calls["supervisor"] += 1

View file

@ -167,9 +167,9 @@ def test_cyber_generic_post_saves_controls_and_preserves_fact_provenance(cyber_s
assert "owner-test-key" not in json.dumps(change)
@pytest.mark.parametrize("mode,status", [("cyber_pro", 200), ("pro", 409)])
@pytest.mark.parametrize("mode,status", [("cyber_pro", 200), ("pro", 200)])
@pytest.mark.parametrize("context_mode", ["low", "nano"])
def test_context_owner_endpoint_allows_cyber_during_work(cyber_settings, monkeypatch, mode, status, context_mode):
def test_context_owner_endpoint_updates_future_actors_during_work(cyber_settings, monkeypatch, mode, status, context_mode):
from ouroboros import config as cfg
from ouroboros.gateway import settings as settings_mod
from supervisor.active_activity import get_direct_activity_registry

View file

@ -1,5 +1,6 @@
"""Native Presence returns durable replies while existing synthesis retains custody."""
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
@ -10,6 +11,7 @@ import pytest
from ouroboros import agent as agent_module, agent_task_pipeline as pipeline, loop
from ouroboros.model_wait import current_model_wait
from ouroboros.presence_runner import PresenceTurnGate, run_presence_turn
from ouroboros.settings_integrity import task_settings_snapshot
from ouroboros.task_results import load_task_result
from tests.test_presence_completion import _call
from tests.test_presence_runner import _admission, _event
@ -32,7 +34,8 @@ def test_native_agent_returns_durable_result_before_synthesis_and_retry_reuses_i
monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off")
monkeypatch.setattr(agent_module, "validate_task_authority_sources", lambda *_a: None)
monkeypatch.setattr(agent_module.OuroborosAgent, "_start_task_heartbeat_loop", lambda *_a: None)
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose", lambda *_a: None)
monkeypatch.setattr(agent_module.subagent_runtime, "apply_task_start_settings_or_disclose",
lambda *_a: task_settings_snapshot({}, os.environ))
started, release = threading.Event(), threading.Event()
stages, owners, agents = [], [], []
threads_before = set(threading.enumerate())

View file

@ -0,0 +1,305 @@
"""Room cognition is copied once; owner saves cannot alter another room's future work."""
from __future__ import annotations
import json
import os
from unittest.mock import patch
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from ouroboros import config, projects_registry as projects
from ouroboros.composer_cognition import COGNITION_KEYS, cognition_overrides, read_cognition
from ouroboros.gateway.owner_effort import api_owner_effort_range
from ouroboros.gateway.settings import api_owner_context_mode, api_settings_post
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, EFFORT_RANGE_KEYS, choose_effort, effort_range
@pytest.fixture
def room_settings(tmp_path, monkeypatch):
with patch.dict(os.environ):
root = tmp_path / "data"
root.mkdir()
monkeypatch.setattr(config, "DATA_DIR", root)
monkeypatch.setattr(config, "SETTINGS_PATH", root / "settings.json")
for key in (*COGNITION_KEYS, "OUROBOROS_CONTEXT_MODE_AUTO_LOW"):
monkeypatch.delenv(key, raising=False)
config.reset_runtime_mode_baseline_for_tests()
config.initialize_runtime_mode_baseline("cyber_pro")
config.SETTINGS_PATH.write_text(json.dumps({
"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false",
"OUROBOROS_EFFORT_TASK": "ultra", "OUROBOROS_RUNTIME_MODE": "cyber_pro",
}), encoding="utf-8")
config.apply_settings_to_env(config.load_settings())
app = Starlette(routes=[
Route("/api/owner/effort-range", api_owner_effort_range, methods=["POST"]),
Route("/api/owner/context-mode", api_owner_context_mode, methods=["POST"]),
Route("/api/settings", api_settings_post, methods=["POST"]),
])
app.state.drive_root = root
yield root, TestClient(app)
config.reset_runtime_mode_baseline_for_tests()
# Undo test-body patches before restoring the outer environment snapshot.
monkeypatch.undo()
def _legacy_rows(root, *ids):
for pid in ids:
projects.create_project(root, pid, origin="owner_ui")
path = root / "state/projects.json"
data = json.loads(path.read_text(encoding="utf-8"))
for row in data["projects"]:
row.pop("cognition", None)
path.write_text(json.dumps(data), encoding="utf-8")
def test_two_projects_keep_independent_choices_and_saves_never_project_to_env(room_settings):
root, client = room_settings
a = projects.create_project(root, "a", origin="owner_ui")
projects.create_project(root, "b", origin="owner_ui")
baseline = read_cognition(root)
before_file, before_env = config.SETTINGS_PATH.read_bytes(), {key: os.environ.get(key) for key in COGNITION_KEYS}
effort = {"min": "low", "recommended": "max", "max": "medium"}
response = client.post("/api/owner/effort-range", json={"project_id": "a", **effort})
assert response.status_code == 200, response.text
assert response.json()["project_id"] == "a" and response.json()["effort_range"] == effort
response = client.post("/api/owner/context-mode", json={"project_id": "a", "mode": "nano"})
assert response.status_code == 200, response.text
assert response.json()["cognition"] == {"context_mode": "nano", "effort_range": effort}
assert config.SETTINGS_PATH.read_bytes() == before_file
assert {key: os.environ.get(key) for key in COGNITION_KEYS} == before_env
assert read_cognition(root, "b") == read_cognition(root) == baseline
assert projects.create_project(root, "a")["cognition"] == response.json()["cognition"]
before_registry = (root / "state/projects.json").read_bytes()
assert projects.projects_summary(root, chat_id=a["chat_id"])[0]["cognition"] == response.json()["cognition"]
assert (root / "state/projects.json").read_bytes() == before_registry
def test_main_effort_readback_keeps_forwarded_context(room_settings, monkeypatch):
root, client = room_settings
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
monkeypatch.setenv("OUROBOROS_CONTEXT_MODE", "nano")
values = {"min": "low", "recommended": "max", "max": "medium"}
response = client.post("/api/owner/effort-range", json=values)
assert response.status_code == 200, response.text
assert response.json()["cognition"] == {"context_mode": "nano", "effort_range": values}
assert config.get_context_mode() == "nano"
@pytest.mark.parametrize("endpoint,body", [
("/api/owner/context-mode", {"mode": "nano"}),
("/api/owner/effort-range", {"min": "low", "recommended": "low", "max": "low"}),
("/api/settings", {"OUROBOROS_CONTEXT_MODE": "nano", "OUROBOROS_EFFORT_TASK": "low"}),
])
def test_main_changes_seed_unopened_legacy_projects_before_new_default(room_settings, endpoint, body):
root, client = room_settings
_legacy_rows(root, "old-a", "old-b")
previous = read_cognition(root)
assert previous["effort_range"] == {"min": "low", "recommended": "ultra", "max": "ultra"}
response = client.post(endpoint, json=body)
assert response.status_code == 200, response.text
assert read_cognition(root) != previous
assert read_cognition(root, "old-a") == read_cognition(root, "old-b") == previous
stored = json.loads((root / "state/projects.json").read_text(encoding="utf-8"))
assert all(row["cognition"] == previous for row in stored["projects"])
assert projects.create_project(root, "new")["cognition"] == read_cognition(root)
def test_reconcile_and_registry_recovery_retain_the_copied_cognition(room_settings):
root, client = room_settings
_legacy_rows(root, "proj_saved")
(root / "projects/proj_saved").mkdir(parents=True)
projects.reconcile_projects(root)
expected = read_cognition(root, "proj_saved")
assert client.post("/api/owner/context-mode", json={"mode": "low"}).status_code == 200
projects.reconcile_projects(root)
path = root / "state/projects.json"
data = json.loads(path.read_text(encoding="utf-8"))
data["projects"] = []
path.write_text(json.dumps(data), encoding="utf-8")
assert projects.reconcile_projects(root) == 1
assert read_cognition(root, "proj_saved") == expected
@pytest.mark.parametrize("primary,bounds,auto", [
("max", ("low", "medium"), "medium"), ("none", ("high", "ultra"), "high"),
])
def test_independent_primary_is_not_moved_and_auto_helpers_stay_inside(primary, bounds, auto):
desired = {"context_mode": "nano", "effort_range": {"min": bounds[0], "recommended": primary, "max": bounds[1]}}
rng = effort_range(cognition_overrides(desired))
assert rng == desired["effort_range"]
assert choose_effort(rng=rng, binds=True) == (auto, "auto")
assert choose_effort(rng=rng, binds=False) == (auto, "auto")
assert choose_effort(rng=rng, default_top=True) == (bounds[1], "auto")
assert choose_effort("low", pin="ultra", binds=True, rng=rng) == ("ultra", "pin")
def test_raw_legacy_bounds_survive_default_merge_and_roundtrip(room_settings):
root, _client = room_settings
values = config.load_settings()
assert values["OUROBOROS_EFFORT_MAX"] == "ultra"
assert config.normalize_settings_raw(config.normalize_settings_raw({"OUROBOROS_EFFORT_TASK": "ultra"})) == {
"OUROBOROS_EFFORT_TASK": "ultra"}
config.save_settings(values)
assert read_cognition(root)["effort_range"]["max"] == "ultra"
@pytest.mark.parametrize("project_id,status", [("missing", 404), ("../other", 400), (123, 400)])
def test_unknown_project_never_changes_main(room_settings, project_id, status):
_root, client = room_settings
before = config.SETTINGS_PATH.read_bytes()
response = client.post("/api/owner/context-mode", json={"project_id": project_id, "mode": "nano"})
assert response.status_code == status, response.text
assert config.SETTINGS_PATH.read_bytes() == before
def test_owner_context_changes_do_not_wait_for_unrelated_tasks(room_settings, monkeypatch):
_root, client = room_settings
from ouroboros.gateway import settings
config.reset_runtime_mode_baseline_for_tests()
config.initialize_runtime_mode_baseline("pro")
monkeypatch.setattr(settings, "_has_running_agent_tasks", lambda: True)
response = client.post("/api/owner/context-mode", json={"mode": "nano"})
assert response.status_code == 200, response.text
def test_settings_path_owns_legacy_migration_even_when_data_dir_differs(room_settings, tmp_path, monkeypatch):
root, _client = room_settings
_legacy_rows(root, "untouched")
untouched = (root / "state/projects.json").read_bytes()
other = tmp_path / "other"
other.mkdir()
settings_path = other / "settings.json"
settings_path.write_bytes(config.SETTINGS_PATH.read_bytes())
monkeypatch.setattr(config, "SETTINGS_PATH", settings_path)
_legacy_rows(other, "migrated")
before = read_cognition(other, "migrated")
config.save_settings({**config.load_settings(), "OUROBOROS_CONTEXT_MODE": "nano"})
assert read_cognition(other, "migrated") == before
assert (root / "state/projects.json").read_bytes() == untouched
def test_creation_seeds_legacy_rows_with_the_same_owner_low_as_the_new_room(room_settings):
root, client = room_settings
assert client.post("/api/owner/context-mode", json={"mode": "low"}).status_code == 200
_legacy_rows(root, "legacy-low")
expected = read_cognition(root)
assert expected["context_mode"] == "low"
created = projects.create_project(root, "new-low")
assert created["cognition"] == read_cognition(root, "legacy-low") == expected
@pytest.mark.parametrize("disk,env,expected", [
({"OUROBOROS_EFFORT_MIN": "none"}, {"OUROBOROS_EFFORT_TASK": "ultra"}, ("none", "ultra", "ultra")),
({"OUROBOROS_EFFORT_TASK": "ultra"}, {"OUROBOROS_EFFORT_MIN": "none"}, ("none", "ultra", "ultra")),
({"OUROBOROS_EFFORT_MAX": "ultra"}, {"OUROBOROS_EFFORT_TASK": "none"}, ("none", "none", "ultra")),
({"OUROBOROS_EFFORT_TASK": "high"}, {"OUROBOROS_EFFORT_TASK": "none"}, ("low", "high", "high")),
({EFFORT_FORMAT_KEY: 1, "OUROBOROS_EFFORT_MIN": "low", "OUROBOROS_EFFORT_MAX": "high"},
{"OUROBOROS_EFFORT_TASK": "ultra"}, ("low", "ultra", "high")),
])
def test_legacy_effort_migration_follows_authored_disk_and_environment_precedence(room_settings, monkeypatch, disk, env, expected):
root, _client = room_settings
for key in EFFORT_RANGE_KEYS:
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
config.SETTINGS_PATH.write_text(json.dumps(disk), encoding="utf-8")
values = config.load_settings()
assert tuple(values[key] for key in EFFORT_RANGE_KEYS) == expected
assert values[EFFORT_FORMAT_KEY] == 1
before = config.SETTINGS_PATH.read_bytes()
assert read_cognition(root)["effort_range"] == dict(zip(("min", "recommended", "max"), expected))
assert config.SETTINGS_PATH.read_bytes() == before # read migration stays read-only
config.save_settings(values)
assert tuple(config.load_settings()[key] for key in EFFORT_RANGE_KEYS) == expected
@pytest.mark.parametrize("primary", ["none", "minimal", "xhigh", "max", "ultra"])
def test_upgrade_keeps_effective_bounds_after_old_writers_filled_defaults(room_settings, monkeypatch, primary):
from ouroboros.colab_bootstrap import build_colab_settings
from ouroboros.gateway.owner_settings import _owner_read_settings_raw
from ouroboros.settings_scales import effort_rank
root, client = room_settings
for key in EFFORT_RANGE_KEYS:
monkeypatch.delenv(key, raising=False)
# Both old owner writers persisted this complete shape from a primary-only install.
old = dict(zip(EFFORT_RANGE_KEYS, ("low", primary, "high")))
config.SETTINGS_PATH.write_text(json.dumps(old), encoding="utf-8")
expected = {"min": min("low", primary, key=effort_rank), "recommended": primary,
"max": max("high", primary, key=effort_rank)}
raw = {}
owner = _owner_read_settings_raw(document_out=raw)
assert raw == old and EFFORT_FORMAT_KEY not in raw
for values in (config.load_settings(), owner, build_colab_settings({}, existing=old)):
assert effort_range(values) == expected and values[EFFORT_FORMAT_KEY] == 1
_legacy_rows(root, "old-project")
independent = {"min": "low", "recommended": primary, "max": "high"}
response = client.post("/api/owner/effort-range", json=independent)
assert response.status_code == 200, response.text
assert read_cognition(root, "old-project")["effort_range"] == expected
assert read_cognition(root)["effort_range"] == independent
saved = json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8"))
assert saved[EFFORT_FORMAT_KEY] == 1
assert effort_range(build_colab_settings({}, existing=saved)) == independent
assert EFFORT_FORMAT_KEY not in os.environ and EFFORT_FORMAT_KEY not in config.SETTINGS_DEFAULTS
@pytest.mark.parametrize("exists", [False, True])
def test_first_generic_owner_save_preserves_an_explicit_independent_triple(room_settings, monkeypatch, exists):
root, client = room_settings
for key in EFFORT_RANGE_KEYS:
monkeypatch.delenv(key, raising=False)
if exists:
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
else:
config.SETTINGS_PATH.unlink()
values = dict(zip(EFFORT_RANGE_KEYS, ("low", "ultra", "high")))
response = client.post("/api/settings", json=values)
assert response.status_code == 200, response.text
assert read_cognition(root)["effort_range"] == {"min": "low", "recommended": "ultra", "max": "high"}
assert json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8"))[EFFORT_FORMAT_KEY] == 1
@pytest.mark.parametrize("legacy_mode", ["max", "low"])
@pytest.mark.parametrize("endpoint,body", [
("/api/owner/context-mode", {"mode": "low"}),
("/api/owner/effort-range", {"min": "low", "recommended": "ultra", "max": "high"}),
])
def test_first_main_control_save_normalizes_legacy_context_before_capturing_digest(room_settings, endpoint, body, legacy_mode):
_root, client = room_settings
config.SETTINGS_PATH.write_text(json.dumps({"OUROBOROS_CONTEXT_MODE": legacy_mode}), encoding="utf-8")
response = client.post(endpoint, json=body)
assert response.status_code == 200, response.text
saved = json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8"))
assert saved["OUROBOROS_CONTEXT_MODE_AUTO_LOW"] == "false"
if "mode" in body:
assert saved["OUROBOROS_CONTEXT_MODE"] == response.json()["cognition"]["context_mode"] == "low"
else:
assert effort_range(saved) == response.json()["effort_range"] == body
assert saved[EFFORT_FORMAT_KEY] == 1
@pytest.mark.parametrize("endpoint,module,body", [
("/api/owner/context-mode", "settings", {"mode": "low"}),
("/api/owner/effort-range", "owner_effort", {"min": "low", "recommended": "ultra", "max": "high"}),
])
def test_main_control_save_still_refuses_a_real_write_after_its_digest(room_settings, monkeypatch, endpoint, module, body):
import importlib
_root, client = room_settings
gateway = importlib.import_module("ouroboros.gateway." + module)
update = gateway._owner_update_settings
concurrent = {"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false", "TOTAL_BUDGET": 333}
def intervening_write(*args, **kwargs):
config.SETTINGS_PATH.write_text(json.dumps(concurrent), encoding="utf-8")
return update(*args, **kwargs)
monkeypatch.setattr(gateway, "_owner_update_settings", intervening_write)
response = client.post(endpoint, json=body)
assert response.status_code == 409, response.text
assert response.json()["code"] == "settings_precondition_failed"
assert json.loads(config.SETTINGS_PATH.read_text(encoding="utf-8")) == concurrent

View file

@ -0,0 +1,271 @@
"""Room preferences bind each actor, survive cold return and stay live for new children."""
import json
import threading
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import pytest
from ouroboros import config
from ouroboros.composer_cognition import update_project_cognition
from ouroboros.projects_registry import bind_task_to_project, create_project
from ouroboros.settings_integrity import (
actor_settings_snapshot, captured_cognition, live_effort_range, restore_cognition,
task_settings_scope, task_settings_snapshot,
)
from tests.test_task_settings_snapshot import admitted_agent, document, isolated_runtime, save_owner # noqa: F401
def snapshot(task):
from ouroboros.subagent_runtime import apply_task_start_settings
return actor_settings_snapshot(apply_task_start_settings(), task, config.DATA_DIR)
def choice(context, primary, low, high):
return {"context_mode": context, "effort_range": {"min": low, "recommended": primary, "max": high}}
def set_room(project, cognition):
update_project_cognition(config.DATA_DIR, project, **cognition)
def test_parallel_real_task_entries_keep_main_and_two_projects_independent():
save_owner(document("old"))
create_project(config.DATA_DIR, "alpha")
create_project(config.DATA_DIR, "beta")
alpha = choice("max", "ultra", "low", "low")
beta = choice("nano", "low", "max", "ultra")
set_room("alpha", alpha)
set_room("beta", beta)
barrier = threading.Barrier(3)
def body(task):
before = captured_cognition()
barrier.wait(10)
assert captured_cognition() == before
assert config.get_context_mode() == before["context_mode"]
assert config.resolve_effort("task") == before["effort_range"]["recommended"]
return before
with ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(admitted_agent(body, []).handle_task, {"id": "task-" + pid, "project_id": pid})
for pid in ("", "alpha", "beta")]
main, a, b = [future.result(timeout=20) for future in futures]
assert a == alpha and b == beta
assert main["context_mode"] == "max"
assert main["effort_range"]["recommended"] == "high"
assert config.get_context_mode() == "max" # no project value was published to os.environ
def test_current_room_reaches_new_delegate_and_review_but_not_the_running_parent():
from ouroboros.reviewer_slot_config import review_pool_slots
from ouroboros.subagent_runtime import _leaf_effort_choice
values = document("old")
row = json.loads(values["OUROBOROS_SUBAGENTS"])
row["items"][0]["effort"] = "" # Auto; explicit row pins retain their separate precedence
values["OUROBOROS_SUBAGENTS"] = json.dumps(row)
save_owner(values)
create_project(config.DATA_DIR, "alpha")
create_project(config.DATA_DIR, "beta")
before = choice("max", "ultra", "medium", "high")
later = choice("nano", "low", "xhigh", "max")
set_room("alpha", before)
with task_settings_scope(snapshot({"id": "parent", "project_id": "alpha"})):
set_room("alpha", later)
set_room("beta", choice("low", "none", "none", "low"))
assert captured_cognition() == before
assert live_effort_range() == later["effort_range"]
direct = _leaf_effort_choice(SimpleNamespace(task_metadata={}),
{"route": {"kind": "api_model", "target_id": "openai::probe"}}, "", None, "")
assert direct["applied"] == "xhigh" # omitted helper = primary clamped to its own room bounds
assert review_pool_slots()[0].effort == "max"
with task_settings_scope(snapshot({"id": "child", "project_id": "alpha", "parent_task_id": "parent"})):
assert captured_cognition() == later
assert captured_cognition() == before
def test_converted_roots_and_existing_descendants_resolve_the_current_room():
save_owner(document("old"))
create_project(config.DATA_DIR, "alpha")
later = choice("nano", "low", "max", "ultra")
set_room("alpha", later)
child = {"id": "child", "parent_task_id": "root", "root_task_id": "root", "delegation_role": "subagent"}
with task_settings_scope(snapshot(child)):
original = captured_cognition()
bind_task_to_project(config.DATA_DIR, "root", "alpha", origin={"absent": "system"})
assert live_effort_range() == later["effort_range"]
assert captured_cognition() == original
grandchild = {"id": "grandchild", "parent_task_id": "child", "root_task_id": "root"}
with task_settings_scope(snapshot(grandchild)):
assert captured_cognition() == later
def test_presence_and_unregistered_workspace_keep_main_even_with_a_project_path(tmp_path):
save_owner(document("old"))
row = create_project(config.DATA_DIR, "alpha", working_dir=str(tmp_path))
set_room("alpha", choice("nano", "none", "none", "low"))
for task in ({"id": "presence", "_presence_turn": True, "project_id": "alpha", "chat_id": row["chat_id"]},
{"id": "headless", "workspace_root": str(tmp_path)}):
with task_settings_scope(snapshot(task)):
assert config.get_context_mode() == "max"
def test_checkpoint_carries_only_cognition_and_cold_restore_keeps_it_while_children_read_current():
from ouroboros.owner_wait import continuation_state, restore_continuation_state
save_owner(document("old"))
create_project(config.DATA_DIR, "alpha")
before = choice("max", "ultra", "low", "medium")
later = choice("nano", "none", "high", "max")
set_room("alpha", before)
task = {"id": "parent", "project_id": "alpha"}
ctx = SimpleNamespace(task_id="parent", active_context_mode="low", active_effort="ultra")
with task_settings_scope(snapshot(task)):
state = continuation_state(ctx, [], {}, {}, 3, [], set())
assert state["cognition"] == before
assert set(state["cognition"]) == {"context_mode", "effort_range"}
assert state["route"]["active_context_mode"] == "low" # derived view is a separate fact
set_room("alpha", later)
with task_settings_scope(snapshot(task)):
assert captured_cognition() == later
restore_continuation_state(SimpleNamespace(_ctx=ctx), state, [], {}, {}, set())
assert captured_cognition() == before
assert ctx.active_context_mode == "low"
assert live_effort_range() == later["effort_range"]
assert config.get_context_mode() == "max"
@pytest.mark.parametrize("carrier,loader", [
("_owner_wait_resume", "ouroboros.owner_wait.load_owner_wait"),
("_budget_pause_resume", "ouroboros.budget_pause.load_budget_pause"),
("_working_recovery", "ouroboros.working_checkpoint.load_recovery"),
])
def test_prepare_restores_cognition_before_dispatch_and_building_context(monkeypatch, carrier, loader):
from ouroboros import agent
save_owner(document("new"))
before = choice("max", "ultra", "low", "medium")
monkeypatch.setattr("ouroboros.owner_wait.load_owner_wait", lambda *a: {})
monkeypatch.setattr(loader, lambda *a: {"cognition": before})
monkeypatch.setattr(agent, "validate_task_authority_sources", lambda *a: None)
monkeypatch.setattr(agent, "attach_task_contract", lambda task: task)
monkeypatch.setattr(agent, "apply_consciousness_authority", lambda task: task)
class ReachedDispatch(Exception):
pass
def dispatch(task):
assert captured_cognition() == before
assert config.get_context_mode() == "max"
assert config.resolve_effort("task") == "ultra"
raise ReachedDispatch
monkeypatch.setattr(agent, "resolve_dispatch_axes", dispatch)
obj = object.__new__(agent.OuroborosAgent)
obj.env = SimpleNamespace(drive_root=config.DATA_DIR, drive_path=lambda part: config.DATA_DIR / part)
task = {"id": "continued", carrier: {"source_ref": "fixture"}}
with task_settings_scope(snapshot(task)), pytest.raises(ReachedDispatch):
obj._prepare_task_context(task)
def test_old_checkpoint_without_cognition_retains_existing_fallback_and_scope_exits_cleanly():
baseline = task_settings_snapshot({}, {})
with task_settings_scope(baseline):
before = captured_cognition()
restore_cognition(None)
assert captured_cognition() == before
assert captured_cognition() is None
def test_unrelated_malformed_project_does_not_block_main_or_a_healthy_actor():
save_owner(document("old"))
create_project(config.DATA_DIR, "good")
create_project(config.DATA_DIR, "bad")
path = config.DATA_DIR / "state/projects.json"
rows = json.loads(path.read_text(encoding="utf-8"))
next(row for row in rows["projects"] if row["id"] == "bad")["working_dir"] = []
path.write_text(json.dumps(rows), encoding="utf-8")
for pid in ("", "good"):
with task_settings_scope(snapshot({"id": "actor-" + pid, "project_id": pid})):
assert config.get_context_mode() == "max"
assert live_effort_range()["recommended"] == "high"
with pytest.raises(ValueError, match="malformed routing"):
snapshot({"id": "bad-actor", "project_id": "bad"})
@pytest.mark.parametrize("exists", [True, False])
@pytest.mark.parametrize("primary,low,high", [("ultra", "low", "ultra"), ("none", "none", "high")])
def test_env_only_legacy_effort_preserves_missing_bounds_at_every_start(monkeypatch, exists, primary, low, high):
from ouroboros.settings_scales import effort_range
from ouroboros.subagent_runtime import apply_task_start_settings
if exists:
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
monkeypatch.setenv("OUROBOROS_EFFORT_TASK", primary)
expected = {"min": low, "recommended": primary, "max": high}
for _ in range(2):
assert effort_range(config.load_settings()) == expected
with task_settings_scope(apply_task_start_settings()):
assert effort_range() == expected
@pytest.mark.parametrize("independent", [False, True])
def test_complete_env_effort_triple_uses_the_saved_format(monkeypatch, independent):
from ouroboros.settings_scales import EFFORT_FORMAT_KEY, effort_range
if independent:
config.SETTINGS_PATH.write_text(json.dumps({EFFORT_FORMAT_KEY: 1}), encoding="utf-8")
for key, value in {"OUROBOROS_EFFORT_MIN": "low", "OUROBOROS_EFFORT_TASK": "ultra",
"OUROBOROS_EFFORT_MAX": "medium"}.items():
monkeypatch.setenv(key, value)
assert effort_range(config.load_settings()) == {
"min": "low", "recommended": "ultra", "max": "medium" if independent else "ultra"}
@pytest.mark.parametrize("exists", [True, False])
def test_forwarded_nano_is_main_and_new_projects_choice_and_survives_restore(monkeypatch, exists):
from ouroboros.composer_cognition import read_cognition
if exists:
config.SETTINGS_PATH.write_text("{}", encoding="utf-8")
monkeypatch.setenv("OUROBOROS_CONTEXT_MODE", "nano")
for _ in range(2):
with task_settings_scope(snapshot({"id": "main"})):
assert config.get_context_mode() == config.get_owner_context_mode() == "nano"
saved = captured_cognition()
create_project(config.DATA_DIR, "from-nano")
assert read_cognition(config.DATA_DIR, "from-nano")["context_mode"] == "nano"
with task_settings_scope(snapshot({"id": "project", "project_id": "from-nano"})):
# A project creating another room still copies Main, not its own overlay.
set_room("from-nano", choice("low", "none", "low", "medium"))
create_project(config.DATA_DIR, "second")
assert read_cognition(config.DATA_DIR, "second")["context_mode"] == "nano"
save_owner({"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false"})
with task_settings_scope(snapshot({"id": "resumed"})):
assert config.get_context_mode() == "max"
restore_cognition(saved)
assert config.get_context_mode() == config.get_owner_context_mode() == "nano"
def test_main_copy_prefers_explicit_disk_choice_over_forwarded_context(monkeypatch):
monkeypatch.setenv("OUROBOROS_CONTEXT_MODE", "nano")
save_owner({"OUROBOROS_CONTEXT_MODE": "max", "OUROBOROS_CONTEXT_MODE_AUTO_LOW": "false"})
with task_settings_scope(snapshot({"id": "main"})):
assert config.get_context_mode() == "max"
assert create_project(config.DATA_DIR, "from-disk")["cognition"]["context_mode"] == "max"
def test_failed_reload_and_ambiguous_low_preserve_runtime_presence_on_main():
prior = {"OUROBOROS_CONTEXT_MODE": "low", "OUROBOROS_EFFORT_TASK": "ultra"}
failed_reload = actor_settings_snapshot(task_settings_snapshot({}, prior), {"id": "main"}, config.DATA_DIR)
with task_settings_scope(failed_reload):
assert config.get_context_mode() == "low" and config.get_owner_context_mode() == "max"
assert config.resolve_effort("task") == "ultra"
saved = captured_cognition()
with task_settings_scope(task_settings_snapshot({}, {})):
restore_cognition(saved)
assert config.get_context_mode() == "low" and config.get_owner_context_mode() == "max"
assert config.resolve_effort("task") == "ultra"

View file

@ -337,14 +337,6 @@ def test_settings_js_reads_and_writes_phase2_keys():
assert "fieldValue(id).trim()" in src
def test_chat_context_mode_toggle_reports_owner_endpoint_errors():
# The composer's owner controls (context mode, effort range) live in one module.
src = (REPO / "web" / "modules" / "composer_owner_controls.js").read_text(encoding="utf-8")
assert "/api/owner/context-mode" in src
assert "resp.json()" in src
assert "showToast(message, 'error')" in src
def test_onboarding_js_has_runtime_mode_selector_and_save_payload():
src = (REPO / "web" / "modules" / "onboarding_wizard.js").read_text(encoding="utf-8")
agents = (REPO / "web" / "modules" / "onboarding_agents_step.js").read_text(encoding="utf-8")

View file

@ -772,9 +772,7 @@ def test_owner_context_mode_endpoint_persists_and_hot_applies(isolated_settings,
from ouroboros.gateway import settings as settings_mod
api_owner_context_mode = settings_mod.api_owner_context_mode
# This positive case owns its idle precondition. Other xdist tests exercise
# the process-global supervisor queues and must not make the endpoint test
# order-dependent; the following test covers the busy rejection explicitly.
# Existing actors retain their own choice; this owner save is for future actors.
monkeypatch.setattr(settings_mod, "_has_running_agent_tasks", lambda: False)
_seed_disk(isolated_settings, {
@ -792,7 +790,8 @@ def test_owner_context_mode_endpoint_persists_and_hot_applies(isolated_settings,
response = client.post("/api/owner/context-mode", json={"mode": "low"})
assert response.status_code == 200, response.text
assert response.json() == {"ok": True, "context_mode": "low"}
assert response.json()["ok"] is True and response.json()["context_mode"] == "low"
assert response.json()["project_id"] == "" and response.json()["cognition"]["context_mode"] == "low"
on_disk = json.loads(isolated_settings.read_text(encoding="utf-8"))
assert on_disk["OUROBOROS_CONTEXT_MODE"] == "low"
assert on_disk["OUROBOROS_RUNTIME_MODE"] == "pro"
@ -808,7 +807,7 @@ def test_owner_context_mode_endpoint_persists_and_hot_applies(isolated_settings,
assert os.environ["OUROBOROS_CONTEXT_MODE"] == "low"
def test_owner_context_mode_endpoint_refuses_lowering_while_task_runs(isolated_settings, monkeypatch):
def test_owner_context_mode_endpoint_saves_next_choice_while_task_runs(isolated_settings, monkeypatch):
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
@ -824,10 +823,8 @@ def test_owner_context_mode_endpoint_refuses_lowering_while_task_runs(isolated_s
app.state.drive_root = isolated_settings.parent
response = TestClient(app).post("/api/owner/context-mode", json={"mode": "low"})
assert response.status_code == 409, response.text
assert "only be lowered while Ouroboros is idle" in response.text
assert "queued or running work" in response.text
assert json.loads(isolated_settings.read_text(encoding="utf-8"))["OUROBOROS_CONTEXT_MODE"] == "max"
assert response.status_code == 200, response.text
assert json.loads(isolated_settings.read_text(encoding="utf-8"))["OUROBOROS_CONTEXT_MODE"] == "low"
def test_owner_context_mode_idle_predicate_covers_pending_and_direct_chat_busy(monkeypatch):

View file

@ -409,7 +409,7 @@ export const apiClient = {
ownerRuntimeMode: (mode) => jsonPost('/api/owner/runtime-mode', { mode }),
ownerAutoGrant: (enabled) => jsonPost('/api/owner/auto-grant', { enabled: Boolean(enabled) }),
ownerContextMode: (mode) => jsonPost('/api/owner/context-mode', { mode }),
/** The owner's effort range, saved as one ordered triple. @param {{min: string, recommended: string, max: string}} range */
/** Save room primary effort and ordered helper bounds. @param {import('./client_contract_types.js').OwnerEffortRangeRequest} range */
ownerEffortRange: (range) => jsonPost('/api/owner/effort-range', range),
/** @returns {Promise<import('./api_types.js').OwnerSafetyModeResponse>} */
ownerSafetyMode: (mode) => jsonPost('/api/owner/safety-mode', { mode }),

View file

@ -37,7 +37,7 @@
* @property {boolean} skills_repo_configured
* @property {boolean} github_token_configured
* @property {Object} accounting // physical-attempt ledger projection
* @property {Array<Object>} projects // active/deleting ProjectEntry sidebar projection
* @property {Array<ProjectEntry>} projects // active/deleting sidebar projection
* @property {Array<number>} project_chat_ids // complete (uncapped) project chat_ids — WS fan-out isolation SSOT (v6.32.0)
* @property {Object<string, {project_id: string, chat_id: number, origin_bound?: boolean}>} task_bindings // bound task -> its project: suppress the stray "turn into project" button (v6.33.0 P2) + render a pointer that opens the project panel (v6.33.0 F4). origin_bound marks a task the host included because its OWNER MESSAGE already has a project (#902), so one message cannot keep a second convertible card
* @property {ActiveDirectTurn[]=} active_direct_turns // active direct/ephemeral chat turns snapshot
@ -701,6 +701,7 @@
* @property {boolean=} thread_activity_seen
* @property {string=} last_task_result_id
* @property {boolean=} created
* @property {ProjectCognition} cognition
*/
/**
@ -816,10 +817,12 @@
* @typedef {Object} OwnerContextModeResponse
* @property {boolean} ok
* @property {string} context_mode
* @property {string} project_id
* @property {ProjectCognition} cognition
*/
/**
* @typedef {Object} EffortRange min ≤ recommended ≤ max, each an EFFORT_SCALE tier (the tolerant read)
* @typedef {Object} EffortRange helper min ≤ max; primary recommended independent; each an EFFORT_SCALE tier
* @property {string} min
* @property {string} recommended
* @property {string} max
@ -829,6 +832,14 @@
* @typedef {Object} OwnerEffortRangeResponse
* @property {boolean} ok
* @property {EffortRange} effort_range
* @property {string} project_id
* @property {ProjectCognition} cognition
*/
/**
* @typedef {Object} ProjectCognition
* @property {string} context_mode
* @property {EffortRange} effort_range
*/
/**

View file

@ -283,8 +283,7 @@ export function createChatInstance({
</div>
`;
if (idPrefix !== 'chat') {
// Instance-namespaced ids + mirror classes so the shared #chat-* CSS
// (extended with .chat-* twins) keeps styling secondary instances.
// Namespace ids; mirror classes retain the shared composer styling.
page.querySelectorAll('[id]').forEach((el) => {
if (el.id.startsWith('chat-')) {
el.classList.add(el.id);
@ -310,10 +309,9 @@ export function createChatInstance({
const scrollActivityDot = scrollBottomBtn?.querySelector('.chat-scroll-activity-dot');
let nestedSubagentsExpanded = false;
let _remoteActivityDepth = 0;
let ownerControls = null;
let ownerControls = null, cognitionRead = false;
// Instance lifecycle (P3): destroy() flips this so rAF loops and late async
// continuations become no-ops instead of touching a removed DOM subtree.
// Destroyed instances ignore late async work and animation frames.
let destroyed = false;
// Every ws.on subscription's disposer, released together in destroy().
const wsDisposers = [];
@ -560,6 +558,7 @@ export function createChatInstance({
moreSummary.classList.toggle('has-active', anyActive);
}
ownerControls?.syncState(data);
if (projectId && !data?.projects?.some(project => project.id === projectId && project.cognition)) void refreshProjectCognition();
const budget = headerBudgetPresentation(data);
const budgetText = byId('budget-text');
const budgetFill = byId('budget-bar-fill');
@ -628,6 +627,17 @@ export function createChatInstance({
syncChatStatus();
}
// Rooms outside the bounded sidebar still read their settings.
async function refreshProjectCognition() {
if (destroyed || cognitionRead) return;
cognitionRead = true;
try {
const room = await apiFetch(`/api/projects?chat_id=${encodeURIComponent(chatId)}`, { cache: 'no-store' });
if (room.ok && !destroyed) ownerControls?.syncState(await room.json());
} catch { /* Preserve the last room value, never substitute Main. */ }
finally { cognitionRead = false; }
}
async function refreshHeaderControlState(force = false) {
if (!force && state.activePage !== 'chat') return;
const request = await stateSnapshots.gate(force);
@ -2782,9 +2792,7 @@ export function createChatInstance({
(async () => {
await loadUiPreferences();
// Main waits for the (bounded) idle hydration window;
// project instances pass straight through. The sticky single-flight
// below folds this trigger with the first socket open / refreshHistory.
// Main waits for hydration; Projects pass through. History reads share one flight.
await waitForHydrationWindow();
if (destroyed) return;
// A message this tab kept across a reload that the first read (or its preview) did not show saved is in doubt.
@ -2793,8 +2801,7 @@ export function createChatInstance({
if (await awaitInitialHydration()) return reconcileUnsaved();
try {
const saved = JSON.parse(sessionStorage.getItem(storeKey(CHAT_STORAGE_KEY)) || '[]');
// A snapshot row is the addMessage option bag persistedHistory wrote;
// addMessage normalizes every option, so no field list is kept in sync.
// Replay the persisted addMessage options through the same normalizer.
for (const { text, role, markdown, ts, ...restored } of saved) {
addMessage(text, role, !!markdown, ts || null, false, restored);
}
@ -2813,7 +2820,7 @@ export function createChatInstance({
inputDraft = '';
}
// The field's height follows its text up to the stylesheet cap (chat_input_sizing.js).
// Text height is capped by chat_input_sizing.js.
const inputSizing = attachChatInputSizing(input);
function resizeChatInput() {
inputSizing.sync();
@ -2839,7 +2846,7 @@ export function createChatInstance({
async function sendMessage(planMode = false) {
if (sendBtn.disabled) return; // guard against Enter re-entry during async upload
if (ownerControls.hasPendingSave()) { // an effort change lands before the message it would govern
if (ownerControls.hasPendingSave()) { // both room settings land before the next message
setSendBusy(true, 'Saving');
try { if (!(await ownerControls.pendingSave())) return; } finally { setSendBusy(false); }
}
@ -2848,7 +2855,7 @@ export function createChatInstance({
let uploadedAttachments = [];
if (!text && !hasAttachments) return;
if (hasAttachments) {
// Upload immediately before send; offline queueing would orphan files.
// Upload at Send; offline queuing orphans files.
if (ws.ws?.readyState !== WebSocket.OPEN) {
showToast('Cannot attach file while offline. Reconnect and try again.', 'error');
return;
@ -2863,7 +2870,7 @@ export function createChatInstance({
} finally {
setSendBusy(false);
}
// The model reads these names (an exact /restart stays the command); the bubble shows the server views.
// Model sees file names; /restart stays exact, bubbles use server views.
text = composerText(text, uploadedAttachments.map((item) => item.view?.name || item.display_name));
}
if (!text) return;
@ -2885,7 +2892,7 @@ export function createChatInstance({
showToast('Connection lost before send. Reconnect and try again.', 'error');
return;
}
// One-shot: disarm Swarm now that the message is sent.
// Send consumes Swarm.
if (planMode) setSwarm(false);
if (hasAttachments) composer.clear();
rememberInput(text);
@ -2901,11 +2908,10 @@ export function createChatInstance({
forceStick: true,
attachments: uploadedAttachments.map((item) => item.view),
});
// The socket took it; only the host's saved echo proves acceptance (its frame stays for Send again).
// Socket send is not acceptance; keep the frame until the host's saved echo.
if (hasAttachments) unconfirmed.track({ ...frame, client_message_id: result.clientMessageId },
{ views: uploadedAttachments.map((item) => item.view), ts: sentTs });
// ws.send always coins a client_message_id for chat frames; guard
// only against a non-chat result shape.
// Chat sends carry their minted client_message_id.
const pendingId = result?.clientMessageId || '';
if (pendingId) {
pendingSubmissions.set(pendingId, {
@ -2922,8 +2928,7 @@ export function createChatInstance({
// Send mode lives on DOM so CSS and click/Enter share one source.
const sendGroup = page.querySelector('.chat-send-group');
// Swarm is a one-shot arm: the next send goes through plan_task multi-model
// brainstorm/planning, then the pill auto-disarms so it never sticks.
// Swarm requests planning once, then disarms.
const swarmBtn = byId('swarm');
function swarmArmed() {
return swarmBtn?.dataset.armed === 'true';
@ -2946,11 +2951,11 @@ export function createChatInstance({
swarmBtn?.addEventListener('click', () => setSwarm(!swarmArmed()));
// Nano/Low/Max and the effort range write global owner settings; one module owns them.
// Each composer owns its room's Context and Effort settings.
ownerControls = createComposerOwnerControls({ row: page.querySelector('.chat-toolbar-row'), box: page, byId, apiFetch, showToast,
saveEffortRange: apiClient.ownerEffortRange, refreshState: refreshHeaderControlState, onLayout: () => updateMessagesPadding() });
projectId, saveEffortRange: apiClient.ownerEffortRange, refreshState: refreshHeaderControlState, onLayout: () => updateMessagesPadding() });
// Arrow wrappers avoid MouseEvent leaking into sendMessage(planMode).
// Do not pass MouseEvent as planMode.
sendBtn.addEventListener('click', () => sendMessage(swarmArmed()));
bindEnterSubmit(input, sendBtn);
input.addEventListener('keydown', (e) => {
@ -3056,8 +3061,7 @@ export function createChatInstance({
installChatResizeObservers();
// Per-thread drafts survive destroy-on-close: save on input, restore on
// creation, clear on send.
// Per-thread drafts survive room closure; Send clears them.
function saveInputDraft() {
try {
if (input.value) sessionStorage.setItem(storeKey(CHAT_DRAFT_KEY), input.value);

View file

@ -434,6 +434,15 @@
* `POST /api/owner/context-mode`.
* @typedef {Object} OwnerContextModeRequest
* @property {'nano'|'low'|'max'} mode
* @property {string=} project_id
*/
/**
* @typedef {Object} OwnerEffortRangeRequest
* @property {string} min
* @property {string} recommended
* @property {string} max
* @property {string=} project_id
*/
/**

View file

@ -1,9 +1,6 @@
// The composer's controls that write GLOBAL owner settings (docs/DESIGN.md "Composer
// owner controls"): the Nano/Low/Max context mode and the effort range, two labelled
// capsules (ring · name · value) that each open inline into their strip. Both save
// through their audited owner endpoint, show the server's refusal as a toast, and
// resync every open composer from the next `/api/state` snapshot. Swarm is not one of
// them: it is a per-message flag that never leaves the frame it rides.
// Each composer's saved room settings: Context and Effort, two inline capsules.
// Main and Projects read their own authoritative values and save through the owner
// endpoints. Swarm stays a per-message flag. (DESIGN: Composer owner controls.)
import { fmt } from './i18n.js';
import {
EFFORT_OPTIONS, EFFORT_RANGE_DEFAULT, OWNER_TIERS, effortText, normalizeEffortRange, ownerLevelIndex,
@ -13,7 +10,7 @@ const N = OWNER_TIERS.length - 1;
const HANDLES = ['min', 'rec', 'max'];
const CONTEXT_MODES = ['nano', 'low', 'max'];
const CONTEXT_LABELS = Object.freeze({ nano: 'Nano', low: 'Low', max: 'Max' });
const CONTEXT_TITLE = 'Context mode (owner setting). Nano is the compact owner window, Low fits ~250K / local models, Max is full. Saves immediately; lowering requires Ouroboros to be idle.';
const CONTEXT_TITLE = 'Context mode (owner setting). Nano is the compact owner window, Low fits ~250K / local models, Max is full. Saves for new participants in this room; running work keeps its context.';
const contextModeOf = (mode) => (['nano', 'low', 'max'].includes(mode) ? mode : 'max');
const HOVER_OPEN_MS = 160;
// After the mouse leaves, the open strip waits this long before it closes, and a margin
@ -55,29 +52,22 @@ function observeLayout(win, nodes, layout) {
};
}
/** Each handle pushes the ones it meets: min and max carry the recommended level along, and
* the recommended level carries a bracket past which it moves; min and max never cross. */
/** The primary level is independent; the helper brackets cannot cross each other. */
export function applyHandle(base, which, value) {
const s = { ...base };
const v = clamp(value, 0, N);
if (which === 'min') { s.min = Math.min(v, s.max); s.rec = Math.max(s.rec, s.min); }
else if (which === 'max') { s.max = Math.max(v, s.min); s.rec = Math.min(s.rec, s.max); }
else { s.rec = v; s.min = Math.min(s.min, v); s.max = Math.max(s.max, v); }
if (which === 'min') s.min = Math.min(v, s.max);
else if (which === 'max') s.max = Math.max(v, s.min);
else s.rec = v;
return s;
}
export function snapRange(v) {
const s = { min: Math.round(v.min), rec: Math.round(v.rec), max: Math.round(v.max) };
s.rec = clamp(s.rec, s.min, s.max);
s.rec = clamp(s.rec, 0, N);
return s;
}
/** A tap off the handles: inside the range it sets the recommended level, outside it the bracket on that side moves. */
export function nearestHandle(committed, segment) {
if (segment >= committed.min && segment <= committed.max) return 'rec';
return segment < committed.min ? 'min' : 'max';
}
const indicesOf = (range) => ({
min: ownerLevelIndex(range.min), rec: ownerLevelIndex(range.recommended), max: ownerLevelIndex(range.max),
});
@ -112,10 +102,10 @@ function effortRangeMarkup() {
return capsuleHead(ring, 'Effort', 'chat-effort-head', ' data-i18n-skip')
+ '<div class="chat-capsule-body">'
+ '<div class="chat-effort-strip" data-effort-strip data-scroll="none"><div class="chat-effort-band" data-effort-band aria-hidden="true"></div>'
+ '<div class="chat-effort-handle chat-effort-rec" data-handle="rec" role="slider" tabindex="-1" aria-label="Recommended effort"></div>'
+ '<div class="chat-effort-handle chat-effort-rec" data-handle="rec" role="slider" tabindex="-1" aria-label="Primary effort"></div>'
+ segments
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-min" data-handle="min" role="slider" tabindex="-1" aria-label="Minimum effort"></div>'
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-max" data-handle="max" role="slider" tabindex="-1" aria-label="Maximum effort"></div></div>'
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-min" data-handle="min" role="slider" tabindex="-1" aria-label="Minimum helper effort"></div>'
+ '<div class="chat-effort-handle chat-effort-cap chat-effort-max" data-handle="max" role="slider" tabindex="-1" aria-label="Maximum helper effort"></div></div>'
+ '</div>';
}
@ -509,16 +499,8 @@ function createCapsule({
});
}
/**
* The effort range: the capsule `Effort X-High` (closed, only the recommended level; the ring
* still draws the range and the button's name says it) that opens inline into the seven-level
* strip with min/max brackets and the recommended pill.
*
* Saves happen on gesture end through the owner endpoint, serialized (last wins); a failed save
* shows the server's sentence and returns the control to the server's value. A stored tier
* outside the seven owner tiers (`minimal`) is shown at the nearest owner tier and is rewritten
* only when the owner moves that handle.
*/
/** Inline primary/helper effort controls. Gesture-end saves serialize; refusal restores
* the last server value. A stored `minimal` renders at Low until that handle moves. */
export function createEffortRangeControl({
row, saveEffortRange, showToast, refreshState = () => {}, onLayout = () => {}, onSettled = () => {},
win = globalThis.window, doc = globalThis.document,
@ -581,11 +563,11 @@ export function createEffortRangeControl({
function pick(event) {
const b = boundaryAt(event);
const segment = b === null ? state.shown.rec : Math.min(N, Math.floor(b));
return { which: nearestHandle(state.shown, segment), start: segment };
return { which: 'rec', start: segment }; // helper bounds have their own handles
}
/* --- rendering: every mark sits on whole levels, a drag included (the pill always covers
the word it names, and a pushed bracket moves with it, never behind it) --- */
the word it names independently of the helper brackets) --- */
function render() {
const s = snapRange(state.view || state.shown);
const dir = scaleDir();
@ -607,7 +589,7 @@ export function createEffortRangeControl({
const dot = ringPoint(ringAngle(s.rec));
ring.rec.setAttribute('cx', dot.x.toFixed(2));
ring.rec.setAttribute('cy', dot.y.toFixed(2));
// The recommended level may go anywhere (it pushes the brackets); a bracket stops at the other one.
// Primary may go anywhere; each helper bracket stops at the other one.
const bounds = { min: [0, s.max], rec: [0, N], max: [s.min, N] };
const open = Boolean(capsule?.isOpen());
for (const which of HANDLES) {
@ -622,7 +604,7 @@ export function createEffortRangeControl({
const word = effortText(state.draft.recommended);
if (value.textContent !== word) value.textContent = word;
const stored = state.stored;
head.title = fmt('Effort: {level}, range {min}–{max}. Applies to new work.', {
head.title = fmt('Primary effort: {level}; helpers {min}–{max}. Applies to new participants in this room.', {
level: effortText(stored.recommended), min: effortText(stored.min), max: effortText(stored.max),
});
head.setAttribute('aria-label', head.title);
@ -630,8 +612,7 @@ export function createEffortRangeControl({
/* --- the model: drag preview, commit on gesture end, serialized saves --- */
function beginDrag(which) { state.drag = { which }; state.view = { ...state.shown }; render(); }
// A drag builds on its own preview: a bracket the pill pushed stays pushed when the pointer
// comes back; cancel restores the committed range.
// A drag builds on its preview; cancel restores the committed values.
function dragTo(which, value) { if (!state.drag) return; state.view = applyHandle(state.view || state.shown, which, value); render(); }
function cancelDrag() { state.drag = null; state.view = null; render(); }
function endDrag() {
@ -779,7 +760,7 @@ export function createEffortRangeControl({
isPinned: () => capsule.isPinned(),
shown: () => ({ ...state.shown }),
stored: () => ({ ...state.stored }),
/** Every composer shows the one global value: the `/api/state` snapshot's `effort_range`. */
/** The containing composer supplies only its room's authoritative value. */
syncState(data) {
const range = data?.effort_range;
if (!range || typeof range !== 'object' || typeof range.recommended !== 'string') return;
@ -799,14 +780,8 @@ export function createEffortRangeControl({
};
}
/**
* The context mode: the capsule `Context Max` that opens inline into exactly three modes, the
* chosen one under a glass thumb. A choice (a press, a tap, Enter/Space, an arrow) posts the
* owner endpoint at once, which hot-applies it without a restart; outside Cyber Pro a lower mode
* is accepted only while Ouroboros is idle. The strip waits for the answer; a refusal leaves the
* saved mode and says why, and every answer re-reads /api/state.
*/
function createContextModeControl({ el, row, apiFetch, showToast, refreshState, onLayout, onSettled, win, doc }) {
/** Context saves apply to future room participants; a refusal retains the saved mode. */
function createContextModeControl({ el, row, saveContextMode, showToast, refreshState, onLayout, onSettled, win, doc }) {
el.classList.add('chat-capsule');
el.setAttribute('role', 'group');
el.setAttribute('aria-label', 'Context size mode');
@ -836,32 +811,32 @@ function createContextModeControl({ el, row, apiFetch, showToast, refreshState,
thumb.style.width = `${seg.offsetWidth}px`;
}
}
const postMode = (mode) => apiFetch('/api/owner/context-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
});
async function choose(mode) {
const next = ['nano', 'low', 'max'].includes(mode) ? mode : 'max';
if (el.dataset.disabled === 'true' || next === current()) return;
let saving = null, lastOk = true, destroyed = false;
function choose(mode) {
const next = contextModeOf(mode);
if (saving || destroyed || next === current()) return;
el.dataset.disabled = 'true';
try {
const resp = await postMode(next);
if (resp.ok) {
el.dataset.contextMode = next;
} else {
let message = 'Could not change context mode.';
try { const p = await resp.json(); if (p?.error) message = p.error; } catch {}
showToast(message, 'error');
saving = (async () => {
try {
const saved = await saveContextMode(next);
if (destroyed) return false;
el.dataset.contextMode = contextModeOf(saved.context_mode);
lastOk = true;
} catch (e) {
if (destroyed) return false;
lastOk = false;
showToast(e.message || 'Could not change context mode.', 'error');
} finally {
saving = null;
if (!destroyed) {
el.dataset.disabled = 'false';
render();
refreshState(true);
}
}
} catch (e) {
showToast(`Could not change context mode: ${e.message || e}`, 'error');
/* leave the current value; /api/state refresh will resync */
} finally {
el.dataset.disabled = 'false';
render();
refreshState(true);
}
return lastOk;
})();
return saving;
}
const onChoose = (event) => {
const seg = event.target?.closest?.('.chat-seg');
@ -896,13 +871,16 @@ function createContextModeControl({ el, row, apiFetch, showToast, refreshState,
return {
el,
capsule,
/** The one global value: the `/api/state` snapshot's `context_mode`; a save in flight answers for itself. */
/** A save in flight answers for itself; the composer supplies this room's value. */
syncState(data) {
if (typeof data?.context_mode !== 'string' || el.dataset.disabled === 'true') return;
el.dataset.contextMode = contextModeOf(data.context_mode);
render();
},
hasPendingSave: () => Boolean(saving),
pendingSave: () => saving || Promise.resolve(lastOk),
destroy() {
destroyed = true;
capsule.destroy();
el.removeEventListener('click', onChoose);
strip.removeEventListener('keydown', onKey);
@ -921,8 +899,22 @@ function createContextModeControl({ el, row, apiFetch, showToast, refreshState,
* @param {() => void} [deps.onLayout] a strip opened or closed on its own line
* @param {HTMLElement} [deps.box] independently sized page the composer docks inside
*/
export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRange, showToast, refreshState, onLayout, box = null, win = globalThis.window, doc = globalThis.document }) {
export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRange, showToast, refreshState, onLayout, projectId = '', box = null, win = globalThis.window, doc = globalThis.document }) {
let destroyed = false;
const scoped = body => projectId ? { ...body, project_id: projectId } : body;
const readback = reply => {
if ((reply?.project_id || '') !== projectId) throw new Error('Settings response belongs to another room.');
return reply?.cognition || reply;
};
const saveContextMode = async mode => {
const response = await apiFetch('/api/owner/context-mode', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scoped({ mode })),
});
const reply = await response.json();
if (!response.ok) throw new Error(reply?.error || 'Could not change context mode.');
return readback(reply);
};
const saveRoomEffort = async range => readback(await saveEffortRange(scoped(range)));
const capsules = [];
// Measure the settled, closed row synchronously: keep names while it fits, else rings and
// values. If long translated values still wrap, cap the widest with an ellipsis; accessible
@ -958,8 +950,8 @@ export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRan
}
const shared = { showToast, refreshState, onLayout, onSettled: fitNames, win, doc };
const contextEl = byId('context-mode');
const context = contextEl ? createContextModeControl({ el: contextEl, row: row || contextEl.parentElement, apiFetch, ...shared }) : null;
const effort = row ? createEffortRangeControl({ row, saveEffortRange, ...shared }) : null;
const context = contextEl ? createContextModeControl({ el: contextEl, row: row || contextEl.parentElement, saveContextMode, ...shared }) : null;
const effort = row ? createEffortRangeControl({ row, saveEffortRange: saveRoomEffort, ...shared }) : null;
for (const control of [context, effort]) if (control) capsules.push(control.capsule);
// One capsule is open at a time.
if (context && effort) { context.capsule.peer = effort.capsule; effort.capsule.peer = context.capsule; }
@ -982,9 +974,14 @@ export function createComposerOwnerControls({ row, byId, apiFetch, saveEffortRan
return {
context,
effort,
syncState(data) { context?.syncState(data); effort?.syncState(data); fitNames(); },
hasPendingSave: () => Boolean(effort?.hasPendingSave()),
pendingSave: () => (effort ? effort.pendingSave() : Promise.resolve(true)),
syncState(data) {
const values = projectId ? data?.projects?.find(project => project.id === projectId)?.cognition : data;
if (destroyed || !values) return;
context?.syncState(values); effort?.syncState(values); fitNames();
},
hasPendingSave: () => Boolean(context?.hasPendingSave() || effort?.hasPendingSave()),
pendingSave: async () => (await Promise.all([context, effort]
.filter(control => control?.hasPendingSave()).map(control => control.pendingSave()))).every(ok => ok !== false),
destroy() {
destroyed = true;
stopObserving();

View file

@ -51,15 +51,10 @@ export function ownerLevelIndex(tier) {
return OWNER_TIERS.findIndex((owner) => effortRank(owner) >= rank);
}
/**
* The server's tolerant read of the three stored keys: an unknown value is that key's
* default; the minimum is lowered to the recommended level and the maximum raised to it,
* so the triple always orders min ≤ recommended ≤ max.
*/
/** The primary is independent; tolerant helper bounds are ordered without moving it. */
export function normalizeEffortRange(range = {}) {
const pick = (key) => effortTier(range?.[key]) || EFFORT_RANGE_DEFAULT[key];
const recommended = pick('recommended');
const lower = (a, b) => (effortRank(a) <= effortRank(b) ? a : b);
const higher = (a, b) => (effortRank(a) >= effortRank(b) ? a : b);
return { min: lower(pick('min'), recommended), recommended, max: higher(pick('max'), recommended) };
const pick = key => effortTier(range?.[key]) || EFFORT_RANGE_DEFAULT[key];
const min = pick('min'), max = pick('max');
const ordered = effortRank(min) <= effortRank(max);
return { min: ordered ? min : max, recommended: pick('recommended'), max: ordered ? max : min };
}

View file

@ -1,7 +1,7 @@
// The composer's owner controls (docs/DESIGN.md "Composer owner controls"): two labelled
// capsules. The effort range's handle math, the open/pin state machine with and without a
// hovering pointer, keyboard, serialized saves with a refused save returning to the server's
// value, the one global value from /api/state, teardown; the context capsule's three modes, its
// value, independent room readback, teardown; the context capsule's three modes, its
// save and a scrolling strip that keeps the chosen mode in view; one capsule open at a time, and
// the names that give way on a narrow line.
import test from 'node:test';
@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
import { ElementStub } from './chat_dom_fixture.js';
import {
applyHandle, createComposerOwnerControls, createEffortRangeControl, nearestHandle, snapRange,
applyHandle, createComposerOwnerControls, createEffortRangeControl, snapRange,
} from '../modules/composer_owner_controls.js';
/* A document whose listeners can be fired, and a window that may or may not hover. */
@ -76,28 +76,13 @@ function mount({ hover = false, save, win } = {}) {
};
}
test('handle math: every handle pushes the ones it meets; min and max never cross', () => {
test('primary and helper bounds are independent, and helper bounds cannot cross', () => {
const base = { min: 1, rec: 2, max: 3 };
assert.deepEqual(applyHandle(base, 'rec', 5), { min: 1, rec: 5, max: 5 }, 'rec carries max up');
assert.deepEqual(applyHandle(base, 'rec', 0), { min: 0, rec: 0, max: 3 }, 'rec carries min down');
assert.deepEqual(applyHandle(base, 'rec', 9), { min: 1, rec: 6, max: 6 }, 'never past the last level');
assert.deepEqual(applyHandle(base, 'rec', 3), { min: 1, rec: 3, max: 3 }, 'inside the range nothing else moves');
assert.deepEqual(applyHandle(base, 'min', 3), { min: 3, rec: 3, max: 3 }, 'min pushes rec up');
assert.deepEqual(applyHandle(base, 'min', 6), { min: 3, rec: 3, max: 3 }, 'min never passes max');
assert.deepEqual(applyHandle(base, 'max', 1), { min: 1, rec: 1, max: 1 }, 'max pushes rec down');
assert.deepEqual(applyHandle(base, 'max', -4), { min: 1, rec: 1, max: 1 }, 'max never passes min');
assert.deepEqual(applyHandle(base, 'min', 0), { min: 0, rec: 2, max: 3 });
assert.deepEqual(applyHandle(base, 'max', 6), { min: 1, rec: 2, max: 6 });
assert.deepEqual(snapRange({ min: 0.6, rec: 0.2, max: 2.4 }), { min: 1, rec: 1, max: 2 }, 'a snap keeps the order');
// A tap inside the range moves the recommended level, outside it the bracket on that side.
assert.equal(nearestHandle(base, 2), 'rec');
assert.equal(nearestHandle(base, 1), 'rec');
assert.equal(nearestHandle(base, 0), 'min');
assert.equal(nearestHandle(base, 6), 'max');
// Coinciding levels: the brackets stay reachable from either side of the pill.
assert.equal(nearestHandle({ min: 3, rec: 3, max: 3 }, 3), 'rec');
assert.equal(nearestHandle({ min: 3, rec: 3, max: 3 }, 2), 'min');
assert.equal(nearestHandle({ min: 3, rec: 3, max: 3 }, 4), 'max');
for (const rec of [0, 3, 6]) assert.deepEqual(applyHandle(base, 'rec', rec), { ...base, rec });
assert.deepEqual(applyHandle(base, 'min', 6), { min: 3, rec: 2, max: 3 });
assert.deepEqual(applyHandle(base, 'max', -4), { min: 1, rec: 2, max: 1 });
assert.deepEqual(applyHandle(base, 'rec', 9), { min: 1, rec: 6, max: 3 });
assert.deepEqual(snapRange({ min: 0.6, rec: 0.2, max: 2.4 }), { min: 1, rec: 0, max: 2 });
});
test('the control mounts after the pills, closed: ring, name and the recommended level only; three sliders out of the tab order', () => {
@ -121,7 +106,7 @@ test('the control mounts after the pills, closed: ring, name and the recommended
assert.deepEqual(m.control.shown(), { min: 1, rec: 2, max: 3 }, 'the default before the first /api/state');
assert.equal(m.el.dataset.known, 'false');
assert.equal(m.handles.rec.getAttribute('aria-valuetext'), 'Medium');
assert.match(m.head.title, /^Effort: Medium, range Low–High\. Applies to new work\.$/);
assert.match(m.head.title, /^Primary effort: Medium; helpers Low–High\. Applies to new participants in this room\.$/);
assert.equal(m.head.getAttribute('aria-label'), m.head.title, 'the closed button says the state, not just "Effort"');
assert.match(m.el.querySelector('[data-ring-band]').getAttribute('d'), /^M[\d.]+ [\d.]+ A70 70 0 0 1 /);
assert.equal(m.segs.length, 7);
@ -132,7 +117,7 @@ test('the control mounts after the pills, closed: ring, name and the recommended
assert.ok(m.row.children.includes(m.el));
});
test('/api/state is the one global value: it paints every composer, a stored minimal shows at Low', () => {
test('an authoritative room value paints the control; a stored minimal shows at Low', () => {
const m = mount();
m.control.syncState({ effort_range: { min: 'minimal', recommended: 'high', max: 'ultra' } });
assert.equal(m.el.dataset.known, 'true');
@ -234,46 +219,26 @@ test('a drag that ends inside the hover zone does not close the strip a second l
assert.equal(m.control.isOpen(), false, 'leaving the zone closes it');
});
test('keyboard: arrows, Home, End, PageUp/PageDown move a handle, save the full triple on each step, and push neighbours', async () => {
test('keyboard moves primary beyond either bound while each helper handle leaves primary alone', async () => {
const m = mount();
m.control.syncState({ effort_range: { min: 'low', recommended: 'medium', max: 'high' } });
m.headClick(0);
m.key('rec', 'ArrowRight');
assert.deepEqual(m.control.shown(), { min: 1, rec: 3, max: 3 });
m.key('rec', 'End');
await m.settle();
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'high', max: 'high' });
m.key('rec', 'ArrowUp');
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'ultra', max: 'high' });
m.key('rec', 'Home');
await m.settle();
assert.deepEqual(m.control.shown(), { min: 1, rec: 4, max: 4 }, 'rec pushes max along');
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'xhigh', max: 'xhigh' });
m.key('rec', 'ArrowDown');
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'none', max: 'high' });
m.key('min', 'End');
await m.settle();
assert.deepEqual(m.control.shown(), { min: 1, rec: 3, max: 4 }, 'a pushed bracket stays where it was pushed');
const saved = m.saves.length;
m.key('max', 'End');
m.key('max', 'End');
assert.deepEqual(m.saves.at(-1), { min: 'high', recommended: 'none', max: 'high' });
m.key('max', 'Home');
await m.settle();
assert.equal(m.saves.length, saved + 1, 'an unchanged range saves nothing');
m.key('max', 'End');
m.key('min', 'Home');
assert.deepEqual(m.control.shown(), { min: 3, rec: 0, max: 3 });
m.key('max', 'PageUp');
await m.settle();
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'high', max: 'ultra' });
m.key('min', 'PageUp');
m.key('min', 'PageUp');
m.key('min', 'PageUp');
m.key('min', 'ArrowRight');
await m.settle();
assert.deepEqual(m.control.shown(), { min: 4, rec: 4, max: 6 }, 'min pushes rec along');
assert.deepEqual(m.saves.at(-1), { min: 'xhigh', recommended: 'xhigh', max: 'ultra' });
m.key('max', 'PageDown');
m.key('max', 'ArrowDown');
m.key('max', 'ArrowLeft');
await m.settle();
assert.deepEqual(m.control.shown(), { min: 4, rec: 4, max: 4 }, 'max comes down to min and never passes it');
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4');
assert.equal(m.handles.max.getAttribute('aria-valuetext'), 'X-High');
assert.deepEqual(m.saves.at(-1), { min: 'xhigh', recommended: 'xhigh', max: 'xhigh' });
assert.equal(m.refreshes.at(-1), true, 'every settled save re-reads /api/state for every composer');
assert.deepEqual(m.saves.at(-1), { min: 'high', recommended: 'none', max: 'xhigh' });
m.control.destroy();
});
test('a stored tier outside the owner tiers survives until the owner moves that handle', async () => {
@ -293,68 +258,37 @@ test('a stored tier outside the owner tiers survives until the owner moves that
assert.deepEqual(m.saves.at(-1), { min: 'minimal', recommended: 'low', max: 'high' }, 'the untouched minimum keeps minimal');
m.key('rec', 'ArrowLeft');
await m.settle();
assert.deepEqual(m.control.shown(), { min: 0, rec: 0, max: 3 }, 'one more step pushes the minimum');
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'none', max: 'high' }, 'pushed: rewritten to an owner tier');
assert.deepEqual(m.control.shown(), { min: 1, rec: 0, max: 3 }, 'primary may pass the shown minimum');
assert.deepEqual(m.saves.at(-1), { min: 'minimal', recommended: 'none', max: 'high' }, 'the untouched bound keeps its stored tier');
});
test('pointer: a press inside the range moves the recommended level, outside it the nearest bracket; the pill pushes brackets and they stay pushed', async () => {
test('pointer taps anywhere choose primary, a drag never moves helper bounds, and cancel restores', async () => {
const m = mount();
m.control.syncState({ effort_range: { min: 'low', recommended: 'medium', max: 'high' } });
m.headClick(1);
m.down(60); // inside: segment 1 -> rec
assert.deepEqual(m.control.shown(), { min: 1, rec: 2, max: 3 }, 'a preview is not a commit');
assert.equal(m.handles.rec.dataset.dragging, 'true');
assert.equal(m.el.dataset.dragging, 'false', 'a press that has not moved is still a tap: its marks animate');
assert.equal(m.doc.activeElement, m.handles.rec);
m.move(180); // segment 4: the pill carries max along
assert.equal(m.el.dataset.dragging, 'true', 'once the pointer moves, the marks follow it without animating');
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4', 'the preview shows the pushed bracket');
assert.equal(m.handles.rec.style.left, '160px', 'the pill sits on a whole level while dragging');
m.move(100); // back to segment 2
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4', 'no spring: a pushed bracket stays pushed');
m.up(100);
await m.settle();
assert.deepEqual(m.control.shown(), { min: 1, rec: 2, max: 4 });
assert.equal(m.handles.rec.dataset.dragging, 'false');
assert.equal(m.el.dataset.dragging, 'false', 'a tap or a key animates again');
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'medium', max: 'xhigh' });
m.down(250); // outside above: segment 6 -> max
m.down(60);
m.move(250);
assert.equal(m.handles.rec.style.left, '240px');
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '3');
m.up(250);
await m.settle();
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'medium', max: 'ultra' });
m.down(10); // outside below: segment 0 -> min
m.up(10);
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'ultra', max: 'high' });
m.down(10); m.up(10);
await m.settle();
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'medium', max: 'ultra' });
// Grab the max bracket (boundary x=280 is level 7-1 = 6) and drag it by two segments: the
// offset between the grab point and the bracket is kept, so the bracket lands at 4.
m.down(285, { handle: m.handles.max });
assert.equal(m.handles.max.dataset.dragging, 'true');
m.move(205);
m.up(205);
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'none', max: 'high' });
m.down(165, { handle: m.handles.max }); m.move(285); m.up(285);
await m.settle();
assert.deepEqual(m.control.shown(), { min: 0, rec: 2, max: 4 });
assert.deepEqual(m.saves.at(-1), { min: 'none', recommended: 'medium', max: 'xhigh' });
// pointercancel cancels the preview, a pushed bracket included, without a save.
assert.deepEqual(m.saves.at(-1), { min: 'low', recommended: 'none', max: 'ultra' });
const before = m.saves.length;
m.down(100);
m.move(270);
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '6');
m.cancel();
assert.equal(m.handles.rec.dataset.dragging, 'false');
m.down(10); m.move(270); m.cancel();
await m.settle();
assert.equal(m.saves.length, before);
assert.deepEqual(m.control.shown(), { min: 0, rec: 2, max: 4 });
assert.equal(m.handles.max.getAttribute('aria-valuenow'), '4', 'the bracket is back where it was saved');
// A second pointer's events are ignored while the first drags; a press on the recommended
// level's own segment released there changes nothing and saves nothing.
m.down(100);
m.move(200, 2);
m.up(200, 2);
assert.deepEqual(m.control.shown(), { min: 1, rec: 0, max: 6 });
m.down(10); m.move(200, 2); m.up(200, 2);
assert.equal(m.handles.rec.dataset.dragging, 'true');
m.up(100);
await m.settle();
assert.equal(m.saves.length, before, 'released where it was pressed: no change, no save');
m.up(10); await m.settle();
assert.equal(m.saves.length, before);
m.control.destroy();
});
test('a strip that scrolls keeps the handle that moved in view, clear of the fade, and says where it continues', () => {
@ -387,7 +321,7 @@ test('hover opens only where the button stays under the mouse; alone or scrollin
row.appendChild(pills);
const styles = new Map([[row, { paddingLeft: '8px', paddingRight: '8px', columnGap: '8px' }]]);
const win = { ...hoverWindow(true), getComputedStyle: (node) => styles.get(node) || {} };
const control = createEffortRangeControl({ row, doc, win, saveEffortRange: async (triple) => ({ effort_range: triple }), showToast: () => {} });
const control = createEffortRangeControl({ row, doc, win, saveEffortRange: async body => ({ effort_range: body }), showToast: () => {} });
const el = control.el;
el.querySelector('.chat-effort-head').getBoundingClientRect = () => ({ width: 30 });
el.querySelectorAll('.chat-effort-seg').forEach((seg) => {
@ -457,7 +391,7 @@ test('fit: beside the other controls when the strip fits there, alone when it do
addEventListener: (type, fn) => { if (type === 'resize') resize.push(fn); }, removeEventListener: () => {},
};
const control = createEffortRangeControl({
row, doc, win, saveEffortRange: async (triple) => ({ effort_range: triple }), showToast: () => {},
row, doc, win, saveEffortRange: async body => ({ effort_range: body }), showToast: () => {},
});
const el = control.el;
assert.equal(row.children.indexOf(el), 2, 'after the pills and the context capsule');
@ -614,7 +548,7 @@ test('destroy removes the element, its document listeners and its timers', async
});
/* Both capsules in one composer row, as chat.js builds it: the pills, then the context anchor. */
function mountPair({ hover = false, reply, win, withBox = false } = {}) {
function mountPair({ hover = false, reply, win, withBox = false, projectId = '' } = {}) {
const doc = stubDocument();
const box = doc.createElement('section');
const row = doc.createElement('div');
@ -632,10 +566,15 @@ function mountPair({ hover = false, reply, win, withBox = false } = {}) {
const toasts = [];
const refreshes = [];
const answer = { value: reply || { ok: true } };
const effortAnswer = { value: null };
const controls = createComposerOwnerControls({
row, box: withBox ? box : null, doc, win: win || hoverWindow(hover), byId: (suffix) => (suffix === 'context-mode' ? contextEl : null),
apiFetch: async (url, init) => { posts.push([url, JSON.parse(init.body)]); return answer.value; },
saveEffortRange: async (triple) => ({ effort_range: triple }),
projectId, row, box: withBox ? box : null, doc, win: win || hoverWindow(hover), byId: (suffix) => (suffix === 'context-mode' ? contextEl : null),
apiFetch: async (url, init) => {
const body = JSON.parse(init.body); posts.push([url, body]);
const response = await answer.value;
return response.json ? response : { ...response, json: async () => ({ project_id: projectId, context_mode: body.mode }) };
},
saveEffortRange: async (body) => { posts.push(['/api/owner/effort-range', body]); return effortAnswer.value || { project_id: projectId, effort_range: body }; },
showToast: (message, tone) => toasts.push([message, tone]),
refreshState: (force) => refreshes.push(force),
});
@ -643,7 +582,7 @@ function mountPair({ hover = false, reply, win, withBox = false } = {}) {
const effortEl = controls.effort.el;
const seg = (mode) => contextEl.querySelectorAll('.chat-seg').find((node) => node.dataset.mode === mode);
return {
controls, row, box, doc, contextEl, effortEl, posts, toasts, refreshes, answer, seg,
controls, row, box, doc, contextEl, effortEl, posts, toasts, refreshes, answer, effortAnswer, seg,
contextHead: contextEl.querySelector('.chat-context-head'), effortHead: effortEl.querySelector('.chat-effort-head'),
press: (head, detail = 1) => fire(head, 'click', { detail }),
choose: (mode) => fire(contextEl, 'click', { target: { closest: (selector) => (selector === '.chat-seg' ? seg(mode) : null) } }),
@ -678,7 +617,7 @@ test('the context capsule: ring, Context and the mode; it opens into exactly thr
assert.equal(m.contextEl.dataset.open, 'false');
assert.equal(m.doc.activeElement, m.contextHead, 'Esc returns focus to the head');
m.controls.syncState({ context_mode: 'nano' });
assert.equal(m.contextEl.dataset.contextMode, 'nano', 'every composer shows the one global mode');
assert.equal(m.contextEl.dataset.contextMode, 'nano', 'the Main composer reads Main mode');
assert.equal(m.contextEl.querySelector('.chat-capsule-value').textContent, 'Nano');
m.controls.syncState({ context_mode: 'huge' });
assert.equal(m.contextEl.dataset.contextMode, 'max', 'an unknown mode reads as Max');
@ -724,7 +663,7 @@ test('a context choice posts the owner endpoint at once, waits for the answer, s
assert.equal(m.contextEl.dataset.contextMode, 'max');
await m.key('max', 'ArrowDown');
assert.equal(m.posts.length, 5, 'past the end nothing moves and nothing is posted');
assert.equal(m.controls.hasPendingSave(), false, 'Send waits for the effort save only');
assert.equal(m.controls.hasPendingSave(), false, 'both saves are settled');
assert.equal(await m.controls.pendingSave(), true);
m.controls.destroy();
});
@ -975,3 +914,67 @@ test('a window resize fits the row immediately; destroy cancels a pending frame
w.frames[0]();
assert.equal(m.row.dataset.ownerCompact, undefined, 'a late frame writes nothing');
});
test('Main and two projects read and save only their room, including delayed context saves', async () => {
const main = mountPair(), alpha = mountPair({ projectId: 'alpha' }), beta = mountPair({ projectId: 'beta' });
const data = { context_mode: 'max', effort_range: { min: 'low', recommended: 'ultra', max: 'high' }, projects: [
{ id: 'alpha', cognition: { context_mode: 'low', effort_range: { min: 'high', recommended: 'none', max: 'max' } } },
{ id: 'beta', cognition: { context_mode: 'nano', effort_range: { min: 'none', recommended: 'max', max: 'low' } } },
] };
for (const m of [main, alpha, beta]) m.controls.syncState(data);
assert.deepEqual([main, alpha, beta].map(m => m.contextEl.dataset.contextMode), ['max', 'low', 'nano']);
assert.deepEqual([main, alpha, beta].map(m => m.controls.effort.stored().recommended), ['ultra', 'none', 'max']);
alpha.controls.syncState({ context_mode: 'max', effort_range: data.effort_range });
assert.equal(alpha.contextEl.dataset.contextMode, 'low', 'missing project never falls back to Main');
let release;
alpha.answer.value = new Promise(resolve => { release = resolve; });
const saving = alpha.choose('nano');
assert.equal(alpha.controls.hasPendingSave(), true);
release({ ok: true }); await saving;
assert.equal(await alpha.controls.pendingSave(), true);
assert.deepEqual(alpha.posts[0], ['/api/owner/context-mode', { mode: 'nano', project_id: 'alpha' }]);
assert.equal(main.contextEl.dataset.contextMode, 'max');
assert.equal(beta.contextEl.dataset.contextMode, 'nano');
alpha.answer.value = { ok: true, json: async () => ({ project_id: 'beta', context_mode: 'max' }) };
const refused = alpha.choose('max');
const send = alpha.controls.pendingSave();
await refused;
assert.equal(alpha.contextEl.dataset.contextMode, 'nano', 'foreign-scope response cannot repaint this room');
assert.equal(await send, false, 'the save this Send awaited was refused');
assert.equal(await alpha.controls.pendingSave(), true, 'a reported refusal does not block a later Send');
for (const m of [main, alpha, beta]) m.controls.destroy();
});
for (const failed of ['context', 'effort']) for (const accepts of [true, false]) {
test(`Send waits only for pending controls: old ${failed} refusal, new save ${accepts ? 'accepted' : 'refused'}`, async () => {
const m = mountPair({ projectId: 'alpha' });
const effort = () => {
const handle = m.controls.effort.el.querySelector('.chat-effort-rec');
for (const fn of handle.listeners.get('keydown') || []) fn({ key: 'ArrowRight', currentTarget: handle, preventDefault() {} });
};
try {
if (failed === 'context') {
m.answer.value = { ok: false, json: async () => ({ error: 'Earlier refusal' }) };
await m.choose('low');
} else {
m.effortAnswer.value = Promise.reject(new Error('Earlier refusal'));
effort(); await m.controls.effort.pendingSave();
}
assert.equal(m.toasts.length, 1);
assert.equal(m.controls.hasPendingSave(), false);
let resolve, reject;
const pending = new Promise((yes, no) => { resolve = yes; reject = no; });
if (failed === 'context') { m.effortAnswer.value = pending; effort(); }
else { m.answer.value = pending; void m.choose('low'); }
assert.equal(m.controls.hasPendingSave(), true);
const send = m.controls.pendingSave();
if (!accepts) reject(new Error('Current refusal'));
else resolve(failed === 'context'
? { project_id: 'alpha', effort_range: { min: 'low', recommended: 'high', max: 'high' } }
: { ok: true });
assert.equal(await send, accepts, 'only this pending save decides whether Send proceeds');
assert.equal(m.toasts.length, accepts ? 1 : 2, 'a refused current save remains visible');
assert.equal(m.controls.hasPendingSave(), false);
} finally { m.controls.destroy(); }
});
}

View file

@ -26,10 +26,17 @@ test('a stored tier outside the owner tiers is shown at the nearest owner tier',
assert.equal(ownerLevelIndex('bogus'), -1);
});
test("the tolerant read: unknown values take the key's default, min lowers to rec, max rises to rec", () => {
test("the tolerant read: unknown tiers take defaults, bounds order independently of primary", () => {
assert.deepEqual(normalizeEffortRange({}), EFFORT_RANGE_DEFAULT);
// The owner's install: TASK=high, no MIN/MAX -> Low · High · High.
assert.deepEqual(normalizeEffortRange({ recommended: 'high' }), { min: 'low', recommended: 'high', max: 'high' });
assert.deepEqual(normalizeEffortRange({ min: 'ultra', recommended: 'low', max: 'none' }), { min: 'low', recommended: 'low', max: 'low' });
assert.deepEqual(normalizeEffortRange({ min: 'ultra', recommended: 'low', max: 'none' }), { min: 'none', recommended: 'low', max: 'ultra' });
assert.deepEqual(normalizeEffortRange({ min: 'minimal', recommended: 'extreme', max: 'MAX' }), { min: 'minimal', recommended: 'medium', max: 'max' });
});
test('primary outside helper bounds survives readback in both directions', () => {
for (const recommended of ['none', 'ultra']) {
const value = { min: 'medium', recommended, max: 'high' };
assert.deepEqual(normalizeEffortRange(value), value);
}
});