diff --git a/docs/_memory/glossary.md b/docs/_memory/glossary.md index 92fc21202..f0ba789be 100644 --- a/docs/_memory/glossary.md +++ b/docs/_memory/glossary.md @@ -204,6 +204,20 @@ Default write scope is declared per agent in `memory.scope`. ## Autonomy +### Background role + +A named daemon-owned responsibility routed through the closed `[roles]` roster: `coordinator`, +`dream`, `checkpoint_summary`, `memory_extractor`, `auto_title`, or `memory_controller`. Empty +session-role agents resolve either to a virtual builtin (`coordinator` or `dreaming-curator`) or to +the invoking context (`memory_extractor` and `auto_title`); the memory controller is an in-process +model call and has no agent identity. + +`[roles]` owns routing — enabled state, agent/provider/model/reasoning selection, ordered fallbacks, +and the small amount of policy inseparable from coordinator sessions or controller calls. The +owning subsystem keeps its operational policy, gates, scoring, cadence, and prompts. Background +roles do not replace or govern Loop DSL model defaults, `TaskExecutionProfile`, or automation +resources. + ### `task_run` The single durable work-queue row. Carries `claim_token`, `lease_until`, `heartbeat_at`, the owning `session_id`, and the execution's immutable resolved Network participation snapshot. A `run_kind = "network_wake"` row is taskless and identifies the wake, owner execution, and target session explicitly. **Never duplicated by a parallel queue.** diff --git a/docs/qa/_seeds/final-qa/_children/05-memory-soul.md b/docs/qa/_seeds/final-qa/_children/05-memory-soul.md index a1a270c98..f104f08fd 100644 --- a/docs/qa/_seeds/final-qa/_children/05-memory-soul.md +++ b/docs/qa/_seeds/final-qa/_children/05-memory-soul.md @@ -190,9 +190,11 @@ For consolidation scenarios that need a fast cycle: [memory] enabled = true -[memory.dream] +[roles.dream] enabled = true agent = "claude-code" + +[memory.dream] min_hours = 0.001 # ≈ 3.6s; only acceptable in QA scenarios min_sessions = 1 # MEM-04 raises to 5 to cover the sessions gate check_interval = "2s" @@ -1365,7 +1367,7 @@ risk: low live: true provider: real-openclaw preconditions: - - `[memory.dream]` config with `agent="openclaw"`. + - `[roles.dream]` config with `agent="openclaw"`. - Same seeds as MEM-16. code_refs: - /Users/pedronauck/Dev/compozy/agh/internal/memory/consolidation/runtime.go:250 diff --git a/docs/qa/bugs/BUG-20260724-bundle-agent-snapshot-loss.md b/docs/qa/bugs/BUG-20260724-bundle-agent-snapshot-loss.md new file mode 100644 index 000000000..d0161566f --- /dev/null +++ b/docs/qa/bugs/BUG-20260724-bundle-agent-snapshot-loss.md @@ -0,0 +1,43 @@ +# BUG-20260724-bundle-agent-snapshot-loss: Installed bundle profiles silently lose packaged agents + +- **Status:** verified +- **Impact (user-side):** Functional +- **Severity:** High · **Priority:** P1 +- **Persona Affected:** Ada +- **Journey Step:** J-32 reserved-name bundle materialization probe +- **Scenarios:** RT-reserved-builtin-agent-names +- **Found:** 2026-07-24 · **Report:** docs/qa/reports/2026-07-24-agent-roles.md + +## Summary + +A locally installed extension loaded a bundle profile that packaged `agents/coordinator/AGENT.md`, but the public bundle service received an empty agent list. Preview showed no resources and activation succeeded as an empty profile, bypassing both normal agent materialization and reserved-name validation. + +## Reproduction + +- **Charter:** CH-reserved-builtin-name-sweep · **Tour:** Garbage Tour +- **Environment:** desktop / wifi-fast / en-US, isolated `devtool-oss-launch` lab + +1. Install an extension whose bundle profile contains `[[profiles.agents]] path = "agents/coordinator"`. +2. Preview or activate the profile through the daemon. +3. Inspect the activation inventory and agent catalog. + +**Expected:** The complete packaged agent reaches bundle validation, which rejects the reserved identity before any write. +**Actual:** The profile snapshot contains zero agents; an empty activation succeeds and materializes no agent. + +## Evidence + +- `reserved-bundle-{extension-install,catalog-before-activate,preview-current}.json` in the run's `qa-artifacts/qa` directory. +- The accidental empty activation `act_16b6f8f030a6fd9a` was immediately deactivated; `reserved-bundle-empty-activation-deactivate.json` proves cleanup. + +## Fix + +- **Root cause:** `cloneBundleSpecs` copied channels, jobs, triggers, and bridges but omitted `BundleProfile.Agents`. Bundle services consume the Manager snapshot rather than the mutable installed object. +- **Fix:** Extension snapshots now deep-clone every packaged agent plus Soul and Heartbeat sidecars. +- **Fix commit:** `c841d7e06428c28e4e1b4ba8c17bccb4a103eea1` +- **Regression test:** `internal/extension/manager_test.go` — `TestManagerCloneExtensionReturnsIsolatedSnapshot` proves agents survive and mutable fields do not alias the installed extension. + +## Verification + +- After rebuild and restart, the same fixture reached reserved-name validation before preview or activation. +- No activation or agent catalog residue remained after the valid probe. +- The extension `-race` suite passed 789 tests; repository lint passed. diff --git a/docs/qa/bugs/BUG-20260724-coordinator-config-list-path.md b/docs/qa/bugs/BUG-20260724-coordinator-config-list-path.md new file mode 100644 index 000000000..7d68130cc --- /dev/null +++ b/docs/qa/bugs/BUG-20260724-coordinator-config-list-path.md @@ -0,0 +1,42 @@ +# BUG-20260724-coordinator-config-list-path: Coordinator enabled state is published under a path operators cannot use + +- **Status:** verified +- **Impact (user-side):** Friction +- **Severity:** Medium · **Priority:** P2 +- **Persona Affected:** Dora +- **Journey Step:** J-route-background-work baseline inspection, step 1 +- **Scenarios:** MS-background-role-routing +- **Found:** 2026-07-24 · **Report:** docs/qa/reports/2026-07-24-agent-roles.md + +## Summary + +Dora listed effective configuration before changing a background role and saw the coordinator toggle as `roles.coordinator.roleconfig.enabled`. That path is not part of the public `[roles]` contract and cannot be used with `agh config set`; the supported path is `roles.coordinator.enabled`. + +## Reproduction + +- **Charter:** CH-background-role-routing-scopes · **Tour:** Feature Tour +- **Environment:** desktop / wifi-fast / en-US, isolated `devtool-oss-launch` lab + +1. Start the isolated daemon with pristine role defaults. +2. Run `agh config list -o json`. +3. Inspect the entries beginning with `roles.coordinator`. + +**Expected:** The coordinator toggle is listed as `roles.coordinator.enabled`. +**Actual:** The list contains `roles.coordinator.roleconfig.enabled` and omits the canonical enabled path. + +## Evidence + +- `/Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/config-list-roles.json` +- Independent write-path check: `agh config set roles.coordinator.max_children 6` resolves the canonical coordinator branch and returns its bounded validation error, while the published `roleconfig` segment is absent from the supported registry. + +## Fix + +- **Root cause:** The redacted config projector treated every exported struct field as a named TOML node. Go's anonymous `RoleConfig` field is flattened by the TOML contract, but the projector lowercased its Go type name to `roleconfig` and inserted a segment that does not exist in the file or validated write registry. +- **Fix:** Anonymous untagged TOML struct fields are now merged into their parent projection while explicit outer fields retain precedence. +- **Fix commit:** `69b2099f3cada66395ced4c8ae862b21b5ebc996` +- **Regression test:** `internal/cli/config_test.go` — `TestConfigRenderingAndMutationHelpers/Should flatten anonymous TOML fields into canonical parent paths` failed on the invalid `roleconfig` segment before the fix and passes after it. + +## Verification + +- **Retested:** 2026-07-24, Dora / J-route-background-work baseline from a freshly rebuilt and restarted isolated daemon · **Report:** docs/qa/reports/2026-07-24-agent-roles.md +- **Result:** `agh config list -o json` returns `roles.coordinator.enabled`; `agh config get roles.coordinator.enabled -o json` returns `false`; the invalid `roles.coordinator.roleconfig.enabled` path is absent. Package `-race` and repository lint gates pass. diff --git a/docs/qa/bugs/BUG-20260724-inherited-role-provider-resolution.md b/docs/qa/bugs/BUG-20260724-inherited-role-provider-resolution.md new file mode 100644 index 000000000..297dfabdd --- /dev/null +++ b/docs/qa/bugs/BUG-20260724-inherited-role-provider-resolution.md @@ -0,0 +1,42 @@ +# BUG-20260724-inherited-role-provider-resolution: Model-only inherited roles omit the invoking provider + +- **Status:** verified +- **Impact (user-side):** Functional +- **Severity:** High · **Priority:** P1 +- **Persona Affected:** Dora; Ada +- **Journey Step:** J-route-background-work, live Settings save → next auto-title invocation +- **Scenarios:** MS-background-role-routing; MS-background-role-fallback; MS-settings-roles-panel +- **Found:** 2026-07-24 · **Report:** docs/qa/reports/2026-07-24-agent-roles.md + +## Summary + +A valid model-only override on the inherited `auto_title` or `memory_extractor` role left the effective provider empty. Session creation rejected the primary route before acceptance, so auto-title either failed or used a configured fallback even though the invoking agent had a valid provider. + +## Reproduction + +- **Charters:** CH-background-role-routing-scopes, CH-role-fallback-boundary, CH-settings-roles-live-truth +- **Environment:** desktop / wifi-fast / en-US, isolated `devtool-oss-launch` lab + +1. Configure `roles.auto_title.enabled = true`, `model = "gpt-5.6-luna"`, and no role-level provider. +2. Start a `general` session on provider `codex` and complete its first turn. +3. Inspect the generated title, hidden auto-title child, and `role.fallback.used` logs. + +**Expected:** The inherited role resolves `codex` from the invoking agent, starts the hidden child on `codex/gpt-5.6-luna`, and needs no fallback. +**Actual:** The primary creation spec contains `provider = ""` with `model = "gpt-5.6-luna"`; validation rejects it before acceptance and advances to the fallback when one is configured. + +## Evidence + +- Before: `ui-live-auto-title-session.json`, `ui-live-fallback-{cli,http}.json` in the run's `qa-artifacts/qa` directory. +- After: `inherit-provider-fix-{session-create,auto-title-child,parent-after-title,fallback-events,visible-sessions,session-stop}.json` in the same directory. + +## Fix + +- **Root cause:** `RoleResolver.resolveEffective` returned immediately for an inherited role before reading invocation correlation or resolving the invoking agent's provider chain. +- **Fix:** In invocation context, inherited roles now resolve the correlated agent and apply the documented agent-provider → default-provider chain while preserving the role's explicit model override. Projection reads without invocation context remain honestly unresolved. +- **Fix commit:** `a9a8fcad63f4354505e4c9a0701a6d0f559cc991` +- **Regression test:** `internal/daemon/role_resolver_test.go` — `TestRoleResolver/Should resolve inherited role overrides through the invoking agent provider chain` covers both an explicit invoking-agent provider and an invoking agent that inherits `defaults.provider`. + +## Verification + +- The focused regression failed red for both roles before the fix, then passed in four parallel cases; the complete daemon package passed 1,399 `-race` tests and repository lint passed with zero issues. +- A rebuilt/restarted daemon created hidden child `sess-afc891322e1060b8` as `general/codex/gpt-5.6-luna`, completed it, generated the parent title `inherited provider probe complete`, emitted zero `role.fallback.used` events, and kept the child out of the public session list. diff --git a/docs/qa/bugs/BUG-20260724-memory-extractor-agent-tier.md b/docs/qa/bugs/BUG-20260724-memory-extractor-agent-tier.md new file mode 100644 index 000000000..bfa697b53 --- /dev/null +++ b/docs/qa/bugs/BUG-20260724-memory-extractor-agent-tier.md @@ -0,0 +1,44 @@ +# BUG-20260724-memory-extractor-agent-tier: Extractor emits agent tier metadata outside agent scope + +- **Status:** verified +- **Impact (user-side):** Functional +- **Severity:** High · **Priority:** P1 +- **Persona Affected:** Dora +- **Journey Step:** J-digest-sessions-into-memory, extractor harvest +- **Scenarios:** MS-011 +- **Found:** 2026-07-24 · **Report:** docs/qa/reports/2026-07-24-agent-roles.md + +## Summary + +The default Memory extractor produced a durable global candidate with `agent_tier = "global"`. Agent tier is valid only for agent-scoped memories, so the controller rejected the candidate and moved it to the extractor DLQ. + +## Reproduction + +- **Charter:** CH-dream-pipeline-canary · **Tour:** Feature Tour +- **Environment:** desktop / wifi-fast / en-US, isolated `devtool-oss-launch` lab + +1. Run a normal provider-backed session while the default memory extractor is enabled. +2. Let the extractor return a global or workspace JSONL candidate with a non-empty `agent_tier`. +3. Drain the extractor and inspect `agh memory extractor list-pending -o json`. + +**Expected:** The extractor boundary returns a contract-valid candidate; `agent_tier` is empty outside `scope = "agent"`. +**Actual:** The controller rejects the candidate with `memory controller: candidate frontmatter: agent tier requires agent scope` and records a DLQ item. + +## Evidence + +- `/Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/archive/20260724T112506.161087000Z-20260724T112506.009718000Z-257.jsonl.processing.json` +- `/Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/memory-extractor-status-after-source.json` + +## Fix + +- **Root cause:** The extractor prompt presented `agent_tier` as a universal field, and `candidateFromExtractedLine` preserved it for every scope even though Memory v2 permits it only for agent scope. +- **Fix:** The prompt now makes `agent_tier` conditional, while the untrusted-output adapter strips it from global and workspace candidates before controller submission. +- **Fix commit:** `b6f7408439a68b9e5225b1b086770b4e37347e58` +- **Regression test:** `internal/daemon/memory_runtime_test.go` — `TestCollectMemoryExtractorOutput` covers both workspace and global candidates carrying an inapplicable tier. + +## Verification + +- **Retested:** 2026-07-24 from a rebuilt and restarted isolated daemon. +- A fresh provider-backed session produced two workspace memories; drain returned `remaining = 0`, no new DLQ record appeared, and health returned `status = "ok"` with two workspace files. +- Evidence: `memory-pipeline-retest-{drain,extractor-status,list,health}.json` under the run's `qa-artifacts/qa` directory. +- Scoped `-race` gates passed 1,396 daemon tests and 32 memory-prompt tests; repository lint passed. diff --git a/docs/qa/bugs/BUG-20260724-reserved-bundle-error-mapping.md b/docs/qa/bugs/BUG-20260724-reserved-bundle-error-mapping.md new file mode 100644 index 000000000..d01d7def6 --- /dev/null +++ b/docs/qa/bugs/BUG-20260724-reserved-bundle-error-mapping.md @@ -0,0 +1,44 @@ +# BUG-20260724-reserved-bundle-error-mapping: Reserved bundle agents return internal errors + +- **Status:** verified +- **Impact (user-side):** Friction +- **Severity:** Medium · **Priority:** P2 +- **Persona Affected:** Ada; agent operators +- **Journey Step:** J-32 reserved-name bundle materialization probe +- **Scenarios:** RT-reserved-builtin-agent-names +- **Found:** 2026-07-24 · **Report:** docs/qa/reports/2026-07-24-agent-roles.md + +## Summary + +Once a packaged `coordinator` reached bundle validation, the domain returned the correct `ErrAgentNameReserved` sentinel and wrote nothing. HTTP and UDS nevertheless classified it as 500, and the native adapter collapsed it to `tool_backend_failed` rather than the public `agent_name_reserved` code. + +## Reproduction + +- **Charter:** CH-reserved-builtin-name-sweep · **Tour:** Garbage Tour +- **Environment:** desktop / wifi-fast / en-US, isolated `devtool-oss-launch` lab + +1. Activate a bundle profile that packages an agent named `coordinator`. +2. Repeat through HTTP, UDS/CLI, and `agh__bundles_activate`. +3. Inspect status, diagnostic code, and post-attempt activation/catalog state. + +**Expected:** HTTP and UDS return 422, CLI exits with its diagnostic exit class, native returns `agent_name_reserved`, and no state changes. +**Actual:** HTTP and UDS return 500; native classifies the same validation error as a backend failure. + +## Evidence + +- Before: `reserved-bundle-{http,uds,cli}-valid-probe.*` in the run's `qa-artifacts/qa` directory. +- After: `reserved-bundle-{http,uds,cli}-fixed-probe.*` and `native-bundle-capable-session-history.json`. + +## Fix + +- **Root cause:** `StatusForBundleError` did not classify `ErrAgentNameReserved`, and the native bundle adapter relied only on the resulting generic HTTP status mapping. +- **Fix:** Bundle API mapping now returns 422 for the sentinel; the native adapter preserves `ErrorCodeAgentNameReserved` with invalid-input/schema semantics. +- **Fix commit:** `a1c966c01b40ae37372e4431704703acd92e679a` +- **Regression tests:** `TestStatusForBundleErrorAndChannelHelpers/agent name reserved` and `TestDaemonNativeTools/Should preserve the reserved agent code from bundle activation`. + +## Verification + +- CLI/HTTP/UDS returned `agent_name_reserved`; HTTP and UDS returned 422 and named `.agh/bundles//agents/coordinator/AGENT.md`. +- A real Codex session invoked `agh__bundles_activate` once and received `agent_name_reserved: Unprocessable Entity`. +- Activation and agent catalogs were byte-stable across every rejected attempt. +- API core passed 1,346 `-race` tests, daemon passed 1,396, and repository lint passed. diff --git a/docs/qa/charters/CH-background-role-routing-scopes.md b/docs/qa/charters/CH-background-role-routing-scopes.md new file mode 100644 index 000000000..f02ea542a --- /dev/null +++ b/docs/qa/charters/CH-background-role-routing-scopes.md @@ -0,0 +1,39 @@ +# CH-background-role-routing-scopes: Routing changes exactly the scope I chose — live, bounded, and nowhere else + +```yaml +charter: + id: CH-background-role-routing-scopes + mission: "As Dora, drive the whole [roles] write plane — global config set, workspace overlay, and the native agh__config_* tools — and prove every accepted route becomes live for the next eligible invocation in exactly the chosen scope, every invalid or deleted key is rejected with its exact path, and routed catalog agents still behave as their role." + mode: charter-with-tour + persona: + name: Dora + device: desktop + network: wifi-fast + locale: en-US + journey: J-route-background-work + scenarios: [MS-background-role-routing] + tour: Feature Tour + time_box_minutes: 90 + guidance: + must_try: + - "Baseline first: `agh config list -o json` (read the exact roles.* leaves — `agh config get` takes one flattened leaf like roles.dream.model, never the roles branch) and `agh roles list -o json` on a fresh home must show the pinned defaults (coordinator off; dream/checkpoint on builtin dreaming-curator; auto_title/extractor inherit; controller haiku@250ms)." + - "Global route: `agh config set roles.dream.model ` then trigger a dream (`agh memory dream trigger`) — the hidden dream session resolves the routed model with the builtin identity, and the session stays out of fleet/session lists (Invariant 10)." + - "Workspace route: `agh config set --scope workspace --workspace roles.dream.agent ` — that workspace's next dream runs the catalog agent (AGH role overlay still applied, ADR-003/Invariant 12) while a sibling workspace keeps global routing; survive a fresh config read (Invariant 11)." + - "Live toggle: `roles.auto_title.enabled false` → next session gets no title spawn; re-enable → titles resume, all without daemon restart." + - "Native plane: `agh__config_list` serves the exact roles.* leaves and `agh__config_get roles.auto_title.model` reads one; `agh__config_set roles.auto_title.model` accepted and `agh__config_unset` restores the inherited value; `agh__config_path` proves only the selected global/workspace config file target and scope; every removed path (`memory.dream.agent`, `memory.controller.llm.model`, `session.auto_title_enabled`, `autonomy.coordinator.*`) rejected deterministically by both `agh config set` and `agh__config_set` (Invariant 9)." + - "Rejection quality: `roles.coordinator.max_children 6` names the ≤5 cap (Invariant 8); `roles.dream.timeout` in TOML fails load naming `roles.dream.timeout` (Invariant 7); an old deleted key inside config.toml fails load naming the key; the prior good config stays authoritative after every rejection." + - "Ghost route: `roles.dream.agent ghost` — the next invocation fails explicitly (`role_agent_not_found` / role.resolve.error), with no silent builtin fallback (Invariant 4)." + must_avoid: + - "The Settings web panel (CH-settings-roles-live-truth owns it) and fallback chains under failure (CH-role-fallback-boundary owns them)." + - "Editing role policy knobs that stayed subsystem-side (dream cadence/gates, extractor pipeline) — the routing-vs-policy split means they are out of mission." + coverage: + surfaces: + - "[roles] via agh config set (global) + --scope workspace --workspace overlay" + - "agh__config_list|get|set|unset roles.* accept + removed-path reject; agh__config_path scope-target proof only" + - "routed invocation evidence (dream, auto_title) incl. hidden-session visibility" + - "docs entry origin: runtime/core/configuration/config-toml [roles] worked example matches observed behavior" + invariants: [4, 7, 8, 9, 10, 11, 12] + adrs: [ADR-002, ADR-003] +``` + + diff --git a/docs/qa/charters/CH-dream-pipeline-canary.md b/docs/qa/charters/CH-dream-pipeline-canary.md new file mode 100644 index 000000000..3764eecf8 --- /dev/null +++ b/docs/qa/charters/CH-dream-pipeline-canary.md @@ -0,0 +1,37 @@ +# CH-dream-pipeline-canary: Default background memory still works end-to-end after the roles rewiring + +```yaml +charter: + id: CH-dream-pipeline-canary + mission: "As Dora, walk the untouched-default memory pipeline end-to-end — real session work, extractor harvest, dream trigger, hidden dreaming-curator run, health, recall — as the adjacent canary proving the six-consumer roles rewiring caused zero default-behavior drift." + mode: charter-with-tour + persona: + name: Dora + device: desktop + network: wifi-fast + locale: en-US + journey: J-digest-sessions-into-memory + scenarios: [MS-011, MS-016, MS-017] + tour: Feature Tour + time_box_minutes: 30 + guidance: + must_try: + - "On pristine default [roles] config, do real session work, confirm extractor evidence (`agh memory extractor status` / `list-pending`), then `agh memory dream trigger` — a truthful running (or skipped+reason) response, never a fake run." + - "The dream session runs the builtin dreaming-curator hidden: absent from session list, fleet, and agent catalogs while `agh memory dream status|show ` reports it truthfully to a terminal outcome (retry available on failure)." + - "Pipeline health: `GET /api/memory/health` (and `agh memory health`) returns ok with real counts after the run; a fresh memory list/recall returns the consolidated knowledge." + - "Disabled branch: set `roles.dream.enabled false`, trigger → skipped naming the reason; re-enable → next trigger runs — both without daemon restart (live roles apply on the pipeline path)." + must_avoid: + - "Routing overrides beyond the single enabled-toggle probe (CH-background-role-routing-scopes owns routing) and fallback failure injection (CH-role-fallback-boundary)." + - "Knowledge-catalog depth — paging, FTS recovery, identity isolation belong to J-25's charters." + - "Memory-v2 dream promotion — out of the feature's scope by spec." + coverage: + surfaces: + - "session work → extractor status/list-pending → POST /api/memory/dreams/trigger + agh memory dream trigger|status|show|retry" + - "GET /api/memory/dreams*; GET /api/memory/health + agh memory health; fresh memory list/recall" + - "hidden-session visibility across session list, fleet, and agent catalogs" + - "live roles.dream.enabled toggle on the real pipeline" + invariants: [10, "default-preservation regression (TechSpec Known Risks: default-behavior drift)"] + adrs: [ADR-001] +``` + + diff --git a/docs/qa/charters/CH-reserved-builtin-name-sweep.md b/docs/qa/charters/CH-reserved-builtin-name-sweep.md new file mode 100644 index 000000000..b8a1da5f4 --- /dev/null +++ b/docs/qa/charters/CH-reserved-builtin-name-sweep.md @@ -0,0 +1,38 @@ +# CH-reserved-builtin-name-sweep: No surface lets me author, smuggle, or shadow a builtin identity + +```yaml +charter: + id: CH-reserved-builtin-name-sweep + mission: "As Ada, attack every agent-authoring path with the reserved names coordinator and dreaming-curator — CLI, HTTP, UDS, native tool, duplicate, rename, bundle, and an on-disk shadow directory — and prove each rejects with agent_name_reserved leaving zero residue, while the builtins themselves never surface in any catalog." + mode: charter-with-tour + persona: + name: Ada + device: desktop + network: wifi-fast + locale: en-US + journey: J-32 + scenarios: [RT-reserved-builtin-agent-names] + tour: Garbage Tour + time_box_minutes: 60 + guidance: + must_try: + - "Create sweep: `agh agent create coordinator` (CLI→UDS), `POST /api/agents` over HTTP with dreaming-curator, and native `agh__agent_create` — each rejects with the exact `agent_name_reserved` envelope (422-class), creates no directory, and leaves `agh agent list` byte-stable." + - "Mutation sweep: rename an existing agent to a reserved name and duplicate onto a reserved target — both reject; the source agent stays untouched." + - "Normalization edges: case/whitespace variants (`Coordinator`, ` coordinator `) reject; near-miss `coordinator-helper` succeeds and is cleaned up — reservation is exact-name after normalization, not prefix." + - "Bundle path: activate a bundle whose profile ships an agent named coordinator — activation fails with `agent_name_reserved` naming the bundle path and materializes nothing from that profile." + - "Shadow path: plant `$AGH_HOME/agents/coordinator/AGENT.md` before boot — boot succeeds, a warning diagnostic names the skipped path, the directory never enters agent list/catalog, and the coordinator role still resolves the virtual builtin (no shadow resolution)." + - "Catalog hiding throughout: after every attempt, `GET /api/agents?workspace=` and `GET /api/agents/catalog?workspace=` (HTTP and UDS) and the fleet UI contain neither builtin name (Invariant 1)." + must_avoid: + - "General agent CRUD depth (J-32's duplicate/delete/restart lifecycle beyond the reservation fence) — CH owns only the reserved-name boundary." + - "Deleting or modifying the operator's real agents; use disposable names for the near-miss probe." + coverage: + surfaces: + - "agh agent create|update|duplicate (CLI/UDS); POST/PUT /api/agents (HTTP); agh__agent_create; bundle activation" + - "boot-time discovery skip of a pre-existing reserved directory + its warning diagnostic" + - "GET /api/agents?workspace= + GET /api/agents/catalog?workspace= (HTTP/UDS), fleet UI catalog hiding" + - "docs entry origin: runtime/core/configuration/agent-md reserved-names note matches enforcement" + invariants: [1, 2, 3] + adrs: [ADR-001, ADR-004] +``` + + diff --git a/docs/qa/charters/CH-role-fallback-boundary.md b/docs/qa/charters/CH-role-fallback-boundary.md new file mode 100644 index 000000000..43488c410 --- /dev/null +++ b/docs/qa/charters/CH-role-fallback-boundary.md @@ -0,0 +1,36 @@ +# CH-role-fallback-boundary: Fallback fires only before acceptance — ordered, once each, observable, and never after + +```yaml +charter: + id: CH-role-fallback-boundary + mission: "As Ada, break role primaries in every pre- and post-acceptance way and prove the fallback chain runs serialized attempts once each in declared order, emits one durable correlated role.fallback.used event per attempt, exhausts into the role's deterministic error with zero residue, and never reroutes an accepted session." + mode: charter-with-tour + persona: + name: Ada + device: desktop + network: wifi-fast + locale: en-US + journey: J-route-background-work + scenarios: [MS-background-role-fallback] + tour: Network Tour + time_box_minutes: 60 + guidance: + must_try: + - "Happy advance: point a role's primary at a provider that fails pre-acceptance (dead command in the isolated lab) with one healthy fallback entry; trigger the role — work completes on the fallback, and exactly one `role.fallback.used` (attempt index, target provider/model, workspace + parent-session correlation) is durably readable in the cross-session runtime logs via `agh logs --workspace --session --type role.fallback.used --last 10 -o json`." + - "Order and single-try: a two-entry chain with a dead first entry — events show attempt 1 then attempt 2 in declared order, one try each, never concurrent." + - "Exhaustion: all routes dead — the invocation surfaces the deterministic exhausted error; then prove zero residue: no session rows appear for failed attempts, session list and spawn capacity match the pre-trigger baseline (Invariant 5's nothing-to-clean-up claim, checked, not assumed)." + - "The fence: force a failure after acceptance (kill the provider once the session is accepted) — the failure follows the normal session lifecycle and produces zero fallback attempts and zero new fallback events." + - "Empty chain: no fallback_chain → single attempt, no event." + must_avoid: + - "Treating memory_controller as a live fallback surface — the current runtime makes no controller LLM call (config-only seam); record that branch as skipped with this reasoning." + - "Chronic-fallback masking questions (provider health diagnosis) beyond confirming events make the pattern visible — that is an observability concern, not this boundary." + coverage: + surfaces: + - "roles..fallback_chain config; forced pre-acceptance provider failure; forced post-acceptance failure" + - "agh logs --workspace --session --type role.fallback.used --last 10 -o json and GET /api/logs?workspace_id=&session_id=&type=role.fallback.used&limit=10 as the durable cross-session query surfaces (workspace_id + parent session_id correlation)" + - "session list / spawn-capacity baseline comparison for residue" + invariants: [5, 6, 8] + adrs: [ADR-004] +``` + + diff --git a/docs/qa/charters/CH-roles-projection-truthfulness.md b/docs/qa/charters/CH-roles-projection-truthfulness.md new file mode 100644 index 000000000..6fbaa2abe --- /dev/null +++ b/docs/qa/charters/CH-roles-projection-truthfulness.md @@ -0,0 +1,37 @@ +# CH-roles-projection-truthfulness: The roles projection never guesses — parity, provenance, and honest nulls everywhere + +```yaml +charter: + id: CH-roles-projection-truthfulness + mission: "As Ada, read the six-role projection through every transport and prove CLI, HTTP, and UDS agree field-for-field, provenance names each field's true winning layer, inherit-mode values stay null instead of fabricated, diagnostics surface broken routes on a 200, and unknown roles fail with role_unknown — with the docs describing exactly this contract." + mode: charter-with-tour + persona: + name: Ada + device: desktop + network: wifi-fast + locale: en-US + journey: J-route-background-work + scenarios: [MS-inspect-background-role-routing] + tour: Feature Tour + time_box_minutes: 60 + guidance: + must_try: + - "Three-way parity: `agh roles list -o json`, HTTP `GET /api/roles?workspace=`, and the UDS route return the same six roles with identical fields; repeat for the single read `agh roles show dream -o json` vs `GET /api/roles/dream` on both transports." + - "Per-field provenance: set one field globally and a different field in a workspace overlay — each non-null routed field reports its own layer (default/global/workspace), including a workspace override that repeats the global value (must still say workspace); fields the projection omits get no invented provenance." + - "Honest nulls: auto_title and memory_extractor in inherit mode show agent null and invocation-dependent fields null — nothing fabricated; timeout appears only on memory_controller." + - "Diagnostics without failure: route dream at a ghost agent — list/show still return 200/exit 0 with a `role_agent_not_found` diagnostic naming the missing agent; `agh roles show bogus` exits non-zero with `role_unknown`, and HTTP/UDS return 404 with the `role_unknown` body code." + - "Truth boundary: while builtins drive dream/checkpoint, `GET /api/agents?workspace=` and `GET /api/agents/catalog?workspace=` stay builtin-free (Invariant 1 re-check from the read side); the api-reference/roles and config-toml docs pages claim exactly the fields and semantics observed — flag any doc claim the runtime does not honor." + must_avoid: + - "Writing routes (CH-background-role-routing-scopes owns mutations beyond the minimal overlay needed for provenance evidence) and the web panel rendering (CH-settings-roles-live-truth)." + - "Inventing a context-specific resolver expectation — the projection is configuration, not an invocation simulation; do not fail it for not predicting inherit-mode outcomes." + coverage: + surfaces: + - "agh roles list|show -o json; GET /api/roles + GET /api/roles/{role} over HTTP and UDS" + - "role_unknown 404 contract; role_agent_not_found diagnostics on 200" + - "per-field provenance truth incl. workspace-equal-to-global; inherit nulls; timeout only in-process" + - "agents catalog builtin-hiding re-check; docs runtime/api-reference/roles + config-toml [roles] parity" + invariants: [1, 4, 11] + adrs: [ADR-001, ADR-003] +``` + + diff --git a/docs/qa/charters/CH-settings-roles-live-truth.md b/docs/qa/charters/CH-settings-roles-live-truth.md new file mode 100644 index 000000000..267a38746 --- /dev/null +++ b/docs/qa/charters/CH-settings-roles-live-truth.md @@ -0,0 +1,38 @@ +# CH-settings-roles-live-truth: The Roles panel tells the truth, applies live, and survives every navigation I throw at it + +```yaml +charter: + id: CH-settings-roles-live-truth + mission: "As Dora, work the Settings → Roles panel with hostile navigation — back, refresh, deep-link, dirty drafts — and prove it renders only what the daemon models (badges, honest nulls, no session-role timeout, no prompt editor), applies valid edits live with truthful confirmation, recovers from invalid fallback entries without losing my draft, and keeps builtins out of the fleet — while Memory settings no longer carries the role-owned controls." + mode: charter-with-tour + persona: + name: Dora + device: desktop + network: wifi-fast + locale: en-US + journey: J-route-background-work + scenarios: [MS-settings-roles-panel, MS-026] + tour: Back-Button Tour + time_box_minutes: 60 + guidance: + must_try: + - "Truthful render: six roles in product order (Coordinator, Dream, Checkpoint summary, Memory extractor, Auto title, Memory controller) with BUILTIN/INHERIT/OFF states, 'Resolves at invocation.' on null fields, provenance chips only where the projection provides them, timeout input only on memory_controller, and no prompt customization control anywhere (ADR-003)." + - "Live save: edit auto_title model → save bar → 'Saved · applied immediately' → hard reload → value persisted; then press back from the panel, return, and confirm no stale draft resurrects." + - "Back-button hostility: dirty the form and navigate away/back (browser back, nav sidebar, refresh mid-save, bookmark /settings/roles and reopen) — a discarded draft never half-applies, and the last applied config remains authoritative (journey abandonment path)." + - "Fallback editor recovery: add/remove/reorder entries; submit an entry missing provider/model — first invalid field focused, draft retained, nothing submitted; fix it and save clean." + - "Diagnostics: with dream routed at a ghost agent, the row shows the role_agent_not_found warning (mono agent name); after repairing the route, a fresh projection clears it." + - "Boundary checks: Agents fleet page shows neither coordinator nor dreaming-curator; Memory settings no longer offers controller-LLM/dream-enable/dream-agent/extractor-model controls while its retained runtime controls still edit and save (settles MS-026); spot-check the panel at a compact viewport width for layout survival." + must_avoid: + - "Full screen-reader/keyboard a11y depth — the panel composes the settings primitives already swept by the Sol settings charters (CH-011 lineage); record this as a deliberate skip and propose a follow-up Sol charter if any regression is suspected." + - "Transport parity and provenance-layer proofs (CH-roles-projection-truthfulness owns them); config-file/CLI writes (CH-background-role-routing-scopes)." + coverage: + surfaces: + - "Web /settings/roles (render, edit, fallback editor, diagnostics, compact viewport)" + - "GET/PATCH /api/settings/roles apply path + GET /api/roles projection read" + - "Live lifecycle confirmation + reload persistence + dirty-draft abandonment" + - "Agents fleet builtin-hiding; Memory settings role-controls hard cut (MS-026)" + invariants: [1, 4, 7, 11] + adrs: [ADR-003, ADR-004] +``` + + diff --git a/docs/qa/journeys/J-digest-sessions-into-memory.md b/docs/qa/journeys/J-digest-sessions-into-memory.md new file mode 100644 index 000000000..63a6fa7e0 --- /dev/null +++ b/docs/qa/journeys/J-digest-sessions-into-memory.md @@ -0,0 +1,63 @@ +# J-digest-sessions-into-memory — Digest working sessions into durable memory + +An operator works normally and trusts the daemon-owned memory pipeline to turn that work into durable, recallable knowledge without any hand-holding: the extractor harvests turns in hidden child work, dream consolidation runs the builtin `dreaming-curator` behind the scenes, and the health/dream surfaces report the whole pipeline truthfully. This journey is the adjacent canary for the `[roles]` rewiring — six consumers changed read paths, and the promise is that default behavior did not drift. + +```mermaid +flowchart TD + A[Entry: normal working session] --> B[Complete real turns] + B --> C[Side effect: hidden extractor work harvests memories] + C --> D[Read extractor status and pending queue] + D --> E{Dream gates satisfied or manual trigger?} + E -->|trigger accepted| F[Hidden dream session runs builtin dreaming-curator] + E -->|gates skip or role disabled| S[Truthful skipped response naming the reason] + S --> B + F --> G[Side effect: consolidated knowledge and decisions written] + G --> H[Dream run inspectable by id: status, show, retry on failure] + H --> I[Memory health reports ok with real counts] + I --> J{Hidden work leaked into public catalogs?} + J -->|yes| X1[BLOCKER: dream or extractor session visible in fleet or session list] + J -->|no| K[True end: fresh memory list and recall return the consolidated knowledge] + F -.->|operator walks away mid-dream| AB[Abandon: close terminal or tab] + AB -.->|return later| H +``` + +```yaml +journey: + id: J-digest-sessions-into-memory + name: "Digest working sessions into durable memory" + value_statement: "My everyday sessions become durable, recallable knowledge automatically, and every stage of that background pipeline is inspectable and truthful." + personas: [Dora, Théo] + entry_points: + - url: "any working session (web session thread or agh session prompt)" + origin: direct + - url: "agh memory dream trigger|status|show|retry; agh memory extractor status|list-pending" + origin: direct + - url: "POST /api/memory/dreams/trigger; GET /api/memory/dreams*; GET /api/memory/health" + origin: direct + actions: + - step: 1 + verb: "Do real session work and let the extractor harvest it" + expected_observable: "Extractor status/pending surfaces show harvested turns without any visible extractor session in public catalogs." + - step: 2 + verb: "Trigger dream consolidation (or hit the gates naturally)" + expected_observable: "Trigger reports running truthfully, or skipped with the exact gate/disabled reason; nothing pretends to run." + - step: 3 + verb: "Inspect the dream run and pipeline health" + expected_observable: "Dream list/status/show report the run by id with a terminal outcome; memory health returns ok with real counts; retry exists for failed runs." + - step: 4 + verb: "Recall the consolidated knowledge" + expected_observable: "A fresh memory list/recall returns the consolidated artifacts produced by this run." + goal: + observable: "One pass of real work flows through extraction and consolidation into recallable knowledge with all surfaces truthful." + side_effects: [hidden-extractor-work, hidden-dream-session, memory-files-written, dream-run-recorded] + true_end_state: "After the dream run reaches a terminal state, fresh reads of memory list, recall, dream show, and memory health agree on what was produced — and no hidden pipeline session ever appeared in the fleet, session list, or agent catalog." + exit: + natural: "The operator returns to normal work trusting the background pipeline." + abandonment: + - at_step: 3 + how: "The operator walks away while the dream session is still running." + resume: "The dream completes in the background; dream show reconstructs the outcome later, and retry covers a failed run." + crosses: [session-runtime, memory-extractor, dream-consolidation, role-resolver, observability, knowledge-catalog] +``` + +Taxonomy sweep: this journey owns the functional pipeline walk (extract → consolidate → recall), the truthful skipped/disabled branches, the hidden-session visibility boundary, and the abandonment/retry path. Experiential depth on the knowledge catalog (paging, recovery, identity isolation) is deliberately owned by J-25, and routing overrides are owned by J-route-background-work — this journey walks defaults on purpose, because its job is catching default-behavior drift. diff --git a/docs/qa/journeys/J-route-background-work.md b/docs/qa/journeys/J-route-background-work.md new file mode 100644 index 000000000..9092d1c9d --- /dev/null +++ b/docs/qa/journeys/J-route-background-work.md @@ -0,0 +1,79 @@ +# J-route-background-work — Route background work deliberately + +An operator assigns the runtime identity and model used by daemon-owned background work, then confirms that global defaults and workspace overrides affect only the intended scope. + +```mermaid +flowchart TD + A[Entry: config.toml, Settings, or agh roles] --> B[Read the effective roles configuration] + B --> C{Choose global or workspace scope} + C -->|global| D[Set a role route and optional fallback] + C -->|workspace| E[Set a workspace role override] + D --> F[Trigger eligible background work] + E --> F + F --> G{Primary route accepted?} + G -->|yes| H[Inspect the resulting hidden session] + G -->|fails before acceptance| FB[Try declared fallbacks once in order] + FB -->|one accepts| H + FB -->|exhausted| R2[No role session accepted; failure remains inspectable] + H --> H2[Fresh work resolves the configured identity and model] + H2 --> NF[Failure after acceptance never changes route] + NF --> I[True end: routing changes only the selected scope and survives a fresh config read] + C -->|invalid role value| R[Reject the write with the exact roles path] + R --> B + R2 --> B + D -.->|discard before save| X[Abandon: the prior routing remains authoritative] +``` + +```yaml +journey: + id: J-route-background-work + name: "Route background work deliberately" + value_statement: "An operator can control the runtime identity and model used by daemon-owned work without changing its policy or leaking configuration across workspaces." + personas: [Dora, Ada] + entry_points: + - url: "config.toml" + origin: direct + - url: "agh config set roles.. (--scope workspace --workspace for overlays)" + origin: direct + - url: "agh__config_list|get|set|unset over exact roles.* leaves (agh__config_path for the selected scope target only)" + origin: direct + - url: "agh roles list|show; GET /api/roles and GET /api/roles/{role} over HTTP or UDS" + origin: direct + - url: "Web /settings/roles" + origin: direct + - url: "docs runtime/core/configuration/config-toml [roles] + runtime/api-reference/roles" + origin: external-share + actions: + - step: 1 + verb: "Read the effective role routing" + expected_observable: "All six roles expose deterministic builtin or inherited defaults and the selected scope provenance." + - step: 2 + verb: "Change one global or workspace role, including an ordered fallback when needed" + expected_observable: "A valid write becomes live for new work; an invalid value names the exact path and preserves the last good configuration." + - step: 3 + verb: "Trigger eligible background work" + expected_observable: "The resulting hidden session uses the resolved agent, provider, model, prompt, and role-specific lifecycle." + - step: 4 + verb: "Compare another workspace and re-read configuration" + expected_observable: "Workspace overrides stay isolated, while global routing remains authoritative elsewhere and survives a fresh read." + goal: + observable: "New background work uses the configured role routing with truthful provenance and workspace isolation." + side_effects: [config-persisted, workspace-cache-invalidated, background-session-started] + true_end_state: "After a fresh config read and a second eligible run, the selected scope still resolves the intended identity and unrelated workspaces remain unchanged." + exit: + natural: "The operator returns to normal work with the desired background routing active." + abandonment: + - at_step: 2 + how: "The operator discards the edit before saving." + resume: "The previous routing remains authoritative and can be edited later." + - at_step: 2 + how: "Validation rejects an invalid role value." + resume: "The exact path identifies the correction; a valid write can be retried without replacing the last good configuration." + crosses: [config-lifecycle, role-resolver, workspace-isolation, session-runtime] +``` + +Taxonomy sweep: this journey owns the functional global/workspace round trip, structured role +discovery (including the native `agh__config_*` write path and the docs pages as entry origin), +pre-acceptance fallback, invalid-value recovery, fresh-read continuity, cross-workspace +isolation, and the Settings Roles surface. Responsive and accessibility checks belong to the +Settings-panel scenario rather than being duplicated by the transport scenarios. diff --git a/docs/qa/reports/2026-07-24-agent-roles.md b/docs/qa/reports/2026-07-24-agent-roles.md new file mode 100644 index 000000000..5fcfa020b --- /dev/null +++ b/docs/qa/reports/2026-07-24-agent-roles.md @@ -0,0 +1,156 @@ +# QA Run Report — 2026-07-24 — Agent Roles + +- **Scope:** Targeted release-grade execution of the six-role routing, fallback, reserved-name, projection, Settings, and default dream-pipeline contracts on `feat/agent-roles`. +- **Cadence tier:** targeted +- **Build:** `a9a8fcad63f4354505e4c9a0701a6d0f559cc991` plus the uncommitted Task 05/06 QA artifacts · **Environment:** isolated `devtool-oss-launch` lab `agent-roles-devtool-oss-launch-20260724-094737-758561`, HTTP `127.0.0.1:51624`, dedicated UDS/runtime/provider homes. +- **Started:** 2026-07-24T11:15:48Z · **Status:** blocked — full Web E2E precondition is red outside Agent Roles + +## Personas + +| Persona | Base | Device / Network / Locale | Sessions | +|---|---|---|---| +| Dora | `devtool-oss-launch` | desktop / wifi-fast / en-US | CH-background-role-routing-scopes, CH-settings-roles-live-truth, CH-dream-pipeline-canary | +| Ada | `devtool-oss-launch` | desktop / wifi-fast / en-US | CH-role-fallback-boundary, CH-reserved-builtin-name-sweep, CH-roles-projection-truthfulness | + +## Flows in Scope + +- `J-route-background-work` — route background work without restart or scope bleed (`../journeys/J-route-background-work.md`). +- `J-digest-sessions-into-memory` — preserve the default extraction and dream pipeline after role rewiring (`../journeys/J-digest-sessions-into-memory.md`). +- `J-32` — retain the existing agent-definition lifecycle while fencing reserved builtin identities. + +## Session Matrix & Results + +| # | Charter | Journey / Scenario | Persona | Tour | Status | Issue | Fix commit | +|---|---|---|---|---|---|---|---| +| 1 | CH-background-role-routing-scopes | J-route-background-work / MS-background-role-routing | Dora | Feature Tour | Fixed | BUG-20260724-coordinator-config-list-path; BUG-20260724-inherited-role-provider-resolution | 69b2099f3; a9a8fcad | +| 2 | CH-role-fallback-boundary | J-route-background-work / MS-background-role-fallback | Ada | Network Tour | Fixed | BUG-20260724-inherited-role-provider-resolution | a9a8fcad | +| 3 | CH-reserved-builtin-name-sweep | J-32 / RT-reserved-builtin-agent-names | Ada | Garbage Tour | Fixed | BUG-20260724-bundle-agent-snapshot-loss; BUG-20260724-reserved-bundle-error-mapping | c841d7e06; a1c966c01 | +| 4 | CH-roles-projection-truthfulness | J-route-background-work / MS-inspect-background-role-routing | Ada | Feature Tour | Pass | | | +| 5 | CH-settings-roles-live-truth | J-route-background-work / MS-settings-roles-panel, MS-026 | Dora | Back-Button Tour | Fixed | BUG-20260724-inherited-role-provider-resolution | a9a8fcad | +| 6 | CH-dream-pipeline-canary | J-digest-sessions-into-memory / MS-011, MS-016, MS-017 | Dora | Feature Tour | Fixed | BUG-20260724-memory-extractor-agent-tier | b6f740843 | + +Status legend: `Pending | Pass | Fixed | Skipped | Blocked (needs human verify) | Blocked (human decision)` + +## Automated Preconditions + +| Gate | Result | Evidence | +|---|---|---| +| `make test-e2e-runtime` | Pass after two invalid new assertions were corrected in their canonical daemon suite | Focused `-race` replay passed 4 cases; the complete rerun ended without a reported failure. Initial log: `/Users/pedronauck/Library/Application Support/rtk/tee/1784886632_make_test-e2e-runtime.log`. | +| `make test-e2e-web` | Fail — 51 passed, 62 failed in 1.2h | `/Users/pedronauck/Library/Application Support/rtk/tee/1784891469_make_test-e2e-web.log`; the feature-owned `settings.spec.ts:402` Roles journey passed in 5.7s. The 62-case matrix exactly reproduces the broad pre-existing OS-shell selector/fixture drift already observed during Task 03. | +| `make verify` | Pass | Final source-frozen run on 2026-07-24 exited 0: codegen/installer checks, Bun lint/typecheck/test, Web build, Go fmt/lint/race tests/build, and boundaries all passed with zero reported errors. | +| QA teardown | Pass | `qa-artifacts/qa/teardown.json`: `clean: true`, killed only registered Web PID 89757 and daemon PID 93662, `survivors: []`. | + +The Web gate failure is inherited automated-suite debt rather than a reproduced Agent Roles product defect. It remains a release blocker: this report cannot conclude `ready`, and Task 06 cannot satisfy its all-green exit contract while the full lane is red. + +## Session Debriefs + +### CH-background-role-routing-scopes — Fixed + +- Global and workspace `dream` fields retained independent provenance, including a workspace override equal to the global value; the workspace agent route repaired cleanly from `ghost` to `qa-curator`. +- Exact CLI config writes rejected the max-children bound, four deleted paths, invalid `roles.dream.timeout`, and an old `[memory.dream] agent` key while preserving the last good configuration. +- A real Codex session resolved and called `agh__config_list|get|path|set|unset`; the temporary auto-title model was live immediately and restored afterward. +- Two defects were fixed and retested: the leaked `roleconfig` path and the inherited provider chain. The latter's final hidden auto-title child ran on `general/codex/gpt-5.6-luna` with no restart and no fallback. + +### CH-role-fallback-boundary — Fixed + +- A real auto-title primary failed before acceptance and advanced exactly once to `codex/gpt-5.6-sol`; CLI and HTTP returned the same durable event id `sum-60ee72216e65824f`, parent session, workspace, role, attempt, provider, and model. +- After the inherited-provider fix, the same public workflow ran the primary `codex/gpt-5.6-luna`, generated the title, and emitted zero fallback events. +- Ordered multi-entry exhaustion, single-try behavior, zero failed-attempt residue, empty-chain behavior, and the no-fallback-after-acceptance fence passed in the real-daemon integration lane. +- Deliberate skip: the public CLI/HTTP/UDS contract has no operation that kills only an accepted hidden ACP child. `agh session stop` is a normal lifecycle action and would not prove the fence, so no synthetic public evidence was claimed. The `memory_controller` branch was also skipped because it has no live LLM invocation. + +### CH-reserved-builtin-name-sweep — Fixed + +- CLI, HTTP, UDS, and real native `agh__agent_create` rejected reserved creates; rename and duplicate rejected without mutating the source; case/whitespace variants rejected; `coordinator-helper` succeeded and was cleaned up. +- The bundle probe found and fixed both snapshot loss and error-code collapse. After rebuild, CLI/HTTP/UDS/native activation returned `agent_name_reserved`, HTTP/UDS used 422, and activation/agent catalogs remained unchanged. +- A pre-existing `$AGH_HOME/agents/coordinator/AGENT.md` was diagnosed and skipped at boot while the virtual coordinator still resolved. CLI, API, UDS, native, and Fleet catalogs stayed builtin-free. + +### CH-roles-projection-truthfulness — Pass + +- Normalized six-role list and single-role `dream` responses matched field-for-field across CLI, HTTP, and UDS. +- Provenance reported global/workspace ownership per field, including an equal-value workspace override. Inherited projection fields stayed null, timeout appeared only on memory-controller, and no field gained invented provenance. +- A ghost dream agent returned a 200/exit-0 projection with `role_agent_not_found`; an unknown role returned nonzero/404 with exact `role_unknown` envelopes on all three surfaces. + +### CH-settings-roles-live-truth — Fixed + +- Browser QA rendered all six roles in product order with truthful BUILTIN/INHERIT/OFF states, per-field provenance, only the memory-controller timeout, no prompt editor, and no horizontal overflow at 900×700. +- Auto-title model/fallback saved live and survived hard reload. A dirty model draft disappeared after navigating away/back; a blank fallback retained its draft and focused `auto_title.fallback.0.provider`; the repaired entry saved cleanly. +- A `ghost` dream agent rendered an inline diagnostic with the mono agent name, which cleared after repair. Fleet showed five authored agents and neither builtin. +- The adjacent Memory page retained its persistence, recall, dreaming-policy, ledger, daily-log, and workspace controls while exposing none of the removed role-owned controls. Deep screen-reader/keyboard re-sweep was deliberately skipped under the charter's existing Settings-primitives coverage. +- The live save exposed BUG-20260724-inherited-role-provider-resolution; the rebuilt-daemon retest generated the title on the primary route and no fallback event. + +### CH-dream-pipeline-canary — Fixed + +- The first sparse-lab dream trigger truthfully skipped with no candidate run. Live disabling returned `dream consolidation is disabled`; re-enabling changed the next trigger to the truthful gate-not-satisfied reason without restart. +- A real provider-backed turn exposed invalid extractor `agent_tier` metadata outside agent scope. After the fix, a fresh turn produced two workspace memories, drain reached zero pending, no new DLQ appeared, and health returned `ok` with two indexed workspace files and zero orphans. +- Dream status/list remained truthfully empty because no run met the gates; no fake run was created. Hidden-session and agent-catalog checks remained clean. + +## What Was Fixed + +### BUG-20260724-coordinator-config-list-path: Coordinator enabled state is published under a path operators cannot use + +- **Symptom:** `agh config list` exposed `roles.coordinator.roleconfig.enabled` instead of the writable public path `roles.coordinator.enabled`. +- **Root cause:** the redacted reflection projector named an anonymous embedded TOML struct after its Go type instead of flattening its fields. +- **Fix:** `69b2099f3cada66395ced4c8ae862b21b5ebc996` merges anonymous untagged TOML struct fields into their parent projection. +- **Regression test:** `internal/cli/config_test.go` — the canonical config rendering suite failed before and passes after the fix. +- **Retested:** J-route-background-work baseline from a rebuilt/restarted daemon; list and single-value reads expose only the canonical path. Package `-race` and repository lint gates pass. + +### BUG-20260724-memory-extractor-agent-tier: Extractor emits agent tier metadata outside agent scope + +- **Symptom:** global/workspace candidates could carry `agent_tier`, causing controller rejection and a DLQ entry. +- **Root cause:** the prompt presented the field universally and the untrusted-output adapter preserved it for every scope. +- **Fix:** `b6f7408439a68b9e5225b1b086770b4e37347e58` makes the prompt conditional and strips the field outside agent scope. +- **Retested:** a real session produced two valid workspace memories; pending and new-DLQ counts were zero and Memory health was `ok`. + +### BUG-20260724-bundle-agent-snapshot-loss: Installed bundle profiles silently lose packaged agents + +- **Symptom:** a profile containing `agents/coordinator` previewed/activated as empty, bypassing materialization validation. +- **Root cause:** `cloneBundleSpecs` omitted `BundleProfile.Agents`. +- **Fix:** `c841d7e06428c28e4e1b4ba8c17bccb4a103eea1` deep-clones packaged agents and their Soul/Heartbeat sidecars. +- **Retested:** the same installed fixture reached reserved-name validation with no activation or agent residue. + +### BUG-20260724-reserved-bundle-error-mapping: Reserved bundle agents return internal errors + +- **Symptom:** the domain sentinel became HTTP/UDS 500 and native `tool_backend_failed`. +- **Root cause:** bundle status mapping omitted `ErrAgentNameReserved`, and the native adapter relied on the generic status. +- **Fix:** `a1c966c01b40ae37372e4431704703acd92e679a` maps 422 and preserves `agent_name_reserved` natively. +- **Retested:** CLI/HTTP/UDS/native all returned the exact code and left catalogs unchanged. + +### BUG-20260724-inherited-role-provider-resolution: Model-only inherited roles omit the invoking provider + +- **Symptom:** valid model-only inherited auto-title/extractor routes reached Spawn with an empty provider and failed before acceptance. +- **Root cause:** the resolver returned early for `Inherit` before reading invocation correlation and the invoking-agent/default provider chain. +- **Fix:** `a9a8fcad63f4354505e4c9a0701a6d0f559cc991` resolves the correlated agent only in invocation context while projection reads remain honestly unresolved. +- **Retested:** hidden child `sess-afc891322e1060b8` ran `general/codex/gpt-5.6-luna`, completed, generated the parent title, stayed out of the fleet, and emitted zero fallback events. The daemon package passed 1,399 `-race` tests and lint passed. + +## Paper Cuts + +| Persona | Where (journey/step) | Felt | Sharpness | Outcome | +|---|---|---|---|---| +| Dora | J-route-background-work / inherited config inspection | `agh config get` on an unset inherited leaf returns path-not-found while the Roles projection truthfully returns null. | Low | Accepted contract boundary: config reads persisted leaves; role projection owns effective/inherited truth. | + +## Runtime Errors Observed + +- The complete daemon-served Playwright matrix reproduced 62 pre-existing failures across Agents, Automation, Bridges, Dashboard, Jobs, Knowledge, Marketplace, Network, OS shell, Sandbox, Sessions, Settings, Tasks, Triggers, and Workspace Setup. Representative failures wait for retired `nav-agents`/sidebar selectors while the current product uses the OS dock; several catalog/detail fixtures also expect stale UI text or state. The Agent Roles case passed. Evidence: `/Users/pedronauck/Library/Application Support/rtk/tee/1784891469_make_test-e2e-web.log`. + +## Human Verifications Needed + +None. The two deliberate charter skips are bounded contract limitations, not hidden requests for manual confirmation: full screen-reader/keyboard depth is owned by the existing Settings-primitives lineage, and no public surface can fault only an accepted hidden child. + +## Decisions for a Human + +### Restore the full daemon-served Web E2E precondition + +- **What's broken:** `make test-e2e-web` is red with 62 broad failures even though the feature-owned Roles journey is green. +- **Why not auto-fixed:** the backlog spans more than twenty spec files and many product surfaces, has multiple root-cause clusters, and fails the QA fix-loop bounds for small size, demonstrated single cause, and contained blast radius. +- **Options:** + 1. Run a dedicated owner-by-owner E2E stabilization round, align each stale journey with the current OS-shell/product contract, and rerun the full matrix — larger scope, but restores the required release signal. + 2. Explicitly accept the inherited gate exception for this feature after preserving the green Roles-specific case — faster, but leaves Task 06's written all-green success criterion unsatisfied. +- **Recommendation:** option 1 through a dedicated `agent-output-audit`/frontend stabilization workstream, then rerun Task 06's complete Web lane. + +## Learnings + +- A feature-specific green Playwright journey does not replace the repository's complete daemon-served browser precondition. + +## Final Status + +Blocked only by the full daemon-served Web E2E decision. All six charters are executed, every reproduced Agent Roles defect is fixed and retested, the feature-owned browser E2E is green, final `make verify` passed, and mandatory teardown is clean with zero survivors. diff --git a/docs/qa/scenarios/MS-011.md b/docs/qa/scenarios/MS-011.md index c3234126e..d4a6d9dba 100644 --- a/docs/qa/scenarios/MS-011.md +++ b/docs/qa/scenarios/MS-011.md @@ -3,16 +3,16 @@ id: MS-011 area: MS title: Memory health snapshot persona: Rafa -journey: +journey: J-digest-sessions-into-memory expected: `GET /api/memory/health?workspace_id=` returns status ok/disabled/degraded/unavailable + counts. entry_points: Web memory settings health card; `GET /api/memory/health` qa_status: pass -bug_ids: BUG-0031 +bug_ids: BUG-0031;BUG-20260724-memory-extractor-agent-tier fix_status: fixed retest_status: pass -fix_commits: f6748f2f -evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-025-memory-core/ms001-ms024-memory-core.json; /home/pedronauck/dev/qa-labs/agh-northstar-pay-20260711-153916-425791-lab/qa-artifacts/qa/notes/bug-0026-memory-health-retest.json -last_report: docs/qa/reports/2026-07-11-northstar-pay.md +fix_commits: f6748f2f;b6f7408439a68b9e5225b1b086770b4e37347e58 +evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-025-memory-core/ms001-ms024-memory-core.json; /home/pedronauck/dev/qa-labs/agh-northstar-pay-20260711-153916-425791-lab/qa-artifacts/qa/notes/bug-0026-memory-health-retest.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/memory-pipeline-retest-health.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md overlaps: --- @@ -29,3 +29,7 @@ src: docs/qa/_seeds/feature-stories/05_analysis_memory-settings.md inventory: Implemented QA 2026-07-11: exact source counting verified through /api/status, /api/memory/health, and agh__memory_health with global=4, workspace=206, indexed=210, orphaned=0. + +QA 2026-07-24: BUG-20260724-memory-extractor-agent-tier fixed and retested. A provider-backed turn produced two workspace memories with no new DLQ record; Memory health returned `status=ok`, `workspace_files=2`, `indexed_files=2`, and `orphaned_files=0`. + +Planning 2026-07-24 (Task 05): linked to J-digest-sessions-into-memory. Verdict kept `pass` — the Agent Roles rewiring pins default behavior by spec; CH-dream-pipeline-canary re-walks this as the adjacent drift canary instead of a flag reset. diff --git a/docs/qa/scenarios/MS-016.md b/docs/qa/scenarios/MS-016.md index b5a26cfca..278262b75 100644 --- a/docs/qa/scenarios/MS-016.md +++ b/docs/qa/scenarios/MS-016.md @@ -3,16 +3,16 @@ id: MS-016 area: MS title: Trigger dream consolidation persona: Dora -journey: +journey: J-digest-sessions-into-memory expected: `POST /api/memory/dreams/trigger`: if `DreamTrigger` nil/disabled → 200 skipped+reason; else running/skipped. entry_points: Web `useTriggerMemoryDream`; `POST /api/memory/dreams/trigger` -qa_status: pass +qa_status: untested bug_ids: fix_status: retest_status: fix_commits: -evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-025-memory-core/ms001-ms024-memory-core.json -last_report: +evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-025-memory-core/ms001-ms024-memory-core.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/dream-trigger-disabled.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/dream-trigger-reenabled.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md overlaps: --- @@ -27,3 +27,11 @@ HTTP and UDS CLI dream trigger returned truthful running/skipped trigger payload src: docs/qa/_seeds/feature-stories/05_analysis_memory-settings.md inventory: Implemented + +Planning 2026-07-24 (Task 05): linked to J-digest-sessions-into-memory and persona normalized to Dora. The disabled branch is now governed by `roles.dream.enabled` (live-applied, evaluated per trigger) instead of the deleted `memory.dream.enabled`. Verdict kept `pass`; CH-dream-pipeline-canary re-walks trigger truthfulness — including the skipped+reason branch — as the adjacent drift canary. + +QA 2026-07-24: disabling `roles.dream.enabled` live returned skipped with `dream consolidation is disabled`; re-enabling live changed the next trigger to the truthful sparse-lab gate reason `dream consolidation gates are not satisfied`, without a daemon restart. + +QA impact 2026-07-24 (final review remediation): Dream enablement now resolves per workspace even +when the global role is disabled, and rollback failures remain observable instead of being reported +as clean skips. The next QA cycle owns both corrected branches. diff --git a/docs/qa/scenarios/MS-017.md b/docs/qa/scenarios/MS-017.md index 9d9a21cfd..9e68fe6e9 100644 --- a/docs/qa/scenarios/MS-017.md +++ b/docs/qa/scenarios/MS-017.md @@ -3,7 +3,7 @@ id: MS-017 area: MS title: Dreams list/status/get/retry persona: Dora -journey: +journey: J-digest-sessions-into-memory expected: `GET /api/memory/dreams`, `/dreams/status` (always `[]` — truthful empty), `/dreams/:id`, `POST /dreams/:id/retry`. entry_points: Dream routes qa_status: pass @@ -11,8 +11,8 @@ bug_ids: fix_status: retest_status: fix_commits: -evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-025-memory-core/ms001-ms024-memory-core.json -last_report: +evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-025-memory-core/ms001-ms024-memory-core.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/memory-dream-status-after-trigger.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md overlaps: --- @@ -27,3 +27,7 @@ Dream status/list returned non-nil lists, missing dream get returned 404, retry src: docs/qa/_seeds/feature-stories/05_analysis_memory-settings.md inventory: Implemented + +Planning 2026-07-24 (Task 05): linked to J-digest-sessions-into-memory and persona normalized to Dora. Verdict kept `pass`; CH-dream-pipeline-canary re-walks dream run inspection (list/status/show/retry) end-to-end as the adjacent drift canary for the roles rewiring. + +QA 2026-07-24: the sparse isolated lab truthfully skipped the re-enabled trigger because the gates were not satisfied; status/list remained a valid empty projection with no fabricated run, preserving the existing get/retry contract for actual run ids. diff --git a/docs/qa/scenarios/MS-026.md b/docs/qa/scenarios/MS-026.md index 1674feb82..fc33a785f 100644 --- a/docs/qa/scenarios/MS-026.md +++ b/docs/qa/scenarios/MS-026.md @@ -6,13 +6,13 @@ persona: Dora journey: expected: `GET/PATCH /api/settings/memory`; non-local provider validated against registry. entry_points: Web `/settings/memory`; memory settings routes -qa_status: untested +qa_status: pass bug_ids: BUG-0016 fix_status: fixed retest_status: pass fix_commits: -evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-026-settings-core/ms025-ms037-settings-core.json -last_report: +evidence: /Users/pedronauck/dev/qa-labs/agh-feature-stories-20260621-215417-232496-lab/qa-artifacts/qa/evidence/batch-026-settings-core/ms025-ms037-settings-core.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/screenshots/settings-memory-role-controls-absent.png +last_report: docs/qa/reports/2026-07-24-agent-roles.md overlaps: --- @@ -29,3 +29,7 @@ src: docs/qa/_seeds/feature-stories/05_analysis_memory-settings.md inventory: Implemented 2026-07-21: qa_status reset to untested — the opendesign redesigns restructured this scenario's web entry surface (task detail/run detail 3-tab IA, settings takeover shell, or providers page); the pass verdict predates that surface. + +2026-07-23: remains untested — Agent Roles removed role-owned routing controls from Memory settings (`controller.llm`, dream enable/agent, extractor enable/model). Verify that retained memory-runtime controls remain editable and that role routing is managed only through the Roles surface. + +QA 2026-07-24: the redesigned Memory page loaded against the real daemon with its persistence, recall, dreaming policy/gates, ledger, daily-log, and workspace-identity controls intact. It exposed none of the removed controller-LLM, dream enable/agent, extractor enable/model, or auto-title routing controls; routing lives only under Settings → Roles. diff --git a/docs/qa/scenarios/MS-background-role-fallback.md b/docs/qa/scenarios/MS-background-role-fallback.md new file mode 100644 index 000000000..18eb679d0 --- /dev/null +++ b/docs/qa/scenarios/MS-background-role-fallback.md @@ -0,0 +1,34 @@ +--- +id: MS-background-role-fallback +area: MS +title: Fall back background role routing before acceptance +persona: Ada +journey: J-route-background-work +expected: When a primary role route fails before acceptance, AGH tries each declared fallback once in order, emits one correlated role.fallback.used event before each attempt, and never reroutes an accepted ACP session. +entry_points: config.toml roles..fallback_chain; eligible coordinator, dream, extractor, auto-title, or checkpoint-summary invocation; agh logs --workspace --session --type role.fallback.used --last 10 -o json; GET /api/logs?workspace_id=&session_id=&type=role.fallback.used&limit=10 +qa_status: untested +bug_ids: BUG-20260724-inherited-role-provider-resolution +fix_status: fixed +retest_status: pass +fix_commits: a9a8fcad63f4354505e4c9a0701a6d0f559cc991 +evidence: /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/ui-live-fallback-cli.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/ui-live-fallback-http.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/inherit-provider-fix-fallback-events.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md +overlaps: MS-background-role-routing;MS-inspect-background-role-routing +--- + +QA impact 2026-07-23: ordered role fallback is new behavior. Planning flag only; the next QA cycle +owns successful advance, exhaustion cleanup, durable event correlation, and the no-fallback-after- +acceptance fence. + +Planning 2026-07-24 (Task 05): entry points repaired for runtime truth — the memory controller has +no live LLM invocation in the current runtime (its fallback chain is a config-only seam, Task 02 +evidence), so it is not an eligible fallback surface; the correlated `role.fallback.used` records +are cross-session runtime logs — not a session-events feed — read via `agh logs` or +`GET /api/logs` filtered by type, with workspace_id and parent session_id correlation preserved +on every record. Session charter: CH-role-fallback-boundary. + +QA 2026-07-24: a real auto-title primary failed before acceptance, the configured `codex/gpt-5.6-sol` fallback completed the work, and CLI/HTTP returned the same single correlated `role.fallback.used` event. After fixing the primary inherited-provider chain, the same workflow completed on `codex/gpt-5.6-luna` with zero fallback events. Ordered exhaustion, zero residue, and the post-acceptance fence passed in the real-daemon integration lane; public post-acceptance fault injection was explicitly skipped because no supported surface can kill only the accepted hidden ACP child. + +QA impact 2026-07-24 (final review remediation): structured root `[roles]` mutations can now carry +ordered fallback chains through CLI and native config surfaces. The next QA cycle owns this new +mutation path; prior runtime fallback evidence remains historical. diff --git a/docs/qa/scenarios/MS-background-role-routing.md b/docs/qa/scenarios/MS-background-role-routing.md new file mode 100644 index 000000000..4ba4f93d9 --- /dev/null +++ b/docs/qa/scenarios/MS-background-role-routing.md @@ -0,0 +1,27 @@ +--- +id: MS-background-role-routing +area: MS +title: Route background work by global and workspace role +persona: Dora +journey: J-route-background-work +expected: New coordinator, dream, extractor, auto-title, checkpoint-summary, and memory-controller work resolves the configured global or workspace identity and model without changing role policy or leaking across workspaces. +entry_points: config.toml; agh config set roles.. ; agh config set roles ''; agh config set --scope workspace --workspace roles ''; agh__config_list|get|set|unset over exact roles.* leaves or the structured roles table (agh__config_path proves the selected scope target only); docs runtime/core/configuration/config-toml [roles] +qa_status: untested +bug_ids: BUG-20260724-coordinator-config-list-path;BUG-20260724-inherited-role-provider-resolution +fix_status: fixed +retest_status: pass +fix_commits: 69b2099f3cada66395ced4c8ae862b21b5ebc996;a9a8fcad63f4354505e4c9a0701a6d0f559cc991 +evidence: /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/config-list-coordinator-after-fix.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/config-get-coordinator-enabled-after-fix.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/native-tools-session-2-history.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/inherit-provider-fix-auto-title-child.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md +overlaps: MS-026; RT-033; RT-session-auto-title; MS-workspace-checkpoint-continuity +--- + +QA impact 2026-07-23: the six daemon-owned background roles moved to the live `[roles]` routing model with global defaults, workspace overlays, builtin identities, and strict rejection of the deleted role-bearing keys. Planning update only; the next QA cycle owns the real-user walk. + +Planning 2026-07-24 (Task 05): persona reconciled Bruno → Dora — role routing is runtime administration (config keys, daemon ownership), which is Dora's definition. Entry points widened to the full write plane: workspace-scoped `agh config set`, the native `agh__config_list|get|set|unset` tools over exact `roles.*` leaves (removed paths must reject deterministically; `agh__config_path` only proves the selected global/workspace config file target and scope), and the docs config reference as the entry origin real users start from. Session charter: CH-background-role-routing-scopes. + +QA 2026-07-24: global/workspace overlays, strict removed-key rejection, bounded validation, native config lifecycle, ghost diagnostics, hidden builtin sessions, and live invocation all passed. Two contained defects were fixed and retested: the flattened coordinator config path and model-only inherited-provider resolution. + +QA impact 2026-07-24 (final review remediation): CLI and native config mutation now accept the +complete structured `[roles]` table at global and workspace scope, including fallback chains. The +next QA cycle owns this broader agent-manageable write path. diff --git a/docs/qa/scenarios/MS-inspect-background-role-routing.md b/docs/qa/scenarios/MS-inspect-background-role-routing.md new file mode 100644 index 000000000..8ee984b76 --- /dev/null +++ b/docs/qa/scenarios/MS-inspect-background-role-routing.md @@ -0,0 +1,23 @@ +--- +id: MS-inspect-background-role-routing +area: MS +title: Inspect effective background role routing +persona: Ada +journey: J-route-background-work +expected: CLI, HTTP, and UDS expose the same six-role projection with truthful per-field provenance, nullable inherited values, actionable diagnostics, and no builtin identities in agent catalogs. +entry_points: agh roles list|show -o json; GET /api/roles and GET /api/roles/{role} over HTTP; GET /api/roles and GET /api/roles/{role} over UDS; docs runtime/api-reference/roles +qa_status: pass +bug_ids: +fix_status: +retest_status: pass +fix_commits: +evidence: /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/roles-cli.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/roles-http.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/roles-uds.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/role-unknown-http.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/role-dream-ghost-http.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md +overlaps: MS-background-role-routing +--- + +QA impact 2026-07-23: the read-only roles contract and structured CLI verbs are new. Planning flag only; the next QA cycle owns the real-user parity walk. + +Planning 2026-07-24 (Task 05): entry points widened to include the single-role read (`GET /api/roles/{role}` on both transports — `role_unknown` 404 is part of the contract) and the API reference docs page as the entry origin. Session charter: CH-roles-projection-truthfulness. + +QA 2026-07-24: normalized list/show payloads matched across CLI, HTTP, and UDS; equal-value workspace provenance remained `workspace`; inherited fields stayed null; a ghost route returned a 200 projection with `role_agent_not_found`; and unknown roles returned the exact nonzero/404 `role_unknown` contract. diff --git a/docs/qa/scenarios/MS-settings-roles-panel.md b/docs/qa/scenarios/MS-settings-roles-panel.md new file mode 100644 index 000000000..d7c960362 --- /dev/null +++ b/docs/qa/scenarios/MS-settings-roles-panel.md @@ -0,0 +1,31 @@ +--- +id: MS-settings-roles-panel +area: MS +title: Configure background roles in Settings +persona: Dora +journey: J-route-background-work +expected: Settings → Roles renders the six roles in product order with truthful builtin, inherit, off, provenance, fallback, and diagnostic states; a valid edit applies Live, survives reload, and never exposes virtual builtins in the Agents fleet. +entry_points: Web /settings/roles; GET/PATCH /api/settings/roles; GET /api/roles +qa_status: untested +bug_ids: BUG-20260724-inherited-role-provider-resolution +fix_status: fixed +retest_status: pass +fix_commits: a9a8fcad63f4354505e4c9a0701a6d0f559cc991 +evidence: /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/screenshots/settings-roles-loaded.png; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/screenshots/settings-roles-ghost-diagnostic.png; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/screenshots/settings-roles-compact.png; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/ui-settings-roles-after-save.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/inherit-provider-fix-parent-after-title.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md +overlaps: MS-background-role-routing;MS-inspect-background-role-routing;MS-026;RT-reserved-builtin-agent-names +--- + +QA impact 2026-07-23: the Roles settings surface is new. Planning flag only; the next QA cycle owns +desktop and compact viewport behavior, accessibility, validation recovery, Live save/reload, and +truthful catalog separation. + +Planning 2026-07-24 (Task 05): persona reconciled Bruno → Dora (Settings pages are Dora's secondary +surface; the panel is runtime administration). Session charter: CH-settings-roles-live-truth, which +also settles MS-026's retained-memory-controls check on the adjacent Memory settings page. + +QA 2026-07-24: the real panel rendered six roles in product order with truthful states/provenance, applied and reloaded a model plus fallback live, discarded a dirty draft on navigation, retained/focused invalid fallback input, cleared a repaired ghost diagnostic, hid builtins from the fleet, and survived a 900x700 viewport without horizontal overflow. The saved model-only inherited route initially exposed BUG-20260724-inherited-role-provider-resolution; its rebuilt-daemon retest generated the title on the primary route with no fallback. + +QA impact 2026-07-24 (final review remediation): incomplete role rosters now fail closed and invalid +numeric fields receive focus when they block save. The next QA cycle owns these corrected error and +recovery states. diff --git a/docs/qa/scenarios/RT-reserved-builtin-agent-names.md b/docs/qa/scenarios/RT-reserved-builtin-agent-names.md new file mode 100644 index 000000000..ef1d8a7cb --- /dev/null +++ b/docs/qa/scenarios/RT-reserved-builtin-agent-names.md @@ -0,0 +1,25 @@ +--- +id: RT-reserved-builtin-agent-names +area: RT +title: Reject authored builtin agent identities +persona: Ada +journey: J-32 +expected: Create, rename, duplicate, native-tool, and bundle materialization attempts for coordinator or dreaming-curator fail with agent_name_reserved, create no authored directory, and leave the agent catalog unchanged. +entry_points: agh agent create|update|duplicate; POST/PUT /api/agents over HTTP or UDS; agh__agent_create; bundle activation; pre-existing $AGH_HOME/agents// directory at boot (discovery skip); docs runtime/core/configuration/agent-md reserved builtin names +qa_status: pass +bug_ids: BUG-20260724-bundle-agent-snapshot-loss;BUG-20260724-reserved-bundle-error-mapping +fix_status: fixed +retest_status: pass +fix_commits: c841d7e06428c28e4e1b4ba8c17bccb4a103eea1;a1c966c01b40ae37372e4431704703acd92e679a +evidence: /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/reserved-bundle-http-fixed-probe.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/reserved-bundle-uds-fixed-probe.json; /Users/pedronauck/dev/qa-labs/agh-agent-roles-devtool-oss-launch-20260724-094737-758561-lab/qa-artifacts/qa/native-bundle-capable-session-history.json +last_report: docs/qa/reports/2026-07-24-agent-roles.md +overlaps: RT-081;MS-inspect-background-role-routing +--- + +QA impact 2026-07-23: builtin identities are newly reserved across every authoring surface. Planning flag only; the next QA cycle owns the real-user rejection sweep. + +Planning 2026-07-24 (Task 05): entry points widened to the discovery-skip surface (a pre-existing on-disk reserved directory must be skipped at boot with a warning diagnostic naming the path, and must never shadow the builtin) and the agent-md docs note real users will read first. Session charter: CH-reserved-builtin-name-sweep. + +QA 2026-07-24: bundle snapshot loss and reserved-error mapping were fixed and retested. CLI, HTTP, UDS, and a real `agh__bundles_activate` call reject the packaged coordinator with `agent_name_reserved`; HTTP/UDS return 422, the synthetic bundle path is named, and activation/agent catalogs remain unchanged. + +The create/update/duplicate/native/normalization/near-miss sweep and a pre-existing on-disk coordinator shadow also passed; the shadow was diagnosed and skipped while the virtual builtin continued to resolve, and neither builtin appeared in CLI, HTTP, UDS, native, or fleet catalogs. diff --git a/internal/api/contract/authored_context_helpers.go b/internal/api/contract/authored_context_helpers.go index 5be02bf74..49738d84c 100644 --- a/internal/api/contract/authored_context_helpers.go +++ b/internal/api/contract/authored_context_helpers.go @@ -2,7 +2,6 @@ package contract import ( "encoding/json" - "fmt" "strings" "time" @@ -11,6 +10,11 @@ import ( soulpkg "github.com/compozy/agh/internal/soul" ) +const ( + authoredContextModelField = "model" + authoredContextProviderField = "provider" +) + func soulDiagnosticsPayload(items []soulpkg.Diagnostic) []AuthoredContextDiagnosticPayload { if len(items) == 0 { return nil @@ -164,7 +168,7 @@ func ownerSurfaceForAuthoredDiagnostic(code string, field string, section string switch key { case "tools", "tool", "capabilities", "capability": return "capabilities.toml" - case "provider", "model", "permission", "permissions": + case authoredContextProviderField, authoredContextModelField, "permission", "permissions": return "AGENT.md" case "task", "tasks", "lease", "claim", "claim_token", "heartbeat": return "task runtime" diff --git a/internal/api/contract/diagnostics.go b/internal/api/contract/diagnostics.go index 9d01e352b..f161939bd 100644 --- a/internal/api/contract/diagnostics.go +++ b/internal/api/contract/diagnostics.go @@ -39,6 +39,7 @@ const ( ) const ( + CodeAgentNameReserved = diagnosticcontract.CodeAgentNameReserved CodeBinaryVersionMismatch = diagnosticcontract.CodeBinaryVersionMismatch CodeBridgeHealthUnavailable = diagnosticcontract.CodeBridgeHealthUnavailable CodeBridgeNotFound = diagnosticcontract.CodeBridgeNotFound @@ -115,6 +116,8 @@ const ( CodeProviderTransientFailure = diagnosticcontract.CodeProviderTransientFailure CodeReasoningEffortUnsupported = diagnosticcontract.CodeReasoningEffortUnsupported CodeReasoningOptionMissing = diagnosticcontract.CodeReasoningOptionMissing + CodeRoleAgentNotFound = diagnosticcontract.CodeRoleAgentNotFound + CodeRoleUnknown = diagnosticcontract.CodeRoleUnknown CodeRetryChainTooDeep = diagnosticcontract.CodeRetryChainTooDeep CodeSchedulerReady = diagnosticcontract.CodeSchedulerReady CodeSchedulerPaused = diagnosticcontract.CodeSchedulerPaused diff --git a/internal/api/contract/roles.go b/internal/api/contract/roles.go new file mode 100644 index 000000000..43089017a --- /dev/null +++ b/internal/api/contract/roles.go @@ -0,0 +1,49 @@ +package contract + +// RoleResolutionMode identifies how one configured background role finds its identity. +type RoleResolutionMode string + +const ( + RoleResolutionModeBuiltin RoleResolutionMode = "builtin" + RoleResolutionModeCatalog RoleResolutionMode = "catalog" + RoleResolutionModeInherit RoleResolutionMode = "inherit" +) + +// RoleFallbackStatus is one configured fallback route in declaration order. +type RoleFallbackStatus struct { + Provider string `json:"provider"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` +} + +// RoleDiagnostic reports a current configuration-resolution problem without simulating an invocation. +type RoleDiagnostic struct { + Code string `json:"code"` + Message string `json:"message"` + Agent string `json:"agent,omitempty"` +} + +// RoleStatus is the effective configuration projection for one background role. +type RoleStatus struct { + Role string `json:"role"` + Enabled bool `json:"enabled"` + ResolutionMode RoleResolutionMode `json:"resolution_mode"` + Agent *string `json:"agent"` + Provider *string `json:"provider"` + Model *string `json:"model"` + ReasoningEffort *string `json:"reasoning_effort"` + Timeout *string `json:"timeout,omitempty"` + FallbackChain []RoleFallbackStatus `json:"fallback_chain"` + Provenance map[string]string `json:"provenance"` + Diagnostics []RoleDiagnostic `json:"diagnostics"` +} + +// RolesResponse wraps the closed role roster. +type RolesResponse struct { + Roles []RoleStatus `json:"roles"` +} + +// RoleStatusResponse wraps one role projection. +type RoleStatusResponse struct { + Role RoleStatus `json:"role"` +} diff --git a/internal/api/contract/roles_test.go b/internal/api/contract/roles_test.go new file mode 100644 index 000000000..c8e99e615 --- /dev/null +++ b/internal/api/contract/roles_test.go @@ -0,0 +1,85 @@ +package contract + +import ( + "encoding/json" + "reflect" + "sort" + "testing" +) + +func TestRoleStatusJSONContract(t *testing.T) { + t.Parallel() + + t.Run("Should preserve inherit nulls and omit session role timeout", func(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(RoleStatus{ + Role: "auto_title", + Enabled: true, + ResolutionMode: RoleResolutionModeInherit, + FallbackChain: []RoleFallbackStatus{}, + Provenance: map[string]string{"enabled": "default", "fallback_chain": "default"}, + Diagnostics: []RoleDiagnostic{}, + }) + if err != nil { + t.Fatalf("json.Marshal(RoleStatus) error = %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatalf("json.Unmarshal(RoleStatus) error = %v", err) + } + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + sort.Strings(keys) + want := []string{ + "agent", + "diagnostics", + "enabled", + "fallback_chain", + "model", + "provenance", + "provider", + "reasoning_effort", + "resolution_mode", + "role", + } + if !reflect.DeepEqual(keys, want) { + t.Fatalf("RoleStatus JSON fields = %#v, want %#v", keys, want) + } + for _, field := range []string{"agent", "provider", "model", "reasoning_effort"} { + if string(fields[field]) != "null" { + t.Fatalf("RoleStatus.%s JSON = %s, want null", field, fields[field]) + } + } + if string(fields["fallback_chain"]) != "[]" || string(fields["diagnostics"]) != "[]" { + t.Fatalf("RoleStatus arrays = %s/%s, want []/[]", fields["fallback_chain"], fields["diagnostics"]) + } + }) + + t.Run("Should include timeout for the in-process role", func(t *testing.T) { + t.Parallel() + + timeout := "250ms" + encoded, err := json.Marshal(RoleStatus{ + Role: "memory_controller", + Enabled: true, + ResolutionMode: RoleResolutionModeInherit, + Timeout: &timeout, + FallbackChain: []RoleFallbackStatus{}, + Provenance: map[string]string{"timeout": "default"}, + Diagnostics: []RoleDiagnostic{}, + }) + if err != nil { + t.Fatalf("json.Marshal(RoleStatus) error = %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatalf("json.Unmarshal(RoleStatus) error = %v", err) + } + if got := string(fields["timeout"]); got != `"250ms"` { + t.Fatalf("RoleStatus.timeout JSON = %s, want %q", got, timeout) + } + }) +} diff --git a/internal/api/contract/settings.go b/internal/api/contract/settings.go index 4806e17bf..25d8bfc4b 100644 --- a/internal/api/contract/settings.go +++ b/internal/api/contract/settings.go @@ -33,6 +33,7 @@ type SettingsSectionName string const ( SettingsSectionGeneral SettingsSectionName = "general" SettingsSectionMemory SettingsSectionName = "memory" + SettingsSectionRoles SettingsSectionName = "roles" SettingsSectionSkills SettingsSectionName = "skills" SettingsSectionAutomation SettingsSectionName = "automation" SettingsSectionNetwork SettingsSectionName = "network" @@ -55,6 +56,7 @@ type SettingsApplyTargetName string const ( SettingsApplyTargetGeneral SettingsApplyTargetName = SettingsApplyTargetName(SettingsSectionGeneral) SettingsApplyTargetMemory SettingsApplyTargetName = SettingsApplyTargetName(SettingsSectionMemory) + SettingsApplyTargetRoles SettingsApplyTargetName = SettingsApplyTargetName(SettingsSectionRoles) SettingsApplyTargetSkills SettingsApplyTargetName = SettingsApplyTargetName(SettingsSectionSkills) SettingsApplyTargetAutomation SettingsApplyTargetName = SettingsApplyTargetName(SettingsSectionAutomation) SettingsApplyTargetNetwork SettingsApplyTargetName = SettingsApplyTargetName(SettingsSectionNetwork) diff --git a/internal/api/contract/settings_config_payloads.go b/internal/api/contract/settings_config_payloads.go index 6bbf525cb..6565b2115 100644 --- a/internal/api/contract/settings_config_payloads.go +++ b/internal/api/contract/settings_config_payloads.go @@ -98,19 +98,9 @@ type SettingsMemoryControllerPayload struct { Mode string `json:"mode"` MaxLatency string `json:"max_latency"` DefaultOpOnFail string `json:"default_op_on_fail"` - LLM SettingsMemoryControllerLLMPayload `json:"llm"` Policy SettingsMemoryControllerPolicyPayload `json:"policy"` } -type SettingsMemoryControllerLLMPayload struct { - Enabled bool `json:"enabled"` - Model string `json:"model"` - TopK int `json:"top_k"` - PromptVersion string `json:"prompt_version"` - Timeout string `json:"timeout"` - MaxTokensOut int `json:"max_tokens_out"` -} - type SettingsMemoryControllerPolicyPayload struct { MaxContentChars int `json:"max_content_chars"` MaxWritesPerMin int `json:"max_writes_per_min"` @@ -152,14 +142,12 @@ type SettingsMemoryDecisionsPayload struct { } type SettingsMemoryExtractorPayload struct { - Enabled bool `json:"enabled"` Mode string `json:"mode"` ThrottleTurns int `json:"throttle_turns"` Deadline string `json:"deadline"` SandboxInboxOnly bool `json:"sandbox_inbox_only"` InboxPath string `json:"inbox_path"` DLQPath string `json:"dlq_path"` - Model string `json:"model"` Queue SettingsMemoryExtractorQueuePayload `json:"queue"` } @@ -169,8 +157,6 @@ type SettingsMemoryExtractorQueuePayload struct { } type SettingsMemoryDreamPayload struct { - Enabled bool `json:"enabled"` - Agent string `json:"agent"` MinHours float64 `json:"min_hours"` MinSessions int `json:"min_sessions"` Debounce string `json:"debounce"` @@ -180,6 +166,49 @@ type SettingsMemoryDreamPayload struct { Scoring SettingsMemoryDreamScoringPayload `json:"scoring"` } +type SettingsRolesConfigPayload struct { + Coordinator SettingsCoordinatorRoleConfigPayload `json:"coordinator"` + Dream SettingsRoleConfigPayload `json:"dream"` + CheckpointSummary SettingsRoleConfigPayload `json:"checkpoint_summary"` + MemoryExtractor SettingsRoleConfigPayload `json:"memory_extractor"` + AutoTitle SettingsRoleConfigPayload `json:"auto_title"` + MemoryController SettingsMemoryControllerRoleConfigPayload `json:"memory_controller"` +} + +type SettingsRoleConfigPayload struct { + Enabled bool `json:"enabled"` + Agent string `json:"agent"` + Provider string `json:"provider"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort"` + FallbackChain []SettingsRoleFallbackPayload `json:"fallback_chain"` +} + +type SettingsCoordinatorRoleConfigPayload struct { + SettingsRoleConfigPayload + TTL string `json:"ttl"` + MaxChildren int `json:"max_children"` + MaxActiveSessionsPerWorkspace int `json:"max_active_sessions_per_workspace"` +} + +type SettingsMemoryControllerRoleConfigPayload struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort"` + Timeout string `json:"timeout"` + TopK int `json:"top_k"` + PromptVersion string `json:"prompt_version"` + MaxTokensOut int `json:"max_tokens_out"` + FallbackChain []SettingsRoleFallbackPayload `json:"fallback_chain"` +} + +type SettingsRoleFallbackPayload struct { + Provider string `json:"provider"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort"` +} + type SettingsMemoryDreamGatesPayload struct { MinUnpromoted int `json:"min_unpromoted"` MinRecallCount int `json:"min_recall_count"` diff --git a/internal/api/contract/settings_mutations.go b/internal/api/contract/settings_mutations.go index 98d93af5b..4c1b88621 100644 --- a/internal/api/contract/settings_mutations.go +++ b/internal/api/contract/settings_mutations.go @@ -10,6 +10,10 @@ type UpdateSettingsMemoryRequest struct { Config SettingsMemoryConfigPayload `json:"config"` } +type UpdateSettingsRolesRequest struct { + Config SettingsRolesConfigPayload `json:"config"` +} + type UpdateSettingsSkillsRequest struct { Config SettingsSkillsConfigPayload `json:"config"` } @@ -66,6 +70,11 @@ type SettingsMemoryResponse struct { Actions SettingsMemoryActionsPayload `json:"actions"` } +type SettingsRolesResponse struct { + SettingsGlobalSectionResponseMetaPayload + Config SettingsRolesConfigPayload `json:"config"` +} + type SettingsSkillsResponse struct { SettingsSkillsSectionResponseMetaPayload Config SettingsSkillsConfigPayload `json:"config"` diff --git a/internal/api/core/agent_channels_internal_test.go b/internal/api/core/agent_channels_internal_test.go index ad2e9c6b5..10c0b47af 100644 --- a/internal/api/core/agent_channels_internal_test.go +++ b/internal/api/core/agent_channels_internal_test.go @@ -536,7 +536,7 @@ func TestAgentMeCoreHandlerEnrichesContextAndChannels(t *testing.T) { }) } -func TestAgentCoordinatorConfigCoreHandlerReturnsResolvedWorkspaceConfig(t *testing.T) { +func TestAgentCoordinatorRoleCoreHandlerReturnsResolvedWorkspaceConfig(t *testing.T) { t.Parallel() t.Run("Should return resolved coordinator config for the caller workspace", func(t *testing.T) { @@ -935,18 +935,18 @@ func (f agentCoreContextService) ContextForSession( return f(ctx, info) } -type agentCoreCoordinatorConfigResolver struct{} +type agentCoreCoordinatorRoleResolver struct{} -func (agentCoreCoordinatorConfigResolver) ResolveCoordinatorConfig( +func (agentCoreCoordinatorRoleResolver) ResolveCoordinatorRole( _ context.Context, _ string, -) (aghconfig.CoordinatorConfig, error) { - return aghconfig.CoordinatorConfig{ +) (aghconfig.ResolvedCoordinatorRole, error) { + return aghconfig.ResolvedCoordinatorRole{ Enabled: true, AgentName: "coordinator", Provider: "codex", Model: "gpt-4o", - DefaultTTL: 45 * time.Minute, + TTL: 45 * time.Minute, MaxChildren: 5, MaxActiveSessionsPerWorkspace: 5, }, nil @@ -977,7 +977,7 @@ func newAgentCoreTestRouterWithNetworkStore( Network: networkService, NetworkStore: networkStore, AgentContextService: agentCoreContextService(agentCoreContextPayload), - CoordinatorConfig: agentCoreCoordinatorConfigResolver{}, + CoordinatorRole: agentCoreCoordinatorRoleResolver{}, Config: cfg, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), StreamDone: make(chan struct{}), @@ -991,7 +991,7 @@ func newAgentCoreTestRouterWithNetworkStore( engine := gin.New() engine.GET("/agent/me", handlers.AgentMe) engine.GET("/agent/context", handlers.AgentContext) - engine.GET("/agent/coordinator/config", handlers.AgentCoordinatorConfig) + engine.GET("/agent/coordinator/config", handlers.AgentCoordinatorRole) engine.GET("/agent/channels", handlers.AgentChannels) engine.GET("/agent/channels/:channel/recv", handlers.AgentChannelRecv) engine.POST("/agent/channels/:channel/send", handlers.AgentChannelSend) diff --git a/internal/api/core/agent_contracts.go b/internal/api/core/agent_contracts.go index d0a90cfbe..b11d69057 100644 --- a/internal/api/core/agent_contracts.go +++ b/internal/api/core/agent_contracts.go @@ -9,7 +9,7 @@ import ( // CoordinatorConfigPayloadFromConfig converts resolved coordinator config into a safe read model. func CoordinatorConfigPayloadFromConfig( - cfg aghconfig.CoordinatorConfig, + cfg aghconfig.ResolvedCoordinatorRole, source contract.CoordinatorConfigSource, workspaceID string, ) contract.CoordinatorConfigPayload { @@ -18,7 +18,7 @@ func CoordinatorConfigPayloadFromConfig( AgentName: strings.TrimSpace(cfg.AgentName), Provider: strings.TrimSpace(cfg.Provider), Model: strings.TrimSpace(cfg.Model), - DefaultTTLSeconds: int64(cfg.DefaultTTL.Seconds()), + DefaultTTLSeconds: int64(cfg.TTL.Seconds()), MaxChildren: cfg.MaxChildren, MaxActiveSessionsPerWorkspace: cfg.MaxActiveSessionsPerWorkspace, Source: source, diff --git a/internal/api/core/agent_contracts_test.go b/internal/api/core/agent_contracts_test.go index 381bb9f1f..7a20b7cdc 100644 --- a/internal/api/core/agent_contracts_test.go +++ b/internal/api/core/agent_contracts_test.go @@ -113,19 +113,19 @@ func testAgentPayloadEffectiveRuntime(t *testing.T) { func TestCoordinatorConfigPayloadFromConfig(t *testing.T) { t.Parallel() - baseConfig := aghconfig.CoordinatorConfig{ + baseConfig := aghconfig.ResolvedCoordinatorRole{ Enabled: true, AgentName: " coordinator ", Provider: " codex ", Model: " gpt-5.4 ", - DefaultTTL: 90 * time.Minute, + TTL: 90 * time.Minute, MaxChildren: 5, MaxActiveSessionsPerWorkspace: 5, } tests := []struct { name string - cfg aghconfig.CoordinatorConfig + cfg aghconfig.ResolvedCoordinatorRole source contract.CoordinatorConfigSource workspaceID string assert func(*testing.T, contract.CoordinatorConfigPayload) @@ -172,7 +172,7 @@ func TestCoordinatorConfigPayloadFromConfig(t *testing.T) { }, { name: "Should preserve disabled configs", - cfg: aghconfig.CoordinatorConfig{Enabled: false}, + cfg: aghconfig.ResolvedCoordinatorRole{Enabled: false}, source: contract.CoordinatorConfigSourceDefault, workspaceID: "", assert: func(t *testing.T, payload contract.CoordinatorConfigPayload) { diff --git a/internal/api/core/agent_create.go b/internal/api/core/agent_create.go index 0dd89e00b..bf3e9b2ed 100644 --- a/internal/api/core/agent_create.go +++ b/internal/api/core/agent_create.go @@ -53,7 +53,7 @@ func createAgentDraftFromRequest(req contract.CreateAgentRequest) (aghconfig.Age errors.New("agent.name is required"), ) } - if err := aghconfig.ValidateAgentName(agentName); err != nil { + if err := aghconfig.ValidateAuthoredAgentName(agentName); err != nil { return aghconfig.AgentDefinitionDraft{}, errors.Join( errCreateAgentRequestInvalid, aghconfig.ErrInvalidAgentDefinition, diff --git a/internal/api/core/agent_definitions.go b/internal/api/core/agent_definitions.go index d12f0f501..0ced13a54 100644 --- a/internal/api/core/agent_definitions.go +++ b/internal/api/core/agent_definitions.go @@ -36,7 +36,13 @@ func (h *BaseHandlers) UpdateAgent(c *gin.Context) { return } name := aghconfig.NormalizeAgentName(c.Param("name")) - if aghconfig.NormalizeAgentName(req.Agent.Name) != name { + requestedName := aghconfig.NormalizeAgentName(req.Agent.Name) + if err := aghconfig.ValidateAuthoredAgentName(requestedName); err != nil { + wrapped := errors.Join(errAgentDefinitionInvalid, err) + h.respondError(c, statusForAgentDefinitionError(wrapped), wrapped) + return + } + if requestedName != name { h.respondError(c, http.StatusBadRequest, errors.Join( errAgentDefinitionInvalid, fmt.Errorf("agent.name must equal route name %q", name), @@ -178,8 +184,9 @@ func (h *BaseHandlers) DuplicateAgent(c *gin.Context) { h.respondError(c, http.StatusServiceUnavailable, errAgentDefinitionSyncUnavailable) return } - if err := aghconfig.ValidateAgentName(aghconfig.NormalizeAgentName(req.Name)); err != nil { - h.respondError(c, http.StatusBadRequest, errors.Join(errAgentDefinitionInvalid, err)) + if err := aghconfig.ValidateAuthoredAgentName(aghconfig.NormalizeAgentName(req.Name)); err != nil { + wrapped := errors.Join(errAgentDefinitionInvalid, err) + h.respondError(c, statusForAgentDefinitionError(wrapped), wrapped) return } source, err := h.resolveAgentDefinition(c.Request.Context(), req.Workspace, c.Param("name")) @@ -396,6 +403,8 @@ func (h *BaseHandlers) logAgentMutationFailure( func statusForAgentDefinitionError(err error) int { switch { + case errors.Is(err, aghconfig.ErrAgentNameReserved): + return http.StatusUnprocessableEntity case errors.Is(err, errAgentDefinitionInvalid), errors.Is(err, errCreateAgentRequestInvalid), errors.Is(err, aghconfig.ErrInvalidAgentDefinition): diff --git a/internal/api/core/agent_identity.go b/internal/api/core/agent_identity.go index ed52e3a54..10454b6fc 100644 --- a/internal/api/core/agent_identity.go +++ b/internal/api/core/agent_identity.go @@ -14,13 +14,13 @@ import ( ) const ( - agentActionMe = "agent.me" - agentActionCoordinatorConfig = "agent.coordinator.config" + agentActionMe = "agent.me" + agentActionCoordinatorRole = "agent.coordinator.config" ) var ( errAgentIdentityUnavailable = errors.New("api: session service is not configured") - errCoordinatorConfigMissing = errors.New("api: coordinator config service is not configured") + errCoordinatorRoleMissing = errors.New("api: coordinator role resolver is not configured") ) // StatusForAgentIdentityError maps agent identity failures to transport statuses. @@ -53,15 +53,15 @@ func (h *BaseHandlers) AgentMe(c *gin.Context) { c.JSON(http.StatusOK, contract.AgentMeResponse{Me: contract.NormalizeAgentMePayload(payload)}) } -// AgentCoordinatorConfig returns the resolved coordinator policy for the caller workspace. -func (h *BaseHandlers) AgentCoordinatorConfig(c *gin.Context) { - caller, ok := h.requireAgentCaller(c, agentActionCoordinatorConfig) +// AgentCoordinatorRole returns the resolved coordinator policy for the caller workspace. +func (h *BaseHandlers) AgentCoordinatorRole(c *gin.Context) { + caller, ok := h.requireAgentCaller(c, agentActionCoordinatorRole) if !ok { return } payload, err := h.agentCoordinatorConfigPayload(c.Request.Context(), caller.Session.WorkspaceID) if err != nil { - h.respondError(c, statusForCoordinatorConfigError(err), err) + h.respondError(c, statusForCoordinatorRoleError(err), err) return } c.JSON(http.StatusOK, contract.AgentCoordinatorConfigResponse{Coordinator: payload}) @@ -165,11 +165,11 @@ func (h *BaseHandlers) agentCoordinatorConfigPayload( ctx context.Context, workspaceID string, ) (contract.CoordinatorConfigPayload, error) { - if h == nil || h.CoordinatorConfig == nil { - return contract.CoordinatorConfigPayload{}, errCoordinatorConfigMissing + if h == nil || h.CoordinatorRole == nil { + return contract.CoordinatorConfigPayload{}, errCoordinatorRoleMissing } trimmedWorkspaceID := strings.TrimSpace(workspaceID) - cfg, err := h.CoordinatorConfig.ResolveCoordinatorConfig(ctx, trimmedWorkspaceID) + cfg, err := h.CoordinatorRole.ResolveCoordinatorRole(ctx, trimmedWorkspaceID) if err != nil { return contract.CoordinatorConfigPayload{}, fmt.Errorf("resolve coordinator config: %w", err) } @@ -180,8 +180,8 @@ func (h *BaseHandlers) agentCoordinatorConfigPayload( return CoordinatorConfigPayloadFromConfig(cfg, source, trimmedWorkspaceID), nil } -func statusForCoordinatorConfigError(err error) int { - if errors.Is(err, errCoordinatorConfigMissing) { +func statusForCoordinatorRoleError(err error) int { + if errors.Is(err, errCoordinatorRoleMissing) { return http.StatusServiceUnavailable } return http.StatusInternalServerError diff --git a/internal/api/core/base_handlers.go b/internal/api/core/base_handlers.go index f562ddb18..d9b93d1c4 100644 --- a/internal/api/core/base_handlers.go +++ b/internal/api/core/base_handlers.go @@ -75,7 +75,8 @@ type BaseHandlerConfig struct { HeartbeatWake HeartbeatWakeService SessionHealth SessionHealthReader HeartbeatWakeEvents HeartbeatWakeEventReader - CoordinatorConfig CoordinatorConfigResolver + CoordinatorRole CoordinatorRoleResolver + Roles RolesStatusProvider SkillsRegistry SkillsRegistry SkillResources SkillResourceSyncer SkillMarketplace SkillMarketplaceService @@ -152,7 +153,8 @@ type BaseHandlers struct { HeartbeatWake HeartbeatWakeService SessionHealth SessionHealthReader HeartbeatWakeEvents HeartbeatWakeEventReader - CoordinatorConfig CoordinatorConfigResolver + CoordinatorRole CoordinatorRoleResolver + Roles RolesStatusProvider SkillsRegistry SkillsRegistry SkillResources SkillResourceSyncer SkillMarketplace SkillMarketplaceService @@ -232,7 +234,8 @@ func NewBaseHandlers(cfg *BaseHandlerConfig) *BaseHandlers { AgentContextService: cfg.AgentContextService, SoulHistoryPurger: cfg.SoulHistoryPurger, HeartbeatHistoryPurger: cfg.HeartbeatHistoryPurger, - CoordinatorConfig: cfg.CoordinatorConfig, + CoordinatorRole: cfg.CoordinatorRole, + Roles: cfg.Roles, SkillsRegistry: cfg.SkillsRegistry, SkillResources: cfg.SkillResources, SkillMarketplace: cfg.SkillMarketplace, diff --git a/internal/api/core/bundles.go b/internal/api/core/bundles.go index be125dd16..1003dc150 100644 --- a/internal/api/core/bundles.go +++ b/internal/api/core/bundles.go @@ -9,6 +9,7 @@ import ( "github.com/compozy/agh/internal/api/contract" bundlepkg "github.com/compozy/agh/internal/bundles" + aghconfig "github.com/compozy/agh/internal/config" extensionpkg "github.com/compozy/agh/internal/extension" "github.com/compozy/agh/internal/resources" workspacepkg "github.com/compozy/agh/internal/workspace" @@ -357,7 +358,8 @@ func StatusForBundleError(err error) int { errors.Is(err, bundlepkg.ErrNetworkRequirementConfirmationRequired), errors.Is(err, resources.ErrConflict): return http.StatusConflict - case errors.Is(err, bundlepkg.ErrAgentReferenceNotFound): + case errors.Is(err, bundlepkg.ErrAgentReferenceNotFound), + errors.Is(err, aghconfig.ErrAgentNameReserved): return http.StatusUnprocessableEntity case errors.Is(err, bundlepkg.ErrWebhookUnsupported), errors.Is(err, resources.ErrValidation), diff --git a/internal/api/core/conversions_settings_runtime.go b/internal/api/core/conversions_settings_runtime.go index 4a89fa347..1b05d8820 100644 --- a/internal/api/core/conversions_settings_runtime.go +++ b/internal/api/core/conversions_settings_runtime.go @@ -83,14 +83,6 @@ func settingsMemoryControllerPayload(value aghconfig.MemoryControllerConfig) con Mode: strings.TrimSpace(value.Mode), MaxLatency: value.MaxLatency.String(), DefaultOpOnFail: strings.TrimSpace(value.DefaultOpOnFail), - LLM: contract.SettingsMemoryControllerLLMPayload{ - Enabled: value.LLM.Enabled, - Model: strings.TrimSpace(value.LLM.Model), - TopK: value.LLM.TopK, - PromptVersion: strings.TrimSpace(value.LLM.PromptVersion), - Timeout: value.LLM.Timeout.String(), - MaxTokensOut: value.LLM.MaxTokensOut, - }, Policy: contract.SettingsMemoryControllerPolicyPayload{ MaxContentChars: value.Policy.MaxContentChars, MaxWritesPerMin: value.Policy.MaxWritesPerMin, @@ -133,14 +125,12 @@ func settingsMemoryDecisionsPayload(value aghconfig.MemoryDecisionsConfig) contr func settingsMemoryExtractorPayload(value aghconfig.MemoryExtractorConfig) contract.SettingsMemoryExtractorPayload { return contract.SettingsMemoryExtractorPayload{ - Enabled: value.Enabled, Mode: strings.TrimSpace(value.Mode), ThrottleTurns: value.ThrottleTurns, Deadline: value.Deadline.String(), SandboxInboxOnly: value.SandboxInboxOnly, InboxPath: strings.TrimSpace(value.InboxPath), DLQPath: strings.TrimSpace(value.DLQPath), - Model: strings.TrimSpace(value.Model), Queue: contract.SettingsMemoryExtractorQueuePayload{ Capacity: value.Queue.Capacity, CoalesceMax: value.Queue.CoalesceMax, @@ -150,8 +140,6 @@ func settingsMemoryExtractorPayload(value aghconfig.MemoryExtractorConfig) contr func settingsMemoryDreamPayload(value aghconfig.DreamConfig) contract.SettingsMemoryDreamPayload { return contract.SettingsMemoryDreamPayload{ - Enabled: value.Enabled, - Agent: strings.TrimSpace(value.Agent), MinHours: value.MinHours, MinSessions: value.MinSessions, Debounce: value.Debounce.String(), diff --git a/internal/api/core/conversions_settings_sections.go b/internal/api/core/conversions_settings_sections.go index 60827ae43..f71c03009 100644 --- a/internal/api/core/conversions_settings_sections.go +++ b/internal/api/core/conversions_settings_sections.go @@ -3,11 +3,9 @@ package core import ( "errors" "fmt" - "strings" "github.com/compozy/agh/internal/api/contract" - settingspkg "github.com/compozy/agh/internal/settings" ) @@ -18,6 +16,8 @@ func SettingsSectionResponseFromEnvelope(envelope settingspkg.SectionEnvelope) ( return settingsGeneralSectionResponse(envelope) case settingspkg.SectionMemory: return settingsMemorySectionResponse(envelope) + case settingspkg.SectionRoles: + return settingsRolesSectionResponse(envelope) case settingspkg.SectionSkills: return settingsSkillsSectionResponse(envelope) case settingspkg.SectionAutomation: @@ -35,6 +35,16 @@ func SettingsSectionResponseFromEnvelope(envelope settingspkg.SectionEnvelope) ( } } +func settingsRolesSectionResponse(envelope settingspkg.SectionEnvelope) (any, error) { + if envelope.Roles == nil { + return nil, errors.New("settings roles section is required") + } + return contract.SettingsRolesResponse{ + SettingsGlobalSectionResponseMetaPayload: settingsGlobalSectionMetaPayload(envelope), + Config: settingsRolesConfigPayload(&envelope.Roles.Config), + }, nil +} + func settingsGeneralSectionResponse(envelope settingspkg.SectionEnvelope) (any, error) { if envelope.General == nil { return nil, errors.New("settings general section is required") @@ -169,6 +179,7 @@ func SettingsSectionMutationResultPayloadFromResult(result settingspkg.MutationR switch result.Section { case settingspkg.SectionGeneral, settingspkg.SectionMemory, + settingspkg.SectionRoles, settingspkg.SectionAutomation, settingspkg.SectionNetwork, settingspkg.SectionWindowManager, diff --git a/internal/api/core/errors.go b/internal/api/core/errors.go index 1b2542ca6..5c9f1de85 100644 --- a/internal/api/core/errors.go +++ b/internal/api/core/errors.go @@ -110,15 +110,6 @@ func errorPayloadForNormalizedStatus( return errorPayloadForMessage(message, err) } -func errorPayloadForMessage(message string, err error) contract.ErrorPayload { - message = diagnosticspkg.Redact(taskpkg.RedactClaimTokens(message)) - payload := contract.ErrorPayload{Error: message} - if item, ok := diagnosticspkg.ItemFromError(err); ok { - payload.Diagnostic = &item - } - return payload -} - // StatusForSessionError maps session and workspace-domain errors to transport statuses. func StatusForSessionError(err error) int { return statusForSessionError(err) diff --git a/internal/api/core/errors_diagnostic_payload.go b/internal/api/core/errors_diagnostic_payload.go new file mode 100644 index 000000000..1b43c08a7 --- /dev/null +++ b/internal/api/core/errors_diagnostic_payload.go @@ -0,0 +1,56 @@ +package core + +import ( + "errors" + "strings" + + "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" + diagnosticspkg "github.com/compozy/agh/internal/diagnostics" + taskpkg "github.com/compozy/agh/internal/task" +) + +func errorPayloadForMessage(message string, err error) contract.ErrorPayload { + message = diagnosticspkg.Redact(taskpkg.RedactClaimTokens(message)) + payload := contract.ErrorPayload{Error: message} + if item, ok := diagnosticspkg.ItemFromError(err); ok { + payload.Diagnostic = &item + } else if errors.Is(err, aghconfig.ErrAgentNameReserved) { + item := diagnosticspkg.NewItem( + "agent.name.reserved", + contract.CodeAgentNameReserved, + contract.CategoryConfig, + "Agent name is reserved", + message, + contract.SeverityError, + contract.FreshnessLive, + ) + payload.Diagnostic = &item + } else if code := diagnosticCodeFromError(err); code != "" { + if category, ok := contract.DiagnosticCodeCategory(code); ok { + item := diagnosticspkg.NewItem( + strings.ReplaceAll(code, "_", "."), + code, + category, + "Role operation failed", + message, + contract.SeverityError, + contract.FreshnessLive, + ) + payload.Diagnostic = &item + } + } + return payload +} + +type diagnosticCodeCarrier interface { + DiagnosticCode() string +} + +func diagnosticCodeFromError(err error) string { + var carrier diagnosticCodeCarrier + if !errors.As(err, &carrier) { + return "" + } + return strings.TrimSpace(carrier.DiagnosticCode()) +} diff --git a/internal/api/core/handlers_agents.go b/internal/api/core/handlers_agents.go index 5a50ed1ab..988f10b12 100644 --- a/internal/api/core/handlers_agents.go +++ b/internal/api/core/handlers_agents.go @@ -2,19 +2,15 @@ package core import ( "context" - "errors" "fmt" - "net/http" "os" "sort" - "strings" "github.com/compozy/agh/internal/api/contract" aghconfig "github.com/compozy/agh/internal/config" - workspacepkg "github.com/compozy/agh/internal/workspace" "github.com/gin-gonic/gin" ) @@ -66,10 +62,21 @@ func (h *BaseHandlers) workspaceAgentEntriesWithDiagnostics( return entries, strings.TrimSpace(resolved.ID), resolved.Config, - append([]workspacepkg.AgentDiagnostic(nil), resolved.AgentDiagnostics...), + publicAgentDiagnostics(resolved.AgentDiagnostics), nil } +func publicAgentDiagnostics(diagnostics []workspacepkg.AgentDiagnostic) []workspacepkg.AgentDiagnostic { + visible := make([]workspacepkg.AgentDiagnostic, 0, len(diagnostics)) + for _, diagnostic := range diagnostics { + if aghconfig.IsReservedAgentName(diagnostic.Name) { + continue + } + visible = append(visible, diagnostic) + } + return visible +} + func (h *BaseHandlers) workspaceAgentDef( ctx context.Context, workspaceRef string, @@ -159,6 +166,8 @@ func statusForAgentWorkspaceError(err error) int { func statusForCreateAgentError(err error) int { switch { + case errors.Is(err, aghconfig.ErrAgentNameReserved): + return http.StatusUnprocessableEntity case errors.Is(err, errCreateAgentRequestInvalid), errors.Is(err, aghconfig.ErrInvalidAgentDefinition): return http.StatusBadRequest diff --git a/internal/api/core/handlers_test.go b/internal/api/core/handlers_test.go index 7512b761a..15fef0abf 100644 --- a/internal/api/core/handlers_test.go +++ b/internal/api/core/handlers_test.go @@ -1953,6 +1953,80 @@ func TestBaseHandlersCreateAgentEndpoint(t *testing.T) { } }) + t.Run("Should reject an exactly reserved name without creating a directory", func(t *testing.T) { + t.Parallel() + + fixture := newHandlerFixture( + t, + testutil.StubSessionManager{}, + testutil.StubObserver{}, + testutil.StubWorkspaceService{}, + nil, + nil, + ) + resp := performRequest( + t, + fixture.Engine, + http.MethodPost, + "/agents", + mustJSON(t, contract.CreateAgentRequest{ + Scope: contract.AgentCreateScopeGlobal, + Agent: contract.CreateAgentPayload{ + Name: "coordinator", Provider: "codex", Prompt: "Do not persist.", + }, + }), + ) + if resp.Code != http.StatusUnprocessableEntity { + t.Fatalf("reserved create status = %d, want 422; body=%s", resp.Code, resp.Body.String()) + } + var payload contract.ErrorPayload + decodeJSON(t, resp.Body.Bytes(), &payload) + if payload.Diagnostic == nil || payload.Diagnostic.Code != contract.CodeAgentNameReserved { + t.Fatalf("reserved create payload = %#v, want agent_name_reserved", payload) + } + path := filepath.Join(fixture.HomePaths.AgentsDir, "coordinator") + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("os.Stat(reserved agent directory) error = %v, want os.ErrNotExist", err) + } + }) + + t.Run("Should allow a name whose prefix matches a reserved identity", func(t *testing.T) { + t.Parallel() + + fixture := newHandlerFixture( + t, + testutil.StubSessionManager{}, + testutil.StubObserver{}, + testutil.StubWorkspaceService{}, + nil, + nil, + ) + fixture.Handlers.AgentDefinitionSync = &recordingAgentDefinitionSync{} + resp := performRequest( + t, + fixture.Engine, + http.MethodPost, + "/agents", + mustJSON(t, contract.CreateAgentRequest{ + Scope: contract.AgentCreateScopeGlobal, + Agent: contract.CreateAgentPayload{ + Name: "coordinator-helper", Provider: "codex", Prompt: "Persist exactly.", + }, + }), + ) + if resp.Code != http.StatusCreated { + t.Fatalf("prefixed create status = %d, want 201; body=%s", resp.Code, resp.Body.String()) + } + path := filepath.Join( + fixture.HomePaths.AgentsDir, + "coordinator-helper", + aghconfig.AgentDefinitionFileName, + ) + if _, err := os.Stat(path); err != nil { + t.Fatalf("os.Stat(prefixed agent) error = %v", err) + } + }) + t.Run("Should reject an inherited runtime when the target scope has no provider default", func(t *testing.T) { t.Parallel() @@ -2517,6 +2591,87 @@ func TestBaseHandlersAgentDefinitionMutations(t *testing.T) { } }) + t.Run("Should reject reserved update and duplicate targets without touching authored state", func(t *testing.T) { + t.Parallel() + + fixture := newHandlerFixture( + t, + testutil.StubSessionManager{}, + testutil.StubObserver{}, + testutil.StubWorkspaceService{}, + nil, + nil, + ) + path := filepath.Join(fixture.HomePaths.AgentsDir, "coder", aghconfig.AgentDefinitionFileName) + current, err := aghconfig.CreateAgentDefFile(path, aghconfig.AgentDefinitionDraft{ + Name: "coder", Provider: "codex", Prompt: "Original.", + }, false) + if err != nil { + t.Fatalf("CreateAgentDefFile() error = %v", err) + } + digest, err := aghconfig.AgentDefinitionDigest(current) + if err != nil { + t.Fatalf("AgentDefinitionDigest() error = %v", err) + } + + update := performRequest( + t, + fixture.Engine, + http.MethodPut, + "/agents/coder", + mustJSON(t, contract.UpdateAgentRequest{ + Agent: contract.CreateAgentPayload{ + Name: "dreaming-curator", Provider: "codex", Prompt: "Replacement.", + }, + ExpectedDigest: digest, + }), + ) + if update.Code != http.StatusUnprocessableEntity { + t.Fatalf("reserved update status = %d, want 422; body=%s", update.Code, update.Body.String()) + } + var updatePayload contract.ErrorPayload + decodeJSON(t, update.Body.Bytes(), &updatePayload) + if updatePayload.Diagnostic == nil || updatePayload.Diagnostic.Code != contract.CodeAgentNameReserved { + t.Fatalf("reserved update payload = %#v, want agent_name_reserved", updatePayload) + } + unchanged, err := aghconfig.LoadAgentDefFile(path) + if err != nil { + t.Fatalf("LoadAgentDefFile(original) error = %v", err) + } + unchangedDigest, err := aghconfig.AgentDefinitionDigest(unchanged) + if err != nil { + t.Fatalf("AgentDefinitionDigest(unchanged) error = %v", err) + } + if unchanged.Prompt != "Original." || unchangedDigest != digest { + t.Fatalf( + "reserved update changed agent/digest = %#v/%q, want original/%q", + unchanged, + unchangedDigest, + digest, + ) + } + + duplicate := performRequest( + t, + fixture.Engine, + http.MethodPost, + "/agents/coder/duplicate", + mustJSON(t, contract.DuplicateAgentRequest{Name: "coordinator"}), + ) + if duplicate.Code != http.StatusUnprocessableEntity { + t.Fatalf("reserved duplicate status = %d, want 422; body=%s", duplicate.Code, duplicate.Body.String()) + } + var duplicatePayload contract.ErrorPayload + decodeJSON(t, duplicate.Body.Bytes(), &duplicatePayload) + if duplicatePayload.Diagnostic == nil || duplicatePayload.Diagnostic.Code != contract.CodeAgentNameReserved { + t.Fatalf("reserved duplicate payload = %#v, want agent_name_reserved", duplicatePayload) + } + reservedPath := filepath.Join(fixture.HomePaths.AgentsDir, "coordinator") + if _, err := os.Stat(reservedPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("os.Stat(reserved duplicate directory) error = %v, want os.ErrNotExist", err) + } + }) + t.Run("Should delete only the effective workspace definition and disclose the global twin", func(t *testing.T) { t.Parallel() workspaceRoot := t.TempDir() @@ -3347,6 +3502,10 @@ func TestBaseHandlersWorkspaceAgentEndpoints(t *testing.T) { }, "beta": {Total: 1}, "gamma": {Total: 3, Active: 2}, + "coordinator": { + Total: 99, + Active: 99, + }, }, nil }, } diff --git a/internal/api/core/interfaces.go b/internal/api/core/interfaces.go index 9e2f47bb0..bcdf32488 100644 --- a/internal/api/core/interfaces.go +++ b/internal/api/core/interfaces.go @@ -234,9 +234,15 @@ type HeartbeatWakeEventReader interface { ListHeartbeatWakeEvents(ctx context.Context, query heartbeat.WakeEventListQuery) ([]heartbeat.WakeEvent, error) } -// CoordinatorConfigResolver resolves safe coordinator policy for agent-facing reads. -type CoordinatorConfigResolver interface { - ResolveCoordinatorConfig(ctx context.Context, workspaceID string) (aghconfig.CoordinatorConfig, error) +// CoordinatorRoleResolver resolves safe coordinator policy for agent-facing reads. +type CoordinatorRoleResolver interface { + ResolveCoordinatorRole(ctx context.Context, workspaceID string) (aghconfig.ResolvedCoordinatorRole, error) +} + +// RolesStatusProvider projects effective role configuration without simulating an invocation. +type RolesStatusProvider interface { + RoleStatuses(ctx context.Context, workspaceID string) ([]contract.RoleStatus, error) + RoleStatus(ctx context.Context, workspaceID, role string) (contract.RoleStatus, error) } // NetworkStore exposes persisted network audit, channel metadata CRUD, and timeline queries to the API layer. diff --git a/internal/api/core/memory_health_payload.go b/internal/api/core/memory_health_payload.go index 6b956dfbe..18ea449be 100644 --- a/internal/api/core/memory_health_payload.go +++ b/internal/api/core/memory_health_payload.go @@ -14,6 +14,7 @@ import ( memcontract "github.com/compozy/agh/internal/memory/contract" "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/memory" ssepkg "github.com/compozy/agh/internal/sse" @@ -28,20 +29,28 @@ func (h *BaseHandlers) memoryHealth(c *gin.Context) (contract.MemoryHealthPayloa ) } +func memoryHealthConfigPayload(cfg *aghconfig.Config) contract.MemoryHealthPayload { + dreamAgent := strings.TrimSpace(cfg.Roles.Dream.Agent) + if dreamAgent == "" { + dreamAgent = aghconfig.BuiltinDreamingCuratorAgentName + } + return contract.MemoryHealthPayload{ + Status: memoryHealthStatusOK, + Enabled: cfg.Memory.Enabled, + Configured: strings.TrimSpace(cfg.Memory.GlobalDir) != "", + GlobalDir: strings.TrimSpace(cfg.Memory.GlobalDir), + DreamAgent: dreamAgent, + DreamMinHours: cfg.Memory.Dream.MinHours, + DreamMinSessions: cfg.Memory.Dream.MinSessions, + DreamCheckInterval: cfg.Memory.Dream.CheckInterval.String(), + } +} + func (h *BaseHandlers) memoryHealthSnapshot( ctx context.Context, rawWorkspace string, ) (contract.MemoryHealthPayload, error) { - payload := contract.MemoryHealthPayload{ - Status: memoryHealthStatusOK, - Enabled: h.Config.Memory.Enabled, - Configured: strings.TrimSpace(h.Config.Memory.GlobalDir) != "", - GlobalDir: strings.TrimSpace(h.Config.Memory.GlobalDir), - DreamAgent: strings.TrimSpace(h.Config.Memory.Dream.Agent), - DreamMinHours: h.Config.Memory.Dream.MinHours, - DreamMinSessions: h.Config.Memory.Dream.MinSessions, - DreamCheckInterval: h.Config.Memory.Dream.CheckInterval.String(), - } + payload := memoryHealthConfigPayload(&h.Config) if !payload.Enabled { payload.Status = memoryHealthStatusDisabled payload.Reason = "memory is disabled" diff --git a/internal/api/core/memory_payloads.go b/internal/api/core/memory_payloads.go index ce912a066..4128b1a63 100644 --- a/internal/api/core/memory_payloads.go +++ b/internal/api/core/memory_payloads.go @@ -224,10 +224,7 @@ func (h *BaseHandlers) memoryMutableConfigPaths() []string { return []string{ "memory.enabled", "memory.controller.mode", - "memory.controller.llm.enabled", "memory.recall.top_k", - "memory.extractor.enabled", - "memory.dream.enabled", "memory.provider.name", } } diff --git a/internal/api/core/payload_helpers_test.go b/internal/api/core/payload_helpers_test.go index cd8b49510..88f15f5e4 100644 --- a/internal/api/core/payload_helpers_test.go +++ b/internal/api/core/payload_helpers_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -229,6 +230,11 @@ func TestStatusForBundleErrorAndChannelHelpers(t *testing.T) { err: bundlepkg.ErrAgentReferenceNotFound, want: http.StatusUnprocessableEntity, }, + { + name: "Should map a reserved agent name to unprocessable entity", + err: fmt.Errorf("bundle agent: %w", aghconfig.ErrAgentNameReserved), + want: http.StatusUnprocessableEntity, + }, { name: "extension has active bundles", err: extensionpkg.ErrExtensionHasActiveBundles, diff --git a/internal/api/core/roles_handlers.go b/internal/api/core/roles_handlers.go new file mode 100644 index 000000000..ec944fb65 --- /dev/null +++ b/internal/api/core/roles_handlers.go @@ -0,0 +1,51 @@ +package core + +import ( + "errors" + "net/http" + "strings" + + "github.com/compozy/agh/internal/api/contract" + "github.com/gin-gonic/gin" +) + +var errRolesStatusUnavailable = errors.New("api: roles status provider is unavailable") + +// ListRoles returns the closed effective role roster for an optional workspace. +func (h *BaseHandlers) ListRoles(c *gin.Context) { + if h.Roles == nil { + h.respondError(c, http.StatusServiceUnavailable, errRolesStatusUnavailable) + return + } + statuses, err := h.Roles.RoleStatuses(c.Request.Context(), strings.TrimSpace(c.Query("workspace"))) + if err != nil { + h.respondError(c, statusForRoleStatusError(err), err) + return + } + c.JSON(http.StatusOK, contract.RolesResponse{Roles: statuses}) +} + +// GetRole returns one effective role projection for an optional workspace. +func (h *BaseHandlers) GetRole(c *gin.Context) { + if h.Roles == nil { + h.respondError(c, http.StatusServiceUnavailable, errRolesStatusUnavailable) + return + } + status, err := h.Roles.RoleStatus( + c.Request.Context(), + strings.TrimSpace(c.Query("workspace")), + strings.TrimSpace(c.Param("role")), + ) + if err != nil { + h.respondError(c, statusForRoleStatusError(err), err) + return + } + c.JSON(http.StatusOK, contract.RoleStatusResponse{Role: status}) +} + +func statusForRoleStatusError(err error) int { + if diagnosticCodeFromError(err) == contract.CodeRoleUnknown { + return http.StatusNotFound + } + return statusForWorkspaceError(err) +} diff --git a/internal/api/core/roles_handlers_test.go b/internal/api/core/roles_handlers_test.go new file mode 100644 index 000000000..35d5d8f3f --- /dev/null +++ b/internal/api/core/roles_handlers_test.go @@ -0,0 +1,151 @@ +package core + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/compozy/agh/internal/api/contract" + "github.com/gin-gonic/gin" +) + +func TestRoleStatusHandlers(t *testing.T) { + t.Parallel() + + t.Run("Should list the provider projection for the requested workspace", func(t *testing.T) { + t.Parallel() + + provider := &rolesStatusProviderStub{ + statuses: []contract.RoleStatus{ + { + Role: "auto_title", + FallbackChain: []contract.RoleFallbackStatus{}, + Diagnostics: []contract.RoleDiagnostic{}, + }, + { + Role: "checkpoint_summary", + FallbackChain: []contract.RoleFallbackStatus{}, + Diagnostics: []contract.RoleDiagnostic{}, + }, + { + Role: "coordinator", + FallbackChain: []contract.RoleFallbackStatus{}, + Diagnostics: []contract.RoleDiagnostic{}, + }, + { + Role: "dream", + FallbackChain: []contract.RoleFallbackStatus{}, + Diagnostics: []contract.RoleDiagnostic{}, + }, + { + Role: "memory_controller", + FallbackChain: []contract.RoleFallbackStatus{}, + Diagnostics: []contract.RoleDiagnostic{}, + }, + { + Role: "memory_extractor", + FallbackChain: []contract.RoleFallbackStatus{}, + Diagnostics: []contract.RoleDiagnostic{}, + }, + }, + } + engine := roleStatusHandlerEngine(provider) + response := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/roles?workspace=ws-a", http.NoBody) + engine.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("GET /api/roles status = %d, want 200; body=%s", response.Code, response.Body.String()) + } + var payload contract.RolesResponse + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(RolesResponse) error = %v", err) + } + wantRoles := []string{ + "auto_title", + "checkpoint_summary", + "coordinator", + "dream", + "memory_controller", + "memory_extractor", + } + if len(payload.Roles) != len(wantRoles) || provider.workspace != "ws-a" { + t.Fatalf("roles/workspace = %d/%q, want %d/ws-a", len(payload.Roles), provider.workspace, len(wantRoles)) + } + for index, wantRole := range wantRoles { + if payload.Roles[index].Role != wantRole { + t.Fatalf("roles[%d].Role = %q, want %q", index, payload.Roles[index].Role, wantRole) + } + } + }) + + t.Run("Should return role_unknown for an unsupported role", func(t *testing.T) { + t.Parallel() + + provider := &rolesStatusProviderStub{statusErr: roleStatusDiagnosticError{code: contract.CodeRoleUnknown}} + engine := roleStatusHandlerEngine(provider) + response := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/roles/judge", http.NoBody) + engine.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("GET /api/roles/judge status = %d, want 404; body=%s", response.Code, response.Body.String()) + } + var payload contract.ErrorPayload + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal(ErrorPayload) error = %v", err) + } + if payload.Diagnostic == nil || payload.Diagnostic.Code != contract.CodeRoleUnknown { + t.Fatalf("GET /api/roles/judge payload = %#v, want role_unknown", payload) + } + if provider.role != "judge" { + t.Fatalf("RoleStatus role = %q, want judge", provider.role) + } + }) +} + +func roleStatusHandlerEngine(provider RolesStatusProvider) *gin.Engine { + handlers := NewBaseHandlers(&BaseHandlerConfig{TransportName: "test", Roles: provider}) + engine := gin.New() + engine.GET("/api/roles", handlers.ListRoles) + engine.GET("/api/roles/:role", handlers.GetRole) + return engine +} + +type rolesStatusProviderStub struct { + statuses []contract.RoleStatus + status contract.RoleStatus + statusErr error + workspace string + role string +} + +func (s *rolesStatusProviderStub) RoleStatuses( + _ context.Context, + workspaceID string, +) ([]contract.RoleStatus, error) { + s.workspace = workspaceID + return append([]contract.RoleStatus(nil), s.statuses...), s.statusErr +} + +func (s *rolesStatusProviderStub) RoleStatus( + _ context.Context, + workspaceID string, + role string, +) (contract.RoleStatus, error) { + s.workspace = workspaceID + s.role = role + return s.status, s.statusErr +} + +type roleStatusDiagnosticError struct { + code string +} + +func (e roleStatusDiagnosticError) Error() string { + return e.code +} + +func (e roleStatusDiagnosticError) DiagnosticCode() string { + return e.code +} diff --git a/internal/api/core/settings.go b/internal/api/core/settings.go index e5befdd5d..713ff629f 100644 --- a/internal/api/core/settings.go +++ b/internal/api/core/settings.go @@ -2,11 +2,8 @@ package core import ( "bufio" - "errors" - "net/http" - "strings" "time" @@ -68,6 +65,21 @@ func (h *BaseHandlers) UpdateSettingsMemory(c *gin.Context) { h.updateSettingsSection(c, req) } +// GetSettingsRoles returns the background-role routing settings section. +func (h *BaseHandlers) GetSettingsRoles(c *gin.Context) { + h.getSettingsSection(c, settingspkg.SectionRoles) +} + +// UpdateSettingsRoles persists the background-role routing settings section. +func (h *BaseHandlers) UpdateSettingsRoles(c *gin.Context) { + req, err := parseUpdateSettingsRolesRequest(c) + if err != nil { + h.respondError(c, StatusForSettingsError(err), err) + return + } + h.updateSettingsSection(c, req) +} + // GetSettingsSkills returns the skills settings section. func (h *BaseHandlers) GetSettingsSkills(c *gin.Context) { h.getSettingsSection(c, settingspkg.SectionSkills) diff --git a/internal/api/core/settings_internal_test.go b/internal/api/core/settings_internal_test.go index 428e56c87..4b4af49f1 100644 --- a/internal/api/core/settings_internal_test.go +++ b/internal/api/core/settings_internal_test.go @@ -488,7 +488,7 @@ func TestMemorySettingsPayloadRoundTripIncludesV2Config(t *testing.T) { want.Controller.Policy.AllowOrigins = []string{"cli", "tool"} want.Recall.IncludeAlreadySurfaced = true want.Extractor.Queue.CoalesceMax = 12 - want.Dream.Agent = "curator" + want.Dream.MinHours = 18 want.Session.UnboundPartition = "_orphans" want.Provider.Name = "local" want.Workspace.AutoCreate = false diff --git a/internal/api/core/settings_memory_config.go b/internal/api/core/settings_memory_config.go index a6de31c42..b48372cbd 100644 --- a/internal/api/core/settings_memory_config.go +++ b/internal/api/core/settings_memory_config.go @@ -77,22 +77,10 @@ func memoryControllerConfigFromPayload( if err != nil { return aghconfig.MemoryControllerConfig{}, err } - timeout, err := parseSettingsDuration("memory.config.controller.llm.timeout", payload.LLM.Timeout) - if err != nil { - return aghconfig.MemoryControllerConfig{}, err - } return aghconfig.MemoryControllerConfig{ Mode: strings.TrimSpace(payload.Mode), MaxLatency: maxLatency, DefaultOpOnFail: strings.TrimSpace(payload.DefaultOpOnFail), - LLM: aghconfig.MemoryControllerLLMConfig{ - Enabled: payload.LLM.Enabled, - Model: strings.TrimSpace(payload.LLM.Model), - TopK: payload.LLM.TopK, - PromptVersion: strings.TrimSpace(payload.LLM.PromptVersion), - Timeout: timeout, - MaxTokensOut: payload.LLM.MaxTokensOut, - }, Policy: aghconfig.MemoryControllerPolicyConfig{ MaxContentChars: payload.Policy.MaxContentChars, MaxWritesPerMin: payload.Policy.MaxWritesPerMin, @@ -141,14 +129,12 @@ func memoryExtractorConfigFromPayload( return aghconfig.MemoryExtractorConfig{}, err } return aghconfig.MemoryExtractorConfig{ - Enabled: payload.Enabled, Mode: strings.TrimSpace(payload.Mode), ThrottleTurns: payload.ThrottleTurns, Deadline: deadline, SandboxInboxOnly: payload.SandboxInboxOnly, InboxPath: strings.TrimSpace(payload.InboxPath), DLQPath: strings.TrimSpace(payload.DLQPath), - Model: strings.TrimSpace(payload.Model), Queue: aghconfig.MemoryExtractorQueueConfig{ Capacity: payload.Queue.Capacity, CoalesceMax: payload.Queue.CoalesceMax, @@ -166,8 +152,6 @@ func memoryDreamConfigFromPayload(payload contract.SettingsMemoryDreamPayload) ( return aghconfig.DreamConfig{}, err } return aghconfig.DreamConfig{ - Enabled: payload.Enabled, - Agent: strings.TrimSpace(payload.Agent), MinHours: payload.MinHours, MinSessions: payload.MinSessions, Debounce: debounce, diff --git a/internal/api/core/settings_roles_config.go b/internal/api/core/settings_roles_config.go new file mode 100644 index 000000000..9fa7a1a84 --- /dev/null +++ b/internal/api/core/settings_roles_config.go @@ -0,0 +1,122 @@ +package core + +import ( + "errors" + "strings" + + "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" +) + +var errSettingsRolesConfigRequired = errors.New("roles.config is required") + +func settingsRolesConfigPayload(value *aghconfig.RolesConfig) contract.SettingsRolesConfigPayload { + return contract.SettingsRolesConfigPayload{ + Coordinator: contract.SettingsCoordinatorRoleConfigPayload{ + SettingsRoleConfigPayload: settingsRoleConfigPayload(value.Coordinator.RoleConfig), + TTL: value.Coordinator.TTL.String(), + MaxChildren: value.Coordinator.MaxChildren, + MaxActiveSessionsPerWorkspace: value.Coordinator.MaxActiveSessionsPerWorkspace, + }, + Dream: settingsRoleConfigPayload(value.Dream), + CheckpointSummary: settingsRoleConfigPayload(value.CheckpointSummary), + MemoryExtractor: settingsRoleConfigPayload(value.MemoryExtractor), + AutoTitle: settingsRoleConfigPayload(value.AutoTitle), + MemoryController: contract.SettingsMemoryControllerRoleConfigPayload{ + Enabled: value.MemoryController.Enabled, + Provider: strings.TrimSpace(value.MemoryController.Provider), + Model: strings.TrimSpace(value.MemoryController.Model), + ReasoningEffort: strings.TrimSpace(value.MemoryController.ReasoningEffort), + Timeout: value.MemoryController.Timeout.String(), + TopK: value.MemoryController.TopK, + PromptVersion: strings.TrimSpace(value.MemoryController.PromptVersion), + MaxTokensOut: value.MemoryController.MaxTokensOut, + FallbackChain: settingsRoleFallbackPayloads(value.MemoryController.FallbackChain), + }, + } +} + +func settingsRoleConfigPayload(value aghconfig.RoleConfig) contract.SettingsRoleConfigPayload { + return contract.SettingsRoleConfigPayload{ + Enabled: value.Enabled, + Agent: strings.TrimSpace(value.Agent), + Provider: strings.TrimSpace(value.Provider), + Model: strings.TrimSpace(value.Model), + ReasoningEffort: strings.TrimSpace(value.ReasoningEffort), + FallbackChain: settingsRoleFallbackPayloads(value.FallbackChain), + } +} + +func settingsRoleFallbackPayloads(values []aghconfig.RoleFallback) []contract.SettingsRoleFallbackPayload { + payloads := make([]contract.SettingsRoleFallbackPayload, 0, len(values)) + for _, value := range values { + payloads = append(payloads, contract.SettingsRoleFallbackPayload{ + Provider: strings.TrimSpace(value.Provider), + Model: strings.TrimSpace(value.Model), + ReasoningEffort: strings.TrimSpace(value.ReasoningEffort), + }) + } + return payloads +} + +func rolesConfigFromPayload(payload *contract.SettingsRolesConfigPayload) (aghconfig.RolesConfig, error) { + if payload == nil { + return aghconfig.RolesConfig{}, NewSettingsValidationError( + errSettingsRolesConfigRequired, + ) + } + ttl, err := parseSettingsDuration("roles.config.coordinator.ttl", payload.Coordinator.TTL) + if err != nil { + return aghconfig.RolesConfig{}, err + } + timeout, err := parseSettingsDuration("roles.config.memory_controller.timeout", payload.MemoryController.Timeout) + if err != nil { + return aghconfig.RolesConfig{}, err + } + return aghconfig.RolesConfig{ + Coordinator: aghconfig.CoordinatorRoleConfig{ + RoleConfig: roleConfigFromSettingsPayload(payload.Coordinator.SettingsRoleConfigPayload), + TTL: ttl, + MaxChildren: payload.Coordinator.MaxChildren, + MaxActiveSessionsPerWorkspace: payload.Coordinator.MaxActiveSessionsPerWorkspace, + }, + Dream: roleConfigFromSettingsPayload(payload.Dream), + CheckpointSummary: roleConfigFromSettingsPayload(payload.CheckpointSummary), + MemoryExtractor: roleConfigFromSettingsPayload(payload.MemoryExtractor), + AutoTitle: roleConfigFromSettingsPayload(payload.AutoTitle), + MemoryController: aghconfig.MemoryControllerRoleConfig{ + Enabled: payload.MemoryController.Enabled, + Provider: strings.TrimSpace(payload.MemoryController.Provider), + Model: strings.TrimSpace(payload.MemoryController.Model), + ReasoningEffort: strings.TrimSpace(payload.MemoryController.ReasoningEffort), + Timeout: timeout, + TopK: payload.MemoryController.TopK, + PromptVersion: strings.TrimSpace(payload.MemoryController.PromptVersion), + MaxTokensOut: payload.MemoryController.MaxTokensOut, + FallbackChain: roleFallbacksFromSettingsPayload(payload.MemoryController.FallbackChain), + }, + }, nil +} + +func roleConfigFromSettingsPayload(payload contract.SettingsRoleConfigPayload) aghconfig.RoleConfig { + return aghconfig.RoleConfig{ + Enabled: payload.Enabled, + Agent: strings.TrimSpace(payload.Agent), + Provider: strings.TrimSpace(payload.Provider), + Model: strings.TrimSpace(payload.Model), + ReasoningEffort: strings.TrimSpace(payload.ReasoningEffort), + FallbackChain: roleFallbacksFromSettingsPayload(payload.FallbackChain), + } +} + +func roleFallbacksFromSettingsPayload(values []contract.SettingsRoleFallbackPayload) []aghconfig.RoleFallback { + fallbacks := make([]aghconfig.RoleFallback, 0, len(values)) + for _, value := range values { + fallbacks = append(fallbacks, aghconfig.RoleFallback{ + Provider: strings.TrimSpace(value.Provider), + Model: strings.TrimSpace(value.Model), + ReasoningEffort: strings.TrimSpace(value.ReasoningEffort), + }) + } + return fallbacks +} diff --git a/internal/api/core/settings_roles_request.go b/internal/api/core/settings_roles_request.go new file mode 100644 index 000000000..4d882a2a6 --- /dev/null +++ b/internal/api/core/settings_roles_request.go @@ -0,0 +1,34 @@ +package core + +import ( + "fmt" + + "github.com/compozy/agh/internal/api/contract" + settingspkg "github.com/compozy/agh/internal/settings" + "github.com/gin-gonic/gin" +) + +func parseUpdateSettingsRolesRequest(c *gin.Context) (settingspkg.SectionUpdateRequest, error) { + var body struct { + Config *contract.SettingsRolesConfigPayload `json:"config"` + } + if err := decodeStrictJSONBody(c, &body); err != nil { + return settingspkg.SectionUpdateRequest{}, NewSettingsValidationError( + fmt.Errorf("decode roles settings request: %w", err), + ) + } + if body.Config == nil { + return settingspkg.SectionUpdateRequest{}, NewSettingsValidationError( + errSettingsRolesConfigRequired, + ) + } + req, err := parseSettingsSectionRequest(c, settingspkg.SectionRoles) + if err != nil { + return settingspkg.SectionUpdateRequest{}, err + } + config, err := rolesConfigFromPayload(body.Config) + if err != nil { + return settingspkg.SectionUpdateRequest{}, err + } + return settingspkg.SectionUpdateRequest{SectionRequest: req, Roles: &config}, nil +} diff --git a/internal/api/core/settings_test.go b/internal/api/core/settings_test.go index f8cdba858..0af4e0333 100644 --- a/internal/api/core/settings_test.go +++ b/internal/api/core/settings_test.go @@ -517,6 +517,8 @@ func registerSettingsRoutes(engine *gin.Engine, handlers *core.BaseHandlers) { settings.PATCH("/general", handlers.UpdateSettingsGeneral) settings.GET("/memory", handlers.GetSettingsMemory) settings.PATCH("/memory", handlers.UpdateSettingsMemory) + settings.GET("/roles", handlers.GetSettingsRoles) + settings.PATCH("/roles", handlers.UpdateSettingsRoles) settings.GET("/skills", handlers.GetSettingsSkills) settings.PATCH("/skills", handlers.UpdateSettingsSkills) settings.GET("/automation", handlers.GetSettingsAutomation) @@ -1111,8 +1113,6 @@ func TestSettingsSectionAndCollectionConversions(t *testing.T) { Enabled: true, GlobalDir: "/tmp/home/memory", Dream: aghconfig.DreamConfig{ - Enabled: true, - Agent: "dreamer", MinHours: 1.5, MinSessions: 2, CheckInterval: time.Hour, @@ -1628,6 +1628,7 @@ func TestUpdateSettingsSectionHandlersRejectInvalidPayloads(t *testing.T) { }{ {name: "general", path: "/api/settings/general", want: "general.config is required"}, {name: "memory", path: "/api/settings/memory", want: "memory.config is required"}, + {name: "Should require roles config", path: "/api/settings/roles", want: "roles.config is required"}, {name: "skills", path: "/api/settings/skills", want: "skills.config is required"}, {name: "automation", path: "/api/settings/automation", want: "automation.config is required"}, {name: "network", path: "/api/settings/network", want: "network.config is required"}, @@ -1780,7 +1781,6 @@ func TestUpdateSettingsSectionHandlersDelegateValidPayloads(t *testing.T) { memoryPayload := validSettingsMemoryConfigPayload() memoryPayload.GlobalDir = "/tmp/memory" - memoryPayload.Dream.Agent = "dreamer" memoryPayload.Dream.MinHours = 1.5 memoryPayload.Dream.MinSessions = 2 memoryPayload.Dream.CheckInterval = "1h" @@ -1825,11 +1825,27 @@ func TestUpdateSettingsSectionHandlersDelegateValidPayloads(t *testing.T) { }, assert: func(t *testing.T, req settingspkg.SectionUpdateRequest) { t.Helper() - if req.Memory == nil || req.Memory.Dream.Agent != "dreamer" { + if req.Memory == nil || req.Memory.Dream.MinHours != 1.5 { t.Fatalf("req.Memory = %#v, want populated memory config", req.Memory) } }, }, + { + name: "roles", + path: "/api/settings/roles", + body: contract.UpdateSettingsRolesRequest{ + Config: validSettingsRolesConfigPayload(), + }, + assert: func(t *testing.T, req settingspkg.SectionUpdateRequest) { + t.Helper() + if req.Roles == nil || req.Roles.Dream.Model != "claude-opus" || + req.Roles.Coordinator.TTL != 2*time.Hour || + len(req.Roles.AutoTitle.FallbackChain) != 1 || + req.Roles.AutoTitle.FallbackChain[0].Model != "gpt-5-mini" { + t.Fatalf("req.Roles = %#v, want complete role settings", req.Roles) + } + }, + }, { name: "skills", path: "/api/settings/skills", @@ -2042,6 +2058,45 @@ func validSettingsWindowManagerConfigPayload() contract.SettingsWindowManagerCon } } +func validSettingsRolesConfigPayload() contract.SettingsRolesConfigPayload { + role := func(model string) contract.SettingsRoleConfigPayload { + return contract.SettingsRoleConfigPayload{ + Enabled: true, + Provider: "anthropic", + Model: model, + ReasoningEffort: "high", + FallbackChain: []contract.SettingsRoleFallbackPayload{{ + Provider: "openai", Model: "gpt-5-mini", ReasoningEffort: "medium", + }}, + } + } + return contract.SettingsRolesConfigPayload{ + Coordinator: contract.SettingsCoordinatorRoleConfigPayload{ + SettingsRoleConfigPayload: role("claude-sonnet"), + TTL: "2h", + MaxChildren: 5, + MaxActiveSessionsPerWorkspace: 3, + }, + Dream: role("claude-opus"), + CheckpointSummary: role("claude-haiku"), + MemoryExtractor: role("claude-haiku"), + AutoTitle: role("claude-haiku"), + MemoryController: contract.SettingsMemoryControllerRoleConfigPayload{ + Enabled: true, + Provider: "anthropic", + Model: "claude-haiku", + ReasoningEffort: "low", + Timeout: "250ms", + TopK: 5, + PromptVersion: "v1", + MaxTokensOut: 256, + FallbackChain: []contract.SettingsRoleFallbackPayload{{ + Provider: "openai", Model: "gpt-5-mini", ReasoningEffort: "medium", + }}, + }, + } +} + func validSettingsMemoryConfigPayload() contract.SettingsMemoryConfigPayload { return contract.SettingsMemoryConfigPayload{ Enabled: true, @@ -2050,14 +2105,6 @@ func validSettingsMemoryConfigPayload() contract.SettingsMemoryConfigPayload { Mode: "hybrid", MaxLatency: "300ms", DefaultOpOnFail: "noop", - LLM: contract.SettingsMemoryControllerLLMPayload{ - Enabled: true, - Model: "anthropic/claude-haiku-4", - TopK: 5, - PromptVersion: "v1", - Timeout: "250ms", - MaxTokensOut: 256, - }, Policy: contract.SettingsMemoryControllerPolicyPayload{ MaxContentChars: 4096, MaxWritesPerMin: 60, @@ -2098,7 +2145,6 @@ func validSettingsMemoryConfigPayload() contract.SettingsMemoryConfigPayload { MaxPostContentBytes: 65536, }, Extractor: contract.SettingsMemoryExtractorPayload{ - Enabled: true, Mode: "post_message", ThrottleTurns: 1, Deadline: "60s", @@ -2111,8 +2157,6 @@ func validSettingsMemoryConfigPayload() contract.SettingsMemoryConfigPayload { }, }, Dream: contract.SettingsMemoryDreamPayload{ - Enabled: true, - Agent: "dreaming-curator", MinHours: 24, MinSessions: 3, Debounce: "10m", @@ -2764,11 +2808,17 @@ func TestSettingsRemainingReadAndDeleteHandlers(t *testing.T) { Config: aghconfig.MemoryConfig{ Enabled: true, Dream: aghconfig.DreamConfig{ - Agent: "dreamer", CheckInterval: time.Hour, }, }, } + case settingspkg.SectionRoles: + roles := aghconfig.DefaultRolesConfig() + roles.Dream.Model = "claude-opus" + roles.AutoTitle.FallbackChain = []aghconfig.RoleFallback{{ + Provider: "openai", Model: "gpt-5-mini", ReasoningEffort: "medium", + }} + envelope.Roles = &settingspkg.RolesSection{Config: roles} case settingspkg.SectionSkills: envelope.Skills = &settingspkg.SkillsSection{ Config: aghconfig.SkillsConfig{ @@ -2845,6 +2895,7 @@ func TestSettingsRemainingReadAndDeleteHandlers(t *testing.T) { for _, path := range []string{ "/api/settings/memory", + "/api/settings/roles", "/api/settings/skills", "/api/settings/automation", "/api/settings/network", @@ -2857,6 +2908,16 @@ func TestSettingsRemainingReadAndDeleteHandlers(t *testing.T) { } } + rolesResp := performRequest(t, fixture.Engine, http.MethodGet, "/api/settings/roles", nil) + var roles contract.SettingsRolesResponse + decodeJSON(t, rolesResp.Body.Bytes(), &roles) + if rolesResp.Code != http.StatusOK || roles.Section != contract.SettingsSectionRoles || + roles.Config.Dream.Model != "claude-opus" || + len(roles.Config.AutoTitle.FallbackChain) != 1 || + roles.Config.AutoTitle.FallbackChain[0].Model != "gpt-5-mini" { + t.Fatalf("GET settings roles = status %d payload %#v", rolesResp.Code, roles) + } + for _, path := range []string{ "/api/settings/providers/openai", "/api/settings/sandboxes/local", diff --git a/internal/api/core/tool_errors.go b/internal/api/core/tool_errors.go index aea7344fb..90079ad90 100644 --- a/internal/api/core/tool_errors.go +++ b/internal/api/core/tool_errors.go @@ -65,7 +65,8 @@ func statusForToolCode(code toolspkg.ErrorCode, reasons []toolspkg.ReasonCode) i case toolspkg.ErrorCodeUnavailable, toolspkg.ErrorCodeResultTooLarge, toolspkg.ErrorCodeModelNotFound, - toolspkg.ErrorCodeReasoningEffortUnsupported: + toolspkg.ErrorCodeReasoningEffortUnsupported, + toolspkg.ErrorCodeAgentNameReserved: return http.StatusUnprocessableEntity case toolspkg.ErrorCodeResultPersistenceFailed: return http.StatusInsufficientStorage diff --git a/internal/api/core/tools_test.go b/internal/api/core/tools_test.go index 8654a3ff0..bd2632a7e 100644 --- a/internal/api/core/tools_test.go +++ b/internal/api/core/tools_test.go @@ -293,9 +293,30 @@ func TestToolArtifactHandlersPreserveWorkspaceScopeAndExactPages(t *testing.T) { }) } -func TestToolErrorsPreserveBoundedPartialResults(t *testing.T) { +func TestToolErrorResponses(t *testing.T) { t.Parallel() + t.Run("Should return HTTP 422 for reserved agent names", func(t *testing.T) { + t.Parallel() + + err := toolspkg.NewToolError( + toolspkg.ErrorCodeAgentNameReserved, + toolspkg.ToolIDAgentCreate, + "agent name is reserved", + toolspkg.ErrToolInvalidInput, + toolspkg.ReasonSchemaInvalid, + ) + status := core.StatusForToolError(err) + payload := core.ToolErrorResponseForError(err, status, true) + + if status != http.StatusUnprocessableEntity { + t.Fatalf("tool error status = %d, want %d", status, http.StatusUnprocessableEntity) + } + if payload.Error.Code != toolspkg.ErrorCodeAgentNameReserved { + t.Fatalf("tool error code = %q, want %q", payload.Error.Code, toolspkg.ErrorCodeAgentNameReserved) + } + }) + t.Run("Should return HTTP 507 with the safe partial result", func(t *testing.T) { t.Parallel() diff --git a/internal/api/httpapi/agent_routes.go b/internal/api/httpapi/agent_routes.go index ec08a4348..e25880e5d 100644 --- a/internal/api/httpapi/agent_routes.go +++ b/internal/api/httpapi/agent_routes.go @@ -8,7 +8,7 @@ func registerAgentRoutes(api gin.IRouter, handlers *Handlers) { agent.GET("/context", handlers.AgentContext) agent.GET("/soul", handlers.AgentSoul) agent.POST("/soul/validate", handlers.ValidateAgentSoul) - agent.GET("/coordinator/config", handlers.AgentCoordinatorConfig) + agent.GET("/coordinator/config", handlers.AgentCoordinatorRole) agent.POST("/spawn", handlers.AgentSpawn) agent.GET("/channels", handlers.AgentChannels) agent.GET("/channels/:channel/recv", handlers.AgentChannelRecv) diff --git a/internal/api/httpapi/handlers.go b/internal/api/httpapi/handlers.go index c063c6b4c..af09742d0 100644 --- a/internal/api/httpapi/handlers.go +++ b/internal/api/httpapi/handlers.go @@ -56,7 +56,8 @@ type handlerConfig struct { modelCatalog core.ModelCatalogService marketplaceCatalog core.MarketplaceCatalogService agentContext core.AgentContextService - coordinatorConfig core.CoordinatorConfigResolver + coordinatorRole core.CoordinatorRoleResolver + roles core.RolesStatusProvider soulAuthoring core.SoulAuthoringService soulHistoryPurger core.SoulHistoryPurger soulRefresher core.SoulRefresher @@ -164,7 +165,8 @@ func coreHandlerConfig(cfg *handlerConfig, boundHost string) *core.BaseHandlerCo ModelCatalog: cfg.modelCatalog, MarketplaceCatalog: cfg.marketplaceCatalog, AgentContextService: cfg.agentContext, - CoordinatorConfig: cfg.coordinatorConfig, + CoordinatorRole: cfg.coordinatorRole, + Roles: cfg.roles, SoulAuthoring: cfg.soulAuthoring, SoulHistoryPurger: cfg.soulHistoryPurger, SoulRefresher: cfg.soulRefresher, diff --git a/internal/api/httpapi/handlers_test.go b/internal/api/httpapi/handlers_test.go index 4e80c5a44..516591574 100644 --- a/internal/api/httpapi/handlers_test.go +++ b/internal/api/httpapi/handlers_test.go @@ -191,6 +191,8 @@ func assertRegisteredRouteContract(t *testing.T) { "GET /api/model-catalog/*catalog_path", "GET /api/providers", "GET /api/providers/:provider_id", + "GET /api/roles", + "GET /api/roles/:role", "GET /api/sessions", "GET /api/sessions/catalog-stream", "GET /api/sessions/:session_id", @@ -229,6 +231,7 @@ func assertRegisteredRouteContract(t *testing.T) { "GET /api/settings/observability/log-tail", "GET /api/settings/providers", "GET /api/settings/providers/:name", + "GET /api/settings/roles", "GET /api/settings/skills", "GET /api/support/bundles/:operation_id", "GET /api/support/bundles/:operation_id/download", @@ -283,6 +286,7 @@ func assertRegisteredRouteContract(t *testing.T) { "PATCH /api/settings/network", "PATCH /api/settings/window-manager", "PATCH /api/settings/observability", + "PATCH /api/settings/roles", "PATCH /api/settings/skills", "PATCH /api/tasks/:id", "PATCH /api/workspaces/:workspace_id/network/channels/:channel", diff --git a/internal/api/httpapi/roles_routes.go b/internal/api/httpapi/roles_routes.go new file mode 100644 index 000000000..3a0ae1865 --- /dev/null +++ b/internal/api/httpapi/roles_routes.go @@ -0,0 +1,9 @@ +package httpapi + +import "github.com/gin-gonic/gin" + +func registerRoleRoutes(api gin.IRouter, handlers *Handlers) { + roles := api.Group("/roles") + roles.GET("", handlers.ListRoles) + roles.GET("/:role", handlers.GetRole) +} diff --git a/internal/api/httpapi/routes.go b/internal/api/httpapi/routes.go index b12671c0a..cd7b0deaa 100644 --- a/internal/api/httpapi/routes.go +++ b/internal/api/httpapi/routes.go @@ -25,6 +25,7 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) { registerWindowManagerRoutes(api, handlers) registerSessionRoutes(api, handlers) registerAgentRoutes(api, handlers) + registerRoleRoutes(api, handlers) registerLogsRoutes(api, handlers) registerSupportRoutes(api, handlers) registerObserveRoutes(api, handlers) @@ -378,6 +379,8 @@ func registerSettingsRoutes(api gin.IRouter, handlers *Handlers) { settings.PATCH("/general", privileged, handlers.UpdateSettingsGeneral) settings.GET("/memory", handlers.GetSettingsMemory) settings.PATCH("/memory", privileged, handlers.UpdateSettingsMemory) + settings.GET("/roles", handlers.GetSettingsRoles) + settings.PATCH("/roles", privileged, handlers.UpdateSettingsRoles) settings.GET("/skills", handlers.GetSettingsSkills) settings.PATCH("/skills", privileged, handlers.UpdateSettingsSkills) settings.GET("/automation", handlers.GetSettingsAutomation) diff --git a/internal/api/httpapi/routes_refac_test.go b/internal/api/httpapi/routes_refac_test.go index b7d0a20d4..b98f168e3 100644 --- a/internal/api/httpapi/routes_refac_test.go +++ b/internal/api/httpapi/routes_refac_test.go @@ -13,7 +13,7 @@ import ( "github.com/gin-gonic/gin" ) -var _ core.CoordinatorConfigResolver = httpapiCoordinatorConfigResolverFunc(nil) +var _ core.CoordinatorRoleResolver = httpapiCoordinatorRoleResolverFunc(nil) func TestHTTPAgentKernelRoutesMatchDocumentedSpecOperations(t *testing.T) { t.Run("Should register every HTTP agent operation in the spec", func(t *testing.T) { @@ -29,32 +29,32 @@ func TestHTTPAgentKernelRoutesMatchDocumentedSpecOperations(t *testing.T) { }) } -func TestServerHandlerConfigIncludesCoordinatorConfig(t *testing.T) { +func TestServerHandlerConfigIncludesCoordinatorRole(t *testing.T) { t.Run("Should carry coordinator resolver into handlers", func(t *testing.T) { t.Parallel() - resolver := httpapiCoordinatorConfigResolverFunc( - func(_ context.Context, workspaceID string) (aghconfig.CoordinatorConfig, error) { + resolver := httpapiCoordinatorRoleResolverFunc( + func(_ context.Context, workspaceID string) (aghconfig.ResolvedCoordinatorRole, error) { if workspaceID != "ws-1" { - t.Fatalf("ResolveCoordinatorConfig() workspaceID = %q, want ws-1", workspaceID) + t.Fatalf("ResolveCoordinatorRole() workspaceID = %q, want ws-1", workspaceID) } - return aghconfig.CoordinatorConfig{AgentName: "coordinator"}, nil + return aghconfig.ResolvedCoordinatorRole{AgentName: "coordinator"}, nil }, ) server := &Server{} - WithCoordinatorConfig(resolver)(server) + WithCoordinatorRole(resolver)(server) handlers := newHandlers(server.handlerConfig(nil)) - if handlers.CoordinatorConfig == nil { - t.Fatal("handlers.CoordinatorConfig is nil, want configured resolver") + if handlers.CoordinatorRole == nil { + t.Fatal("handlers.CoordinatorRole is nil, want configured resolver") } - cfg, err := handlers.CoordinatorConfig.ResolveCoordinatorConfig(context.Background(), "ws-1") + cfg, err := handlers.CoordinatorRole.ResolveCoordinatorRole(context.Background(), "ws-1") if err != nil { - t.Fatalf("ResolveCoordinatorConfig() error = %v", err) + t.Fatalf("ResolveCoordinatorRole() error = %v", err) } if got, want := cfg.AgentName, "coordinator"; got != want { - t.Fatalf("CoordinatorConfig.AgentName = %q, want %q", got, want) + t.Fatalf("CoordinatorRole.AgentName = %q, want %q", got, want) } }) } @@ -99,11 +99,11 @@ func normalizeHTTPAgentSpecRoutePath(routePath string) string { return strings.Join(parts, "/") } -type httpapiCoordinatorConfigResolverFunc func(context.Context, string) (aghconfig.CoordinatorConfig, error) +type httpapiCoordinatorRoleResolverFunc func(context.Context, string) (aghconfig.ResolvedCoordinatorRole, error) -func (f httpapiCoordinatorConfigResolverFunc) ResolveCoordinatorConfig( +func (f httpapiCoordinatorRoleResolverFunc) ResolveCoordinatorRole( ctx context.Context, workspaceID string, -) (aghconfig.CoordinatorConfig, error) { +) (aghconfig.ResolvedCoordinatorRole, error) { return f(ctx, workspaceID) } diff --git a/internal/api/httpapi/server.go b/internal/api/httpapi/server.go index a86dbd9d5..3d4211e00 100644 --- a/internal/api/httpapi/server.go +++ b/internal/api/httpapi/server.go @@ -2,12 +2,10 @@ package httpapi import ( "context" - "fmt" "log/slog" "net" "net/http" - "sync" "time" @@ -78,7 +76,8 @@ type Server struct { modelCatalog core.ModelCatalogService marketplaceCatalog core.MarketplaceCatalogService agentContext core.AgentContextService - coordinatorConfig core.CoordinatorConfigResolver + coordinatorRole core.CoordinatorRoleResolver + roles core.RolesStatusProvider soulAuthoring core.SoulAuthoringService soulHistoryPurger core.SoulHistoryPurger soulRefresher core.SoulRefresher @@ -225,10 +224,17 @@ func WithAgentContext(service core.AgentContextService) Option { } } -// WithCoordinatorConfig injects the resolved coordinator policy reader. -func WithCoordinatorConfig(resolver core.CoordinatorConfigResolver) Option { +// WithCoordinatorRole injects the resolved coordinator policy reader. +func WithCoordinatorRole(resolver core.CoordinatorRoleResolver) Option { + return func(server *Server) { + server.coordinatorRole = resolver + } +} + +// WithRolesStatusProvider injects the effective role configuration reader. +func WithRolesStatusProvider(provider core.RolesStatusProvider) Option { return func(server *Server) { - server.coordinatorConfig = resolver + server.roles = provider } } diff --git a/internal/api/httpapi/server_handler_config.go b/internal/api/httpapi/server_handler_config.go index 65e7c72c6..475a6c8f2 100644 --- a/internal/api/httpapi/server_handler_config.go +++ b/internal/api/httpapi/server_handler_config.go @@ -43,7 +43,8 @@ func (s *Server) handlerConfig(staticFS fs.FS) *handlerConfig { modelCatalog: s.modelCatalog, marketplaceCatalog: s.marketplaceCatalog, agentContext: s.agentContext, - coordinatorConfig: s.coordinatorConfig, + coordinatorRole: s.coordinatorRole, + roles: s.roles, soulAuthoring: s.soulAuthoring, soulHistoryPurger: s.soulHistoryPurger, soulRefresher: s.soulRefresher, diff --git a/internal/api/httpapi/transport_parity_integration_test.go b/internal/api/httpapi/transport_parity_integration_test.go index bffebfad9..4e6ca9cb6 100644 --- a/internal/api/httpapi/transport_parity_integration_test.go +++ b/internal/api/httpapi/transport_parity_integration_test.go @@ -61,6 +61,7 @@ func TestHTTPTransportApprovalFlowUsesSharedRuntimeHarness(t *testing.T) { if err != nil { t.Fatalf("CreateSession() error = %v", err) } + session = waitForHTTPTransportSessionActive(t, ctx, runtimeHarness, session) var approvedRequestID string events, err := runtimeHarness.PromptSessionHTTPWithEvents( @@ -158,6 +159,7 @@ func TestHTTPTransportSessionProviderLifecycle(t *testing.T) { if created.Session.Provider != provider { t.Fatalf("HTTP create provider = %q, want %q", created.Session.Provider, provider) } + created.Session = waitForHTTPTransportSessionActive(t, ctx, runtimeHarness, created.Session) var detail aghcontract.SessionResponse if err := runtimeHarness.HTTPJSON( @@ -195,6 +197,7 @@ func TestHTTPTransportSessionProviderLifecycle(t *testing.T) { }, &created); err != nil { t.Fatalf("HTTP create session error = %v", err) } + created.Session = waitForHTTPTransportSessionActive(t, ctx, runtimeHarness, created.Session) stopResp := mustHTTPRequest( t, @@ -341,6 +344,7 @@ func TestHTTPTransportPromptFailureProjectionUsesSharedRuntimeHarness(t *testing if err != nil { t.Fatalf("CreateSession() error = %v", err) } + session = waitForHTTPTransportSessionActive(t, ctx, runtimeHarness, session) stream, err := runtimeHarness.PromptSessionHTTP(ctx, session.ID, "trigger invalid frame") if err != nil { @@ -368,6 +372,20 @@ func TestHTTPTransportPromptFailureProjectionUsesSharedRuntimeHarness(t *testing } } +func waitForHTTPTransportSessionActive( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + accepted aghcontract.SessionPayload, +) aghcontract.SessionPayload { + t.Helper() + active, err := harness.WaitForSessionActive(ctx, accepted.ID) + if err != nil { + t.Fatalf("WaitForSessionActive(%q) error = %v; accepted=%#v", accepted.ID, err, accepted) + } + return active +} + func transportHarnessSessionPath( t testing.TB, harness *e2etest.RuntimeHarness, diff --git a/internal/api/spec/agent_definitions.go b/internal/api/spec/agent_definitions.go index 209fcb9ee..0a28c6e7f 100644 --- a/internal/api/spec/agent_definitions.go +++ b/internal/api/spec/agent_definitions.go @@ -2,6 +2,8 @@ package spec import "github.com/compozy/agh/internal/api/contract" +const agentNameReservedDescription = "Agent name is reserved" + var agentDefinitionMutationOperationRegistry = []OperationSpec{ { Method: httpMethodPut, @@ -20,6 +22,7 @@ var agentDefinitionMutationOperationRegistry = []OperationSpec{ {Status: 404, Description: specAgentNotFoundDescription, Body: contract.ErrorPayload{}}, {Status: 409, Description: "Agent definition digest conflict", Body: contract.ErrorPayload{}}, {Status: 410, Description: specWorkspaceRootMissingDescription, Body: contract.ErrorPayload{}}, + {Status: 422, Description: agentNameReservedDescription, Body: contract.ErrorPayload{}}, {Status: 500, Description: specInternalServerErrorDescription, Body: contract.ErrorPayload{}}, { Status: 503, @@ -68,6 +71,7 @@ var agentDefinitionMutationOperationRegistry = []OperationSpec{ {Status: 404, Description: specAgentNotFoundDescription, Body: contract.ErrorPayload{}}, {Status: 409, Description: "Target agent definition already exists", Body: contract.ErrorPayload{}}, {Status: 410, Description: specWorkspaceRootMissingDescription, Body: contract.ErrorPayload{}}, + {Status: 422, Description: agentNameReservedDescription, Body: contract.ErrorPayload{}}, {Status: 500, Description: specInternalServerErrorDescription, Body: contract.ErrorPayload{}}, { Status: 503, diff --git a/internal/api/spec/operation_registry.go b/internal/api/spec/operation_registry.go index 83ad5ade3..766a07989 100644 --- a/internal/api/spec/operation_registry.go +++ b/internal/api/spec/operation_registry.go @@ -9,6 +9,7 @@ func buildOperationRegistry() []OperationSpec { registryToolOperations(), registryToolsetOperations(), registryAgentOperations(), + registryRolesOperations(), registryAutomationOperations(), registryOnboardingOperations(), registryFilesystemOperations(), diff --git a/internal/api/spec/registry_agents.go b/internal/api/spec/registry_agents.go index 93d9a6ea1..4479fac6f 100644 --- a/internal/api/spec/registry_agents.go +++ b/internal/api/spec/registry_agents.go @@ -17,6 +17,7 @@ func registryAgentOperations() []OperationSpec { {Status: 404, Description: specWorkspaceNotFoundDescription, Body: contract.ErrorPayload{}}, {Status: 409, Description: "Agent definition already exists", Body: contract.ErrorPayload{}}, {Status: 410, Description: specWorkspaceRootMissingDescription, Body: contract.ErrorPayload{}}, + {Status: 422, Description: agentNameReservedDescription, Body: contract.ErrorPayload{}}, {Status: 500, Description: specInternalServerErrorDescription, Body: contract.ErrorPayload{}}, {Status: 503, Description: "Workspace resolver unavailable", Body: contract.ErrorPayload{}}, }, diff --git a/internal/api/spec/registry_roles.go b/internal/api/spec/registry_roles.go new file mode 100644 index 000000000..36881c193 --- /dev/null +++ b/internal/api/spec/registry_roles.go @@ -0,0 +1,48 @@ +package spec + +import "github.com/compozy/agh/internal/api/contract" + +func registryRolesOperations() []OperationSpec { + workspace := queryParam( + specWorkspaceKey, + "Workspace id, name, or path used to resolve effective role configuration", + false, + ) + return []OperationSpec{ + { + Method: httpMethodGet, + Path: "/api/roles", + OperationID: "listRoles", + Summary: "List effective background role configuration", + Tags: []string{specRolesKey}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{workspace}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.RolesResponse{}}, + {Status: 404, Description: specWorkspaceNotFoundDescription, Body: contract.ErrorPayload{}}, + {Status: 410, Description: specWorkspaceRootMissingDescription, Body: contract.ErrorPayload{}}, + {Status: 500, Description: specInternalServerErrorDescription, Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Roles status provider unavailable", Body: contract.ErrorPayload{}}, + }, + }, + { + Method: httpMethodGet, + Path: "/api/roles/{role}", + OperationID: "getRole", + Summary: "Get effective background role configuration", + Tags: []string{specRolesKey}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Parameters: []ParameterSpec{ + pathParam("role", "Background role name"), + workspace, + }, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.RoleStatusResponse{}}, + {Status: 404, Description: "Role or workspace not found", Body: contract.ErrorPayload{}}, + {Status: 410, Description: specWorkspaceRootMissingDescription, Body: contract.ErrorPayload{}}, + {Status: 500, Description: specInternalServerErrorDescription, Body: contract.ErrorPayload{}}, + {Status: 503, Description: "Roles status provider unavailable", Body: contract.ErrorPayload{}}, + }, + }, + } +} diff --git a/internal/api/spec/registry_settings_features.go b/internal/api/spec/registry_settings_features.go index b3c70c2dc..18a384447 100644 --- a/internal/api/spec/registry_settings_features.go +++ b/internal/api/spec/registry_settings_features.go @@ -6,6 +6,8 @@ func registrySettingsFeatureOperations() []OperationSpec { return []OperationSpec{ getSettingsMemoryOperationSpec(), updateSettingsMemoryOperationSpec(), + getSettingsRolesOperationSpec(), + updateSettingsRolesOperationSpec(), getSettingsNetworkOperationSpec(), updateSettingsNetworkOperationSpec(), getSettingsWindowManagerOperationSpec(), @@ -21,6 +23,40 @@ func registrySettingsFeatureOperations() []OperationSpec { updateSettingsSkillsOperationSpec(), } } + +func getSettingsRolesOperationSpec() OperationSpec { + return OperationSpec{ + Method: httpMethodGet, + Path: specAPISettingsRolesPath, + OperationID: "getSettingsRoles", + Summary: "Read the background-role routing settings section", + Tags: []string{specSettingsKey}, + Transports: []Transport{TransportHTTP, TransportUDS}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.SettingsRolesResponse{}}, + {Status: 500, Description: specInternalServerErrorDescription, Body: contract.ErrorPayload{}}, + }, + } +} + +func updateSettingsRolesOperationSpec() OperationSpec { + return OperationSpec{ + Method: httpMethodPatch, + Path: specAPISettingsRolesPath, + OperationID: "updateSettingsRoles", + Summary: "Update the background-role routing settings section", + Tags: []string{specSettingsKey}, + Transports: []Transport{TransportHTTP, TransportUDS}, + RequestBody: contract.UpdateSettingsRolesRequest{}, + Responses: []ResponseSpec{ + {Status: 200, Description: "OK", Body: contract.SettingsApplyResponse{}}, + {Status: 400, Description: specInvalidSettingsPayloadDescription, Body: contract.ErrorPayload{}}, + {Status: 403, Description: specForbiddenDescription, Body: contract.ErrorPayload{}}, + {Status: 409, Description: specConflictingSettingsChangeDescription, Body: contract.ErrorPayload{}}, + {Status: 500, Description: specInternalServerErrorDescription, Body: contract.ErrorPayload{}}, + }, + } +} func getSettingsMemoryOperationSpec() OperationSpec { return OperationSpec{ Method: httpMethodGet, diff --git a/internal/api/spec/roles_test.go b/internal/api/spec/roles_test.go new file mode 100644 index 000000000..684c40b49 --- /dev/null +++ b/internal/api/spec/roles_test.go @@ -0,0 +1,67 @@ +package spec + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestRolesRoutesAndSchemas(t *testing.T) { + t.Parallel() + + doc, err := Document() + if err != nil { + t.Fatalf("Document() error = %v", err) + } + + t.Run("Should register list and detail on HTTP and UDS", func(t *testing.T) { + t.Parallel() + + list := operationFor(t, doc, "/api/roles", "GET") + assertOperationTransports(t, list, TransportHTTP, TransportUDS) + assertParameter(t, list, "workspace", openapi3.ParameterInQuery, false) + for _, status := range []int{200, 404, 410, 500, 503} { + assertResponseStatus(t, list, status) + } + + detail := operationFor(t, doc, "/api/roles/{role}", "GET") + assertOperationTransports(t, detail, TransportHTTP, TransportUDS) + assertParameter(t, detail, "role", openapi3.ParameterInPath, true) + assertParameter(t, detail, "workspace", openapi3.ParameterInQuery, false) + for _, status := range []int{200, 404, 410, 500, 503} { + assertResponseStatus(t, detail, status) + } + }) + + t.Run("Should describe the complete role projection", func(t *testing.T) { + t.Parallel() + + list := operationFor(t, doc, "/api/roles", "GET") + response := jsonResponseSchema(t, list, 200) + assertRequired(t, response, "roles") + roles := propertySchema(t, response, "roles") + if roles.Items == nil || roles.Items.Value == nil { + t.Fatal("roles schema items are missing") + } + role := roles.Items.Value + assertRequired( + t, + role, + "role", + "enabled", + "resolution_mode", + "agent", + "provider", + "model", + "reasoning_effort", + "fallback_chain", + "provenance", + "diagnostics", + ) + assertEnumValues(t, propertySchema(t, role, "resolution_mode"), "builtin", "catalog", "inherit") + + detail := operationFor(t, doc, "/api/roles/{role}", "GET") + detailResponse := jsonResponseSchema(t, detail, 200) + assertRequired(t, detailResponse, "role") + }) +} diff --git a/internal/api/spec/schema_enum_registry.go b/internal/api/spec/schema_enum_registry.go index 671ff94c0..6f77d73ab 100644 --- a/internal/api/spec/schema_enum_registry.go +++ b/internal/api/spec/schema_enum_registry.go @@ -52,6 +52,7 @@ var schemaEnumValues = withSettingsWindowManagerSchemaEnumValues( reflect.TypeFor[contract.AgentCreateScope](): agentCreateScopeValues(), reflect.TypeFor[contract.AgentOrigin](): agentOriginValues(), reflect.TypeFor[contract.CoordinatorConfigSource](): coordinatorConfigSourceValues(), + reflect.TypeFor[contract.RoleResolutionMode](): roleResolutionModeValues(), reflect.TypeFor[contract.LoopSource](): loopSourceValues(), reflect.TypeFor[contract.LoopRunStatus](): loopRunStatusValues(), reflect.TypeFor[contract.LoopRunEventKind](): loopRunEventKindValues(), @@ -196,6 +197,14 @@ var schemaEnumValues = withSettingsWindowManagerSchemaEnumValues( }), ) +func roleResolutionModeValues() []string { + return []string{ + string(contract.RoleResolutionModeBuiltin), + string(contract.RoleResolutionModeCatalog), + string(contract.RoleResolutionModeInherit), + } +} + func drainStateValues() []string { return []string{string(contract.DrainStateActive), string(contract.DrainStateDraining)} } diff --git a/internal/api/spec/settings_test.go b/internal/api/spec/settings_test.go index e81bd3c4f..73bd3a04f 100644 --- a/internal/api/spec/settings_test.go +++ b/internal/api/spec/settings_test.go @@ -28,6 +28,8 @@ func TestSettingsRoutesAndSchemas(t *testing.T) { {path: "/api/settings/general", method: "PATCH", transports: []Transport{TransportHTTP, TransportUDS}}, {path: "/api/settings/memory", method: "GET", transports: []Transport{TransportHTTP, TransportUDS}}, {path: "/api/settings/memory", method: "PATCH", transports: []Transport{TransportHTTP, TransportUDS}}, + {path: "/api/settings/roles", method: "GET", transports: []Transport{TransportHTTP, TransportUDS}}, + {path: "/api/settings/roles", method: "PATCH", transports: []Transport{TransportHTTP, TransportUDS}}, {path: "/api/settings/skills", method: "GET", transports: []Transport{TransportHTTP, TransportUDS}}, {path: "/api/settings/skills", method: "PATCH", transports: []Transport{TransportHTTP, TransportUDS}}, {path: "/api/settings/automation", method: "GET", transports: []Transport{TransportHTTP, TransportUDS}}, @@ -247,6 +249,21 @@ func TestSettingsRoutesAndSchemas(t *testing.T) { "deny-all", ) + updateRoles := operationFor(t, doc, "/api/settings/roles", "PATCH") + rolesRequest := jsonRequestSchema(t, updateRoles) + assertRequired(t, rolesRequest, "config") + rolesConfig := propertySchema(t, rolesRequest, "config") + assertRequired( + t, + rolesConfig, + "coordinator", + "dream", + "checkpoint_summary", + "memory_extractor", + "auto_title", + "memory_controller", + ) + mutationSchema := jsonResponseSchema(t, updateGeneral, 200) assertRequired( t, diff --git a/internal/api/spec/settings_values.go b/internal/api/spec/settings_values.go index 06a21f406..ec000a3e3 100644 --- a/internal/api/spec/settings_values.go +++ b/internal/api/spec/settings_values.go @@ -43,6 +43,7 @@ func settingsSectionValues() []string { return []string{ string(contract.SettingsSectionGeneral), string(contract.SettingsSectionMemory), + string(contract.SettingsSectionRoles), string(contract.SettingsSectionSkills), string(contract.SettingsSectionAutomation), string(contract.SettingsSectionNetwork), @@ -56,6 +57,7 @@ func settingsApplyTargetValues() []string { return []string{ string(contract.SettingsApplyTargetGeneral), string(contract.SettingsApplyTargetMemory), + string(contract.SettingsApplyTargetRoles), string(contract.SettingsApplyTargetSkills), string(contract.SettingsApplyTargetAutomation), string(contract.SettingsApplyTargetNetwork), diff --git a/internal/api/spec/spec.go b/internal/api/spec/spec.go index 9307935bc..95a532cf7 100644 --- a/internal/api/spec/spec.go +++ b/internal/api/spec/spec.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "reflect" "github.com/getkin/kin-openapi/openapi3" @@ -46,6 +45,7 @@ const ( specAPISettingsHooksNamePath = "/api/settings/hooks/{name}" specAPISettingsMCPServersNamePath = "/api/settings/mcp-servers/{name}" specAPISettingsMemoryPath = "/api/settings/memory" + specAPISettingsRolesPath = "/api/settings/roles" specAPISettingsNetworkPath = "/api/settings/network" specAPISettingsWindowManagerPath = "/api/settings/window-manager" specAPISettingsObservabilityPath = "/api/settings/observability" @@ -157,6 +157,7 @@ const ( specObserveKey = "observe" specProvidersKey = "providers" specResourcesKey = "resources" + specRolesKey = "roles" specSessionsKey = "sessions" specSettingsKey = "settings" specSkillsKey = "skills" @@ -255,6 +256,7 @@ func Document() (*openapi3.T, error) { {Name: specOpenAIKey}, {Name: specProvidersKey}, {Name: specResourcesKey}, + {Name: specRolesKey}, {Name: specSessionsKey}, {Name: specSettingsKey}, {Name: specSupportKey}, diff --git a/internal/api/udsapi/agent_channels_test.go b/internal/api/udsapi/agent_channels_test.go index 9944fd350..5acf55431 100644 --- a/internal/api/udsapi/agent_channels_test.go +++ b/internal/api/udsapi/agent_channels_test.go @@ -82,7 +82,7 @@ func TestAgentContextReturnsSituationPayload(t *testing.T) { }) } -func TestAgentCoordinatorConfigRouteReturnsResolvedPayload(t *testing.T) { +func TestAgentCoordinatorRoleRouteReturnsResolvedPayload(t *testing.T) { t.Parallel() t.Run("Should return resolved workspace coordinator payload", func(t *testing.T) { @@ -90,17 +90,17 @@ func TestAgentCoordinatorConfigRouteReturnsResolvedPayload(t *testing.T) { manager := activeAgentSessionManager(t) handlers := newTestHandlers(t, manager, stubObserver{}, newTestHomePaths(t)) - handlers.CoordinatorConfig = agentCoordinatorConfigResolverFunc( - func(_ context.Context, workspaceID string) (aghconfig.CoordinatorConfig, error) { + handlers.CoordinatorRole = agentCoordinatorRoleResolverFunc( + func(_ context.Context, workspaceID string) (aghconfig.ResolvedCoordinatorRole, error) { if workspaceID != "ws-1" { - t.Fatalf("ResolveCoordinatorConfig() workspaceID = %q, want ws-1", workspaceID) + t.Fatalf("ResolveCoordinatorRole() workspaceID = %q, want ws-1", workspaceID) } - return aghconfig.CoordinatorConfig{ + return aghconfig.ResolvedCoordinatorRole{ Enabled: true, AgentName: "coordinator", Provider: "codex", Model: "gpt-4o", - DefaultTTL: 45 * time.Minute, + TTL: 45 * time.Minute, MaxChildren: 5, MaxActiveSessionsPerWorkspace: 5, }, nil @@ -440,12 +440,12 @@ func (f agentContextServiceFunc) ContextForSession( return f(ctx, info) } -type agentCoordinatorConfigResolverFunc func(context.Context, string) (aghconfig.CoordinatorConfig, error) +type agentCoordinatorRoleResolverFunc func(context.Context, string) (aghconfig.ResolvedCoordinatorRole, error) -func (f agentCoordinatorConfigResolverFunc) ResolveCoordinatorConfig( +func (f agentCoordinatorRoleResolverFunc) ResolveCoordinatorRole( ctx context.Context, workspaceID string, -) (aghconfig.CoordinatorConfig, error) { +) (aghconfig.ResolvedCoordinatorRole, error) { return f(ctx, workspaceID) } diff --git a/internal/api/udsapi/handler_config.go b/internal/api/udsapi/handler_config.go index c4c268a26..c46497ea4 100644 --- a/internal/api/udsapi/handler_config.go +++ b/internal/api/udsapi/handler_config.go @@ -60,7 +60,8 @@ type handlerConfig struct { heartbeatWake core.HeartbeatWakeService sessionHealth core.SessionHealthReader wakeEvents core.HeartbeatWakeEventReader - coordinatorConfig core.CoordinatorConfigResolver + coordinatorRole core.CoordinatorRoleResolver + roles core.RolesStatusProvider skillsRegistry core.SkillsRegistry skillResources core.SkillResourceSyncer memoryStore *memory.Store diff --git a/internal/api/udsapi/handlers_test.go b/internal/api/udsapi/handlers_test.go index e63a306e7..f3aaf74a0 100644 --- a/internal/api/udsapi/handlers_test.go +++ b/internal/api/udsapi/handlers_test.go @@ -288,6 +288,8 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/model-catalog/*catalog_path", "GET /api/providers", "GET /api/providers/:provider_id", + "GET /api/roles", + "GET /api/roles/:role", "GET /api/resources", "GET /api/resources/:kind", "GET /api/resources/:kind/:id", @@ -329,6 +331,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "GET /api/settings/observability/log-tail", "GET /api/settings/providers", "GET /api/settings/providers/:name", + "GET /api/settings/roles", "GET /api/settings/skills", "GET /api/support/bundles/:operation_id", "GET /api/support/bundles/:operation_id/download", @@ -381,6 +384,7 @@ func TestRegisterRoutesCoversTechSpecEndpoints(t *testing.T) { "PATCH /api/settings/hooks-extensions", "PATCH /api/settings/memory", "PATCH /api/settings/network", + "PATCH /api/settings/roles", "PATCH /api/settings/window-manager", "PATCH /api/settings/observability", "PATCH /api/settings/skills", @@ -1156,7 +1160,7 @@ func TestRegisterTaskRoutesUseSharedHandlerBindings(t *testing.T) { "GET /api/agent/channels": "AgentChannels", "GET /api/agent/channels/:channel/recv": "AgentChannelRecv", "GET /api/agent/context": "AgentContext", - "GET /api/agent/coordinator/config": "AgentCoordinatorConfig", + "GET /api/agent/coordinator/config": "AgentCoordinatorRole", "GET /api/agent/me": "AgentMe", "POST /api/agent/channels/:channel/send": "AgentChannelSend", "POST /api/agent/channels/reply": "AgentChannelReply", diff --git a/internal/api/udsapi/roles_routes.go b/internal/api/udsapi/roles_routes.go new file mode 100644 index 000000000..6a5943463 --- /dev/null +++ b/internal/api/udsapi/roles_routes.go @@ -0,0 +1,9 @@ +package udsapi + +import "github.com/gin-gonic/gin" + +func registerRoleRoutes(api gin.IRouter, handlers *Handlers) { + roles := api.Group("/roles") + roles.GET("", handlers.ListRoles) + roles.GET("/:role", handlers.GetRole) +} diff --git a/internal/api/udsapi/routes.go b/internal/api/udsapi/routes.go index e44a35ffd..231299991 100644 --- a/internal/api/udsapi/routes.go +++ b/internal/api/udsapi/routes.go @@ -15,6 +15,7 @@ func RegisterRoutes(router gin.IRouter, handlers *Handlers) { registerWindowManagerRoutes(api, handlers) registerSessionRoutes(api, handlers) registerAgentRoutes(api, handlers) + registerRoleRoutes(api, handlers) registerAgentKernelRoutes(api, handlers) registerLogsRoutes(api, handlers) registerSupportRoutes(api, handlers) @@ -91,7 +92,7 @@ func registerAgentKernelRoutes(api gin.IRouter, handlers *Handlers) { agent.GET("/context", handlers.AgentContext) agent.GET("/soul", handlers.AgentSoul) agent.POST("/soul/validate", handlers.ValidateAgentSoul) - agent.GET("/coordinator/config", handlers.AgentCoordinatorConfig) + agent.GET("/coordinator/config", handlers.AgentCoordinatorRole) agent.POST("/spawn", handlers.AgentSpawn) agent.GET("/channels", handlers.AgentChannels) agent.GET("/channels/:channel/recv", handlers.AgentChannelRecv) @@ -422,6 +423,8 @@ func registerSettingsRoutes(api gin.IRouter, handlers *Handlers) { settings.PATCH("/general", handlers.UpdateSettingsGeneral) settings.GET("/memory", handlers.GetSettingsMemory) settings.PATCH("/memory", handlers.UpdateSettingsMemory) + settings.GET("/roles", handlers.GetSettingsRoles) + settings.PATCH("/roles", handlers.UpdateSettingsRoles) settings.GET("/skills", handlers.GetSettingsSkills) settings.PATCH("/skills", handlers.UpdateSettingsSkills) settings.GET("/automation", handlers.GetSettingsAutomation) diff --git a/internal/api/udsapi/server.go b/internal/api/udsapi/server.go index cd7ade32a..b56ffea4a 100644 --- a/internal/api/udsapi/server.go +++ b/internal/api/udsapi/server.go @@ -7,12 +7,10 @@ import ( "log/slog" "net" "net/http" - "sync" "time" core "github.com/compozy/agh/internal/api/core" - aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/doctor" mcppkg "github.com/compozy/agh/internal/mcp" @@ -103,7 +101,8 @@ type Server struct { heartbeatWake core.HeartbeatWakeService sessionHealth core.SessionHealthReader wakeEvents core.HeartbeatWakeEventReader - coordinatorConfig core.CoordinatorConfigResolver + coordinatorRole core.CoordinatorRoleResolver + roles core.RolesStatusProvider skillsRegistry core.SkillsRegistry skillResources core.SkillResourceSyncer memoryStore *memory.Store @@ -295,10 +294,17 @@ func WithHeartbeatWakeEventReader(reader core.HeartbeatWakeEventReader) Option { } } -// WithCoordinatorConfig injects the resolved coordinator policy reader. -func WithCoordinatorConfig(resolver core.CoordinatorConfigResolver) Option { +// WithCoordinatorRole injects the resolved coordinator policy reader. +func WithCoordinatorRole(resolver core.CoordinatorRoleResolver) Option { + return func(server *Server) { + server.coordinatorRole = resolver + } +} + +// WithRolesStatusProvider injects the effective role configuration reader. +func WithRolesStatusProvider(provider core.RolesStatusProvider) Option { return func(server *Server) { - server.coordinatorConfig = resolver + server.roles = provider } } diff --git a/internal/api/udsapi/server_handler_config.go b/internal/api/udsapi/server_handler_config.go index c80f5647e..d178e31d8 100644 --- a/internal/api/udsapi/server_handler_config.go +++ b/internal/api/udsapi/server_handler_config.go @@ -46,7 +46,8 @@ func (s *Server) handlerConfig() *handlerConfig { heartbeatWake: s.heartbeatWake, sessionHealth: s.sessionHealth, wakeEvents: s.wakeEvents, - coordinatorConfig: s.coordinatorConfig, + coordinatorRole: s.coordinatorRole, + roles: s.roles, skillsRegistry: s.skillsRegistry, skillResources: s.skillResources, memoryStore: s.memoryStore, diff --git a/internal/api/udsapi/server_handlers.go b/internal/api/udsapi/server_handlers.go index 00fe3b607..5e2975a6b 100644 --- a/internal/api/udsapi/server_handlers.go +++ b/internal/api/udsapi/server_handlers.go @@ -70,7 +70,8 @@ func udsCoreHandlerConfig(cfg *handlerConfig) *core.BaseHandlerConfig { HeartbeatWake: cfg.heartbeatWake, SessionHealth: cfg.sessionHealth, HeartbeatWakeEvents: cfg.wakeEvents, - CoordinatorConfig: cfg.coordinatorConfig, + CoordinatorRole: cfg.coordinatorRole, + Roles: cfg.roles, SkillsRegistry: cfg.skillsRegistry, SkillResources: cfg.skillResources, MemoryStore: cfg.memoryStore, diff --git a/internal/api/udsapi/transport_parity_integration_test.go b/internal/api/udsapi/transport_parity_integration_test.go index d9da663d6..9eb685169 100644 --- a/internal/api/udsapi/transport_parity_integration_test.go +++ b/internal/api/udsapi/transport_parity_integration_test.go @@ -381,6 +381,7 @@ func TestUDSTransportAutomaticSessionTitlePersistsAndMatchesHTTP(t *testing.T) { if sessionPayload.Name != "" { t.Fatalf("created session name = %q, want unnamed", sessionPayload.Name) } + sessionPayload = waitForTransportSessionActive(t, ctx, runtimeHarness, sessionPayload) if _, err := runtimeHarness.PromptSessionHTTP( ctx, @@ -438,6 +439,7 @@ func TestUDSTransportApprovalFlowMatchesHTTP(t *testing.T) { if err != nil { t.Fatalf("CreateSession() error = %v", err) } + session = waitForTransportSessionActive(t, ctx, runtimeHarness, session) var approvedRequestID string events, err := runtimeHarness.PromptSessionHTTPWithEvents( @@ -610,6 +612,7 @@ func TestUDSTransportResumeMissingProviderReturnsExplicitBadRequest(t *testing.T }, &created); err != nil { t.Fatalf("UDS create session error = %v", err) } + created.Session = waitForTransportSessionActive(t, ctx, runtimeHarness, created.Session) stopResp := mustUnixRequest( t, @@ -1026,6 +1029,7 @@ func TestUDSTransportPromptFailureProjectionUsesSharedRuntimeHarness(t *testing. if err != nil { t.Fatalf("CreateSession() error = %v", err) } + session = waitForTransportSessionActive(t, ctx, runtimeHarness, session) stream, err := runtimeHarness.PromptSession(ctx, session.ID, "trigger crash mid-stream") if err != nil { @@ -1075,6 +1079,7 @@ func TestUDSTransportObserveHarnessLifecycleParityMatchesHTTP(t *testing.T) { if err != nil { t.Fatalf("CreateSession() error = %v", err) } + session = waitForTransportSessionActive(t, ctx, runtimeHarness, session) stream, err := runtimeHarness.PromptSessionHTTP(ctx, session.ID, "hello alpha") if err != nil { @@ -1739,6 +1744,20 @@ func waitForTransportSessionTitle( } } +func waitForTransportSessionActive( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + accepted aghcontract.SessionPayload, +) aghcontract.SessionPayload { + t.Helper() + active, err := harness.WaitForSessionActive(ctx, accepted.ID) + if err != nil { + t.Fatalf("WaitForSessionActive(%q) error = %v; accepted=%#v", accepted.ID, err, accepted) + } + return active +} + func transportCatalogHasSingleTitle( catalog aghcontract.SessionsResponse, sessionID string, diff --git a/internal/bundles/service_test.go b/internal/bundles/service_test.go index c1b08e0cb..94b9a09ea 100644 --- a/internal/bundles/service_test.go +++ b/internal/bundles/service_test.go @@ -764,6 +764,63 @@ func TestServiceActivationSpecDriftUsesContentHashAndClearsOnReapply(t *testing. func TestServiceAgentValidation(t *testing.T) { t.Parallel() + t.Run("Should reject a reserved bundle agent before activation", func(t *testing.T) { + t.Parallel() + + store := newMemoryStore() + ext := newMarketingExtension() + profile := &ext.Bundles[0].Profiles[0] + profile.Agents[0].Agent.Name = "coordinator" + profile.Jobs[0].AgentName = "coordinator" + profile.Triggers[0].AgentName = "coordinator" + service := newServiceForExtensions(store, []*extensionpkg.Extension{ext}, WithLogger(discardBundleTestLogger())) + + _, err := service.Activate(testutil.Context(t), ActivateRequest{ + ExtensionName: "marketing-team", + BundleName: "marketing", + ProfileName: "default", + Scope: ScopeGlobal, + }) + if !errors.Is(err, aghconfig.ErrAgentNameReserved) { + t.Fatalf("Activate() error = %v, want ErrAgentNameReserved", err) + } + if !strings.Contains(err.Error(), "/agents/coordinator/AGENT.md") { + t.Fatalf("Activate() error = %v, want reserved bundle path", err) + } + if len(store.activations) != 0 || len(store.agents) != 0 || len(store.applied) != 0 { + t.Fatalf( + "reserved activation mutated state: activations=%d agents=%d applied=%d", + len(store.activations), + len(store.agents), + len(store.applied), + ) + } + }) + + t.Run("Should materialize an ordinary bundle agent", func(t *testing.T) { + t.Parallel() + + store := newMemoryStore() + ext := newMarketingExtension() + profile := &ext.Bundles[0].Profiles[0] + profile.Agents[0].Agent.Name = "code_implementer" + profile.Jobs[0].AgentName = "code_implementer" + profile.Triggers[0].AgentName = "code_implementer" + service := newServiceForExtensions(store, []*extensionpkg.Extension{ext}, WithLogger(discardBundleTestLogger())) + + if _, err := service.Activate(testutil.Context(t), ActivateRequest{ + ExtensionName: "marketing-team", + BundleName: "marketing", + ProfileName: "default", + Scope: ScopeGlobal, + }); err != nil { + t.Fatalf("Activate() error = %v", err) + } + if len(store.agents) != 1 || store.agents[0].Spec.Name != "code_implementer" { + t.Fatalf("materialized agents = %#v, want code_implementer", store.agents) + } + }) + t.Run("Should reject conflicting visible agent name", func(t *testing.T) { t.Parallel() diff --git a/internal/bundles/service_validate_agents.go b/internal/bundles/service_validate_agents.go index ca39db8e4..cd7b8d88e 100644 --- a/internal/bundles/service_validate_agents.go +++ b/internal/bundles/service_validate_agents.go @@ -4,13 +4,10 @@ import ( "context" "errors" "fmt" - "strings" automationpkg "github.com/compozy/agh/internal/automation" - aghconfig "github.com/compozy/agh/internal/config" - "github.com/compozy/agh/internal/resources" ) @@ -28,6 +25,14 @@ func (s *Service) validateActivationAgentBindings( if name == "" { continue } + if err := aghconfig.ValidateAuthoredAgentName(name); err != nil { + return fmt.Errorf( + "bundles: validate activation agent %q at %q: %w", + name, + agent.Spec.SourcePath, + err, + ) + } if _, exists := available[name]; exists { return fmt.Errorf("%w: %s", ErrAgentConflict, name) } diff --git a/internal/cli/client.go b/internal/cli/client.go index c5f0405cc..6b21f9301 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -2,9 +2,7 @@ package cli import ( "context" - "io" - "time" "github.com/compozy/agh/internal/agentidentity" @@ -167,6 +165,7 @@ type DaemonClient interface { UpdateAgent(ctx context.Context, name string, request contract.UpdateAgentRequest) (AgentRecord, error) DeleteAgent(ctx context.Context, name string, workspace string) (contract.DeleteAgentResponse, error) DuplicateAgent(ctx context.Context, name string, request contract.DuplicateAgentRequest) (AgentRecord, error) + RolesClient GetAgentSoul(ctx context.Context, name string, query AgentQuery) (AgentSoulRecord, error) ValidateAgentSoul(ctx context.Context, name string, request AgentSoulValidateRequest) (AgentSoulRecord, error) PutAgentSoul(ctx context.Context, name string, request AgentSoulPutRequest) (AgentSoulMutationRecord, error) diff --git a/internal/cli/client_roles.go b/internal/cli/client_roles.go new file mode 100644 index 000000000..ab6a3fcb4 --- /dev/null +++ b/internal/cli/client_roles.go @@ -0,0 +1,50 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/compozy/agh/internal/api/contract" +) + +// RoleRecord is one effective background-role configuration projection. +type RoleRecord = contract.RoleStatus + +// RolesClient is the focused daemon surface for background-role inspection. +type RolesClient interface { + ListRoles(context.Context, RoleQuery) ([]RoleRecord, error) + GetRole(context.Context, string, RoleQuery) (RoleRecord, error) +} + +// RoleQuery scopes role resolution to a workspace when supplied. +type RoleQuery struct { + Workspace string +} + +func (c *unixSocketClient) ListRoles(ctx context.Context, query RoleQuery) ([]RoleRecord, error) { + var response contract.RolesResponse + if err := c.doJSON(ctx, http.MethodGet, "/api/roles", roleValues(query), nil, &response); err != nil { + return nil, fmt.Errorf("cli: list roles: %w", err) + } + return response.Roles, nil +} + +func (c *unixSocketClient) GetRole(ctx context.Context, role string, query RoleQuery) (RoleRecord, error) { + var response contract.RoleStatusResponse + path := "/api/roles/" + url.PathEscape(strings.TrimSpace(role)) + if err := c.doJSON(ctx, http.MethodGet, path, roleValues(query), nil, &response); err != nil { + return RoleRecord{}, fmt.Errorf("cli: get role: %w", err) + } + return response.Role, nil +} + +func roleValues(query RoleQuery) url.Values { + values := url.Values{} + if workspace := strings.TrimSpace(query.Workspace); workspace != "" { + values.Set("workspace", workspace) + } + return values +} diff --git a/internal/cli/config.go b/internal/cli/config.go index 712f25a3a..a9c31e681 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -151,6 +151,7 @@ const ( configSetDuration configSetStringSlice configSetFloatSlice + configSetTable ) var ( @@ -191,12 +192,6 @@ var ( "memory.controller.mode": configSetString, "memory.controller.max_latency": configSetDuration, "memory.controller.default_op_on_fail": configSetString, - "memory.controller.llm.enabled": configSetBool, - "memory.controller.llm.model": configSetString, - "memory.controller.llm.top_k": configSetInt, - "memory.controller.llm.prompt_version": configSetString, - "memory.controller.llm.timeout": configSetDuration, - "memory.controller.llm.max_tokens_out": configSetInt, "memory.controller.policy.max_content_chars": configSetInt, "memory.controller.policy.max_writes_per_min": configSetInt, "memory.controller.policy.allow_origins": configSetStringSlice, @@ -216,18 +211,14 @@ var ( "memory.decisions.prune_after_applied_days": configSetInt, "memory.decisions.keep_audit_summary": configSetBool, "memory.decisions.max_post_content_bytes": configSetInt64, - "memory.extractor.enabled": configSetBool, "memory.extractor.mode": configSetString, "memory.extractor.throttle_turns": configSetInt, "memory.extractor.deadline": configSetDuration, "memory.extractor.sandbox_inbox_only": configSetBool, "memory.extractor.inbox_path": configSetString, "memory.extractor.dlq_path": configSetString, - "memory.extractor.model": configSetString, "memory.extractor.queue.capacity": configSetInt, "memory.extractor.queue.coalesce_max": configSetInt, - "memory.dream.enabled": configSetBool, - "memory.dream.agent": configSetString, "memory.dream.min_hours": configSetFloat, "memory.dream.min_sessions": configSetInt, "memory.dream.debounce": configSetDuration, @@ -294,6 +285,7 @@ var ( "agents.heartbeat.session_health_stale_after": configSetDuration, "agents.heartbeat.session_health_hook_min_interval": configSetDuration, }, + roleConfigSetPathKinds(), networkConfigSetPathKinds(), loopAndGoalConfigSetPathKinds(), extensionConfigSetPathKinds(), diff --git a/internal/cli/config_display.go b/internal/cli/config_display.go index 556b40d88..76af10240 100644 --- a/internal/cli/config_display.go +++ b/internal/cli/config_display.go @@ -160,11 +160,27 @@ func configStructNode(value reflect.Value) (any, bool) { if field.PkgPath != "" { continue } + fieldValue := value.Field(i) + if field.Anonymous && field.Tag.Get("toml") == "" { + node, hasValue := configNodeFromValue(fieldValue, "") + if !hasValue { + continue + } + embedded, isStruct := node.(map[string]any) + if !isStruct { + continue + } + for key, embeddedValue := range embedded { + if _, exists := result[key]; !exists { + result[key] = embeddedValue + } + } + continue + } name, omitEmpty, ok := tomlFieldName(field) if !ok { continue } - fieldValue := value.Field(i) if omitEmpty && fieldValue.IsZero() { continue } diff --git a/internal/cli/config_roles.go b/internal/cli/config_roles.go new file mode 100644 index 000000000..abb2f8374 --- /dev/null +++ b/internal/cli/config_roles.go @@ -0,0 +1,34 @@ +package cli + +import aghconfig "github.com/compozy/agh/internal/config" + +func roleConfigSetPathKinds() map[string]configSetValueKind { + result := make(map[string]configSetValueKind) + for path, kind := range aghconfig.RoleMutableConfigKinds() { + result[path] = configSetKindFromToolKind(kind) + } + return result +} + +func configSetKindFromToolKind(kind aghconfig.ValueKind) configSetValueKind { + switch kind { + case aghconfig.ConfigValueString: + return configSetString + case aghconfig.ConfigValueBool: + return configSetBool + case aghconfig.ConfigValueInt: + return configSetInt + case aghconfig.ConfigValueInt64: + return configSetInt64 + case aghconfig.ConfigValueFloat: + return configSetFloat + case aghconfig.ConfigValueDuration: + return configSetDuration + case aghconfig.ConfigValueStringSlice: + return configSetStringSlice + case aghconfig.ConfigValueTable: + return configSetTable + default: + panic("cli: unsupported role config value kind") + } +} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 66304441b..e4b0a4717 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -900,6 +900,25 @@ func TestConfigRenderingAndMutationHelpers(t *testing.T) { {Path: "providers.claude.models", Value: []string{"sonnet", "opus"}}, } + t.Run("Should flatten anonymous TOML fields into canonical parent paths", func(t *testing.T) { + t.Parallel() + + cfg := aghconfig.Config{Roles: aghconfig.DefaultRolesConfig()} + flattened := flattenConfigEntries(redactedConfigMap(&cfg)) + foundCanonical := false + for _, entry := range flattened { + switch entry.Path { + case "roles.coordinator.enabled": + foundCanonical = true + case "roles.coordinator.roleconfig.enabled": + t.Fatalf("config list exposed embedded Go field path %q", entry.Path) + } + } + if !foundCanonical { + t.Fatal("config list omitted canonical path roles.coordinator.enabled") + } + }) + t.Run("Should render show bundle outputs", func(t *testing.T) { t.Parallel() @@ -1081,6 +1100,29 @@ func TestConfigRenderingAndMutationHelpers(t *testing.T) { path: "window_manager.snap.unknown", wantAllowed: false, }, + { + name: "Should allow full roles section", + path: "roles", + wantKind: configSetTable, + wantAllowed: true, + }, + { + name: "Should allow dream role model", + path: "roles.dream.model", + wantKind: configSetString, + wantAllowed: true, + }, + { + name: "Should allow memory controller role timeout", + path: "roles.memory_controller.timeout", + wantKind: configSetDuration, + wantAllowed: true, + }, + {name: "Should reject removed dream agent", path: "memory.dream.agent", wantAllowed: false}, + {name: "Should reject removed dream enabled", path: "memory.dream.enabled", wantAllowed: false}, + {name: "Should reject removed extractor model", path: "memory.extractor.model", wantAllowed: false}, + {name: "Should reject removed extractor enabled", path: "memory.extractor.enabled", wantAllowed: false}, + {name: "Should reject removed controller model", path: "memory.controller.llm.model", wantAllowed: false}, {name: "Should reject unknown sandbox values", path: "sandboxes.dev.unknown.value", wantAllowed: false}, } for _, tc := range testCases { @@ -1136,6 +1178,25 @@ func TestConfigRenderingAndMutationHelpers(t *testing.T) { } }) + t.Run("Should parse a complete roles object", func(t *testing.T) { + t.Parallel() + + value, err := parseConfigSetValue( + configSetTable, + `{"auto_title":{"enabled":true,"fallback_chain":[{"provider":"codex","model":"gpt-5-mini"}]}}`, + ) + if err != nil { + t.Fatalf("parseConfigSetValue(roles object) error = %v", err) + } + table, ok := value.(map[string]any) + if !ok || table["auto_title"] == nil { + t.Fatalf("parseConfigSetValue(roles object) = %#v, want object", value) + } + if _, err := parseConfigSetValue(configSetTable, `[]`); err == nil { + t.Fatal("parseConfigSetValue(array) error = nil, want object error") + } + }) + t.Run("Should parse float slice values", func(t *testing.T) { t.Parallel() diff --git a/internal/cli/config_value_commands.go b/internal/cli/config_value_commands.go index f0fb00c74..8a14ba8cc 100644 --- a/internal/cli/config_value_commands.go +++ b/internal/cli/config_value_commands.go @@ -155,6 +155,13 @@ func runConfigSetCommand( return writeCommandOutput(cmd, configSetBundle(*liveRecord)) } if _, err := aghconfig.EditConfigOverlay(homePaths, workspace, target, func(editor *aghconfig.OverlayEditor) error { + if kind == configSetTable { + table, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("cli: config path %q requires an object", strings.Join(path, ".")) + } + return editor.SetTable(path, table) + } return editor.SetValue(path, value) }); err != nil { return err diff --git a/internal/cli/config_value_parse.go b/internal/cli/config_value_parse.go index 751680b95..19b767be1 100644 --- a/internal/cli/config_value_parse.go +++ b/internal/cli/config_value_parse.go @@ -52,6 +52,15 @@ func parseConfigSetValue(kind configSetValueKind, raw string) (any, error) { return parseStringSliceValue(trimmed) case configSetFloatSlice: return parseFloatSliceValue(trimmed) + case configSetTable: + var value map[string]any + if err := json.Unmarshal([]byte(trimmed), &value); err != nil { + return nil, fmt.Errorf("cli: parse object %q: %w", raw, err) + } + if value == nil { + return nil, errors.New("cli: config object must not be null") + } + return value, nil default: return nil, fmt.Errorf("cli: unsupported config value kind %d", kind) } diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 630c05073..9dafead0d 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -209,6 +209,8 @@ type stubClient struct { updateAgentFn func(context.Context, string, contract.UpdateAgentRequest) (AgentRecord, error) deleteAgentFn func(context.Context, string, string) (contract.DeleteAgentResponse, error) duplicateAgentFn func(context.Context, string, contract.DuplicateAgentRequest) (AgentRecord, error) + listRolesFn func(context.Context, RoleQuery) ([]RoleRecord, error) + getRoleFn func(context.Context, string, RoleQuery) (RoleRecord, error) getAgentSoulFn func(context.Context, string, AgentQuery) (AgentSoulRecord, error) validateAgentSoulFn func(context.Context, string, AgentSoulValidateRequest) (AgentSoulRecord, error) putAgentSoulFn func(context.Context, string, AgentSoulPutRequest) (AgentSoulMutationRecord, error) @@ -1837,6 +1839,20 @@ func (s *stubClient) GetAgent(ctx context.Context, name string, query AgentQuery return AgentRecord{}, errors.New("unexpected GetAgent call") } +func (s *stubClient) ListRoles(ctx context.Context, query RoleQuery) ([]RoleRecord, error) { + if s.listRolesFn != nil { + return s.listRolesFn(ctx, query) + } + return nil, errors.New("unexpected ListRoles call") +} + +func (s *stubClient) GetRole(ctx context.Context, role string, query RoleQuery) (RoleRecord, error) { + if s.getRoleFn != nil { + return s.getRoleFn(ctx, role, query) + } + return RoleRecord{}, errors.New("unexpected GetRole call") +} + func (s *stubClient) CreateAgent(ctx context.Context, request contract.CreateAgentRequest) (AgentRecord, error) { if s.createAgentFn != nil { return s.createAgentFn(ctx, request) diff --git a/internal/cli/hooks.go b/internal/cli/hooks.go index 85d6eae5a..947b271be 100644 --- a/internal/cli/hooks.go +++ b/internal/cli/hooks.go @@ -252,7 +252,7 @@ func renderHookInfoHuman(hooks []HookCatalogRecord) (string, error) { renderHumanSection("Hook", hookInfoRows(item)), renderHumanTable( "Matcher", - []string{"Field", hooksValueValue}, + []string{cliFieldValue, hooksValueValue}, hookMatcherRows(item.Matcher), ), renderHumanTable( @@ -329,7 +329,7 @@ func renderHookInfoToonDetails(item HookCatalogRecord) string { []string{automationNameKey, hooksOrderKey, hooksEventKey}, []string{item.Name, strconv.Itoa(item.Order), item.Event}, ), - renderToonArray("matcher", []string{"field", hooksValueKey}, hookMatcherRows(item.Matcher)), + renderToonArray("matcher", []string{cliFieldKey, hooksValueKey}, hookMatcherRows(item.Matcher)), renderToonArray( "metadata", []string{cliKeyKey, hooksValueKey}, diff --git a/internal/cli/output_labels.go b/internal/cli/output_labels.go new file mode 100644 index 000000000..78c33f39c --- /dev/null +++ b/internal/cli/output_labels.go @@ -0,0 +1,6 @@ +package cli + +const ( + agentOutputLabel = automationAgentValue + roleOutputLabel = "Role" +) diff --git a/internal/cli/roles.go b/internal/cli/roles.go new file mode 100644 index 000000000..3c5261667 --- /dev/null +++ b/internal/cli/roles.go @@ -0,0 +1,213 @@ +package cli + +import ( + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +const rolesListCommandUse = "list" + +func newRolesCommand(deps commandDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "roles", + Short: "Inspect effective background role configuration", + } + cmd.AddCommand(newRolesListCommand(deps)) + cmd.AddCommand(newRolesShowCommand(deps)) + return cmd +} + +func newRolesListCommand(deps commandDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: rolesListCommandUse, + Short: "List effective background roles", + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := clientFromDeps(deps) + if err != nil { + return err + } + query, err := roleQueryFromCommand(cmd) + if err != nil { + return err + } + roles, err := client.ListRoles(cmd.Context(), query) + if err != nil { + return err + } + return writeCommandOutput(cmd, roleListBundle(roles)) + }, + } + cmd.Flags().String("workspace", "", "Resolve roles from a workspace id, name, or path") + return cmd +} + +func newRolesShowCommand(deps commandDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show one effective background role", + Args: exactOneNonBlankArg(), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := clientFromDeps(deps) + if err != nil { + return err + } + query, err := roleQueryFromCommand(cmd) + if err != nil { + return err + } + role, err := client.GetRole(cmd.Context(), args[0], query) + if err != nil { + return err + } + return writeCommandOutput(cmd, roleBundle(role)) + }, + } + cmd.Flags().String("workspace", "", "Resolve the role from a workspace id, name, or path") + return cmd +} + +func roleQueryFromCommand(cmd *cobra.Command) (RoleQuery, error) { + workspace, err := commandWorkspaceFlag(cmd) + if err != nil { + return RoleQuery{}, err + } + return RoleQuery{Workspace: workspace}, nil +} + +func roleListBundle(roles []RoleRecord) outputBundle { + return listBundle( + roles, + roles, + "Roles", + []string{ + roleOutputLabel, + "Enabled", + "Resolution", + agentOutputLabel, + agentKernelProviderValue, + agentKernelModelValue, + "Diagnostics", + }, + "roles", + []string{ + "role", + "enabled", + "resolution_mode", + agentAgentKey, + cliProviderKey, + agentModelKey, + "diagnostic_count", + }, + roleListValues, + roleListValues, + ) +} + +func roleListValues(role RoleRecord) []string { + return []string{ + role.Role, + strconv.FormatBool(role.Enabled), + string(role.ResolutionMode), + roleStatusValue(role.Agent), + roleStatusValue(role.Provider), + roleStatusValue(role.Model), + strconv.Itoa(len(role.Diagnostics)), + } +} + +func roleBundle(role RoleRecord) outputBundle { + return outputBundle{ + jsonValue: role, + human: func() (string, error) { + summary := renderHumanSection(roleOutputLabel, []keyValue{ + {Label: roleOutputLabel, Value: role.Role}, + {Label: "Enabled", Value: strconv.FormatBool(role.Enabled)}, + {Label: "Resolution", Value: string(role.ResolutionMode)}, + {Label: agentOutputLabel, Value: stringOrDash(roleStatusValue(role.Agent))}, + {Label: agentKernelProviderValue, Value: stringOrDash(roleStatusValue(role.Provider))}, + {Label: agentKernelModelValue, Value: stringOrDash(roleStatusValue(role.Model))}, + {Label: "Reasoning Effort", Value: stringOrDash(roleStatusValue(role.ReasoningEffort))}, + {Label: "Timeout", Value: stringOrDash(roleStatusValue(role.Timeout))}, + {Label: "Diagnostics", Value: strconv.Itoa(len(role.Diagnostics))}, + }) + return renderHumanBlocks( + summary, + renderHumanTable( + "Fallback Chain", + []string{"Provider", "Model", "Reasoning Effort"}, + roleFallbackRows(role), + ), + renderHumanTable("Provenance", []string{cliFieldValue, "Source"}, roleProvenanceRows(role)), + renderHumanTable("Diagnostics", []string{"Code", "Message", "Agent"}, roleDiagnosticRows(role)), + ), nil + }, + toon: func() (string, error) { + return renderHumanBlocks(renderToonObject("role", []string{ + "role", + "enabled", + "resolution_mode", + agentAgentKey, + cliProviderKey, + agentModelKey, + "reasoning_effort", + "timeout", + }, []string{ + role.Role, + strconv.FormatBool(role.Enabled), + string(role.ResolutionMode), + roleStatusValue(role.Agent), + roleStatusValue(role.Provider), + roleStatusValue(role.Model), + roleStatusValue(role.ReasoningEffort), + roleStatusValue(role.Timeout), + }), + renderToonArray( + "fallback_chain", + []string{cliProviderKey, agentKernelModelKey, "reasoning_effort"}, + roleFallbackRows(role), + ), + renderToonArray("provenance", []string{cliFieldKey, "source"}, roleProvenanceRows(role)), + renderToonArray("diagnostics", []string{"code", "message", agentAgentKey}, roleDiagnosticRows(role)), + ), nil + }, + } +} + +func roleFallbackRows(role RoleRecord) [][]string { + rows := make([][]string, 0, len(role.FallbackChain)) + for _, fallback := range role.FallbackChain { + rows = append(rows, []string{fallback.Provider, fallback.Model, fallback.ReasoningEffort}) + } + return rows +} + +func roleProvenanceRows(role RoleRecord) [][]string { + fields := make([]string, 0, len(role.Provenance)) + for field := range role.Provenance { + fields = append(fields, field) + } + sort.Strings(fields) + rows := make([][]string, 0, len(fields)) + for _, field := range fields { + rows = append(rows, []string{field, role.Provenance[field]}) + } + return rows +} + +func roleDiagnosticRows(role RoleRecord) [][]string { + rows := make([][]string, 0, len(role.Diagnostics)) + for _, diagnostic := range role.Diagnostics { + rows = append(rows, []string{diagnostic.Code, diagnostic.Message, diagnostic.Agent}) + } + return rows +} + +func roleStatusValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} diff --git a/internal/cli/roles_test.go b/internal/cli/roles_test.go new file mode 100644 index 000000000..a48908cd5 --- /dev/null +++ b/internal/cli/roles_test.go @@ -0,0 +1,154 @@ +package cli + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + + "github.com/compozy/agh/internal/api/contract" +) + +func TestRolesCommands(t *testing.T) { + t.Parallel() + + t.Run("Should render the role roster as the public JSON contract", func(t *testing.T) { + t.Parallel() + + provider := "anthropic" + model := "claude-opus-4-1" + roles := []RoleRecord{ + {Role: "auto_title", Enabled: true, ResolutionMode: contract.RoleResolutionModeInherit, + FallbackChain: []contract.RoleFallbackStatus{}, Provenance: map[string]string{}, + Diagnostics: []contract.RoleDiagnostic{}}, + {Role: "checkpoint_summary", Enabled: true, ResolutionMode: contract.RoleResolutionModeBuiltin, + FallbackChain: []contract.RoleFallbackStatus{}, Provenance: map[string]string{}, + Diagnostics: []contract.RoleDiagnostic{}}, + {Role: "coordinator", Enabled: true, ResolutionMode: contract.RoleResolutionModeBuiltin, + Provider: &provider, Model: &model, + FallbackChain: []contract.RoleFallbackStatus{{ + Provider: "backup", Model: "backup-model", ReasoningEffort: "low", + }}, + Provenance: map[string]string{"provider": "global", "model": "workspace"}, + Diagnostics: []contract.RoleDiagnostic{{ + Code: "role_agent_not_found", Message: "configured agent is unavailable", Agent: "missing", + }}}, + {Role: "dream", Enabled: true, ResolutionMode: contract.RoleResolutionModeBuiltin, + FallbackChain: []contract.RoleFallbackStatus{}, Provenance: map[string]string{}, + Diagnostics: []contract.RoleDiagnostic{}}, + {Role: "memory_controller", Enabled: true, ResolutionMode: contract.RoleResolutionModeInherit, + FallbackChain: []contract.RoleFallbackStatus{}, Provenance: map[string]string{}, + Diagnostics: []contract.RoleDiagnostic{}}, + {Role: "memory_extractor", Enabled: true, ResolutionMode: contract.RoleResolutionModeInherit, + FallbackChain: []contract.RoleFallbackStatus{}, Provenance: map[string]string{}, + Diagnostics: []contract.RoleDiagnostic{}}, + } + deps := newTestDeps(t, &stubClient{ + listRolesFn: func(_ context.Context, query RoleQuery) ([]RoleRecord, error) { + if query.Workspace != "workspace-a" { + t.Fatalf("ListRoles() workspace = %q, want workspace-a", query.Workspace) + } + return roles, nil + }, + }) + + stdout, stderr, err := executeRootCommand( + t, + deps, + "roles", "list", "--workspace", "workspace-a", "-o", "json", + ) + if err != nil { + t.Fatalf("executeRootCommand(roles list) error = %v; stderr=%s", err, stderr) + } + var got []RoleRecord + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("json.Unmarshal(roles list) error = %v; stdout=%s", err, stdout) + } + if !reflect.DeepEqual(got, roles) { + t.Fatalf("roles list = %#v, want %#v", got, roles) + } + }) + + t.Run("Should show one workspace-scoped role as JSON", func(t *testing.T) { + t.Parallel() + + agent := "catalog-agent" + provider := "anthropic" + model := "claude-sonnet" + role := RoleRecord{ + Role: "dream", + Enabled: true, + ResolutionMode: contract.RoleResolutionModeCatalog, + Agent: &agent, + Provider: &provider, + Model: &model, + FallbackChain: []contract.RoleFallbackStatus{{ + Provider: "backup", Model: "fallback-model", ReasoningEffort: "medium", + }}, + Provenance: map[string]string{"agent": "workspace", "provider": "global"}, + Diagnostics: []contract.RoleDiagnostic{{ + Code: "role_warning", Message: "fallback configured", Agent: agent, + }}, + } + deps := newTestDeps(t, &stubClient{ + getRoleFn: func(_ context.Context, name string, query RoleQuery) (RoleRecord, error) { + if name != "dream" || query.Workspace != "workspace-b" { + t.Fatalf("GetRole() role/workspace = %q/%q, want dream/workspace-b", name, query.Workspace) + } + return role, nil + }, + }) + + stdout, stderr, err := executeRootCommand( + t, + deps, + "roles", "show", "dream", "--workspace", "workspace-b", "-o", "json", + ) + if err != nil { + t.Fatalf("executeRootCommand(roles show) error = %v; stderr=%s", err, stderr) + } + var got RoleRecord + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("json.Unmarshal(roles show) error = %v; stdout=%s", err, stdout) + } + if !reflect.DeepEqual(got, role) { + t.Fatalf("roles show = %#v, want %#v", got, role) + } + }) + + t.Run("Should render role_unknown as a structured nonzero failure", func(t *testing.T) { + t.Parallel() + + deps := newTestDeps(t, &stubClient{ + getRoleFn: func(context.Context, string, RoleQuery) (RoleRecord, error) { + return RoleRecord{}, &daemonAPIError{ + statusCode: http.StatusNotFound, + payload: contract.ErrorPayload{ + Error: "role is not supported", + Diagnostic: &contract.DiagnosticItem{ + Code: contract.CodeRoleUnknown, + Message: "role is not supported", + }, + }, + } + }, + }) + + exitCode, stdout, stderr := executeRootCommandWithExit( + t, + deps, + "roles", "show", "judge", "-o", "json", + ) + if exitCode == 0 || stdout != "" { + t.Fatalf("roles show unknown exit/stdout = %d/%q, want nonzero/empty", exitCode, stdout) + } + var payload contract.ErrorPayload + if err := json.Unmarshal([]byte(stderr), &payload); err != nil { + t.Fatalf("json.Unmarshal(role error) error = %v; stderr=%s", err, stderr) + } + if payload.Diagnostic == nil || payload.Diagnostic.Code != contract.CodeRoleUnknown { + t.Fatalf("roles show unknown payload = %#v, want role_unknown", payload) + } + }) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index a55ddfd91..fee796a17 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "strings" "syscall" "time" @@ -16,7 +15,6 @@ import ( aghconfig "github.com/compozy/agh/internal/config" aghdaemon "github.com/compozy/agh/internal/daemon" diagnosticspkg "github.com/compozy/agh/internal/diagnostics" - "github.com/compozy/agh/internal/version" "github.com/spf13/cobra" ) @@ -25,6 +23,8 @@ const ( rootAghKey = "agh" rootVersionKey = "version" cliCodeKey = "code" + cliFieldKey = "field" + cliFieldValue = "Field" cliGetKey = "get" cliKeyKey = "key" cliKeyValue = "Key" @@ -143,6 +143,7 @@ func newRootCommand(deps commandDeps) *cobra.Command { cmd.AddCommand(newWindowCommand(deps)) cmd.AddCommand(newLayoutCommand(deps)) cmd.AddCommand(newAgentCommand(deps)) + cmd.AddCommand(newRolesCommand(deps)) cmd.AddCommand(newExtensionCommand(deps)) cmd.AddCommand(newHooksCommand(deps)) cmd.AddCommand(newAutomationCommand(deps)) diff --git a/internal/cli/skill_output.go b/internal/cli/skill_output.go index e6a4eeccb..1240c2270 100644 --- a/internal/cli/skill_output.go +++ b/internal/cli/skill_output.go @@ -150,7 +150,11 @@ func skillInfoBundle(item skillInfoItem) outputBundle { {Label: skillOutputInactiveValue, Value: stringOrDash(skillInactiveReason(item.Activation))}, }) provenanceRows := skillProvenanceRows(item.Provenance) - provenance := renderHumanTable("Provenance", []string{"Field", skillOutputValueValue}, provenanceRows) + provenance := renderHumanTable( + "Provenance", + []string{cliFieldValue, skillOutputValueValue}, + provenanceRows, + ) metadataRows := make([][]string, 0, len(item.Metadata)) for _, entry := range sortedSkillMetadataEntries(item.Metadata) { @@ -203,7 +207,7 @@ func skillInfoBundle(item skillInfoItem) outputBundle { ), renderToonArray( "provenance", - []string{"field", skillOutputValueKey}, + []string{cliFieldKey, skillOutputValueKey}, skillProvenanceRows(item.Provenance), ), renderToonArray("metadata", []string{cliKeyKey, skillOutputValueKey}, metadataRows), diff --git a/internal/cli/spawn.go b/internal/cli/spawn.go index e3b446f59..a257e138b 100644 --- a/internal/cli/spawn.go +++ b/internal/cli/spawn.go @@ -165,7 +165,7 @@ func agentSpawnBundle(record *AgentSpawnRecord) outputBundle { {Label: spawnParentValue, Value: stringOrDash(record.Lineage.ParentSessionID)}, {Label: spawnRootValue, Value: stringOrDash(record.Lineage.RootSessionID)}, {Label: "Depth", Value: fmt.Sprintf("%d", record.Lineage.SpawnDepth)}, - {Label: "Role", Value: stringOrDash(record.Lineage.SpawnRole)}, + {Label: roleOutputLabel, Value: stringOrDash(record.Lineage.SpawnRole)}, {Label: "TTL Expires", Value: stringOrDash(formatTimePtr(record.Lineage.TTLExpiresAt))}, }), ), nil diff --git a/internal/cli/tool_approvals.go b/internal/cli/tool_approvals.go index 8009d55a3..010e3f8ee 100644 --- a/internal/cli/tool_approvals.go +++ b/internal/cli/tool_approvals.go @@ -156,7 +156,7 @@ func toolApprovalGrantSetBundle(grant ToolApprovalGrantRecord) outputBundle { {Label: "Tool ID", Value: grant.ToolID.String()}, {Label: "Decision", Value: string(grant.Decision)}, {Label: automationScopeValue, Value: scope}, - {Label: "Agent", Value: stringOrDash(grant.AgentName)}, + {Label: agentOutputLabel, Value: stringOrDash(grant.AgentName)}, }), nil }, toon: func() (string, error) { diff --git a/internal/config/agent.go b/internal/config/agent.go index adc5d9163..2d5f44eb2 100644 --- a/internal/config/agent.go +++ b/internal/config/agent.go @@ -208,6 +208,9 @@ func LoadWorkspaceAgentDefs(rootDir string, additionalDirs []string, homePaths H if !entry.IsDir() { continue } + if IsReservedAgentName(entry.Name()) { + continue + } agentPath := filepath.Join(root.AgentsDir(), entry.Name(), agentDefName) agent, err := LoadAgentDefFile(agentPath) @@ -326,6 +329,18 @@ func ValidateAgentName(name string) error { } } +// ValidateAuthoredAgentName rejects names that cannot be materialized in an agent catalog. +func ValidateAuthoredAgentName(name string) error { + trimmed := NormalizeAgentName(name) + if err := ValidateAgentName(trimmed); err != nil { + return err + } + if IsReservedAgentName(trimmed) { + return fmt.Errorf("%w: %q", ErrAgentNameReserved, trimmed) + } + return nil +} + func normalizeAgentSkillsConfig(config AgentSkillsConfig) AgentSkillsConfig { if len(config.Disabled) == 0 { return AgentSkillsConfig{} diff --git a/internal/config/agent_create.go b/internal/config/agent_create.go index 35022af10..9f0bbd1f5 100644 --- a/internal/config/agent_create.go +++ b/internal/config/agent_create.go @@ -73,7 +73,7 @@ func CreateAgentDefFile(path string, draft AgentDefinitionDraft, overwrite bool) // RenderAgentDefinition renders a draft to AGENT.md bytes and validates by parsing the result. func RenderAgentDefinition(draft AgentDefinitionDraft) ([]byte, AgentDef, error) { agentName := NormalizeAgentName(draft.Name) - if err := ValidateAgentName(agentName); err != nil { + if err := ValidateAuthoredAgentName(agentName); err != nil { return nil, AgentDef{}, errors.Join(ErrInvalidAgentDefinition, err) } agent := canonicalAgentDefinition(AgentDef{ diff --git a/internal/config/agent_workspace_discovery_test.go b/internal/config/agent_workspace_discovery_test.go index b0e8375e2..c98a18deb 100644 --- a/internal/config/agent_workspace_discovery_test.go +++ b/internal/config/agent_workspace_discovery_test.go @@ -45,3 +45,49 @@ func TestLoadWorkspaceAgentDefsRejectsMismatchedDirectoryNameContract(t *testing } }) } + +func TestLoadWorkspaceAgentDefsSkipsReservedNames(t *testing.T) { + t.Parallel() + + t.Run("Should exclude reserved workspace and global agent directories", func(t *testing.T) { + t.Parallel() + + homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home")) + if err != nil { + t.Fatalf("ResolveHomePathsFrom() error = %v", err) + } + if err := EnsureHomeLayout(homePaths); err != nil { + t.Fatalf("EnsureHomeLayout() error = %v", err) + } + root := t.TempDir() + writeAgentDefinition( + t, + filepath.Join(root, DirName, AgentsDirName, BuiltinCoordinatorAgentName, agentDefName), + BuiltinCoordinatorAgentName, + "claude", + "shadowed-coordinator", + ) + writeAgentDefinition( + t, + filepath.Join(homePaths.AgentsDir, BuiltinDreamingCuratorAgentName, agentDefName), + BuiltinDreamingCuratorAgentName, + "claude", + "shadowed-curator", + ) + writeAgentDefinition( + t, + filepath.Join(root, DirName, AgentsDirName, "worker", agentDefName), + "worker", + "claude", + "worker-model", + ) + + agents, err := LoadWorkspaceAgentDefs(root, nil, homePaths) + if err != nil { + t.Fatalf("LoadWorkspaceAgentDefs() error = %v", err) + } + if len(agents) != 1 || agents[0].Name != "worker" { + t.Fatalf("LoadWorkspaceAgentDefs() = %#v, want only worker", agents) + } + }) +} diff --git a/internal/config/autonomy.go b/internal/config/autonomy.go index aa975ee55..44aa954c2 100644 --- a/internal/config/autonomy.go +++ b/internal/config/autonomy.go @@ -1,30 +1,13 @@ package config import ( - "errors" "fmt" - "strings" "time" configdefaults "github.com/compozy/agh/internal/config/defaults" ) const ( - // DefaultCoordinatorAgentName is the bundled coordinator identity used when config is silent. - DefaultCoordinatorAgentName = "coordinator" - // DefaultCoordinatorTTL is the conservative coordinator session TTL used by autonomy defaults. - DefaultCoordinatorTTL = 2 * time.Hour - // MinCoordinatorTTL is the shortest coordinator TTL accepted by config validation. - MinCoordinatorTTL = time.Minute - // MaxCoordinatorTTL is the longest coordinator TTL accepted by config validation. - MaxCoordinatorTTL = 24 * time.Hour - // DefaultCoordinatorMaxChildren is the safe per-coordinator child-session cap. - DefaultCoordinatorMaxChildren = 5 - // MaxCoordinatorChildren is the hard MVP cap for coordinator child sessions. - MaxCoordinatorChildren = 5 - // DefaultCoordinatorMaxActiveSessionsPerWorkspace caps managed coordinator and worker sessions. - DefaultCoordinatorMaxActiveSessionsPerWorkspace = 5 - // DefaultSchedulerFanOutAfter is the wake count before the convergence ladder fans out. DefaultSchedulerFanOutAfter = 2 // DefaultSchedulerSpawnAfter is the wake count before a capability-matched worker is spawned. @@ -39,17 +22,10 @@ const ( DefaultBlockRecurrenceLimit = configdefaults.BlockRecurrenceLimit ) -type providerResolver interface { - ResolveProvider(name string) (ProviderConfig, error) -} - -var _ providerResolver = (*Config)(nil) - // AutonomyConfig controls opt-in autonomy features. type AutonomyConfig struct { - BlockRecurrenceLimit int `toml:"block_recurrence_limit"` - Coordinator CoordinatorConfig `toml:"coordinator"` - Scheduler SchedulerConfig `toml:"scheduler"` + BlockRecurrenceLimit int `toml:"block_recurrence_limit"` + Scheduler SchedulerConfig `toml:"scheduler"` } // SchedulerConfig bounds the mechanical scheduler's convergence escalation ladder. The counts are @@ -73,45 +49,11 @@ func DefaultSchedulerConfig() SchedulerConfig { } } -// CoordinatorConfig defines the resolved coordinator policy. -type CoordinatorConfig struct { - Enabled bool `toml:"enabled"` - AgentName string `toml:"agent_name"` - Provider string `toml:"provider,omitempty"` - Model string `toml:"model,omitempty"` - DefaultTTL time.Duration `toml:"default_ttl"` - MaxChildren int `toml:"max_children"` - MaxActiveSessionsPerWorkspace int `toml:"max_active_sessions_per_workspace"` -} - -// DefaultCoordinatorConfig returns the built-in coordinator policy defaults. -func DefaultCoordinatorConfig() CoordinatorConfig { - return CoordinatorConfig{ - Enabled: false, - AgentName: DefaultCoordinatorAgentName, - DefaultTTL: DefaultCoordinatorTTL, - MaxChildren: DefaultCoordinatorMaxChildren, - MaxActiveSessionsPerWorkspace: DefaultCoordinatorMaxActiveSessionsPerWorkspace, - } -} - -// DefaultCoordinatorAgentDef returns the bundled coordinator identity used when no -// workspace or global agent definition has been resolved yet. -func DefaultCoordinatorAgentDef() AgentDef { - return AgentDef{ - Name: DefaultCoordinatorAgentName, - Prompt: "AGH coordinator agent identity.", - } -} - // Validate ensures autonomy config is internally consistent. -func (c AutonomyConfig) Validate(resolver providerResolver) error { +func (c AutonomyConfig) Validate() error { if c.BlockRecurrenceLimit < 0 { return fmt.Errorf("autonomy.block_recurrence_limit must be >= 0: %d", c.BlockRecurrenceLimit) } - if err := c.Coordinator.Validate("autonomy.coordinator", resolver); err != nil { - return err - } return c.Scheduler.Validate("autonomy.scheduler") } @@ -139,109 +81,3 @@ func (c SchedulerConfig) Validate(path string) error { } return nil } - -// Validate ensures coordinator policy is safe to consume. -func (c CoordinatorConfig) Validate(path string, resolver providerResolver) error { - if strings.TrimSpace(c.AgentName) == "" { - return fmt.Errorf("%s.agent_name is required", path) - } - if c.Provider != "" && strings.TrimSpace(c.Provider) == "" { - return fmt.Errorf("%s.provider is required when set", path) - } - if c.Model != "" && strings.TrimSpace(c.Model) == "" { - return fmt.Errorf("%s.model is required when set", path) - } - if c.DefaultTTL < MinCoordinatorTTL || c.DefaultTTL > MaxCoordinatorTTL { - return fmt.Errorf( - "%s.default_ttl must be between %s and %s: %s", - path, - MinCoordinatorTTL, - MaxCoordinatorTTL, - c.DefaultTTL, - ) - } - if c.MaxChildren <= 0 { - return fmt.Errorf("%s.max_children must be positive: %d", path, c.MaxChildren) - } - if c.MaxChildren > MaxCoordinatorChildren { - return fmt.Errorf("%s.max_children must be <= %d: %d", path, MaxCoordinatorChildren, c.MaxChildren) - } - if c.MaxActiveSessionsPerWorkspace <= 0 { - return fmt.Errorf( - "%s.max_active_sessions_per_workspace must be positive: %d", - path, - c.MaxActiveSessionsPerWorkspace, - ) - } - - providerName := strings.TrimSpace(c.Provider) - if providerName == "" { - return nil - } - if resolver == nil { - return fmt.Errorf("%s.provider resolver is required", path) - } - provider, err := resolver.ResolveProvider(providerName) - if err != nil { - return fmt.Errorf("%s.provider: %w", path, err) - } - if strings.TrimSpace(c.Model) == "" && - strings.TrimSpace(provider.Models.Default) == "" && - provider.RequiresRuntimeModel() { - return fmt.Errorf("%s.model is required when provider %q has no default model", path, providerName) - } - return nil -} - -// ResolveCoordinatorConfig resolves coordinator runtime policy using the -// precedence config overlay > fallback agent definition > provider defaults. -func (c *Config) ResolveCoordinatorConfig(fallback AgentDef) (CoordinatorConfig, error) { - if c == nil { - return CoordinatorConfig{}, errors.New("config is required") - } - - resolved := c.Autonomy.Coordinator - if strings.TrimSpace(resolved.AgentName) == "" { - resolved.AgentName = strings.TrimSpace(fallback.Name) - } - if strings.TrimSpace(resolved.AgentName) == "" { - resolved.AgentName = DefaultCoordinatorAgentName - } - if err := resolved.Validate("autonomy.coordinator", c); err != nil { - return CoordinatorConfig{}, err - } - - providerName := firstTrimmedNonEmpty(resolved.Provider, fallback.Provider, c.Defaults.Provider) - model := firstTrimmedNonEmpty(resolved.Model, fallback.Model) - if providerName != "" { - provider, err := c.ResolveProvider(providerName) - if err != nil { - return CoordinatorConfig{}, fmt.Errorf("autonomy.coordinator.provider: %w", err) - } - if model == "" { - model = strings.TrimSpace(provider.Models.Default) - } - if model == "" && provider.RequiresRuntimeModel() { - return CoordinatorConfig{}, fmt.Errorf( - "autonomy.coordinator.model is required when provider %q has no default model", - providerName, - ) - } - } else if model != "" { - return CoordinatorConfig{}, errors.New("autonomy.coordinator.provider is required when model is set") - } - - resolved.AgentName = strings.TrimSpace(resolved.AgentName) - resolved.Provider = providerName - resolved.Model = model - return resolved, nil -} - -func firstTrimmedNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/internal/config/autonomy_test.go b/internal/config/autonomy_test.go index 61be4bdff..3a2244b06 100644 --- a/internal/config/autonomy_test.go +++ b/internal/config/autonomy_test.go @@ -9,41 +9,20 @@ import ( "time" ) -func TestDefaultWithHomeIncludesAutonomyCoordinatorDefaults(t *testing.T) { +func TestDefaultWithHomeIncludesAutonomyDefaults(t *testing.T) { t.Parallel() - homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home")) - if err != nil { - t.Fatalf("ResolveHomePathsFrom() error = %v", err) - } + t.Run("Should preserve the block breaker and scheduler defaults", func(t *testing.T) { + t.Parallel() - cfg := DefaultWithHome(homePaths) - if got, want := cfg.Autonomy.BlockRecurrenceLimit, DefaultBlockRecurrenceLimit; got != want { - t.Fatalf("DefaultWithHome() Autonomy.BlockRecurrenceLimit = %d, want %d", got, want) - } - coordinator := cfg.Autonomy.Coordinator - if coordinator.Enabled { - t.Fatal("DefaultWithHome() Autonomy.Coordinator.Enabled = true, want false") - } - if got, want := coordinator.AgentName, DefaultCoordinatorAgentName; got != want { - t.Fatalf("DefaultWithHome() coordinator AgentName = %q, want %q", got, want) - } - if coordinator.Provider != "" || coordinator.Model != "" { - t.Fatalf( - "DefaultWithHome() coordinator provider/model = %q/%q, want bundled fallback", - coordinator.Provider, - coordinator.Model, - ) - } - if got, want := coordinator.DefaultTTL, DefaultCoordinatorTTL; got != want { - t.Fatalf("DefaultWithHome() coordinator DefaultTTL = %s, want %s", got, want) - } - if got, want := coordinator.MaxChildren, DefaultCoordinatorMaxChildren; got != want { - t.Fatalf("DefaultWithHome() coordinator MaxChildren = %d, want %d", got, want) - } - if got, want := coordinator.MaxActiveSessionsPerWorkspace, DefaultCoordinatorMaxActiveSessionsPerWorkspace; got != want { - t.Fatalf("DefaultWithHome() coordinator MaxActiveSessionsPerWorkspace = %d, want %d", got, want) - } + cfg := DefaultWithHome(HomePaths{}) + if got, want := cfg.Autonomy.BlockRecurrenceLimit, DefaultBlockRecurrenceLimit; got != want { + t.Fatalf("DefaultWithHome() Autonomy.BlockRecurrenceLimit = %d, want %d", got, want) + } + if got, want := cfg.Autonomy.Scheduler, DefaultSchedulerConfig(); got != want { + t.Fatalf("DefaultWithHome() Autonomy.Scheduler = %#v, want %#v", got, want) + } + }) } func TestAutonomyConfigValidatesBlockRecurrenceLimit(t *testing.T) { @@ -51,481 +30,103 @@ func TestAutonomyConfigValidatesBlockRecurrenceLimit(t *testing.T) { t.Run("Should accept zero as breaker disabled", func(t *testing.T) { t.Parallel() + cfg := DefaultWithHome(HomePaths{}) cfg.Autonomy.BlockRecurrenceLimit = 0 - if err := cfg.Autonomy.Validate(&cfg); err != nil { + if err := cfg.Autonomy.Validate(); err != nil { t.Fatalf("Autonomy.Validate(block_recurrence_limit=0) error = %v", err) } }) - t.Run("Should reject negative block recurrence limit", func(t *testing.T) { + t.Run("Should reject a negative block recurrence limit", func(t *testing.T) { t.Parallel() + cfg := DefaultWithHome(HomePaths{}) cfg.Autonomy.BlockRecurrenceLimit = -1 - err := cfg.Autonomy.Validate(&cfg) - if err == nil { - t.Fatal("Autonomy.Validate(block_recurrence_limit=-1) error = nil, want rejection") - } - if !strings.Contains(err.Error(), "autonomy.block_recurrence_limit") { + err := cfg.Autonomy.Validate() + if err == nil || !strings.Contains(err.Error(), "autonomy.block_recurrence_limit") { t.Fatalf("Autonomy.Validate() error = %v, want block_recurrence_limit path", err) } }) } -func TestCoordinatorConfigValidatesMaxActiveSessions(t *testing.T) { - t.Parallel() - - t.Run("Should validate defaults", func(t *testing.T) { - t.Parallel() - base := DefaultCoordinatorConfig() - if err := base.Validate("autonomy.coordinator", nil); err != nil { - t.Fatalf("default coordinator config should validate: %v", err) - } - }) - - t.Run("Should reject non-positive session cap", func(t *testing.T) { - t.Parallel() - cfg := DefaultCoordinatorConfig() - cfg.MaxActiveSessionsPerWorkspace = 0 - err := cfg.Validate("autonomy.coordinator", nil) - if err == nil { - t.Fatal("expected validation error") - } - if !strings.Contains(err.Error(), "max_active_sessions_per_workspace") { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("Should accept higher session cap", func(t *testing.T) { - t.Parallel() - cfg := DefaultCoordinatorConfig() - cfg.MaxActiveSessionsPerWorkspace = 8 - if err := cfg.Validate("autonomy.coordinator", nil); err != nil { - t.Fatalf("max_active_sessions_per_workspace=8 should validate: %v", err) - } - }) -} - func TestSchedulerConfigValidateMonotonic(t *testing.T) { t.Parallel() t.Run("Should accept the monotonic defaults", func(t *testing.T) { t.Parallel() + if err := DefaultSchedulerConfig().Validate("autonomy.scheduler"); err != nil { t.Fatalf("DefaultSchedulerConfig().Validate() error = %v, want nil", err) } }) - t.Run("Should reject non-positive and non-monotonic thresholds", func(t *testing.T) { - t.Parallel() - base := DefaultSchedulerConfig() - cases := []struct { - name string - wantErrContains string - mutate func(*SchedulerConfig) - }{ - { - "Should reject non-positive fan_out", - "fan_out_after must be positive", - func(c *SchedulerConfig) { c.FanOutAfter = 0 }, - }, - { - "Should reject spawn before fan_out", - "spawn_after must be >= fan_out_after", - func(c *SchedulerConfig) { c.SpawnAfter = c.FanOutAfter - 1 }, - }, - { - "Should reject event before spawn", - "event_after must be >= spawn_after", - func(c *SchedulerConfig) { c.EventAfter = c.SpawnAfter - 1 }, - }, - { - "Should reject needs_attention before event", - "needs_attention_after must be >= event_after", - func(c *SchedulerConfig) { c.NeedsAttentionAfter = c.EventAfter - 1 }, - }, - { - "Should reject non-positive min_queued_age", - "min_queued_age must be positive", - func(c *SchedulerConfig) { c.MinQueuedAge = 0 }, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - cfg := base - tc.mutate(&cfg) - err := cfg.Validate("autonomy.scheduler") - if err == nil { - t.Fatalf("Validate(%s) error = nil, want rejection", tc.name) - } - if !strings.Contains(err.Error(), tc.wantErrContains) { - t.Fatalf("Validate(%s) error = %v, want substring %q", tc.name, err, tc.wantErrContains) - } - }) - } - }) -} - -func TestLoadWorkspaceOverridesAutonomyCoordinatorValues(t *testing.T) { - workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) - - writeFile(t, homePaths.ConfigFile, ` -[autonomy.coordinator] -enabled = true -agent_name = "global-coordinator" -provider = "claude" -model = "global-model" - default_ttl = "2h" - max_children = 5 - max_active_sessions_per_workspace = 4 - `) - writeFile(t, filepath.Join(workspaceRoot, DirName, ConfigName), ` -[autonomy.coordinator] -enabled = false -agent_name = "workspace-coordinator" -provider = "codex" -model = "workspace-model" - default_ttl = "3h" - max_children = 2 - max_active_sessions_per_workspace = 6 - `) - - cfg, err := Load(WithWorkspaceRoot(workspaceRoot)) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - - coordinator := cfg.Autonomy.Coordinator - if coordinator.Enabled { - t.Fatal("Load() coordinator Enabled = true, want workspace false") - } - if got, want := coordinator.AgentName, "workspace-coordinator"; got != want { - t.Fatalf("Load() coordinator AgentName = %q, want %q", got, want) - } - if got, want := coordinator.Provider, "codex"; got != want { - t.Fatalf("Load() coordinator Provider = %q, want %q", got, want) - } - if got, want := coordinator.Model, "workspace-model"; got != want { - t.Fatalf("Load() coordinator Model = %q, want %q", got, want) - } - if got, want := coordinator.DefaultTTL, 3*time.Hour; got != want { - t.Fatalf("Load() coordinator DefaultTTL = %s, want %s", got, want) - } - if got, want := coordinator.MaxChildren, 2; got != want { - t.Fatalf("Load() coordinator MaxChildren = %d, want %d", got, want) - } - if got, want := coordinator.MaxActiveSessionsPerWorkspace, 6; got != want { - t.Fatalf("Load() coordinator MaxActiveSessionsPerWorkspace = %d, want %d", got, want) - } -} - -func TestLoadWorkspaceOverridesAutonomyBlockRecurrenceLimit(t *testing.T) { - workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) - - writeFile(t, homePaths.ConfigFile, ` -[autonomy] -block_recurrence_limit = 3 -`) - writeFile(t, filepath.Join(workspaceRoot, DirName, ConfigName), ` -[autonomy] -block_recurrence_limit = 0 -`) - - cfg, err := Load(WithWorkspaceRoot(workspaceRoot)) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if got, want := cfg.Autonomy.BlockRecurrenceLimit, 0; got != want { - t.Fatalf("Load() BlockRecurrenceLimit = %d, want %d", got, want) - } -} - -func TestLoadRejectsInvalidAutonomyBlockRecurrenceLimit(t *testing.T) { - workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) - writeFile(t, homePaths.ConfigFile, ` -[autonomy] -block_recurrence_limit = -1 -`) - - _, err := Load(WithWorkspaceRoot(workspaceRoot)) - if err == nil { - t.Fatal("Load() error = nil, want validation failure") - } - if !strings.Contains(err.Error(), "autonomy.block_recurrence_limit") { - t.Fatalf("Load() error = %v, want block_recurrence_limit path", err) - } -} - -func TestLoadRejectsInvalidAutonomyCoordinatorConfig(t *testing.T) { - tests := []struct { - name string - config string - wantErr string + base := DefaultSchedulerConfig() + for _, tc := range []struct { + name string + wantErrContains string + mutate func(*SchedulerConfig) }{ { - name: "empty agent name", - config: ` -[autonomy.coordinator] -agent_name = "" -`, - wantErr: "autonomy.coordinator.agent_name", + name: "Should reject non-positive fan out", + wantErrContains: "fan_out_after must be positive", + mutate: func(c *SchedulerConfig) { c.FanOutAfter = 0 }, }, { - name: "unknown provider", - config: ` -[autonomy.coordinator] -provider = "missing-provider" -model = "model" -`, - wantErr: "autonomy.coordinator.provider", + name: "Should reject spawn before fan out", + wantErrContains: "spawn_after must be >= fan_out_after", + mutate: func(c *SchedulerConfig) { c.SpawnAfter = c.FanOutAfter - 1 }, }, { - name: "pi provider without default model", - config: ` -[providers.custom-pi] -command = "npx -y pi-acp@latest" -harness = "pi_acp" -[[providers.custom-pi.credential_slots]] -name = "api_key" -target_env = "CUSTOM_API_KEY" -secret_ref = "env:CUSTOM_API_KEY" -kind = "api_key" -required = true - -[autonomy.coordinator] -provider = "custom-pi" -`, - wantErr: "autonomy.coordinator.model", + name: "Should reject event before spawn", + wantErrContains: "event_after must be >= spawn_after", + mutate: func(c *SchedulerConfig) { c.EventAfter = c.SpawnAfter - 1 }, }, { - name: "ttl too short", - config: ` -[autonomy.coordinator] -default_ttl = "30s" -`, - wantErr: "autonomy.coordinator.default_ttl", + name: "Should reject needs attention before event", + wantErrContains: "needs_attention_after must be >= event_after", + mutate: func(c *SchedulerConfig) { c.NeedsAttentionAfter = c.EventAfter - 1 }, }, { - name: "ttl too long", - config: ` -[autonomy.coordinator] -default_ttl = "25h" -`, - wantErr: "autonomy.coordinator.default_ttl", + name: "Should reject non-positive minimum queued age", + wantErrContains: "min_queued_age must be positive", + mutate: func(c *SchedulerConfig) { c.MinQueuedAge = 0 }, }, - { - name: "negative max children", - config: ` -[autonomy.coordinator] -max_children = -1 -`, - wantErr: "autonomy.coordinator.max_children", - }, - { - name: "excess max children", - config: ` -[autonomy.coordinator] -max_children = 6 -`, - wantErr: "autonomy.coordinator.max_children", - }, - { - name: "non-positive session cap", - config: ` -[autonomy.coordinator] -max_active_sessions_per_workspace = 0 -`, - wantErr: "autonomy.coordinator.max_active_sessions_per_workspace", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) - writeFile(t, homePaths.ConfigFile, tt.config) - - _, err := Load(WithWorkspaceRoot(workspaceRoot)) - if err == nil { - t.Fatal("Load() error = nil, want validation failure") - } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("Load() error = %v, want %q", err, tt.wantErr) + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := base + tc.mutate(&cfg) + err := cfg.Validate("autonomy.scheduler") + if err == nil || !strings.Contains(err.Error(), tc.wantErrContains) { + t.Fatalf("Validate() error = %v, want %q", err, tc.wantErrContains) } }) } } -func TestLoadAllowsDirectACPAutonomyProviderWithoutModelDefault(t *testing.T) { - t.Run("Should accept provider-managed model for direct ACP provider", func(t *testing.T) { - workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) - writeFile(t, homePaths.ConfigFile, ` -[autonomy.coordinator] -provider = "opencode" -`) - - cfg, err := Load(WithWorkspaceRoot(workspaceRoot)) - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if got, want := cfg.Autonomy.Coordinator.Provider, "opencode"; got != want { - t.Fatalf("Load() coordinator Provider = %q, want %q", got, want) - } - if got := cfg.Autonomy.Coordinator.Model; got != "" { - t.Fatalf("Load() coordinator Model = %q, want empty", got) - } - }) -} - -func TestLoadRejectsUnknownAutonomyConfigKeys(t *testing.T) { - tests := []struct { - name string - config string - wantErr string - }{ - { - name: "unknown autonomy key", - config: ` -[autonomy] -unknown = true -`, - wantErr: "autonomy.unknown", - }, - { - name: "unknown coordinator key", - config: ` -[autonomy.coordinator] -unknown = true -`, - wantErr: "autonomy.coordinator.unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) - writeFile(t, homePaths.ConfigFile, tt.config) - - _, err := Load(WithWorkspaceRoot(workspaceRoot)) - if err == nil { - t.Fatal("Load() error = nil, want unknown-key failure") - } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("Load() error = %v, want %q", err, tt.wantErr) - } - }) - } -} - -func TestRootExampleConfigIncludesBlockRecurrenceLimit(t *testing.T) { - examplePath := filepath.Join("..", "..", "config.toml") - contents, err := os.ReadFile(examplePath) - if err != nil { - t.Fatalf("ReadFile(config.toml) error = %v", err) - } - if !strings.Contains(string(contents), "block_recurrence_limit = 2") { - t.Fatalf("config.toml missing block_recurrence_limit example") - } - - workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) - writeFile(t, homePaths.ConfigFile, string(contents)) - cfg, err := Load(WithWorkspaceRoot(workspaceRoot)) - if err != nil { - t.Fatalf("Load(config.toml example) error = %v", err) - } - if got, want := cfg.Autonomy.BlockRecurrenceLimit, 2; got != want { - t.Fatalf("Load(config.toml example) BlockRecurrenceLimit = %d, want %d", got, want) - } -} - -func TestResolveCoordinatorConfigUsesProviderModelPrecedence(t *testing.T) { - t.Parallel() - - homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home")) - if err != nil { - t.Fatalf("ResolveHomePathsFrom() error = %v", err) - } - cfg := DefaultWithHome(homePaths) - cfg.Defaults.Provider = "codex" - fallback := AgentDef{ - Name: DefaultCoordinatorAgentName, - Provider: "claude", - Model: "fallback-model", - Prompt: "fallback prompt", - } - - resolved, err := cfg.ResolveCoordinatorConfig(fallback) - if err != nil { - t.Fatalf("ResolveCoordinatorConfig() error = %v", err) - } - if got, want := resolved.Provider, "claude"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Provider = %q, want fallback %q", got, want) - } - if got, want := resolved.Model, "fallback-model"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Model = %q, want fallback %q", got, want) - } - - cfg.Autonomy.Coordinator.Provider = "codex" - cfg.Autonomy.Coordinator.Model = "config-model" - resolved, err = cfg.ResolveCoordinatorConfig(fallback) - if err != nil { - t.Fatalf("ResolveCoordinatorConfig(config override) error = %v", err) - } - if got, want := resolved.Provider, "codex"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Provider = %q, want config %q", got, want) - } - if got, want := resolved.Model, "config-model"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Model = %q, want config %q", got, want) - } -} - -func TestResolveCoordinatorConfigAllowsDirectACPProviderManagedModel(t *testing.T) { - t.Parallel() - - t.Run("Should resolve without model for direct ACP provider", func(t *testing.T) { - t.Parallel() - - homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home")) - if err != nil { - t.Fatalf("ResolveHomePathsFrom() error = %v", err) - } - cfg := DefaultWithHome(homePaths) - cfg.Defaults.Provider = "opencode" - - resolved, err := cfg.ResolveCoordinatorConfig(AgentDef{Name: DefaultCoordinatorAgentName}) - if err != nil { - t.Fatalf("ResolveCoordinatorConfig() error = %v", err) - } - if got, want := resolved.Provider, "opencode"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Provider = %q, want %q", got, want) - } - if got := resolved.Model; got != "" { - t.Fatalf("ResolveCoordinatorConfig() Model = %q, want empty", got) - } - }) -} - -func TestLoadAutonomyOverlayPreservesOtherConfigSections(t *testing.T) { +func TestLoadLayersAutonomyAndRolesWithoutClobberingOtherSections(t *testing.T) { + // not parallel: mutates AGH_HOME through the config test harness. workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) writeFile(t, homePaths.ConfigFile, ` - [providers.claude] - auth_mode = "bound_secret" - [providers.claude.models] - default = "global-model" - [[providers.claude.credential_slots]] - name = "api_key" - target_env = "GLOBAL_KEY" - secret_ref = "env:GLOBAL_KEY" - kind = "api_key" - required = true +[providers.claude] +auth_mode = "bound_secret" +[providers.claude.models] +default = "global-model" +[[providers.claude.credential_slots]] +name = "api_key" +target_env = "GLOBAL_KEY" +secret_ref = "env:GLOBAL_KEY" +kind = "api_key" +required = true [[hooks.declarations]] name = "shared" event = "tool.pre_call" mode = "sync" timeout = "5s" - [hooks.declarations.executor] command = "/bin/global" @@ -535,10 +136,7 @@ max_replay_age = 600 [memory] enabled = true - [memory.dream] -enabled = true -agent = "general" min_hours = 36 min_sessions = 4 check_interval = "20m" @@ -547,51 +145,41 @@ check_interval = "20m" enabled = true disabled_skills = ["global-skill"] poll_interval = "4s" -allowed_marketplace_mcp = ["@global/mcp"] -allowed_marketplace_hooks = ["@global/hook"] - -[skills.marketplace] -registry = "clawhub" -base_url = "https://global.example.test/api/v1" -[autonomy.coordinator] +[roles.coordinator] enabled = true provider = "claude" model = "global-coordinator" -default_ttl = "2h" +ttl = "2h" max_children = 5 max_active_sessions_per_workspace = 5 - `) +`) writeFile(t, filepath.Join(workspaceRoot, DirName, ConfigName), ` - [providers.claude] - auth_mode = "bound_secret" - [[providers.claude.credential_slots]] - name = "api_key" - target_env = "WORKSPACE_KEY" - secret_ref = "env:WORKSPACE_KEY" - kind = "api_key" - required = true - - [[hooks.declarations]] +[providers.claude] +auth_mode = "bound_secret" +[[providers.claude.credential_slots]] +name = "api_key" +target_env = "WORKSPACE_KEY" +secret_ref = "env:WORKSPACE_KEY" +kind = "api_key" +required = true + +[[hooks.declarations]] name = "workspace-only" event = "tool.pre_call" mode = "sync" - [hooks.declarations.executor] command = "/bin/workspace" [network] max_replay_age = 900 - [memory.dream] min_sessions = 6 - [skills] poll_interval = "9s" - -[autonomy.coordinator] +[roles.coordinator] model = "workspace-coordinator" -default_ttl = "3h" +ttl = "3h" max_children = 2 `) @@ -599,65 +187,53 @@ max_children = 2 if err != nil { t.Fatalf("Load() error = %v", err) } - claude, err := cfg.ResolveProvider("claude") if err != nil { t.Fatalf("ResolveProvider(claude) error = %v", err) } if claude.Models.Default != "global-model" { - t.Fatalf("ResolveProvider(claude) = %#v, want merged provider fields", claude) + t.Fatalf("ResolveProvider(claude).Models.Default = %q, want global-model", claude.Models.Default) } - if slots := claude.EffectiveCredentialSlots(); len(slots) != 1 || - slots[0].TargetEnv != "WORKSPACE_KEY" || - slots[0].SecretRef != "env:WORKSPACE_KEY" { + if slots := claude.EffectiveCredentialSlots(); len(slots) != 1 || slots[0].TargetEnv != "WORKSPACE_KEY" { t.Fatalf("ResolveProvider(claude) CredentialSlots = %#v, want workspace slot", slots) } decls, err := HookDeclarations(cfg.Hooks, nil) if err != nil { t.Fatalf("HookDeclarations() error = %v", err) } - if got, want := len(decls), 2; got != want { - t.Fatalf("len(HookDeclarations()) = %d, want %d", got, want) - } - if !cfg.Network.Enabled || cfg.Network.MaxReplayAge != 900 { - t.Fatalf("Load() Network = %#v, want enabled with workspace replay age", cfg.Network) + if len(decls) != 2 || !cfg.Network.Enabled || cfg.Network.MaxReplayAge != 900 { + t.Fatalf("Load() hooks/network = %d/%#v, want two hooks and layered network", len(decls), cfg.Network) } if cfg.Memory.Dream.MinHours != 36 || cfg.Memory.Dream.MinSessions != 6 { - t.Fatalf("Load() Memory.Dream = %#v, want merged dream config", cfg.Memory.Dream) + t.Fatalf("Load() Memory.Dream = %#v, want layered dream policy", cfg.Memory.Dream) } if got, want := cfg.Skills.DisabledSkills, []string{"global-skill"}; !slices.Equal(got, want) { t.Fatalf("Load() Skills.DisabledSkills = %#v, want %#v", got, want) } - if got, want := cfg.Skills.PollInterval, 9*time.Second; got != want { - t.Fatalf("Load() Skills.PollInterval = %s, want %s", got, want) - } - if got, want := cfg.Autonomy.Coordinator.Model, "workspace-coordinator"; got != want { - t.Fatalf("Load() coordinator Model = %q, want %q", got, want) + if cfg.Skills.PollInterval != 9*time.Second { + t.Fatalf("Load() Skills.PollInterval = %s, want 9s", cfg.Skills.PollInterval) } - if got, want := cfg.Autonomy.Coordinator.DefaultTTL, 3*time.Hour; got != want { - t.Fatalf("Load() coordinator DefaultTTL = %s, want %s", got, want) - } - if got, want := cfg.Autonomy.Coordinator.MaxChildren, 2; got != want { - t.Fatalf("Load() coordinator MaxChildren = %d, want %d", got, want) + if cfg.Roles.Coordinator.Model != "workspace-coordinator" || cfg.Roles.Coordinator.TTL != 3*time.Hour || + cfg.Roles.Coordinator.MaxChildren != 2 { + t.Fatalf("Load() Roles.Coordinator = %#v, want workspace values", cfg.Roles.Coordinator) } } func TestLoadAutonomyDoesNotUseAmbientWorkspaceOrMutateEnv(t *testing.T) { + // not parallel: changes the process working directory and AGH_HOME. workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) ambientWorkspace := t.TempDir() - writeFile(t, homePaths.ConfigFile, ` -[autonomy.coordinator] -provider = "claude" -model = "global-coordinator" -`) - writeFile(t, filepath.Join(workspaceRoot, DirName, ConfigName), ` -[autonomy.coordinator] -model = "target-workspace" -`) - writeFile(t, filepath.Join(ambientWorkspace, DirName, ConfigName), ` -[autonomy.coordinator] -model = "ambient-workspace" -`) + writeFile(t, homePaths.ConfigFile, "[roles.coordinator]\nmodel = \"global-coordinator\"\n") + writeFile( + t, + filepath.Join(workspaceRoot, DirName, ConfigName), + "[roles.coordinator]\nmodel = \"target-workspace\"\n", + ) + writeFile( + t, + filepath.Join(ambientWorkspace, DirName, ConfigName), + "[roles.coordinator]\nmodel = \"ambient-workspace\"\n", + ) previousWD, err := os.Getwd() if err != nil { @@ -677,19 +253,76 @@ model = "ambient-workspace" if err != nil { t.Fatalf("LoadForHome(target workspace) error = %v", err) } - if got, want := cfg.Autonomy.Coordinator.Model, "target-workspace"; got != want { - t.Fatalf("LoadForHome() coordinator Model = %q, want explicit workspace %q", got, want) + if cfg.Roles.Coordinator.Model != "target-workspace" { + t.Fatalf("LoadForHome() coordinator model = %q, want target-workspace", cfg.Roles.Coordinator.Model) } if got := os.Getenv("AGH_HOME"); got != beforeHome { t.Fatalf("LoadForHome() mutated AGH_HOME = %q, want %q", got, beforeHome) } - globalOnly, err := LoadForHome(homePaths) if err != nil { t.Fatalf("LoadForHome(global only) error = %v", err) } - if got, want := globalOnly.Autonomy.Coordinator.Model, "global-coordinator"; got != want { - t.Fatalf("LoadForHome(global only) coordinator Model = %q, want %q", got, want) + if globalOnly.Roles.Coordinator.Model != "global-coordinator" { + t.Fatalf("LoadForHome(global only) model = %q, want global-coordinator", globalOnly.Roles.Coordinator.Model) + } +} + +func TestLoadWorkspaceOverridesAutonomyBlockRecurrenceLimit(t *testing.T) { + // not parallel: mutates AGH_HOME through the config test harness. + workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) + writeFile(t, homePaths.ConfigFile, "[autonomy]\nblock_recurrence_limit = 3\n") + writeFile(t, filepath.Join(workspaceRoot, DirName, ConfigName), "[autonomy]\nblock_recurrence_limit = 0\n") + + cfg, err := Load(WithWorkspaceRoot(workspaceRoot)) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Autonomy.BlockRecurrenceLimit != 0 { + t.Fatalf("Load() BlockRecurrenceLimit = %d, want 0", cfg.Autonomy.BlockRecurrenceLimit) + } +} + +func TestLoadRejectsInvalidAutonomyBlockRecurrenceLimit(t *testing.T) { + // not parallel: mutates AGH_HOME through the config test harness. + workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) + writeFile(t, homePaths.ConfigFile, "[autonomy]\nblock_recurrence_limit = -1\n") + + _, err := Load(WithWorkspaceRoot(workspaceRoot)) + if err == nil || !strings.Contains(err.Error(), "autonomy.block_recurrence_limit") { + t.Fatalf("Load() error = %v, want block_recurrence_limit path", err) + } +} + +func TestLoadRejectsUnknownAutonomyConfigKeys(t *testing.T) { + // not parallel: mutates AGH_HOME through the config test harness. + workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) + writeFile(t, homePaths.ConfigFile, "[autonomy]\nunknown = true\n") + + _, err := Load(WithWorkspaceRoot(workspaceRoot)) + if err == nil || !strings.Contains(err.Error(), "autonomy.unknown") { + t.Fatalf("Load() error = %v, want autonomy.unknown", err) + } +} + +func TestRootExampleConfigIncludesBlockRecurrenceLimit(t *testing.T) { + // not parallel: mutates AGH_HOME through the config test harness. + examplePath := filepath.Join("..", "..", "config.toml") + contents, err := os.ReadFile(examplePath) + if err != nil { + t.Fatalf("ReadFile(config.toml) error = %v", err) + } + if !strings.Contains(string(contents), "block_recurrence_limit = 2") { + t.Fatal("config.toml missing block_recurrence_limit example") + } + workspaceRoot, homePaths := prepareAutonomyConfigTestEnv(t) + writeFile(t, homePaths.ConfigFile, string(contents)) + cfg, err := Load(WithWorkspaceRoot(workspaceRoot)) + if err != nil { + t.Fatalf("Load(config.toml example) error = %v", err) + } + if cfg.Autonomy.BlockRecurrenceLimit != 2 { + t.Fatalf("Load(config.toml example) BlockRecurrenceLimit = %d, want 2", cfg.Autonomy.BlockRecurrenceLimit) } } @@ -699,7 +332,6 @@ func prepareAutonomyConfigTestEnv(t *testing.T) (string, HomePaths) { workspaceRoot := t.TempDir() homeRoot := filepath.Join(t.TempDir(), "home") t.Setenv("AGH_HOME", homeRoot) - homePaths, err := ResolveHomePaths() if err != nil { t.Fatalf("ResolveHomePaths() error = %v", err) diff --git a/internal/config/bootstrap.go b/internal/config/bootstrap.go index f0627653c..e7fa4347c 100644 --- a/internal/config/bootstrap.go +++ b/internal/config/bootstrap.go @@ -66,14 +66,6 @@ func SaveBootstrapConfig(homePaths HomePaths, provider string, model string) (Co return Config{}, err } - dreamAgent := "" - currentDreamAgent := strings.TrimSpace(current.Memory.Dream.Agent) - if currentDreamAgent == "" || - currentDreamAgent == providerClaudeKey || - currentDreamAgent == DefaultAgentName { - dreamAgent = DefaultMemoryDreamAgentName - } - return EditConfigOverlay(homePaths, "", target, func(editor *OverlayEditor) error { if err := editor.SetValue( []string{bootstrapDefaultsKey, string(AgentResourceKind)}, @@ -90,14 +82,6 @@ func SaveBootstrapConfig(homePaths HomePaths, provider string, model string) (Co ); err != nil { return err } - if dreamAgent != "" { - if err := editor.SetValue( - []string{MemoryDirName, "dream", string(AgentResourceKind)}, - dreamAgent, - ); err != nil { - return err - } - } if selectedModel == "" { return nil } diff --git a/internal/config/bootstrap_test.go b/internal/config/bootstrap_test.go index 566c68c55..1a6609be3 100644 --- a/internal/config/bootstrap_test.go +++ b/internal/config/bootstrap_test.go @@ -60,13 +60,6 @@ func TestSaveBootstrapConfigWritesManagedDefaults(t *testing.T) { if cfg.Permissions.Mode != PermissionModeApproveAll { t.Fatalf("SaveBootstrapConfig() Permissions.Mode = %q, want %q", cfg.Permissions.Mode, PermissionModeApproveAll) } - if cfg.Memory.Dream.Agent != DefaultMemoryDreamAgentName { - t.Fatalf( - "SaveBootstrapConfig() Memory.Dream.Agent = %q, want %q", - cfg.Memory.Dream.Agent, - DefaultMemoryDreamAgentName, - ) - } if !cfg.Network.Enabled { t.Fatal("SaveBootstrapConfig() Network.Enabled = false, want inherited enabled default") } @@ -146,36 +139,6 @@ func TestSaveBootstrapConfigAllowsProviderManagedModel(t *testing.T) { } } -func TestSaveBootstrapConfigMigratesPriorBootstrapDreamAgent(t *testing.T) { - t.Parallel() - - homePaths, err := ResolveHomePathsFrom(filepath.Join(t.TempDir(), "home")) - if err != nil { - t.Fatalf("ResolveHomePathsFrom() error = %v", err) - } - - writeFile(t, homePaths.ConfigFile, ` -[memory.dream] -agent = "general" -`) - - cfg, err := SaveBootstrapConfig(homePaths, "claude", "claude-sonnet-4-6") - if err != nil { - t.Fatalf("SaveBootstrapConfig() error = %v", err) - } - if got := cfg.Memory.Dream.Agent; got != DefaultMemoryDreamAgentName { - t.Fatalf("SaveBootstrapConfig() Memory.Dream.Agent = %q, want %q", got, DefaultMemoryDreamAgentName) - } - - reloaded, err := LoadGlobalConfig(homePaths) - if err != nil { - t.Fatalf("LoadGlobalConfig() error = %v", err) - } - if got := reloaded.Memory.Dream.Agent; got != DefaultMemoryDreamAgentName { - t.Fatalf("LoadGlobalConfig() Memory.Dream.Agent = %q, want %q", got, DefaultMemoryDreamAgentName) - } -} - func TestSaveBootstrapConfigRequiresModelForPiProviders(t *testing.T) { t.Parallel() diff --git a/internal/config/builtin_agents.go b/internal/config/builtin_agents.go new file mode 100644 index 000000000..7863aedd4 --- /dev/null +++ b/internal/config/builtin_agents.go @@ -0,0 +1,63 @@ +package config + +import ( + _ "embed" + "errors" + "sort" + "strings" +) + +const ( + BuiltinCoordinatorAgentName = "coordinator" + BuiltinDreamingCuratorAgentName = "dreaming-curator" +) + +// ErrAgentNameReserved marks attempts to author a catalog agent with a builtin identity. +var ErrAgentNameReserved = errors.New("config: agent name is reserved") + +var ( + //go:embed prompts/coordinator.md + coordinatorBuiltinPrompt string + //go:embed prompts/dreaming-curator.md + dreamingCuratorBuiltinPrompt string + + builtinAgentDefs = map[string]AgentDef{ + BuiltinCoordinatorAgentName: { + Name: BuiltinCoordinatorAgentName, + Prompt: strings.TrimSpace(coordinatorBuiltinPrompt), + }, + BuiltinDreamingCuratorAgentName: { + Name: BuiltinDreamingCuratorAgentName, + Prompt: strings.TrimSpace(dreamingCuratorBuiltinPrompt), + }, + } +) + +// BuiltinAgentNames returns the reserved runtime-owned identity names. +func BuiltinAgentNames() []string { + names := make([]string, 0, len(builtinAgentDefs)) + for name := range builtinAgentDefs { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// BuiltinAgentDef returns a detached copy of a runtime-owned agent definition. +func BuiltinAgentDef(name string) (AgentDef, bool) { + def, ok := builtinAgentDefs[normalizeBuiltinAgentName(name)] + if !ok { + return AgentDef{}, false + } + return CloneAgentDef(def), true +} + +// IsReservedAgentName reports whether a catalog name collides with a builtin identity. +func IsReservedAgentName(name string) bool { + _, ok := builtinAgentDefs[normalizeBuiltinAgentName(name)] + return ok +} + +func normalizeBuiltinAgentName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} diff --git a/internal/config/builtin_agents_test.go b/internal/config/builtin_agents_test.go new file mode 100644 index 000000000..56379f990 --- /dev/null +++ b/internal/config/builtin_agents_test.go @@ -0,0 +1,80 @@ +package config + +import ( + "slices" + "strings" + "testing" +) + +func TestBuiltinAgentDefReturnsRuntimeOwnedIdentities(t *testing.T) { + t.Parallel() + + t.Run("Should return the coordinator identity with a real unpinned prompt", func(t *testing.T) { + t.Parallel() + + got, ok := BuiltinAgentDef("coordinator") + if !ok { + t.Fatal("BuiltinAgentDef(coordinator) ok = false, want true") + } + if got.Name != "coordinator" || strings.TrimSpace(got.Prompt) == "" { + t.Fatalf("BuiltinAgentDef(coordinator) = %#v, want named non-empty identity", got) + } + if got.Prompt == "AGH coordinator agent identity." { + t.Fatal("BuiltinAgentDef(coordinator).Prompt uses the deleted placeholder") + } + if got.Provider != "" || got.Model != "" { + t.Fatalf("BuiltinAgentDef(coordinator) route = %q/%q, want unpinned", got.Provider, got.Model) + } + }) + + t.Run("Should return the dreaming curator identity", func(t *testing.T) { + t.Parallel() + + got, ok := BuiltinAgentDef("dreaming-curator") + if !ok || got.Name != "dreaming-curator" || strings.TrimSpace(got.Prompt) == "" { + t.Fatalf("BuiltinAgentDef(dreaming-curator) = (%#v, %t), want named non-empty identity", got, ok) + } + }) + + t.Run("Should reject catalog and unknown names", func(t *testing.T) { + t.Parallel() + + for _, name := range []string{"general", "unknown"} { + if got, ok := BuiltinAgentDef(name); ok { + t.Errorf("BuiltinAgentDef(%q) = %#v, true; want false", name, got) + } + } + }) +} + +func TestBuiltinAgentNamesOwnExactReservations(t *testing.T) { + t.Parallel() + + t.Run("Should return the closed builtin roster", func(t *testing.T) { + t.Parallel() + + if got, want := BuiltinAgentNames(), []string{"coordinator", "dreaming-curator"}; !slices.Equal(got, want) { + t.Fatalf("BuiltinAgentNames() = %#v, want %#v", got, want) + } + }) + + t.Run("Should normalize exact builtin names", func(t *testing.T) { + t.Parallel() + + for _, name := range []string{"coordinator", " COORDINATOR ", "dreaming-curator", " DREAMING-CURATOR "} { + if !IsReservedAgentName(name) { + t.Errorf("IsReservedAgentName(%q) = false, want true", name) + } + } + }) + + t.Run("Should not reserve catalog or prefix names", func(t *testing.T) { + t.Parallel() + + for _, name := range []string{"general", "coordinator-helper"} { + if IsReservedAgentName(name) { + t.Errorf("IsReservedAgentName(%q) = true, want false", name) + } + } + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 7ac86b0b0..73fe78576 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,11 +33,7 @@ const ( skillsMarketplaceRegistryClawhub = "clawhub" ) -const ( - // DefaultMemoryDreamAgentName is the bundled curator used for Memory v2 dreaming. - DefaultMemoryDreamAgentName = "dreaming-curator" - defaultMemoryWorkspaceTOMLPath = "/.agh/workspace.toml" -) +const defaultMemoryWorkspaceTOMLPath = "/.agh/workspace.toml" // ErrSandboxProfileNotFound reports a sandbox profile reference that is not configured. var ErrSandboxProfileNotFound = errors.New("sandbox profile not found") diff --git a/internal/config/config_agent_session.go b/internal/config/config_agent_session.go index 61010d758..8ff39669f 100644 --- a/internal/config/config_agent_session.go +++ b/internal/config/config_agent_session.go @@ -40,11 +40,10 @@ type LimitsConfig struct { // SessionConfig defines session-scoped runtime controls. type SessionConfig struct { - AutoTitleEnabled bool `toml:"auto_title_enabled"` - Limits SessionLimitsConfig `toml:"limits"` - Supervision SessionSupervisionConfig `toml:"supervision"` - BusyInput SessionBusyInputConfig `toml:"busy_input"` - Compaction SessionCompactionConfig `toml:"compaction"` + Limits SessionLimitsConfig `toml:"limits"` + Supervision SessionSupervisionConfig `toml:"supervision"` + BusyInput SessionBusyInputConfig `toml:"busy_input"` + Compaction SessionCompactionConfig `toml:"compaction"` } // SessionLimitsConfig defines runtime limits applied to every session. diff --git a/internal/config/config_extensions_sandbox.go b/internal/config/config_extensions_sandbox.go index df4fadcf4..360deb5cb 100644 --- a/internal/config/config_extensions_sandbox.go +++ b/internal/config/config_extensions_sandbox.go @@ -88,6 +88,8 @@ type Config struct { Log LogConfig `toml:"log"` Redact RedactConfig `toml:"redact"` Memory MemoryConfig `toml:"memory"` + Roles RolesConfig `toml:"roles"` + RoleSources RoleFieldSources `toml:"-" json:"-"` Skills SkillsConfig `toml:"skills"` Extensions ExtensionsConfig `toml:"extensions"` Tools ToolsConfig `toml:"tools"` diff --git a/internal/config/config_memory.go b/internal/config/config_memory.go index e243dd1aa..b50931461 100644 --- a/internal/config/config_memory.go +++ b/internal/config/config_memory.go @@ -30,20 +30,9 @@ type MemoryControllerConfig struct { Mode string `toml:"mode"` MaxLatency time.Duration `toml:"max_latency"` DefaultOpOnFail string `toml:"default_op_on_fail"` - LLM MemoryControllerLLMConfig `toml:"llm"` Policy MemoryControllerPolicyConfig `toml:"policy"` } -// MemoryControllerLLMConfig controls the controller LLM tie-breaker. -type MemoryControllerLLMConfig struct { - Enabled bool `toml:"enabled"` - Model string `toml:"model"` - TopK int `toml:"top_k"` - PromptVersion string `toml:"prompt_version"` - Timeout time.Duration `toml:"timeout"` - MaxTokensOut int `toml:"max_tokens_out"` -} - // MemoryControllerPolicyConfig controls controller safety limits. type MemoryControllerPolicyConfig struct { MaxContentChars int `toml:"max_content_chars"` @@ -92,14 +81,12 @@ type MemoryDecisionsConfig struct { // MemoryExtractorConfig controls the post-message extractor queue. type MemoryExtractorConfig struct { - Enabled bool `toml:"enabled"` Mode string `toml:"mode"` ThrottleTurns int `toml:"throttle_turns"` Deadline time.Duration `toml:"deadline"` SandboxInboxOnly bool `toml:"sandbox_inbox_only"` InboxPath string `toml:"inbox_path"` DLQPath string `toml:"dlq_path"` - Model string `toml:"model"` Queue MemoryExtractorQueueConfig `toml:"queue"` } @@ -111,8 +98,6 @@ type MemoryExtractorQueueConfig struct { // DreamConfig controls background dream consolidation. type DreamConfig struct { - Enabled bool `toml:"enabled"` - Agent string `toml:"agent"` MinHours float64 `toml:"min_hours"` MinSessions int `toml:"min_sessions"` Debounce time.Duration `toml:"debounce"` diff --git a/internal/config/config_memory_defaults.go b/internal/config/config_memory_defaults.go index 11d2b661f..2de08f6de 100644 --- a/internal/config/config_memory_defaults.go +++ b/internal/config/config_memory_defaults.go @@ -40,14 +40,6 @@ func defaultMemoryControllerConfig() MemoryControllerConfig { Mode: configHybridKey, MaxLatency: 300 * time.Millisecond, DefaultOpOnFail: "noop", - LLM: MemoryControllerLLMConfig{ - Enabled: true, - Model: "anthropic/claude-haiku-4", - TopK: 5, - PromptVersion: "v1", - Timeout: 250 * time.Millisecond, - MaxTokensOut: 256, - }, Policy: MemoryControllerPolicyConfig{ MaxContentChars: 4096, MaxWritesPerMin: 60, @@ -87,7 +79,6 @@ func defaultMemoryRecallConfig() MemoryRecallConfig { func defaultMemoryExtractorConfig(homePaths HomePaths) MemoryExtractorConfig { return MemoryExtractorConfig{ - Enabled: true, Mode: configExtractorModePostMessage, ThrottleTurns: 1, Deadline: 60 * time.Second, @@ -100,8 +91,6 @@ func defaultMemoryExtractorConfig(homePaths HomePaths) MemoryExtractorConfig { func defaultMemoryDreamConfig() DreamConfig { return DreamConfig{ - Enabled: true, - Agent: DefaultMemoryDreamAgentName, MinHours: 24, MinSessions: 3, Debounce: 10 * time.Minute, diff --git a/internal/config/config_memory_section_validation.go b/internal/config/config_memory_section_validation.go index c9f77cec4..246807fce 100644 --- a/internal/config/config_memory_section_validation.go +++ b/internal/config/config_memory_section_validation.go @@ -9,12 +9,6 @@ import ( // Validate ensures the dream configuration is internally consistent. func (c DreamConfig) Validate() error { - if !c.Enabled { - return nil - } - if strings.TrimSpace(c.Agent) == "" { - return errors.New("memory.dream.agent is required") - } if c.MinHours <= 0 { return fmt.Errorf("memory.dream.min_hours must be positive: %v", c.MinHours) } @@ -43,9 +37,6 @@ func (c *MemoryControllerConfig) Validate() error { return err } c.Mode = mode - if c.Mode == "llm" && !c.LLM.Enabled { - return errors.New(`memory.controller.llm.enabled must be true when memory.controller.mode is "llm"`) - } if c.MaxLatency <= 0 { return fmt.Errorf("memory.controller.max_latency must be positive: %s", c.MaxLatency) } @@ -54,35 +45,9 @@ func (c *MemoryControllerConfig) Validate() error { return err } c.DefaultOpOnFail = defaultOpOnFail - if err := c.LLM.Validate(); err != nil { - return err - } return c.Policy.Validate() } -// Validate ensures the controller LLM configuration is internally consistent. -func (c MemoryControllerLLMConfig) Validate() error { - if !c.Enabled { - return nil - } - if strings.TrimSpace(c.Model) == "" { - return errors.New("memory.controller.llm.model is required") - } - if c.TopK <= 0 { - return fmt.Errorf("memory.controller.llm.top_k must be positive: %d", c.TopK) - } - if strings.TrimSpace(c.PromptVersion) == "" { - return errors.New("memory.controller.llm.prompt_version is required") - } - if c.Timeout <= 0 { - return fmt.Errorf("memory.controller.llm.timeout must be positive: %s", c.Timeout) - } - if c.MaxTokensOut <= 0 { - return fmt.Errorf("memory.controller.llm.max_tokens_out must be positive: %d", c.MaxTokensOut) - } - return nil -} - // Validate ensures the controller policy configuration is internally consistent. func (c *MemoryControllerPolicyConfig) Validate() error { if c.MaxContentChars <= 0 { @@ -195,9 +160,6 @@ func (c MemoryDecisionsConfig) Validate() error { // Validate ensures extractor settings are internally consistent. func (c *MemoryExtractorConfig) Validate() error { - if !c.Enabled { - return nil - } mode, err := validateEnum("memory.extractor.mode", c.Mode, configExtractorModePostMessage) if err != nil { return err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5dd456632..02e5ef5e0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -142,12 +142,13 @@ enabled = true global_dir = "~/agh-memory-test" [memory.dream] -enabled = true -agent = "claude" min_hours = 48 min_sessions = 5 check_interval = "45m" +[roles.dream] +agent = "claude" + [network] enabled = true max_replay_age = 600 @@ -317,8 +318,8 @@ max_wakes = 80 if got, want := cfg.Memory.GlobalDir, filepath.Join(userHome, "agh-memory-test"); got != want { t.Fatalf("Load() Memory.GlobalDir = %q, want %q", got, want) } - if got, want := cfg.Memory.Dream.Agent, "claude"; got != want { - t.Fatalf("Load() Memory.Dream.Agent = %q, want %q", got, want) + if got, want := cfg.Roles.Dream.Agent, "claude"; got != want { + t.Fatalf("Load() Roles.Dream.Agent = %q, want %q", got, want) } if got, want := cfg.Memory.Dream.MinHours, 48.0; got != want { t.Fatalf("Load() Memory.Dream.MinHours = %v, want %v", got, want) @@ -872,71 +873,6 @@ func TestHeartbeatConfigDefaultsAndValidation(t *testing.T) { } } -func TestLoadDreamAgentKeepsDedicatedCuratorWhenDefaultAgentChanges(t *testing.T) { - homeRoot := filepath.Join(t.TempDir(), "home") - t.Setenv("AGH_HOME", homeRoot) - - homePaths, err := ResolveHomePaths() - if err != nil { - t.Fatalf("ResolveHomePaths() error = %v", err) - } - if err := EnsureHomeLayout(homePaths); err != nil { - t.Fatalf("EnsureHomeLayout() error = %v", err) - } - - writeFile(t, homePaths.ConfigFile, ` -[defaults] -agent = "operator" -provider = "codex" - -[memory.dream] -min_hours = 1 -min_sessions = 1 -check_interval = "1m" -`) - - cfg, err := Load() - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if got, want := cfg.Memory.Dream.Agent, DefaultMemoryDreamAgentName; got != want { - t.Fatalf("Load() Memory.Dream.Agent = %q, want %q", got, want) - } -} - -func TestLoadDreamAgentPreservesExplicitAgent(t *testing.T) { - homeRoot := filepath.Join(t.TempDir(), "home") - t.Setenv("AGH_HOME", homeRoot) - - homePaths, err := ResolveHomePaths() - if err != nil { - t.Fatalf("ResolveHomePaths() error = %v", err) - } - if err := EnsureHomeLayout(homePaths); err != nil { - t.Fatalf("EnsureHomeLayout() error = %v", err) - } - - writeFile(t, homePaths.ConfigFile, ` -[defaults] -agent = "operator" -provider = "codex" - -[memory.dream] -agent = "memory-agent" -min_hours = 1 -min_sessions = 1 -check_interval = "1m" -`) - - cfg, err := Load() - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if got, want := cfg.Memory.Dream.Agent, "memory-agent"; got != want { - t.Fatalf("Load() Memory.Dream.Agent = %q, want %q", got, want) - } -} - func TestSandboxProfileValidationRejectsInvalidSyncMode(t *testing.T) { t.Parallel() @@ -1013,8 +949,8 @@ port = 2123 kind = "api_key" required = true -[session] -auto_title_enabled = false +[roles.auto_title] +enabled = false [session.limits] timeout = "20m" @@ -1049,8 +985,8 @@ display_name = "Workspace Model" reasoning_efforts = ["low", "high"] default_reasoning_effort = "high" -[session] -auto_title_enabled = true +[roles.auto_title] +enabled = true [session.limits] timeout = "45m" @@ -1081,8 +1017,8 @@ base_url = "https://workspace.example.test/api/v1" if got, want := cfg.Session.Limits.Timeout, 45*time.Minute; got != want { t.Fatalf("Load() Session.Limits.Timeout = %s, want %s", got, want) } - if !cfg.Session.AutoTitleEnabled { - t.Fatal("Load() Session.AutoTitleEnabled = false, want workspace override true") + if !cfg.Roles.AutoTitle.Enabled { + t.Fatal("Load() Roles.AutoTitle.Enabled = false, want workspace override true") } if got, want := cfg.Session.Compaction.PressureThreshold, 0.90; got != want { t.Fatalf("Load() Session.Compaction.PressureThreshold = %v, want %v", got, want) @@ -1851,6 +1787,87 @@ unknown = true } } +func TestLoadRejectsTimeoutOnSessionBackedRoles(t *testing.T) { + t.Parallel() + + for _, role := range []RoleName{ + RoleCoordinator, + RoleDream, + RoleCheckpointSummary, + RoleMemoryExtractor, + RoleAutoTitle, + } { + t.Run("Should reject timeout on "+string(role), func(t *testing.T) { + t.Parallel() + + contents := []byte("[roles." + string(role) + "]\ntimeout = \"1s\"\n") + _, err := loadConfigOverlayBytes(contents, "roles.toml") + if err == nil { + t.Fatal("loadConfigOverlayBytes() error = nil, want unknown-key rejection") + } + path := "roles." + string(role) + ".timeout" + if !strings.Contains(err.Error(), path) { + t.Fatalf("loadConfigOverlayBytes() error = %v, want %s", err, path) + } + }) + } +} + +func TestLoadRejectsRemovedRoleConfigKeys(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + config string + wantKey string + }{ + { + name: "Should reject the autonomy coordinator table", + config: "[autonomy.coordinator]\nenabled = true\n", + wantKey: "autonomy.coordinator", + }, + { + name: "Should reject the memory dream agent", + config: "[memory.dream]\nagent = \"curator\"\n", + wantKey: "memory.dream.agent", + }, + { + name: "Should reject the memory dream enabled flag", + config: "[memory.dream]\nenabled = false\n", + wantKey: "memory.dream.enabled", + }, + { + name: "Should reject the memory extractor model", + config: "[memory.extractor]\nmodel = \"model-a\"\n", + wantKey: "memory.extractor.model", + }, + { + name: "Should reject the memory extractor enabled flag", + config: "[memory.extractor]\nenabled = false\n", + wantKey: "memory.extractor.enabled", + }, + { + name: "Should reject the memory controller LLM table", + config: "[memory.controller.llm]\nenabled = true\n", + wantKey: "memory.controller.llm", + }, + { + name: "Should reject the session auto title flag", + config: "[session]\nauto_title_enabled = false\n", + wantKey: "session.auto_title_enabled", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := loadConfigOverlayBytes([]byte(tc.config), "removed-role-key.toml") + if err == nil || !strings.Contains(err.Error(), tc.wantKey) { + t.Fatalf("loadConfigOverlayBytes() error = %v, want removed key %s", err, tc.wantKey) + } + }) + } +} + func TestLoadRejectsUnknownSkillsConfigKeys(t *testing.T) { workspaceRoot := t.TempDir() homeRoot := filepath.Join(t.TempDir(), "home") @@ -2289,8 +2306,6 @@ func TestDreamConfigValidateRejectsNonPositiveThresholds(t *testing.T) { t.Parallel() cfg := DreamConfig{ - Enabled: true, - Agent: "claude", MinHours: 24, MinSessions: 3, CheckInterval: 30 * time.Minute, @@ -2526,8 +2541,8 @@ func TestLoadMissingConfigReturnsDefaults(t *testing.T) { if !cfg.Network.Enabled { t.Fatal("Load() Network.Enabled = false, want true by default") } - if !cfg.Session.AutoTitleEnabled { - t.Fatal("Load() Session.AutoTitleEnabled = false, want true by default") + if !cfg.Roles.AutoTitle.Enabled { + t.Fatal("Load() Roles.AutoTitle.Enabled = false, want true by default") } } @@ -2544,12 +2559,8 @@ func TestDefaultConfigUsesResolvedHomePaths(t *testing.T) { if cfg.Permissions.Mode != PermissionModeApproveAll { t.Fatalf("defaultConfig() Permissions.Mode = %q, want %q", cfg.Permissions.Mode, PermissionModeApproveAll) } - if cfg.Memory.Dream.Agent != DefaultMemoryDreamAgentName { - t.Fatalf( - "defaultConfig() Memory.Dream.Agent = %q, want %q", - cfg.Memory.Dream.Agent, - DefaultMemoryDreamAgentName, - ) + if !cfg.Roles.Dream.Enabled || cfg.Roles.Dream.Agent != "" { + t.Fatalf("defaultConfig() Roles.Dream = %#v, want enabled builtin routing", cfg.Roles.Dream) } if !cfg.Skills.Enabled { t.Fatal("defaultConfig() Skills.Enabled = false, want true") diff --git a/internal/config/config_validate_features.go b/internal/config/config_validate_features.go index b8151addd..abce5748e 100644 --- a/internal/config/config_validate_features.go +++ b/internal/config/config_validate_features.go @@ -48,7 +48,7 @@ func (c *Config) validateFeatures(lookup envLookup) error { if err := c.Network.Validate(); err != nil { return fmt.Errorf("validate network config: %w", err) } - if err := c.Autonomy.Validate(c); err != nil { + if err := c.Autonomy.Validate(); err != nil { return fmt.Errorf("validate autonomy config: %w", err) } return nil diff --git a/internal/config/config_validation.go b/internal/config/config_validation.go index 643e4a6c6..ed61c8359 100644 --- a/internal/config/config_validation.go +++ b/internal/config/config_validation.go @@ -56,6 +56,9 @@ func (c *Config) validateCore() error { if err := c.Permissions.Validate(); err != nil { return err } + if err := c.Roles.Validate("roles", c); err != nil { + return err + } for i, server := range c.MCPServers { if err := server.Validate(fmt.Sprintf("mcp_servers[%d]", i)); err != nil { return err diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 3ab7823aa..13cb825d1 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -26,11 +26,10 @@ func DefaultWithHome(homePaths HomePaths) Config { MaxConcurrentAgents: 20, }, Session: SessionConfig{ - AutoTitleEnabled: true, - Limits: SessionLimitsConfig{}, - Supervision: DefaultSessionSupervisionConfig(), - BusyInput: DefaultSessionBusyInputConfig(), - Compaction: DefaultSessionCompactionConfig(), + Limits: SessionLimitsConfig{}, + Supervision: DefaultSessionSupervisionConfig(), + BusyInput: DefaultSessionBusyInputConfig(), + Compaction: DefaultSessionCompactionConfig(), }, Permissions: PermissionsConfig{ Mode: PermissionModeApproveAll, @@ -57,8 +56,10 @@ func DefaultWithHome(homePaths HomePaths) Config { MaxAgeDays: 30, CompressBackups: false, }, - Redact: RedactConfig{Enabled: true}, - Memory: DefaultMemoryConfig(homePaths), + Redact: RedactConfig{Enabled: true}, + Memory: DefaultMemoryConfig(homePaths), + Roles: DefaultRolesConfig(), + RoleSources: defaultRoleFieldSources(), Skills: SkillsConfig{ Enabled: true, PollInterval: 3 * time.Second, @@ -80,7 +81,6 @@ func DefaultWithHome(homePaths HomePaths) Config { Network: DefaultNetworkConfig(), Autonomy: AutonomyConfig{ BlockRecurrenceLimit: DefaultBlockRecurrenceLimit, - Coordinator: DefaultCoordinatorConfig(), Scheduler: DefaultSchedulerConfig(), }, } diff --git a/internal/config/lifecycle/lifecycle.go b/internal/config/lifecycle/lifecycle.go index 6773db568..bb3215c42 100644 --- a/internal/config/lifecycle/lifecycle.go +++ b/internal/config/lifecycle/lifecycle.go @@ -59,6 +59,7 @@ const ( pathDaemonReloadTimeoutProviders = "daemon.reload_timeouts.providers" pathDaemonReloadTimeoutMCP = "daemon.reload_timeouts.mcp" pathDaemonReloadTimeoutBridges = "daemon.reload_timeouts.bridges" + pathRoles = "roles" ) // Rule records the lifecycle for a config path pattern. @@ -79,6 +80,8 @@ var Matrix = []Rule{ {Pattern: "providers.*.models.*", Lifecycle: Live, DiffClass: DiffClassLive}, {Pattern: "marketplace.catalog.*", Lifecycle: Live, DiffClass: DiffClassLive}, {Pattern: "extensions.marketplace.allow_unverified", Lifecycle: Live, DiffClass: DiffClassLive}, + {Pattern: pathRoles, Lifecycle: Live, DiffClass: DiffClassLive}, + {Pattern: pathRoles + ".*", Lifecycle: Live, DiffClass: DiffClassLive}, {Pattern: "providers.*", Lifecycle: RestartRequired, DiffClass: DiffClassRestartRequired}, {Pattern: "mcp-servers.*", Lifecycle: RestartRequired, DiffClass: DiffClassRestartRequired}, {Pattern: "sandboxes.*", Lifecycle: SessionRebind, DiffClass: DiffClassSessionRebind}, @@ -156,7 +159,7 @@ func ClassifyPaths(paths []string) (Lifecycle, DiffClass, error) { // DiffClassForRoot maps a settings section or collection name onto a diff class. func DiffClassForRoot(root string) DiffClass { switch strings.TrimSpace(root) { - case "skills", "window-manager": + case "skills", pathRoles, "window-manager": return DiffClassLive case "sandboxes": return DiffClassSessionRebind diff --git a/internal/config/lifecycle/lifecycle_test.go b/internal/config/lifecycle/lifecycle_test.go index 34136938a..e68a52993 100644 --- a/internal/config/lifecycle/lifecycle_test.go +++ b/internal/config/lifecycle/lifecycle_test.go @@ -84,6 +84,18 @@ func TestClassifyPath(t *testing.T) { wantLifecycle: Live, wantDiffClass: DiffClassLive, }, + { + name: "Should classify the complete roles section as live", + path: "roles", + wantLifecycle: Live, + wantDiffClass: DiffClassLive, + }, + { + name: "Should classify role changes as live", + path: "roles.dream.model", + wantLifecycle: Live, + wantDiffClass: DiffClassLive, + }, { name: "Should keep extension registry changes restart required", path: "extensions.marketplace.registry", diff --git a/internal/config/memory_v2_config_test.go b/internal/config/memory_v2_config_test.go index 560de7c56..a56897f0a 100644 --- a/internal/config/memory_v2_config_test.go +++ b/internal/config/memory_v2_config_test.go @@ -27,13 +27,6 @@ func TestMemoryV2ConfigDefaultsAndOverlay(t *testing.T) { memory.Controller.DefaultOpOnFail != "noop" { t.Fatalf("DefaultWithHome() Controller = %#v", memory.Controller) } - if memory.Controller.LLM.Model != "anthropic/claude-haiku-4" || - memory.Controller.LLM.TopK != 5 || - memory.Controller.LLM.PromptVersion != "v1" || - memory.Controller.LLM.Timeout != 250*time.Millisecond || - memory.Controller.LLM.MaxTokensOut != 256 { - t.Fatalf("DefaultWithHome() Controller.LLM = %#v", memory.Controller.LLM) - } if !slices.Equal(memory.Controller.Policy.AllowOrigins, []string{ "cli", "http", @@ -65,8 +58,7 @@ func TestMemoryV2ConfigDefaultsAndOverlay(t *testing.T) { memory.Extractor.Queue.CoalesceMax != 16 { t.Fatalf("DefaultWithHome() Extractor = %#v", memory.Extractor) } - if memory.Dream.Agent != DefaultMemoryDreamAgentName || - memory.Dream.Debounce != 10*time.Minute || + if memory.Dream.Debounce != 10*time.Minute || memory.Dream.PromptVersion != "v1" || memory.Dream.Gates.MinScore != 0.75 || memory.Dream.Scoring.RecencyHalfLifeDays != 14 { @@ -107,14 +99,6 @@ mode = "rules" max_latency = "150ms" default_op_on_fail = "reject" -[memory.controller.llm] -enabled = false -model = "anthropic/test" -top_k = 3 -prompt_version = "v2" -timeout = "125ms" -max_tokens_out = 128 - [memory.controller.policy] max_content_chars = 2048 max_writes_per_min = 30 @@ -147,21 +131,18 @@ keep_audit_summary = false max_post_content_bytes = 32768 [memory.extractor] -enabled = true mode = "post_message" throttle_turns = 2 deadline = "45s" sandbox_inbox_only = false inbox_path = "~/agh-inbox" dlq_path = "~/agh-dlq" -model = "extractor-model" [memory.extractor.queue] capacity = 2 coalesce_max = 8 [memory.dream] -agent = "curator" min_hours = 12 min_sessions = 4 debounce = "5m" @@ -223,7 +204,6 @@ auto_create = false memory := cfg.Memory if memory.Controller.Mode != "rules" || memory.Controller.MaxLatency != 150*time.Millisecond || - memory.Controller.LLM.Enabled || !slices.Equal(memory.Controller.Policy.AllowOrigins, []string{"cli", "tool"}) { t.Fatalf("Load() Controller = %#v", memory.Controller) } @@ -270,14 +250,6 @@ func TestMemoryV2ConfigValidationRejectsInvalidValues(t *testing.T) { }, want: "memory.controller.mode", }, - { - name: "llm controller mode with disabled llm", - patch: func(cfg *MemoryConfig) { - cfg.Controller.Mode = "llm" - cfg.Controller.LLM.Enabled = false - }, - want: "memory.controller.llm.enabled", - }, { name: "recall weights", patch: func(cfg *MemoryConfig) { @@ -348,16 +320,11 @@ func TestMemoryV2ConfigValidationCoversOptionalBranches(t *testing.T) { } base := DefaultWithHome(homePaths).Memory - t.Run("Should accept disabled optional workers without nested runtime fields", func(t *testing.T) { + t.Run("Should accept controller LLM mode because role routing is validated separately", func(t *testing.T) { t.Parallel() cfg := base - cfg.Controller.LLM.Enabled = false - cfg.Controller.LLM.Model = "" - cfg.Extractor.Enabled = false - cfg.Extractor.Mode = "" - cfg.Dream.Enabled = false - cfg.Dream.Agent = "" + cfg.Controller.Mode = "llm" if err := cfg.Validate(); err != nil { t.Fatalf("Validate() error = %v, want nil", err) diff --git a/internal/config/merge.go b/internal/config/merge.go index 890a8dac2..3f8490cd7 100644 --- a/internal/config/merge.go +++ b/internal/config/merge.go @@ -71,11 +71,10 @@ type limitsOverlay struct { } type sessionOverlay struct { - AutoTitleEnabled *bool `toml:"auto_title_enabled"` - Limits sessionLimitsOverlay `toml:"limits"` - Supervision sessionSupervisionOverlay `toml:"supervision"` - BusyInput sessionBusyInputOverlay `toml:"busy_input"` - Compaction sessionCompactionOverlay `toml:"compaction"` + Limits sessionLimitsOverlay `toml:"limits"` + Supervision sessionSupervisionOverlay `toml:"supervision"` + BusyInput sessionBusyInputOverlay `toml:"busy_input"` + Compaction sessionCompactionOverlay `toml:"compaction"` } type sessionLimitsOverlay struct { @@ -219,19 +218,9 @@ type memoryControllerOverlay struct { Mode *string `toml:"mode"` MaxLatency *time.Duration `toml:"max_latency"` DefaultOpOnFail *string `toml:"default_op_on_fail"` - LLM memoryControllerLLMOverlay `toml:"llm"` Policy memoryControllerPolicyOverlay `toml:"policy"` } -type memoryControllerLLMOverlay struct { - Enabled *bool `toml:"enabled"` - Model *string `toml:"model"` - TopK *int `toml:"top_k"` - PromptVersion *string `toml:"prompt_version"` - Timeout *time.Duration `toml:"timeout"` - MaxTokensOut *int `toml:"max_tokens_out"` -} - type memoryControllerPolicyOverlay struct { MaxContentChars *int `toml:"max_content_chars"` MaxWritesPerMin *int `toml:"max_writes_per_min"` @@ -273,14 +262,12 @@ type memoryDecisionsOverlay struct { } type memoryExtractorOverlay struct { - Enabled *bool `toml:"enabled"` Mode *string `toml:"mode"` ThrottleTurns *int `toml:"throttle_turns"` Deadline *time.Duration `toml:"deadline"` SandboxInboxOnly *bool `toml:"sandbox_inbox_only"` InboxPath *string `toml:"inbox_path"` DLQPath *string `toml:"dlq_path"` - Model *string `toml:"model"` Queue memoryExtractorQueueOverlay `toml:"queue"` } @@ -290,8 +277,6 @@ type memoryExtractorQueueOverlay struct { } type dreamOverlay struct { - Enabled *bool `toml:"enabled"` - Agent *string `toml:"agent"` MinHours *float64 `toml:"min_hours"` MinSessions *int `toml:"min_sessions"` Debounce *time.Duration `toml:"debounce"` @@ -386,9 +371,8 @@ type extensionsRateLimitOverlay struct { } type autonomyOverlay struct { - BlockRecurrenceLimit *int `toml:"block_recurrence_limit"` - Coordinator coordinatorOverlay `toml:"coordinator"` - Scheduler schedulerOverlay `toml:"scheduler"` + BlockRecurrenceLimit *int `toml:"block_recurrence_limit"` + Scheduler schedulerOverlay `toml:"scheduler"` } type schedulerOverlay struct { @@ -399,16 +383,6 @@ type schedulerOverlay struct { MinQueuedAge *time.Duration `toml:"min_queued_age"` } -type coordinatorOverlay struct { - Enabled *bool `toml:"enabled"` - AgentName *string `toml:"agent_name"` - Provider *string `toml:"provider"` - Model *string `toml:"model"` - DefaultTTL *time.Duration `toml:"default_ttl"` - MaxChildren *int `toml:"max_children"` - MaxActiveSessionsPerWorkspace *int `toml:"max_active_sessions_per_workspace"` -} - type marketplaceOverlay struct { Registry *string `toml:"registry"` BaseURL *string `toml:"base_url"` diff --git a/internal/config/merge_features.go b/internal/config/merge_features.go index 27a03eb10..5ef473583 100644 --- a/internal/config/merge_features.go +++ b/internal/config/merge_features.go @@ -60,7 +60,6 @@ func (o autonomyOverlay) Apply(dst *AutonomyConfig) { if o.BlockRecurrenceLimit != nil { dst.BlockRecurrenceLimit = *o.BlockRecurrenceLimit } - o.Coordinator.Apply(&dst.Coordinator) o.Scheduler.Apply(&dst.Scheduler) } @@ -82,30 +81,6 @@ func (o schedulerOverlay) Apply(dst *SchedulerConfig) { } } -func (o coordinatorOverlay) Apply(dst *CoordinatorConfig) { - if o.Enabled != nil { - dst.Enabled = *o.Enabled - } - if o.AgentName != nil { - dst.AgentName = *o.AgentName - } - if o.Provider != nil { - dst.Provider = *o.Provider - } - if o.Model != nil { - dst.Model = *o.Model - } - if o.DefaultTTL != nil { - dst.DefaultTTL = *o.DefaultTTL - } - if o.MaxChildren != nil { - dst.MaxChildren = *o.MaxChildren - } - if o.MaxActiveSessionsPerWorkspace != nil { - dst.MaxActiveSessionsPerWorkspace = *o.MaxActiveSessionsPerWorkspace - } -} - func (o marketplaceOverlay) Apply(dst *MarketplaceConfig) { if o.Registry != nil { dst.Registry = *o.Registry diff --git a/internal/config/merge_load.go b/internal/config/merge_load.go index cdc4d010d..c45811345 100644 --- a/internal/config/merge_load.go +++ b/internal/config/merge_load.go @@ -19,7 +19,18 @@ func ApplyConfigOverlayFile(path string, dst *Config) error { return err } - return overlay.Apply(dst) + if err := applyConfigOverlay(dst, &overlay, RoleFieldSourceGlobal); err != nil { + return err + } + return nil +} + +func applyConfigOverlay(dst *Config, overlay *configOverlay, roleSource string) error { + if err := overlay.Apply(dst); err != nil { + return err + } + overlay.Roles.recordSources(dst, roleSource) + return nil } func loadConfigOverlayFile(path string) (configOverlay, error) { diff --git a/internal/config/merge_memory_controller.go b/internal/config/merge_memory_controller.go index 7515f5579..160ab513c 100644 --- a/internal/config/merge_memory_controller.go +++ b/internal/config/merge_memory_controller.go @@ -31,31 +31,9 @@ func (o memoryControllerOverlay) Apply(dst *MemoryControllerConfig) { if o.DefaultOpOnFail != nil { dst.DefaultOpOnFail = *o.DefaultOpOnFail } - o.LLM.Apply(&dst.LLM) o.Policy.Apply(&dst.Policy) } -func (o memoryControllerLLMOverlay) Apply(dst *MemoryControllerLLMConfig) { - if o.Enabled != nil { - dst.Enabled = *o.Enabled - } - if o.Model != nil { - dst.Model = *o.Model - } - if o.TopK != nil { - dst.TopK = *o.TopK - } - if o.PromptVersion != nil { - dst.PromptVersion = *o.PromptVersion - } - if o.Timeout != nil { - dst.Timeout = *o.Timeout - } - if o.MaxTokensOut != nil { - dst.MaxTokensOut = *o.MaxTokensOut - } -} - func (o memoryControllerPolicyOverlay) Apply(dst *MemoryControllerPolicyConfig) { if o.MaxContentChars != nil { dst.MaxContentChars = *o.MaxContentChars diff --git a/internal/config/merge_memory_runtime.go b/internal/config/merge_memory_runtime.go index d7e9c1699..0dabbdc07 100644 --- a/internal/config/merge_memory_runtime.go +++ b/internal/config/merge_memory_runtime.go @@ -1,9 +1,6 @@ package config func (o memoryExtractorOverlay) Apply(dst *MemoryExtractorConfig) { - if o.Enabled != nil { - dst.Enabled = *o.Enabled - } if o.Mode != nil { dst.Mode = *o.Mode } @@ -22,9 +19,6 @@ func (o memoryExtractorOverlay) Apply(dst *MemoryExtractorConfig) { if o.DLQPath != nil { dst.DLQPath = *o.DLQPath } - if o.Model != nil { - dst.Model = *o.Model - } o.Queue.Apply(&dst.Queue) } @@ -38,12 +32,6 @@ func (o memoryExtractorQueueOverlay) Apply(dst *MemoryExtractorQueueConfig) { } func (o dreamOverlay) Apply(dst *DreamConfig) { - if o.Enabled != nil { - dst.Enabled = *o.Enabled - } - if o.Agent != nil { - dst.Agent = *o.Agent - } if o.MinHours != nil { dst.MinHours = *o.MinHours } diff --git a/internal/config/merge_overlay.go b/internal/config/merge_overlay.go index 8bf567d47..d42f7a21b 100644 --- a/internal/config/merge_overlay.go +++ b/internal/config/merge_overlay.go @@ -18,6 +18,7 @@ type configOverlay struct { Log logOverlay `toml:"log"` Redact redactOverlay `toml:"redact"` Memory memoryOverlay `toml:"memory"` + Roles rolesOverlay `toml:"roles"` Skills skillsOverlay `toml:"skills"` Extensions extensionsOverlay `toml:"extensions"` Tools toolsOverlay `toml:"tools"` @@ -52,6 +53,7 @@ func (o *configOverlay) Apply(dst *Config) error { o.Log.Apply(&dst.Log) o.Redact.Apply(&dst.Redact) o.Memory.Apply(&dst.Memory) + o.Roles.Apply(&dst.Roles) o.Skills.Apply(&dst.Skills) o.Extensions.Apply(&dst.Extensions) o.Tools.Apply(&dst.Tools) diff --git a/internal/config/merge_roles.go b/internal/config/merge_roles.go new file mode 100644 index 000000000..513cfa5bf --- /dev/null +++ b/internal/config/merge_roles.go @@ -0,0 +1,113 @@ +package config + +import "time" + +type rolesOverlay struct { + Coordinator coordinatorRoleOverlay `toml:"coordinator"` + Dream roleOverlay `toml:"dream"` + CheckpointSummary roleOverlay `toml:"checkpoint_summary"` + MemoryExtractor roleOverlay `toml:"memory_extractor"` + AutoTitle roleOverlay `toml:"auto_title"` + MemoryController memoryControllerRoleOverlay `toml:"memory_controller"` +} + +type roleOverlay struct { + Enabled *bool `toml:"enabled"` + Agent *string `toml:"agent"` + Provider *string `toml:"provider"` + Model *string `toml:"model"` + ReasoningEffort *string `toml:"reasoning_effort"` + FallbackChain *[]RoleFallback `toml:"fallback_chain"` +} + +type coordinatorRoleOverlay struct { + roleOverlay + TTL *time.Duration `toml:"ttl"` + MaxChildren *int `toml:"max_children"` + MaxActiveSessionsPerWorkspace *int `toml:"max_active_sessions_per_workspace"` +} + +type memoryControllerRoleOverlay struct { + Enabled *bool `toml:"enabled"` + Provider *string `toml:"provider"` + Model *string `toml:"model"` + ReasoningEffort *string `toml:"reasoning_effort"` + Timeout *time.Duration `toml:"timeout"` + TopK *int `toml:"top_k"` + PromptVersion *string `toml:"prompt_version"` + MaxTokensOut *int `toml:"max_tokens_out"` + FallbackChain *[]RoleFallback `toml:"fallback_chain"` +} + +func (o rolesOverlay) Apply(dst *RolesConfig) { + o.Coordinator.Apply(&dst.Coordinator) + o.Dream.Apply(&dst.Dream) + o.CheckpointSummary.Apply(&dst.CheckpointSummary) + o.MemoryExtractor.Apply(&dst.MemoryExtractor) + o.AutoTitle.Apply(&dst.AutoTitle) + o.MemoryController.Apply(&dst.MemoryController) +} + +func (o roleOverlay) Apply(dst *RoleConfig) { + if o.Enabled != nil { + dst.Enabled = *o.Enabled + } + if o.Agent != nil { + dst.Agent = *o.Agent + } + if o.Provider != nil { + dst.Provider = *o.Provider + } + if o.Model != nil { + dst.Model = *o.Model + } + if o.ReasoningEffort != nil { + dst.ReasoningEffort = *o.ReasoningEffort + } + if o.FallbackChain != nil { + dst.FallbackChain = append([]RoleFallback(nil), (*o.FallbackChain)...) + } +} + +func (o coordinatorRoleOverlay) Apply(dst *CoordinatorRoleConfig) { + o.roleOverlay.Apply(&dst.RoleConfig) + if o.TTL != nil { + dst.TTL = *o.TTL + } + if o.MaxChildren != nil { + dst.MaxChildren = *o.MaxChildren + } + if o.MaxActiveSessionsPerWorkspace != nil { + dst.MaxActiveSessionsPerWorkspace = *o.MaxActiveSessionsPerWorkspace + } +} + +func (o memoryControllerRoleOverlay) Apply(dst *MemoryControllerRoleConfig) { + if o.Enabled != nil { + dst.Enabled = *o.Enabled + } + if o.Provider != nil { + dst.Provider = *o.Provider + } + if o.Model != nil { + dst.Model = *o.Model + } + if o.ReasoningEffort != nil { + dst.ReasoningEffort = *o.ReasoningEffort + } + if o.Timeout != nil { + dst.Timeout = *o.Timeout + } + if o.TopK != nil { + dst.TopK = *o.TopK + } + if o.PromptVersion != nil { + dst.PromptVersion = *o.PromptVersion + } + if o.MaxTokensOut != nil { + dst.MaxTokensOut = *o.MaxTokensOut + } + if o.FallbackChain != nil { + dst.FallbackChain = append([]RoleFallback(nil), (*o.FallbackChain)...) + } +} diff --git a/internal/config/merge_session.go b/internal/config/merge_session.go index 0b4ea4cbb..4d1ab6570 100644 --- a/internal/config/merge_session.go +++ b/internal/config/merge_session.go @@ -1,9 +1,6 @@ package config func (o sessionOverlay) Apply(dst *SessionConfig) { - if o.AutoTitleEnabled != nil { - dst.AutoTitleEnabled = *o.AutoTitleEnabled - } o.Limits.Apply(&dst.Limits) o.Supervision.Apply(&dst.Supervision) o.BusyInput.Apply(&dst.BusyInput) diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go index 740053e9a..00707661d 100644 --- a/internal/config/merge_test.go +++ b/internal/config/merge_test.go @@ -2,6 +2,7 @@ package config import ( "path/filepath" + "reflect" "slices" "strconv" "strings" @@ -497,3 +498,127 @@ func TestValidateRejectsOverflowingNetworkDurations(t *testing.T) { }) } } + +func TestRolesOverlayPreservesLayeredMergeSemantics(t *testing.T) { + t.Parallel() + + t.Run("Should change one field without clobbering defaults or sibling roles", func(t *testing.T) { + t.Parallel() + + cfg := DefaultWithHome(HomePaths{}) + path := filepath.Join(t.TempDir(), "roles.toml") + writeFile(t, path, "[roles.dream]\nmodel = \"model-x\"\n") + if err := ApplyConfigOverlayFile(path, &cfg); err != nil { + t.Fatalf("ApplyConfigOverlayFile() error = %v", err) + } + if cfg.Roles.Dream.Model != "model-x" || !cfg.Roles.Dream.Enabled { + t.Fatalf("Roles.Dream = %#v, want model override with enabled default", cfg.Roles.Dream) + } + if !reflect.DeepEqual(cfg.Roles.CheckpointSummary, DefaultRolesConfig().CheckpointSummary) { + t.Fatalf("Roles.CheckpointSummary = %#v, want defaults", cfg.Roles.CheckpointSummary) + } + }) + + t.Run("Should leave enabled intact for an empty role table", func(t *testing.T) { + t.Parallel() + + cfg := DefaultWithHome(HomePaths{}) + path := filepath.Join(t.TempDir(), "roles.toml") + writeFile(t, path, "[roles.dream]\n") + if err := ApplyConfigOverlayFile(path, &cfg); err != nil { + t.Fatalf("ApplyConfigOverlayFile() error = %v", err) + } + if !cfg.Roles.Dream.Enabled { + t.Fatal("Roles.Dream.Enabled = false, want default true") + } + }) + + t.Run("Should replace a fallback chain wholesale", func(t *testing.T) { + t.Parallel() + + cfg := DefaultWithHome(HomePaths{}) + cfg.Roles.Dream.FallbackChain = []RoleFallback{ + {Provider: "a", Model: "one"}, + {Provider: "b", Model: "two"}, + } + path := filepath.Join(t.TempDir(), "roles.toml") + writeFile(t, path, ` +[[roles.dream.fallback_chain]] +provider = "c" +model = "three" +`) + if err := ApplyConfigOverlayFile(path, &cfg); err != nil { + t.Fatalf("ApplyConfigOverlayFile() error = %v", err) + } + got := cfg.Roles.Dream.FallbackChain + want := []RoleFallback{{Provider: "c", Model: "three"}} + if !slices.Equal(got, want) { + t.Fatalf("Roles.Dream.FallbackChain = %#v, want %#v", got, want) + } + }) + + t.Run("Should layer workspace values after global values", func(t *testing.T) { + t.Parallel() + + cfg := DefaultWithHome(HomePaths{}) + cfg.Roles.Dream.Model = "default-model" + globalPath := filepath.Join(t.TempDir(), "global.toml") + workspacePath := filepath.Join(t.TempDir(), "workspace.toml") + writeFile(t, globalPath, "[roles.dream]\nmodel = \"global-model\"\n") + writeFile(t, workspacePath, "[roles.dream]\nmodel = \"workspace-model\"\n") + if err := ApplyConfigOverlayFile(globalPath, &cfg); err != nil { + t.Fatalf("ApplyConfigOverlayFile(global) error = %v", err) + } + if err := applyWorkspaceConfigOverlayFile(workspacePath, &cfg); err != nil { + t.Fatalf("applyWorkspaceConfigOverlayFile() error = %v", err) + } + if cfg.Roles.Dream.Model != "workspace-model" { + t.Fatalf("Roles.Dream.Model = %q, want workspace-model", cfg.Roles.Dream.Model) + } + }) + + t.Run("Should accept roles in workspace overlays", func(t *testing.T) { + t.Parallel() + + overlay, err := loadConfigOverlayBytes([]byte("[roles.auto_title]\nenabled = false\n"), "workspace.toml") + if err != nil { + t.Fatalf("loadConfigOverlayBytes() error = %v", err) + } + if err := validateWorkspaceConfigOverlay("workspace.toml", &overlay); err != nil { + t.Fatalf("validateWorkspaceConfigOverlay() error = %v", err) + } + }) + + t.Run("Should honor the last explicit coordinator enabled value", func(t *testing.T) { + t.Parallel() + + falseValue := false + trueValue := true + for _, tc := range []struct { + name string + first *bool + last *bool + want bool + }{ + {name: "Should end enabled", first: &falseValue, last: &trueValue, want: true}, + {name: "Should end disabled", first: &trueValue, last: &falseValue, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := DefaultWithHome(HomePaths{}) + first := rolesOverlay{ + Coordinator: coordinatorRoleOverlay{roleOverlay: roleOverlay{Enabled: tc.first}}, + } + last := rolesOverlay{ + Coordinator: coordinatorRoleOverlay{roleOverlay: roleOverlay{Enabled: tc.last}}, + } + first.Apply(&cfg.Roles) + last.Apply(&cfg.Roles) + if cfg.Roles.Coordinator.Enabled != tc.want { + t.Fatalf("Roles.Coordinator.Enabled = %t, want %t", cfg.Roles.Coordinator.Enabled, tc.want) + } + }) + } + }) +} diff --git a/internal/config/persistence_tree.go b/internal/config/persistence_tree.go index 95b1e3e53..4625d85fb 100644 --- a/internal/config/persistence_tree.go +++ b/internal/config/persistence_tree.go @@ -3,7 +3,6 @@ package config import ( "errors" "fmt" - "strings" tomltree "github.com/pelletier/go-toml" @@ -27,7 +26,7 @@ func validateEffectiveConfigWrite( if err != nil { return Config{}, err } - if err := globalOverlay.Apply(&cfg); err != nil { + if err := applyConfigOverlay(&cfg, &globalOverlay, RoleFieldSourceGlobal); err != nil { return Config{}, fmt.Errorf("apply global config overlay: %w", err) } if err := applyConfigMCPSidecarContent(globalMCPJSONFile(homePaths), target, rendered, &cfg); err != nil { @@ -151,6 +150,8 @@ func normalizeTreeEntry(key string, value any) (any, error) { items = append(items, stringMapToAny(item)) } return normalizeTreeEntry(key, items) + case []any: + return normalizeUntypedTreeSlice(key, typed) default: normalized, err := normalizeTOMLValue(typed) if err != nil { @@ -160,6 +161,29 @@ func normalizeTreeEntry(key string, value any) (any, error) { } } +func normalizeUntypedTreeSlice(key string, values []any) (any, error) { + if len(values) == 0 { + return []any{}, nil + } + + tables := make([]map[string]any, 0, len(values)) + for index, value := range values { + table, ok := value.(map[string]any) + if !ok { + if len(tables) > 0 { + return nil, fmt.Errorf("key %q contains mixed scalar and table array values", key) + } + return normalizeTOMLValue(values) + } + normalized, err := normalizeTreeValue(table) + if err != nil { + return nil, fmt.Errorf("key %q array-table item %d: %w", key, index, err) + } + tables = append(tables, normalized) + } + return tables, nil +} + func normalizeMutationPath(path []string) ([]string, error) { if len(path) == 0 { return nil, errors.New("config: TOML mutation path is required") diff --git a/internal/config/prompts/coordinator.md b/internal/config/prompts/coordinator.md new file mode 100644 index 000000000..c865b5fc0 --- /dev/null +++ b/internal/config/prompts/coordinator.md @@ -0,0 +1,3 @@ +You are AGH's runtime-owned workspace coordinator identity. + +Coordinate executable task runs through AGH's public task, context, channel, and safe-spawn surfaces. Treat task ownership, claim tokens, terminal states, spawn caps, approvals, and session lineage as authoritative runtime boundaries. Delegate bounded worker work when useful, keep operational communication distinct from task state, and never create another coordinator. The daemon supplies the current run context and role-specific operating frame at invocation time. diff --git a/internal/config/prompts/dreaming-curator.md b/internal/config/prompts/dreaming-curator.md new file mode 100644 index 000000000..9d858ed12 --- /dev/null +++ b/internal/config/prompts/dreaming-curator.md @@ -0,0 +1,3 @@ +You are AGH's runtime-owned dreaming curator identity. + +Consolidate durable knowledge from completed sessions and memory artifacts into concise, reusable memory. Preserve the memory taxonomy and scope boundaries, prefer verified evidence over speculation, merge rather than duplicate, record uncertainty explicitly, and never persist secrets or transient execution noise. The daemon supplies the role-specific consolidation or summarization contract at invocation time. diff --git a/internal/config/provider.go b/internal/config/provider.go index c5eb2e99b..f11b26d72 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -7,7 +7,8 @@ import ( ) const ( - providerClaudeKey = "claude" + providerAnthropicKey = "anthropic" + providerClaudeKey = "claude" ) const ( diff --git a/internal/config/provider_builtin.go b/internal/config/provider_builtin.go index a6f03be50..f3ff51438 100644 --- a/internal/config/provider_builtin.go +++ b/internal/config/provider_builtin.go @@ -110,7 +110,7 @@ var builtinProviders = map[string]ProviderConfig{ Command: piACPCommand, DisplayName: "Pi", Harness: ProviderHarnessPiACP, - RuntimeProvider: "anthropic", + RuntimeProvider: providerAnthropicKey, AuthLoginCmd: piACPAuthLoginCommand, Models: ProviderModelsConfig{ Default: modelClaudeOpus47ID, diff --git a/internal/config/role_sources.go b/internal/config/role_sources.go new file mode 100644 index 000000000..70f166939 --- /dev/null +++ b/internal/config/role_sources.go @@ -0,0 +1,135 @@ +package config + +import ( + "maps" + "strings" +) + +const ( + RoleFieldSourceDefault = "default" + RoleFieldSourceGlobal = "global" + RoleFieldSourceWorkspace = "workspace" + roleConfigFieldEnabled = "enabled" + roleConfigFieldAgent = "agent" + roleConfigFieldProvider = "provider" + roleConfigFieldModel = "model" + roleConfigFieldReasoning = "reasoning_effort" + roleConfigFieldFallbacks = "fallback_chain" +) + +// RoleFieldSources records which config layer last wrote each routed role field. +type RoleFieldSources map[RoleName]map[string]string + +// RoleFieldSource reports the winning config layer for one routed role field. +func (c *Config) RoleFieldSource(role RoleName, field string) string { + if c == nil || c.RoleSources == nil { + return "" + } + return strings.TrimSpace(c.RoleSources[role][strings.TrimSpace(field)]) +} + +// CloneRoleFieldSources returns an ownership-safe copy of role source metadata. +func CloneRoleFieldSources(sources RoleFieldSources) RoleFieldSources { + if sources == nil { + return nil + } + cloned := make(RoleFieldSources, len(sources)) + for role, fields := range sources { + clonedFields := make(map[string]string, len(fields)) + maps.Copy(clonedFields, fields) + cloned[role] = clonedFields + } + return cloned +} + +func defaultRoleFieldSources() RoleFieldSources { + sources := make(RoleFieldSources, len(allRoleNames)) + for _, role := range allRoleNames { + fields := []string{ + roleConfigFieldEnabled, + roleConfigFieldProvider, + roleConfigFieldModel, + roleConfigFieldReasoning, + roleConfigFieldFallbacks, + } + if role != RoleMemoryController { + fields = append(fields, roleConfigFieldAgent) + } + switch role { + case RoleCoordinator: + fields = append(fields, "ttl", "max_children", "max_active_sessions_per_workspace") + case RoleMemoryController: + fields = append(fields, "timeout", "top_k", "prompt_version", "max_tokens_out") + } + sources[role] = make(map[string]string, len(fields)) + for _, field := range fields { + sources[role][field] = RoleFieldSourceDefault + } + } + return sources +} + +func (o rolesOverlay) recordSources(dst *Config, source string) { + if dst == nil { + return + } + if dst.RoleSources == nil { + dst.RoleSources = defaultRoleFieldSources() + } + recordRoleOverlaySources(dst.RoleSources, RoleCoordinator, o.Coordinator.roleOverlay, source) + recordRoleOverlaySources(dst.RoleSources, RoleDream, o.Dream, source) + recordRoleOverlaySources(dst.RoleSources, RoleCheckpointSummary, o.CheckpointSummary, source) + recordRoleOverlaySources(dst.RoleSources, RoleMemoryExtractor, o.MemoryExtractor, source) + recordRoleOverlaySources(dst.RoleSources, RoleAutoTitle, o.AutoTitle, source) + recordMemoryControllerSources(dst.RoleSources, o.MemoryController, source) + recordSource(dst.RoleSources, RoleCoordinator, "ttl", source, o.Coordinator.TTL != nil) + recordSource(dst.RoleSources, RoleCoordinator, "max_children", source, o.Coordinator.MaxChildren != nil) + recordSource( + dst.RoleSources, + RoleCoordinator, + "max_active_sessions_per_workspace", + source, + o.Coordinator.MaxActiveSessionsPerWorkspace != nil, + ) +} + +func recordRoleOverlaySources(sources RoleFieldSources, role RoleName, overlay roleOverlay, source string) { + recordSource(sources, role, roleConfigFieldEnabled, source, overlay.Enabled != nil) + recordSource(sources, role, roleConfigFieldAgent, source, overlay.Agent != nil) + recordSource(sources, role, roleConfigFieldProvider, source, overlay.Provider != nil) + recordSource(sources, role, roleConfigFieldModel, source, overlay.Model != nil) + recordSource(sources, role, roleConfigFieldReasoning, source, overlay.ReasoningEffort != nil) + recordSource(sources, role, roleConfigFieldFallbacks, source, overlay.FallbackChain != nil) +} + +func recordMemoryControllerSources( + sources RoleFieldSources, + overlay memoryControllerRoleOverlay, + source string, +) { + recordSource(sources, RoleMemoryController, roleConfigFieldEnabled, source, overlay.Enabled != nil) + recordSource(sources, RoleMemoryController, roleConfigFieldProvider, source, overlay.Provider != nil) + recordSource(sources, RoleMemoryController, roleConfigFieldModel, source, overlay.Model != nil) + recordSource(sources, RoleMemoryController, roleConfigFieldReasoning, source, overlay.ReasoningEffort != nil) + recordSource(sources, RoleMemoryController, "timeout", source, overlay.Timeout != nil) + recordSource(sources, RoleMemoryController, "top_k", source, overlay.TopK != nil) + recordSource(sources, RoleMemoryController, "prompt_version", source, overlay.PromptVersion != nil) + recordSource(sources, RoleMemoryController, "max_tokens_out", source, overlay.MaxTokensOut != nil) + recordSource(sources, RoleMemoryController, roleConfigFieldFallbacks, source, overlay.FallbackChain != nil) +} + +func recordSource( + sources RoleFieldSources, + role RoleName, + field string, + source string, + present bool, +) { + if !present { + return + } + if sources[role] == nil { + sources[role] = make(map[string]string) + } + sources[role][field] = source +} diff --git a/internal/config/role_tool_surface.go b/internal/config/role_tool_surface.go new file mode 100644 index 000000000..0da3a243c --- /dev/null +++ b/internal/config/role_tool_surface.go @@ -0,0 +1,48 @@ +package config + +import "maps" + +var roleMutableConfigKinds = map[string]ValueKind{ + "roles": ConfigValueTable, + "roles.coordinator.enabled": ConfigValueBool, + "roles.coordinator.agent": ConfigValueString, + "roles.coordinator.provider": ConfigValueString, + "roles.coordinator.model": ConfigValueString, + "roles.coordinator.reasoning_effort": ConfigValueString, + "roles.coordinator.ttl": ConfigValueDuration, + "roles.coordinator.max_children": ConfigValueInt, + "roles.coordinator.max_active_sessions_per_workspace": ConfigValueInt, + "roles.dream.enabled": ConfigValueBool, + "roles.dream.agent": ConfigValueString, + "roles.dream.provider": ConfigValueString, + "roles.dream.model": ConfigValueString, + "roles.dream.reasoning_effort": ConfigValueString, + "roles.checkpoint_summary.enabled": ConfigValueBool, + "roles.checkpoint_summary.agent": ConfigValueString, + "roles.checkpoint_summary.provider": ConfigValueString, + "roles.checkpoint_summary.model": ConfigValueString, + "roles.checkpoint_summary.reasoning_effort": ConfigValueString, + "roles.memory_extractor.enabled": ConfigValueBool, + "roles.memory_extractor.agent": ConfigValueString, + "roles.memory_extractor.provider": ConfigValueString, + "roles.memory_extractor.model": ConfigValueString, + "roles.memory_extractor.reasoning_effort": ConfigValueString, + "roles.auto_title.enabled": ConfigValueBool, + "roles.auto_title.agent": ConfigValueString, + "roles.auto_title.provider": ConfigValueString, + "roles.auto_title.model": ConfigValueString, + "roles.auto_title.reasoning_effort": ConfigValueString, + "roles.memory_controller.enabled": ConfigValueBool, + "roles.memory_controller.provider": ConfigValueString, + "roles.memory_controller.model": ConfigValueString, + "roles.memory_controller.reasoning_effort": ConfigValueString, + "roles.memory_controller.timeout": ConfigValueDuration, + "roles.memory_controller.top_k": ConfigValueInt, + "roles.memory_controller.prompt_version": ConfigValueString, + "roles.memory_controller.max_tokens_out": ConfigValueInt, +} + +// RoleMutableConfigKinds returns the config-owned role mutation registry. +func RoleMutableConfigKinds() map[string]ValueKind { + return maps.Clone(roleMutableConfigKinds) +} diff --git a/internal/config/roles.go b/internal/config/roles.go new file mode 100644 index 000000000..eedf671f7 --- /dev/null +++ b/internal/config/roles.go @@ -0,0 +1,307 @@ +package config + +import ( + "fmt" + "strings" + "time" + + "github.com/compozy/agh/internal/reasoning" +) + +type providerResolver interface { + ResolveProvider(name string) (ProviderConfig, error) +} + +var _ providerResolver = (*Config)(nil) + +// RoleName identifies a background runtime role governed by [roles]. +type RoleName string + +const ( + RoleCoordinator RoleName = "coordinator" + RoleDream RoleName = "dream" + RoleCheckpointSummary RoleName = "checkpoint_summary" + RoleMemoryExtractor RoleName = "memory_extractor" + RoleAutoTitle RoleName = "auto_title" + RoleMemoryController RoleName = "memory_controller" +) + +var allRoleNames = []RoleName{ + RoleCoordinator, + RoleDream, + RoleCheckpointSummary, + RoleMemoryExtractor, + RoleAutoTitle, + RoleMemoryController, +} + +// RoleNames returns the complete closed role roster. +func RoleNames() []RoleName { + return append([]RoleName(nil), allRoleNames...) +} + +// RolesConfig is the closed [roles] roster. +type RolesConfig struct { + Coordinator CoordinatorRoleConfig `toml:"coordinator"` + Dream RoleConfig `toml:"dream"` + CheckpointSummary RoleConfig `toml:"checkpoint_summary"` + MemoryExtractor RoleConfig `toml:"memory_extractor"` + AutoTitle RoleConfig `toml:"auto_title"` + MemoryController MemoryControllerRoleConfig `toml:"memory_controller"` +} + +// CloneRolesConfig returns an ownership-safe copy of the roles configuration. +func CloneRolesConfig(source *RolesConfig) RolesConfig { + cloned := *source + cloned.Coordinator.FallbackChain = cloneRoleFallbacks(source.Coordinator.FallbackChain) + cloned.Dream.FallbackChain = cloneRoleFallbacks(source.Dream.FallbackChain) + cloned.CheckpointSummary.FallbackChain = cloneRoleFallbacks(source.CheckpointSummary.FallbackChain) + cloned.MemoryExtractor.FallbackChain = cloneRoleFallbacks(source.MemoryExtractor.FallbackChain) + cloned.AutoTitle.FallbackChain = cloneRoleFallbacks(source.AutoTitle.FallbackChain) + cloned.MemoryController.FallbackChain = cloneRoleFallbacks(source.MemoryController.FallbackChain) + return cloned +} + +func cloneRoleFallbacks(source []RoleFallback) []RoleFallback { + return append([]RoleFallback(nil), source...) +} + +// RoleConfig controls one session-backed role. +type RoleConfig struct { + Enabled bool `toml:"enabled"` + Agent string `toml:"agent,omitempty"` + Provider string `toml:"provider,omitempty"` + Model string `toml:"model,omitempty"` + ReasoningEffort string `toml:"reasoning_effort,omitempty"` + FallbackChain []RoleFallback `toml:"fallback_chain,omitempty"` +} + +// RoleFallback is one ordered invocation fallback route. +type RoleFallback struct { + Provider string `toml:"provider"` + Model string `toml:"model"` + ReasoningEffort string `toml:"reasoning_effort,omitempty"` +} + +// CoordinatorRoleConfig combines routing with coordinator safety policy. +type CoordinatorRoleConfig struct { + RoleConfig + TTL time.Duration `toml:"ttl"` + MaxChildren int `toml:"max_children"` + MaxActiveSessionsPerWorkspace int `toml:"max_active_sessions_per_workspace"` +} + +const ( + // DefaultCoordinatorTTL is the default TTL for [roles.coordinator]. + DefaultCoordinatorTTL = 2 * time.Hour + // MinCoordinatorTTL is the shortest coordinator TTL accepted by config validation. + MinCoordinatorTTL = time.Minute + // MaxCoordinatorTTL is the longest coordinator TTL accepted by config validation. + MaxCoordinatorTTL = 24 * time.Hour + // DefaultCoordinatorMaxChildren is the safe per-coordinator child-session cap. + DefaultCoordinatorMaxChildren = 5 + // MaxCoordinatorChildren is the hard MVP cap for coordinator child sessions. + MaxCoordinatorChildren = 5 + // DefaultCoordinatorMaxActiveSessionsPerWorkspace caps managed coordinator and worker sessions. + DefaultCoordinatorMaxActiveSessionsPerWorkspace = 5 +) + +// MemoryControllerRoleConfig controls the in-process memory-controller model call. +type MemoryControllerRoleConfig struct { + Enabled bool `toml:"enabled"` + Provider string `toml:"provider,omitempty"` + Model string `toml:"model"` + ReasoningEffort string `toml:"reasoning_effort,omitempty"` + Timeout time.Duration `toml:"timeout"` + TopK int `toml:"top_k"` + PromptVersion string `toml:"prompt_version"` + MaxTokensOut int `toml:"max_tokens_out"` + FallbackChain []RoleFallback `toml:"fallback_chain,omitempty"` +} + +// ResolvedCoordinatorRole is the coordinator runtime policy assembled from a resolved role. +type ResolvedCoordinatorRole struct { + Enabled bool + AgentName string + Provider string + Model string + ReasoningEffort string + Fallbacks []RoleFallback + TTL time.Duration + MaxChildren int + MaxActiveSessionsPerWorkspace int +} + +// DefaultResolvedCoordinatorRole returns the coordinator's resolved default identity and policy. +func DefaultResolvedCoordinatorRole() ResolvedCoordinatorRole { + defaults := DefaultRolesConfig().Coordinator + return ResolvedCoordinatorRole{ + Enabled: defaults.Enabled, + AgentName: BuiltinCoordinatorAgentName, + Fallbacks: append([]RoleFallback(nil), defaults.FallbackChain...), + TTL: defaults.TTL, + MaxChildren: defaults.MaxChildren, + MaxActiveSessionsPerWorkspace: defaults.MaxActiveSessionsPerWorkspace, + } +} + +// DefaultRolesConfig preserves the effective routing defaults from the pre-roles config. +func DefaultRolesConfig() RolesConfig { + return RolesConfig{ + Coordinator: CoordinatorRoleConfig{ + RoleConfig: RoleConfig{Enabled: false}, + TTL: DefaultCoordinatorTTL, + MaxChildren: DefaultCoordinatorMaxChildren, + MaxActiveSessionsPerWorkspace: DefaultCoordinatorMaxActiveSessionsPerWorkspace, + }, + Dream: RoleConfig{Enabled: true}, + CheckpointSummary: RoleConfig{Enabled: true}, + MemoryExtractor: RoleConfig{Enabled: true}, + AutoTitle: RoleConfig{Enabled: true}, + MemoryController: MemoryControllerRoleConfig{ + Enabled: true, + Provider: "pi", + Model: "anthropic/claude-haiku-4", + Timeout: 250 * time.Millisecond, + TopK: 5, + PromptVersion: "v1", + MaxTokensOut: 256, + }, + } +} + +// Validate checks role shape, bounds, and configured provider references. +func (c *RolesConfig) Validate(path string, resolver providerResolver) error { + if c == nil { + return fmt.Errorf("%s is required", path) + } + if err := c.Coordinator.validate(path+".coordinator", resolver); err != nil { + return err + } + for _, role := range []struct { + name RoleName + config RoleConfig + }{ + {name: RoleDream, config: c.Dream}, + {name: RoleCheckpointSummary, config: c.CheckpointSummary}, + {name: RoleMemoryExtractor, config: c.MemoryExtractor}, + {name: RoleAutoTitle, config: c.AutoTitle}, + } { + if err := role.config.validate(path+"."+string(role.name), resolver); err != nil { + return err + } + } + return c.MemoryController.validate(path+"."+string(RoleMemoryController), resolver) +} + +func (c CoordinatorRoleConfig) validate(path string, resolver providerResolver) error { + if err := c.RoleConfig.validate(path, resolver); err != nil { + return err + } + if c.TTL < MinCoordinatorTTL || c.TTL > MaxCoordinatorTTL { + return fmt.Errorf("%s.ttl must be between %s and %s: %s", path, MinCoordinatorTTL, MaxCoordinatorTTL, c.TTL) + } + if c.MaxChildren <= 0 { + return fmt.Errorf("%s.max_children must be positive: %d", path, c.MaxChildren) + } + if c.MaxChildren > MaxCoordinatorChildren { + return fmt.Errorf("%s.max_children must be <= %d: %d", path, MaxCoordinatorChildren, c.MaxChildren) + } + if c.MaxActiveSessionsPerWorkspace <= 0 { + return fmt.Errorf( + "%s.max_active_sessions_per_workspace must be positive: %d", + path, + c.MaxActiveSessionsPerWorkspace, + ) + } + return nil +} + +func (c RoleConfig) validate(path string, resolver providerResolver) error { + if err := validateRoleReasoningEffort(path+".reasoning_effort", c.ReasoningEffort); err != nil { + return err + } + if err := validateRoleProvider(path, c.Provider, resolver); err != nil { + return err + } + return validateRoleFallbacks(path, c.FallbackChain, resolver) +} + +func (c MemoryControllerRoleConfig) validate(path string, resolver providerResolver) error { + if err := validateRoleReasoningEffort(path+".reasoning_effort", c.ReasoningEffort); err != nil { + return err + } + if err := validateRoleProvider(path, c.Provider, resolver); err != nil { + return err + } + if err := validateRoleFallbacks(path, c.FallbackChain, resolver); err != nil { + return err + } + if !c.Enabled { + return nil + } + if strings.TrimSpace(c.Model) == "" { + return fmt.Errorf("%s.model is required", path) + } + if c.Timeout <= 0 { + return fmt.Errorf("%s.timeout must be positive: %s", path, c.Timeout) + } + if c.TopK <= 0 { + return fmt.Errorf("%s.top_k must be positive: %d", path, c.TopK) + } + if strings.TrimSpace(c.PromptVersion) == "" { + return fmt.Errorf("%s.prompt_version is required", path) + } + if c.MaxTokensOut <= 0 { + return fmt.Errorf("%s.max_tokens_out must be positive: %d", path, c.MaxTokensOut) + } + return nil +} + +func validateRoleProvider(path, providerName string, resolver providerResolver) error { + providerName = strings.TrimSpace(providerName) + if providerName == "" { + return nil + } + if resolver == nil { + return fmt.Errorf("%s.provider resolver is required", path) + } + _, err := resolver.ResolveProvider(providerName) + if err != nil { + return fmt.Errorf("%s.provider: %w", path, err) + } + return nil +} + +func validateRoleFallbacks(path string, fallbacks []RoleFallback, resolver providerResolver) error { + for i, fallback := range fallbacks { + fallbackPath := fmt.Sprintf("%s.fallback_chain[%d]", path, i) + providerName := strings.TrimSpace(fallback.Provider) + if providerName == "" { + return fmt.Errorf("%s.provider is required", fallbackPath) + } + if strings.TrimSpace(fallback.Model) == "" { + return fmt.Errorf("%s.model is required", fallbackPath) + } + if err := validateRoleReasoningEffort(fallbackPath+".reasoning_effort", fallback.ReasoningEffort); err != nil { + return err + } + if resolver == nil { + return fmt.Errorf("%s.provider resolver is required", fallbackPath) + } + if _, err := resolver.ResolveProvider(providerName); err != nil { + return fmt.Errorf("%s.provider: %w", fallbackPath, err) + } + } + return nil +} + +func validateRoleReasoningEffort(path, value string) error { + if strings.TrimSpace(value) == "" { + return nil + } + if value != strings.TrimSpace(value) || !reasoning.IsValid(value) { + return &reasoning.InvalidEffortError{Path: path, Value: value} + } + return nil +} diff --git a/internal/config/roles_test.go b/internal/config/roles_test.go new file mode 100644 index 000000000..d030c8b12 --- /dev/null +++ b/internal/config/roles_test.go @@ -0,0 +1,246 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +func TestDefaultRolesConfigPreservesRoleBehavior(t *testing.T) { + t.Parallel() + + t.Run("Should preserve coordinator defaults", func(t *testing.T) { + t.Parallel() + + got := DefaultRolesConfig().Coordinator + if got.Enabled { + t.Fatal("DefaultRolesConfig().Coordinator.Enabled = true, want false") + } + if got.Agent != "" { + t.Fatalf("DefaultRolesConfig().Coordinator.Agent = %q, want empty", got.Agent) + } + if got.TTL != 2*time.Hour { + t.Fatalf("DefaultRolesConfig().Coordinator.TTL = %s, want 2h", got.TTL) + } + if got.MaxChildren != 5 { + t.Fatalf("DefaultRolesConfig().Coordinator.MaxChildren = %d, want 5", got.MaxChildren) + } + if got.MaxActiveSessionsPerWorkspace != 5 { + t.Fatalf( + "DefaultRolesConfig().Coordinator.MaxActiveSessionsPerWorkspace = %d, want 5", + got.MaxActiveSessionsPerWorkspace, + ) + } + }) + + t.Run("Should preserve session-backed role defaults", func(t *testing.T) { + t.Parallel() + + got := DefaultRolesConfig() + for _, item := range []struct { + name RoleName + role RoleConfig + }{ + {name: RoleDream, role: got.Dream}, + {name: RoleCheckpointSummary, role: got.CheckpointSummary}, + {name: RoleMemoryExtractor, role: got.MemoryExtractor}, + {name: RoleAutoTitle, role: got.AutoTitle}, + } { + if !item.role.Enabled { + t.Errorf("DefaultRolesConfig().%s.Enabled = false, want true", item.name) + } + if item.role.Agent != "" { + t.Errorf("DefaultRolesConfig().%s.Agent = %q, want empty", item.name, item.role.Agent) + } + } + }) + + t.Run("Should preserve memory controller defaults", func(t *testing.T) { + t.Parallel() + + got := DefaultRolesConfig().MemoryController + if !got.Enabled { + t.Fatal("DefaultRolesConfig().MemoryController.Enabled = false, want true") + } + if got.Provider != "pi" { + t.Fatalf("DefaultRolesConfig().MemoryController.Provider = %q, want pi", got.Provider) + } + if got.Model != "anthropic/claude-haiku-4" { + t.Fatalf("DefaultRolesConfig().MemoryController.Model = %q, want anthropic/claude-haiku-4", got.Model) + } + if got.Timeout != 250*time.Millisecond { + t.Fatalf("DefaultRolesConfig().MemoryController.Timeout = %s, want 250ms", got.Timeout) + } + if got.TopK != 5 || got.PromptVersion != "v1" || got.MaxTokensOut != 256 { + t.Fatalf( + "DefaultRolesConfig().MemoryController = %#v, want top_k=5 prompt_version=v1 max_tokens_out=256", + got, + ) + } + }) + + t.Run("Should clone every fallback chain without shared ownership", func(t *testing.T) { + t.Parallel() + + source := DefaultRolesConfig() + source.Dream.FallbackChain = []RoleFallback{{Provider: "primary", Model: "dream-model"}} + source.MemoryController.FallbackChain = []RoleFallback{{Provider: "backup", Model: "controller-model"}} + cloned := CloneRolesConfig(&source) + cloned.Dream.FallbackChain[0].Model = "changed-dream" + cloned.MemoryController.FallbackChain[0].Model = "changed-controller" + if source.Dream.FallbackChain[0].Model != "dream-model" || + source.MemoryController.FallbackChain[0].Model != "controller-model" { + t.Fatalf("CloneRolesConfig() mutated source fallbacks: %#v", source) + } + }) +} + +func TestRolesConfigValidateEnforcesBoundsAndRoutes(t *testing.T) { + t.Parallel() + + t.Run("Should reject a coordinator TTL below the floor", func(t *testing.T) { + t.Parallel() + + cfg := DefaultRolesConfig() + cfg.Coordinator.TTL = 30 * time.Second + err := cfg.Validate("roles", &Config{}) + if err == nil || !strings.Contains(err.Error(), "roles.coordinator.ttl") || + !strings.Contains(err.Error(), "1m0s") || !strings.Contains(err.Error(), "24h0m0s") { + t.Fatalf("Validate() error = %v, want coordinator TTL path and bounds", err) + } + }) + + t.Run("Should accept both coordinator TTL boundaries", func(t *testing.T) { + t.Parallel() + + for _, ttl := range []time.Duration{time.Minute, 24 * time.Hour} { + cfg := DefaultRolesConfig() + cfg.Coordinator.TTL = ttl + if err := cfg.Validate("roles", &Config{}); err != nil { + t.Fatalf("Validate(ttl=%s) error = %v", ttl, err) + } + } + }) + + t.Run("Should reject a coordinator TTL above the ceiling", func(t *testing.T) { + t.Parallel() + + cfg := DefaultRolesConfig() + cfg.Coordinator.TTL = 24*time.Hour + time.Second + err := cfg.Validate("roles", &Config{}) + if err == nil || !strings.Contains(err.Error(), "roles.coordinator.ttl") { + t.Fatalf("Validate() error = %v, want coordinator TTL error", err) + } + }) + + t.Run("Should reject coordinator child counts outside the safe range", func(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + value int + message string + }{ + {value: 0, message: "must be positive"}, + {value: 6, message: "must be <= 5"}, + } { + cfg := DefaultRolesConfig() + cfg.Coordinator.MaxChildren = tc.value + err := cfg.Validate("roles", &Config{}) + if err == nil || !strings.Contains(err.Error(), "roles.coordinator.max_children") || + !strings.Contains(err.Error(), tc.message) { + t.Errorf("Validate(max_children=%d) error = %v, want %q", tc.value, err, tc.message) + } + } + }) + + t.Run("Should require a provider for every fallback", func(t *testing.T) { + t.Parallel() + + cfg := DefaultRolesConfig() + cfg.Dream.FallbackChain = []RoleFallback{{Model: "model-a"}} + err := cfg.Validate("roles", &Config{}) + if err == nil || !strings.Contains(err.Error(), "roles.dream.fallback_chain[0].provider is required") { + t.Fatalf("Validate() error = %v, want fallback provider path", err) + } + }) + + t.Run("Should reject an unknown fallback provider", func(t *testing.T) { + t.Parallel() + + cfg := DefaultRolesConfig() + cfg.Dream.FallbackChain = []RoleFallback{{Provider: "missing", Model: "model-a"}} + err := cfg.Validate("roles", &Config{}) + if err == nil || !strings.Contains(err.Error(), "roles.dream.fallback_chain[0].provider") || + !strings.Contains(err.Error(), "missing") { + t.Fatalf("Validate() error = %v, want wrapped provider-resolution failure", err) + } + }) + + t.Run("Should reject an invalid reasoning effort", func(t *testing.T) { + t.Parallel() + + cfg := DefaultRolesConfig() + cfg.MemoryExtractor.ReasoningEffort = "extreme" + err := cfg.Validate("roles", &Config{}) + if err == nil || !strings.Contains(err.Error(), "roles.memory_extractor.reasoning_effort") || + !strings.Contains(err.Error(), "extreme") { + t.Fatalf("Validate() error = %v, want reasoning enum error", err) + } + }) + + t.Run("Should defer a catalog agent model to invocation-time resolution", func(t *testing.T) { + t.Parallel() + + cfg := DefaultRolesConfig() + cfg.Dream.Agent = "curator" + cfg.Dream.Provider = "bare-pi" + cfg.Dream.Model = "" + providers := &Config{Providers: map[string]ProviderConfig{ + "bare-pi": { + Command: "pi-acp", + Harness: ProviderHarnessPiACP, + RuntimeProvider: "anthropic", + }, + }} + if err := cfg.Validate("roles", providers); err != nil { + t.Fatalf("Validate(catalog agent supplies model) error = %v", err) + } + }) + + t.Run("Should require a positive enabled controller timeout", func(t *testing.T) { + t.Parallel() + + cfg := DefaultRolesConfig() + cfg.MemoryController.Timeout = 0 + err := cfg.Validate("roles", &Config{}) + if err == nil || !strings.Contains(err.Error(), "roles.memory_controller.timeout must be positive") { + t.Fatalf("Validate() error = %v, want controller timeout error", err) + } + }) +} + +func TestRolesCoordinatorOverlayDecodesEmbeddedFields(t *testing.T) { + t.Parallel() + + t.Run("Should decode shared fields and coordinator extras from one table", func(t *testing.T) { + t.Parallel() + + overlay, err := loadConfigOverlayBytes([]byte(` +[roles.coordinator] +enabled = true +model = "model-a" +ttl = "1h" +`), "roles.toml") + if err != nil { + t.Fatalf("loadConfigOverlayBytes() error = %v", err) + } + cfg := DefaultWithHome(HomePaths{}) + if err := overlay.Apply(&cfg); err != nil { + t.Fatalf("Apply() error = %v", err) + } + if !cfg.Roles.Coordinator.Enabled || cfg.Roles.Coordinator.Model != "model-a" || + cfg.Roles.Coordinator.TTL != time.Hour { + t.Fatalf("Config.Roles.Coordinator = %#v, want enabled/model/ttl overlay", cfg.Roles.Coordinator) + } + }) +} diff --git a/internal/config/tool_surface.go b/internal/config/tool_surface.go index 2ff466764..69d322aeb 100644 --- a/internal/config/tool_surface.go +++ b/internal/config/tool_surface.go @@ -68,7 +68,6 @@ var ( toolSurfaceAgentsHeartbeatSessionHealthStaleAfterPath: ConfigValueDuration, toolSurfaceAgentsHeartbeatSessionHealthHookMinIntervalPath: ConfigValueDuration, "limits.max_concurrent_agents": ConfigValueInt, - "session.auto_title_enabled": ConfigValueBool, "session.limits.timeout": ConfigValueDuration, "session.supervision.activity_heartbeat_interval": ConfigValueDuration, "session.supervision.progress_notify_interval": ConfigValueDuration, @@ -89,12 +88,6 @@ var ( "memory.controller.mode": ConfigValueString, "memory.controller.max_latency": ConfigValueDuration, "memory.controller.default_op_on_fail": ConfigValueString, - "memory.controller.llm.enabled": ConfigValueBool, - "memory.controller.llm.model": ConfigValueString, - "memory.controller.llm.top_k": ConfigValueInt, - "memory.controller.llm.prompt_version": ConfigValueString, - "memory.controller.llm.timeout": ConfigValueDuration, - "memory.controller.llm.max_tokens_out": ConfigValueInt, "memory.controller.policy.max_content_chars": ConfigValueInt, "memory.controller.policy.max_writes_per_min": ConfigValueInt, "memory.controller.policy.allow_origins": ConfigValueStringSlice, @@ -114,16 +107,12 @@ var ( "memory.decisions.prune_after_applied_days": ConfigValueInt, "memory.decisions.keep_audit_summary": ConfigValueBool, "memory.decisions.max_post_content_bytes": ConfigValueInt64, - "memory.extractor.enabled": ConfigValueBool, "memory.extractor.mode": ConfigValueString, "memory.extractor.throttle_turns": ConfigValueInt, "memory.extractor.deadline": ConfigValueDuration, "memory.extractor.sandbox_inbox_only": ConfigValueBool, - "memory.extractor.model": ConfigValueString, "memory.extractor.queue.capacity": ConfigValueInt, configExtractorQueueCoalesceMaxPath: ConfigValueInt, - "memory.dream.enabled": ConfigValueBool, - "memory.dream.agent": ConfigValueString, "memory.dream.min_hours": ConfigValueFloat, "memory.dream.min_sessions": ConfigValueInt, "memory.dream.debounce": ConfigValueDuration, @@ -184,7 +173,7 @@ var ( toolSurfaceToolsArtifactsMaxAgePath: ConfigValueDuration, toolSurfaceToolsArtifactsMaxBytesPath: ConfigValueInt64, toolSurfaceToolsArtifactsMaxCountPath: ConfigValueInt, - }, automationToolPathKinds(), loopAndGoalToolPathKinds(), taskToolSurfaceMutableConfigKinds()) + }, roleMutableConfigKinds, automationToolPathKinds(), loopAndGoalToolPathKinds(), taskToolSurfaceMutableConfigKinds()) ) // RedactedConfigMap converts config to the same redacted map shape used by operator-facing CLI output. @@ -345,6 +334,12 @@ func NormalizeToolConfigValue(kind ValueKind, value any) (any, error) { return trimmed, nil case ConfigValueStringSlice: return coerceConfigStringSlice(value) + case ConfigValueTable: + table, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("config: expected object value, got %T", value) + } + return normalizeTreeValue(table) default: return nil, fmt.Errorf("config: unsupported config value kind %d", kind) } diff --git a/internal/config/tool_surface_reflection.go b/internal/config/tool_surface_reflection.go index 0d3ff79de..6dc50a618 100644 --- a/internal/config/tool_surface_reflection.go +++ b/internal/config/tool_surface_reflection.go @@ -51,11 +51,27 @@ func configStructNode(value reflect.Value) (any, bool) { if field.PkgPath != "" { continue } + fieldValue := value.Field(i) + if field.Anonymous && field.Tag.Get("toml") == "" { + node, hasValue := configNodeFromValue(fieldValue, "") + if !hasValue { + continue + } + embedded, isStruct := node.(map[string]any) + if !isStruct { + continue + } + for key, embeddedValue := range embedded { + if _, exists := result[key]; !exists { + result[key] = embeddedValue + } + } + continue + } name, omitEmpty, ok := tomlFieldName(field) if !ok { continue } - fieldValue := value.Field(i) if omitEmpty && fieldValue.IsZero() { continue } diff --git a/internal/config/tool_surface_test.go b/internal/config/tool_surface_test.go index 1862a7e11..5308e7481 100644 --- a/internal/config/tool_surface_test.go +++ b/internal/config/tool_surface_test.go @@ -28,7 +28,7 @@ func TestToolConfigPathPolicy(t *testing.T) { }, { name: "Should allow automatic title enablement mutation", - path: "session.auto_title_enabled", + path: "roles.auto_title.enabled", kind: ConfigValueBool, }, { @@ -296,6 +296,21 @@ func TestToolConfigPathPolicy(t *testing.T) { path: "memory.provider.timeout", kind: ConfigValueDuration, }, + { + name: "Should allow full roles section mutation", + path: "roles", + kind: ConfigValueTable, + }, + { + name: "Should allow dream role model mutation", + path: "roles.dream.model", + kind: ConfigValueString, + }, + { + name: "Should allow memory controller role timeout mutation", + path: "roles.memory_controller.timeout", + kind: ConfigValueDuration, + }, { name: "Should allow Marketplace catalog TTL mutation", path: "marketplace.catalog.ttl", @@ -366,6 +381,36 @@ func TestToolConfigPathPolicy(t *testing.T) { path: "network.port", denial: ConfigPathForbidden, }, + { + name: "Should reject removed dream agent path", + path: "memory.dream.agent", + denial: ConfigPathForbidden, + }, + { + name: "Should reject removed dream enabled path", + path: "memory.dream.enabled", + denial: ConfigPathForbidden, + }, + { + name: "Should reject removed extractor model path", + path: "memory.extractor.model", + denial: ConfigPathForbidden, + }, + { + name: "Should reject removed extractor enabled path", + path: "memory.extractor.enabled", + denial: ConfigPathForbidden, + }, + { + name: "Should reject removed controller LLM model path", + path: "memory.controller.llm.model", + denial: ConfigPathForbidden, + }, + { + name: "Should reject removed automatic title path", + path: "session.auto_title_enabled", + denial: ConfigPathForbidden, + }, { name: "Should reject tool policy trust root", path: "tools.policy.external_default", @@ -536,6 +581,18 @@ func TestNormalizeToolConfigValue(t *testing.T) { if !ok || len(values) != 2 || values[0] != "codex" || values[1] != "claude" { t.Fatalf("NormalizeToolConfigValue(string slice) = %#v, want two strings", value) } + tableValue, err := NormalizeToolConfigValue(ConfigValueTable, map[string]any{ + "auto_title": map[string]any{ + "fallback_chain": []any{map[string]any{"provider": "codex", "model": "gpt-5-mini"}}, + }, + }) + if err != nil { + t.Fatalf("NormalizeToolConfigValue(table) error = %v", err) + } + table, ok := tableValue.(map[string]any) + if !ok || table["auto_title"] == nil { + t.Fatalf("NormalizeToolConfigValue(table) = %#v, want normalized object", tableValue) + } if _, err := NormalizeToolConfigValue(ConfigValueDuration, "not-a-duration"); err == nil { t.Fatal("NormalizeToolConfigValue(invalid duration) error = nil, want non-nil") @@ -554,6 +611,7 @@ func TestRedactedConfigMapEntriesAndDiff(t *testing.T) { } cfg := DefaultWithHome(homePaths) cfg.Defaults.Agent = "planner" + cfg.Roles.Coordinator.Enabled = true cfg.Sandboxes["dev"] = SandboxProfile{ Backend: "local", Env: map[string]string{ @@ -567,6 +625,17 @@ func TestRedactedConfigMapEntriesAndDiff(t *testing.T) { if !ok || agent.Value != "planner" { t.Fatalf("EntryByPath(defaults.agent) = %#v/%v, want planner", agent, ok) } + coordinatorEnabled, ok := EntryByPath(entries, "roles.coordinator.enabled") + if !ok || coordinatorEnabled.Value != true { + t.Fatalf( + "EntryByPath(roles.coordinator.enabled) = %#v/%v, want true", + coordinatorEnabled, + ok, + ) + } + if nested, exists := EntryByPath(entries, "roles.coordinator.roleconfig.enabled"); exists { + t.Fatalf("embedded coordinator path leaked as %#v", nested) + } soulEnabled, ok := EntryByPath(entries, "agents.soul.enabled") if !ok || soulEnabled.Value != true { t.Fatalf("EntryByPath(agents.soul.enabled) = %#v/%v, want true", soulEnabled, ok) diff --git a/internal/config/tool_surface_types.go b/internal/config/tool_surface_types.go index b5937d90a..777b2c143 100644 --- a/internal/config/tool_surface_types.go +++ b/internal/config/tool_surface_types.go @@ -11,6 +11,7 @@ const ( ConfigValueFloat ConfigValueDuration ConfigValueStringSlice + ConfigValueTable ) // PathDenial is the config package's path-policy decision. diff --git a/internal/config/workspace_overlay.go b/internal/config/workspace_overlay.go index d834b30aa..00da974ec 100644 --- a/internal/config/workspace_overlay.go +++ b/internal/config/workspace_overlay.go @@ -17,7 +17,10 @@ func applyWorkspaceConfigOverlayFile(path string, dst *Config) error { if err := validateWorkspaceConfigOverlay(path, &overlay); err != nil { return err } - return overlay.Apply(dst) + if err := applyConfigOverlay(dst, &overlay, RoleFieldSourceWorkspace); err != nil { + return err + } + return nil } func loadWorkspaceOverlayForWrite( @@ -58,7 +61,7 @@ func applyWorkspaceConfigWrite( if err != nil { return err } - if err := overlay.Apply(cfg); err != nil { + if err := applyConfigOverlay(cfg, &overlay, RoleFieldSourceWorkspace); err != nil { return fmt.Errorf("apply workspace config overlay: %w", err) } if err := applyConfigMCPSidecarContent( diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go index dabca4761..ece5c2088 100644 --- a/internal/coordinator/coordinator.go +++ b/internal/coordinator/coordinator.go @@ -103,7 +103,7 @@ type PromptInput struct { // DecideBootstrap evaluates the mechanical coordinator bootstrap rules. It // does not check for already-running coordinator sessions; that singleton check // belongs to the daemon runtime. -func DecideBootstrap(task taskpkg.Task, run taskpkg.Run, cfg aghconfig.CoordinatorConfig) Decision { +func DecideBootstrap(task taskpkg.Task, run taskpkg.Run, cfg aghconfig.ResolvedCoordinatorRole) Decision { decision := Decision{ WorkspaceID: strings.TrimSpace(task.WorkspaceID), TaskID: strings.TrimSpace(task.ID), @@ -194,17 +194,17 @@ func SpawnRoleAllowed(role string) bool { // Lineage builds root lineage metadata for a managed coordinator session. func Lineage( now time.Time, - cfg aghconfig.CoordinatorConfig, + cfg aghconfig.ResolvedCoordinatorRole, policy store.SessionPermissionPolicy, ) *store.SessionLineage { - ttl := now.UTC().Add(cfg.DefaultTTL) + ttl := now.UTC().Add(cfg.TTL) return &store.SessionLineage{ SpawnRole: string(session.SessionTypeCoordinator), TTLExpiresAt: &ttl, SpawnBudget: store.SessionSpawnBudget{ MaxChildren: cfg.MaxChildren, MaxDepth: session.DefaultSpawnMaxDepth, - TTLSeconds: int64(cfg.DefaultTTL.Seconds()), + TTLSeconds: int64(cfg.TTL.Seconds()), // Coordinator uniqueness is enforced by the daemon singleton; this caps managed sessions. MaxActivePerWorkspace: cfg.MaxActiveSessionsPerWorkspace, }, diff --git a/internal/coordinator/coordinator_bench_test.go b/internal/coordinator/coordinator_bench_test.go index 5fae91e9b..4e557ebfb 100644 --- a/internal/coordinator/coordinator_bench_test.go +++ b/internal/coordinator/coordinator_bench_test.go @@ -44,7 +44,7 @@ func BenchmarkPermissionPolicy(b *testing.B) { } func BenchmarkLineage(b *testing.B) { - cfg := aghconfig.DefaultCoordinatorConfig() + cfg := aghconfig.DefaultResolvedCoordinatorRole() cfg.Enabled = true policy := PermissionPolicy(participation.LocalSpec()) diff --git a/internal/coordinator/coordinator_test.go b/internal/coordinator/coordinator_test.go index 28fc28a35..d54d2dc52 100644 --- a/internal/coordinator/coordinator_test.go +++ b/internal/coordinator/coordinator_test.go @@ -36,14 +36,14 @@ func TestDecideBootstrap(t *testing.T) { ChannelID: "ch-run-1", Source: participation.SourceExplicitRequest, }, "", "", "") - enabled := aghconfig.DefaultCoordinatorConfig() + enabled := aghconfig.DefaultResolvedCoordinatorRole() enabled.Enabled = true tests := []struct { name string task taskpkg.Task run taskpkg.Run - cfg aghconfig.CoordinatorConfig + cfg aghconfig.ResolvedCoordinatorRole want string should bool }{ @@ -58,7 +58,7 @@ func TestDecideBootstrap(t *testing.T) { name: "Should skip disabled config", task: baseTask, run: baseRun, - cfg: aghconfig.DefaultCoordinatorConfig(), + cfg: aghconfig.DefaultResolvedCoordinatorRole(), want: DecisionDisabled, }, { @@ -246,9 +246,9 @@ func TestLineageAndHealthySession(t *testing.T) { t.Parallel() now := time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC) - cfg := aghconfig.DefaultCoordinatorConfig() + cfg := aghconfig.DefaultResolvedCoordinatorRole() cfg.Enabled = true - cfg.DefaultTTL = 2 * time.Hour + cfg.TTL = 2 * time.Hour cfg.MaxChildren = 3 policy := PermissionPolicy(participation.Spec{ Version: participation.SpecVersion, Mode: participation.ModeLive, diff --git a/internal/daemon/agent_skill_resources_integration_test.go b/internal/daemon/agent_skill_resources_integration_test.go index c22de1f06..fe4965be1 100644 --- a/internal/daemon/agent_skill_resources_integration_test.go +++ b/internal/daemon/agent_skill_resources_integration_test.go @@ -28,214 +28,230 @@ import ( ) func TestAgentSkillPublicationAndBootRebuild(t *testing.T) { - db := openDaemonTestGlobalDB(t) - kernel, err := resources.NewKernel(db.DB()) - if err != nil { - t.Fatalf("resources.NewKernel() error = %v", err) - } - - agentCodec, err := aghconfig.NewAgentResourceCodec() - if err != nil { - t.Fatalf("aghconfig.NewAgentResourceCodec() error = %v", err) - } - agentStore, err := resources.NewStore(kernel, agentCodec) - if err != nil { - t.Fatalf("resources.NewStore(agent) error = %v", err) - } - skillCodec, err := skillspkg.NewResourceCodec() - if err != nil { - t.Fatalf("skillspkg.NewResourceCodec() error = %v", err) - } - skillStore, err := resources.NewStore(kernel, skillCodec) - if err != nil { - t.Fatalf("resources.NewStore(skill) error = %v", err) - } - mcpCodec, err := aghconfig.NewMCPServerResourceCodec() - if err != nil { - t.Fatalf("aghconfig.NewMCPServerResourceCodec() error = %v", err) - } - mcpStore, err := resources.NewStore(kernel, mcpCodec) - if err != nil { - t.Fatalf("resources.NewStore(mcp) error = %v", err) - } + t.Run("Should publish agent and skill resources and rebuild them on boot", func(t *testing.T) { + db := openDaemonTestGlobalDB(t) + kernel, err := resources.NewKernel(db.DB()) + if err != nil { + t.Fatalf("resources.NewKernel() error = %v", err) + } - homePaths := agentSkillIntegrationHome(t) - workspaceRoot := agentSkillIntegrationWorkspace(t) - now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) - workspace := workspacepkg.Workspace{ - ID: "ws_agent_skill", - RootDir: workspaceRoot, - Name: "agent-skill", - DefaultAgent: "coder", - CreatedAt: now, - UpdatedAt: now, - } - if err := db.InsertWorkspace(testutil.Context(t), workspace); err != nil { - t.Fatalf("InsertWorkspace() error = %v", err) - } - workspaceResolver, err := workspacepkg.NewResolver( - db, - workspacepkg.WithHomePaths(homePaths), - workspacepkg.WithLogger(discardLogger()), - ) - if err != nil { - t.Fatalf("workspace.NewResolver() error = %v", err) - } + agentCodec, err := aghconfig.NewAgentResourceCodec() + if err != nil { + t.Fatalf("aghconfig.NewAgentResourceCodec() error = %v", err) + } + agentStore, err := resources.NewStore(kernel, agentCodec) + if err != nil { + t.Fatalf("resources.NewStore(agent) error = %v", err) + } + skillCodec, err := skillspkg.NewResourceCodec() + if err != nil { + t.Fatalf("skillspkg.NewResourceCodec() error = %v", err) + } + skillStore, err := resources.NewStore(kernel, skillCodec) + if err != nil { + t.Fatalf("resources.NewStore(skill) error = %v", err) + } + mcpCodec, err := aghconfig.NewMCPServerResourceCodec() + if err != nil { + t.Fatalf("aghconfig.NewMCPServerResourceCodec() error = %v", err) + } + mcpStore, err := resources.NewStore(kernel, mcpCodec) + if err != nil { + t.Fatalf("resources.NewStore(mcp) error = %v", err) + } - extensionRegistry := extensionpkg.NewRegistry(db.DB()) - extensionSnapshot := agentSkillIntegrationExtension(t, extensionRegistry) - runtime := &agentSkillIntegrationRuntime{extension: extensionSnapshot} + homePaths := agentSkillIntegrationHome(t) + workspaceRoot := agentSkillIntegrationWorkspace(t) + now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + workspace := workspacepkg.Workspace{ + ID: "ws_agent_skill", + RootDir: workspaceRoot, + Name: "agent-skill", + DefaultAgent: "coder", + CreatedAt: now, + UpdatedAt: now, + } + if err := db.InsertWorkspace(testutil.Context(t), workspace); err != nil { + t.Fatalf("InsertWorkspace() error = %v", err) + } + workspaceResolver, err := workspacepkg.NewResolver( + db, + workspacepkg.WithHomePaths(homePaths), + workspacepkg.WithLogger(discardLogger()), + ) + if err != nil { + t.Fatalf("workspace.NewResolver() error = %v", err) + } - initialAgentCatalog := newResourceCatalog(cloneAgentDef) - initialSkillRegistry := skillspkg.NewRegistry( - agentSkillIntegrationSkillConfig(homePaths), - skillspkg.WithLogger(discardLogger()), - ) - initialMCPCatalog := newResourceCatalog(cloneDaemonMCPServer) - driver := newAgentSkillIntegrationDriver( - t, - kernel, - agentCodec, - skillCodec, - mcpCodec, - initialAgentCatalog, - initialSkillRegistry, - initialMCPCatalog, - ) + extensionRegistry := extensionpkg.NewRegistry(db.DB()) + extensionSnapshot := agentSkillIntegrationExtension(t, extensionRegistry) + runtime := &agentSkillIntegrationRuntime{extension: extensionSnapshot} - syncer := newAgentSkillSourceSyncer( - kernel, - agentStore, - agentCodec, - newAgentProjector(initialAgentCatalog), - skillStore, - skillCodec, - newSkillProjector(initialSkillRegistry), - mcpStore, - mcpCodec, - agentSkillSyncActor(), - discardLogger(), - func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error { - return driver.Trigger(ctx, kind, reason) - }, - daemonAgentSkillDeclarationProvider( - homePaths, - db, - workspaceResolver, + initialAgentCatalog := newResourceCatalog(cloneAgentDef) + initialSkillRegistry := skillspkg.NewRegistry( + agentSkillIntegrationSkillConfig(homePaths), + skillspkg.WithLogger(discardLogger()), + ) + initialMCPCatalog := newResourceCatalog(cloneDaemonMCPServer) + driver := newAgentSkillIntegrationDriver( + t, + kernel, + agentCodec, + skillCodec, + mcpCodec, + initialAgentCatalog, initialSkillRegistry, + initialMCPCatalog, + ) + + syncer := newAgentSkillSourceSyncer( + kernel, + agentStore, + agentCodec, + newAgentProjector(initialAgentCatalog), + skillStore, + skillCodec, + newSkillProjector(initialSkillRegistry), + mcpStore, + mcpCodec, + agentSkillSyncActor(), discardLogger(), - ), - extensionAgentSkillDeclarationProvider( - extensionRegistry, - func() extensionRuntime { return runtime }, - discardLogger(), - ), - ) - if err := syncer.Sync(testutil.Context(t)); err != nil { - t.Fatalf("syncer.Sync() error = %v", err) - } + func(ctx context.Context, kind resources.ResourceKind, reason resources.ReconcileReason) error { + return driver.Trigger(ctx, kind, reason) + }, + daemonAgentSkillDeclarationProvider( + homePaths, + db, + workspaceResolver, + initialSkillRegistry, + discardLogger(), + ), + extensionAgentSkillDeclarationProvider( + extensionRegistry, + func() extensionRuntime { return runtime }, + discardLogger(), + ), + ) + if err := syncer.Sync(testutil.Context(t)); err != nil { + t.Fatalf("syncer.Sync() error = %v", err) + } - source := agentSkillSyncActor().Source - agents, err := agentStore.List( - testutil.Context(t), - agentSkillSyncActor(), - resources.ResourceFilter{Source: &source}, - ) - if err != nil { - t.Fatalf("agentStore.List() error = %v", err) - } - if got, want := len(agents), 2; got != want { - t.Fatalf("len(agentStore.List()) = %d, want %d (%#v)", got, want, agents) - } - skills, err := skillStore.List( - testutil.Context(t), - agentSkillSyncActor(), - resources.ResourceFilter{Source: &source}, - ) - if err != nil { - t.Fatalf("skillStore.List() error = %v", err) - } - if got, want := len(skills), 2; got != want { - t.Fatalf("len(skillStore.List()) = %d, want %d (%#v)", got, want, skills) - } - servers, err := mcpStore.List(testutil.Context(t), agentSkillSyncActor(), resources.ResourceFilter{Source: &source}) - if err != nil { - t.Fatalf("mcpStore.List() error = %v", err) - } - if got, want := len(servers), 4; got != want { - t.Fatalf("len(mcpStore.List()) = %d, want %d (%#v)", got, want, servers) - } - if err := syncer.Sync(testutil.Context(t)); err != nil { - t.Fatalf("second syncer.Sync() error = %v", err) - } + source := agentSkillSyncActor().Source + agents, err := agentStore.List( + testutil.Context(t), + agentSkillSyncActor(), + resources.ResourceFilter{Source: &source}, + ) + if err != nil { + t.Fatalf("agentStore.List() error = %v", err) + } + if got, want := len(agents), 2; got != want { + t.Fatalf("len(agentStore.List()) = %d, want %d (%#v)", got, want, agents) + } + skills, err := skillStore.List( + testutil.Context(t), + agentSkillSyncActor(), + resources.ResourceFilter{Source: &source}, + ) + if err != nil { + t.Fatalf("skillStore.List() error = %v", err) + } + if got, want := len(skills), 2; got != want { + t.Fatalf("len(skillStore.List()) = %d, want %d (%#v)", got, want, skills) + } + servers, err := mcpStore.List(testutil.Context(t), agentSkillSyncActor(), resources.ResourceFilter{Source: &source}) + if err != nil { + t.Fatalf("mcpStore.List() error = %v", err) + } + if got, want := len(servers), 4; got != want { + t.Fatalf("len(mcpStore.List()) = %d, want %d (%#v)", got, want, servers) + } + if err := syncer.Sync(testutil.Context(t)); err != nil { + t.Fatalf("second syncer.Sync() error = %v", err) + } - rebuiltAgentCatalog := newResourceCatalog(cloneAgentDef) - rebuiltSkillRegistry := skillspkg.NewRegistry( - agentSkillIntegrationSkillConfig(homePaths), - skillspkg.WithLogger(discardLogger()), - ) - rebuiltMCPCatalog := newResourceCatalog(cloneDaemonMCPServer) - bootDriver := newAgentSkillIntegrationDriver( - t, - kernel, - agentCodec, - skillCodec, - mcpCodec, - rebuiltAgentCatalog, - rebuiltSkillRegistry, - rebuiltMCPCatalog, - ) - if err := bootDriver.RunBoot(testutil.Context(t)); err != nil { - t.Fatalf("bootDriver.RunBoot() error = %v", err) - } + rebuiltAgentCatalog := newResourceCatalog(cloneAgentDef) + rebuiltSkillRegistry := skillspkg.NewRegistry( + agentSkillIntegrationSkillConfig(homePaths), + skillspkg.WithLogger(discardLogger()), + ) + rebuiltMCPCatalog := newResourceCatalog(cloneDaemonMCPServer) + bootDriver := newAgentSkillIntegrationDriver( + t, + kernel, + agentCodec, + skillCodec, + mcpCodec, + rebuiltAgentCatalog, + rebuiltSkillRegistry, + rebuiltMCPCatalog, + ) + if err := bootDriver.RunBoot(testutil.Context(t)); err != nil { + t.Fatalf("bootDriver.RunBoot() error = %v", err) + } - resolved, err := workspaceResolver.Resolve(testutil.Context(t), workspace.ID) - if err != nil { - t.Fatalf("workspaceResolver.Resolve() error = %v", err) - } - agentCatalog := agentCatalogDependency(rebuiltAgentCatalog) - coder, err := agentCatalog.ResolveAgent("coder", &resolved) - if err != nil { - t.Fatalf("ResolveAgent(coder) error = %v", err) - } - if !slices.Contains(coder.Tools, "agh__lookup") { - t.Fatalf("ResolveAgent(coder).Tools = %#v, want canonical lookup tool reference preserved", coder.Tools) - } - if !agentHasMCP(coder, "workspace-agent-mcp") { - t.Fatalf("ResolveAgent(coder).MCPServers = %#v, want workspace-agent-mcp", coder.MCPServers) - } - extAgent, err := agentCatalog.ResolveAgent("ext-agent", &resolved) - if err != nil { - t.Fatalf("ResolveAgent(ext-agent) error = %v", err) - } - if !agentHasMCP(extAgent, "ext-agent-mcp") { - t.Fatalf("ResolveAgent(ext-agent).MCPServers = %#v, want ext-agent-mcp", extAgent.MCPServers) - } + resolved, err := workspaceResolver.Resolve(testutil.Context(t), workspace.ID) + if err != nil { + t.Fatalf("workspaceResolver.Resolve() error = %v", err) + } + agentCatalog := agentCatalogDependency(rebuiltAgentCatalog) + coder, err := agentCatalog.ResolveAgent("coder", &resolved) + if err != nil { + t.Fatalf("ResolveAgent(coder) error = %v", err) + } + if !slices.Contains(coder.Tools, "agh__lookup") { + t.Fatalf("ResolveAgent(coder).Tools = %#v, want canonical lookup tool reference preserved", coder.Tools) + } + if !agentHasMCP(coder, "workspace-agent-mcp") { + t.Fatalf("ResolveAgent(coder).MCPServers = %#v, want workspace-agent-mcp", coder.MCPServers) + } + extAgent, err := agentCatalog.ResolveAgent("ext-agent", &resolved) + if err != nil { + t.Fatalf("ResolveAgent(ext-agent) error = %v", err) + } + if !agentHasMCP(extAgent, "ext-agent-mcp") { + t.Fatalf("ResolveAgent(ext-agent).MCPServers = %#v, want ext-agent-mcp", extAgent.MCPServers) + } + resolved.Config.Providers["claude"] = aghconfig.ProviderConfig{Command: "claude-acp"} + resolved.Config.Roles.Dream.Agent = "ext-agent" + roleResolver := newRoleResolver( + &resolved.Config, + roleWorkspaceResolverStub{configs: map[string]aghconfig.Config{workspace.ID: resolved.Config}}, + agentCatalog, + ) + resolvedRole, err := roleResolver.Resolve(testutil.Context(t), workspace.ID, aghconfig.RoleDream) + if err != nil { + t.Fatalf("roleResolver.Resolve(dream) error = %v", err) + } + if resolvedRole.AgentName != "ext-agent" || !agentHasMCP(resolvedRole.AgentDef, "ext-agent-mcp") { + t.Fatalf("roleResolver.Resolve(dream) = %#v, want projected ext-agent resource", resolvedRole) + } - projectedSkills, err := rebuiltSkillRegistry.ForWorkspace(testutil.Context(t), &resolved) - if err != nil { - t.Fatalf("rebuiltSkillRegistry.ForWorkspace() error = %v", err) - } - review := findIntegrationSkill(projectedSkills, "workspace-review") - if review == nil { - t.Fatalf("ForWorkspace() = %#v, want workspace-review", projectedSkills) - } - if !skillHasMCP(review, "workspace-skill-mcp") { - t.Fatalf("workspace-review MCPServers = %#v, want workspace-skill-mcp", review.MCPServers) - } - extSkill := findIntegrationSkill(projectedSkills, "ext-skill") - if extSkill == nil { - t.Fatalf("ForWorkspace() = %#v, want ext-skill", projectedSkills) - } - if !skillHasMCP(extSkill, "ext-skill-mcp") { - t.Fatalf("ext-skill MCPServers = %#v, want ext-skill-mcp", extSkill.MCPServers) - } - if !mcpCatalogHas(rebuiltMCPCatalog, "workspace-agent-mcp") || - !mcpCatalogHas(rebuiltMCPCatalog, "workspace-skill-mcp") || - !mcpCatalogHas(rebuiltMCPCatalog, "ext-agent-mcp") || - !mcpCatalogHas(rebuiltMCPCatalog, "ext-skill-mcp") { - t.Fatalf("rebuilt MCP catalog = %#v, want all agent/skill MCP attachments", rebuiltMCPCatalog.Snapshot()) - } + projectedSkills, err := rebuiltSkillRegistry.ForWorkspace(testutil.Context(t), &resolved) + if err != nil { + t.Fatalf("rebuiltSkillRegistry.ForWorkspace() error = %v", err) + } + review := findIntegrationSkill(projectedSkills, "workspace-review") + if review == nil { + t.Fatalf("ForWorkspace() = %#v, want workspace-review", projectedSkills) + } + if !skillHasMCP(review, "workspace-skill-mcp") { + t.Fatalf("workspace-review MCPServers = %#v, want workspace-skill-mcp", review.MCPServers) + } + extSkill := findIntegrationSkill(projectedSkills, "ext-skill") + if extSkill == nil { + t.Fatalf("ForWorkspace() = %#v, want ext-skill", projectedSkills) + } + if !skillHasMCP(extSkill, "ext-skill-mcp") { + t.Fatalf("ext-skill MCPServers = %#v, want ext-skill-mcp", extSkill.MCPServers) + } + if !mcpCatalogHas(rebuiltMCPCatalog, "workspace-agent-mcp") || + !mcpCatalogHas(rebuiltMCPCatalog, "workspace-skill-mcp") || + !mcpCatalogHas(rebuiltMCPCatalog, "ext-agent-mcp") || + !mcpCatalogHas(rebuiltMCPCatalog, "ext-skill-mcp") { + t.Fatalf("rebuilt MCP catalog = %#v, want all agent/skill MCP attachments", rebuiltMCPCatalog.Snapshot()) + } + }) } func TestAgentDefinitionMutationLifecycleIntegration(t *testing.T) { diff --git a/internal/daemon/auto_title_generator.go b/internal/daemon/auto_title_generator.go index a44502cf5..479aca811 100644 --- a/internal/daemon/auto_title_generator.go +++ b/internal/daemon/auto_title_generator.go @@ -9,6 +9,7 @@ import ( "time" "github.com/compozy/agh/internal/acp" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/session" ) @@ -33,27 +34,32 @@ type autoTitleGenerator interface { type autoTitleRequest struct { SessionID string AgentName string + WorkspaceID string UserMessage string AssistantReply string } type forkedAutoTitleGenerator struct { sessions autoTitleSpawnSessions + roles RoleResolver deadline time.Duration logger *slog.Logger } func newForkedAutoTitleGenerator( sessions autoTitleSpawnSessions, + roles RoleResolver, deadline time.Duration, logger *slog.Logger, ) *forkedAutoTitleGenerator { if logger == nil { logger = slog.Default() } - return &forkedAutoTitleGenerator{sessions: sessions, deadline: deadline, logger: logger} + return &forkedAutoTitleGenerator{sessions: sessions, roles: roles, deadline: deadline, logger: logger} } +var errAutoTitleRoleDisabled = errors.New("daemon: automatic title role is disabled") + func (g *forkedAutoTitleGenerator) Generate( ctx context.Context, request autoTitleRequest, @@ -64,45 +70,60 @@ func (g *forkedAutoTitleGenerator) Generate( if ctx == nil { return "", errors.New("daemon: automatic title context is required") } - runCtx := ctx - if g.deadline > 0 { - var cancel context.CancelFunc - runCtx, cancel = context.WithTimeout(runCtx, g.deadline) - defer cancel() + if g.roles == nil { + return "", errors.New("daemon: automatic title role resolver is not configured") + } + correlation := roleInvocationCorrelation{ + WorkspaceID: strings.TrimSpace(request.WorkspaceID), + SessionID: strings.TrimSpace(request.SessionID), + AgentName: strings.TrimSpace(request.AgentName), + } + roleCtx := withRoleInvocationCorrelation(ctx, correlation) + role, err := g.roles.Resolve(roleCtx, request.WorkspaceID, aghconfig.RoleAutoTitle) + if err != nil { + return "", fmt.Errorf("daemon: resolve automatic title role: %w", err) + } + if !role.Enabled { + return "", errAutoTitleRoleDisabled } + agentName := role.AgentName + if role.Inherit { + agentName = strings.TrimSpace(request.AgentName) + } + runCtx, cancelRun := autoTitleRunContext(ctx, g.deadline) + defer cancelRun() prompt := renderAutoTitlePrompt(request) - child, err := g.sessions.Spawn(runCtx, session.SpawnOpts{ - ParentSessionID: strings.TrimSpace(request.SessionID), - AgentName: strings.TrimSpace(request.AgentName), - Name: autoTitleSessionName, - PromptOverlay: autoTitlePromptOverlay(), - SpawnRole: session.SpawnRoleAutoTitle, - TTL: g.childTTL(), - AutoStopOnParent: true, + role.AgentName = agentName + child, err := invokeRoleWithFallback(runCtx, role, correlation, func( + attemptCtx context.Context, + route roleAttemptRoute, + ) (*session.Session, bool, error) { + spawned, spawnErr := g.sessions.Spawn(attemptCtx, session.SpawnOpts{ + ParentSessionID: strings.TrimSpace(request.SessionID), + AgentName: route.AgentName, + Provider: route.Provider, + Model: route.Model, + ReasoningEffort: route.ReasoningEffort, + Name: autoTitleSessionName, + PromptOverlay: autoTitlePromptOverlay(), + SpawnRole: session.SpawnRoleAutoTitle, + TTL: g.childTTL(), + AutoStopOnParent: true, + }) + return spawned, spawned != nil, spawnErr }) + if child != nil { + defer g.stopAutoTitleSession(ctx, child.ID, &title, &err) + } if err != nil { return "", fmt.Errorf("daemon: spawn automatic title session: %w", err) } - defer func() { - cause := session.CauseCompleted - detail := "automatic title generation completed" - if err != nil { - cause = session.CauseFailed - detail = "automatic title generation failed" - } - stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), autoTitleStopTimeout) - defer cancel() - if stopErr := g.sessions.StopWithCause(stopCtx, child.ID, cause, detail); stopErr != nil { - title = "" - err = errors.Join(err, fmt.Errorf("daemon: stop automatic title session: %w", stopErr)) - } - }() events, err := g.sessions.PromptSynthetic(runCtx, child.ID, session.SyntheticPromptOpts{ Message: prompt, Metadata: acp.PromptSyntheticMeta{ TaskID: autoTitleSyntheticTaskID, - Reason: "auto_title", + Reason: string(aghconfig.RoleAutoTitle), Summary: "generate a concise session title", }, }) @@ -120,6 +141,33 @@ func (g *forkedAutoTitleGenerator) Generate( return title, nil } +func autoTitleRunContext(ctx context.Context, deadline time.Duration) (context.Context, context.CancelFunc) { + if deadline > 0 { + return context.WithTimeout(ctx, deadline) + } + return context.WithCancel(ctx) +} + +func (g *forkedAutoTitleGenerator) stopAutoTitleSession( + ctx context.Context, + sessionID string, + title *string, + operationErr *error, +) { + cause := session.CauseCompleted + detail := "automatic title generation completed" + if *operationErr != nil { + cause = session.CauseFailed + detail = "automatic title generation failed" + } + stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), autoTitleStopTimeout) + defer cancel() + if err := g.sessions.StopWithCause(stopCtx, sessionID, cause, detail); err != nil { + *title = "" + *operationErr = errors.Join(*operationErr, fmt.Errorf("daemon: stop automatic title session: %w", err)) + } +} + func (g *forkedAutoTitleGenerator) childTTL() time.Duration { if g != nil && g.deadline > 0 { return g.deadline + autoTitleStopTimeout diff --git a/internal/daemon/auto_title_role_integration_test.go b/internal/daemon/auto_title_role_integration_test.go new file mode 100644 index 000000000..37a92d791 --- /dev/null +++ b/internal/daemon/auto_title_role_integration_test.go @@ -0,0 +1,396 @@ +//go:build integration && !windows + +package daemon + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + "time" + + acpsdk "github.com/coder/acp-go-sdk" + aghcontract "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" + sessionpkg "github.com/compozy/agh/internal/session" + "github.com/compozy/agh/internal/store" + "github.com/compozy/agh/internal/store/globaldb" + "github.com/compozy/agh/internal/testutil/acpmock" + e2etest "github.com/compozy/agh/internal/testutil/e2e" +) + +func TestAutoTitleRoleLiveToggleIntegration(t *testing.T) { + t.Parallel() + + t.Run("Should apply auto-title role toggles without a daemon restart", func(t *testing.T) { + t.Parallel() + runAutoTitleRoleLiveToggleIntegration(t) + }) + + t.Run("Should fall back after a pre-acceptance provider failure and expose the durable attempt", func(t *testing.T) { + t.Parallel() + runAutoTitleRoleFallbackIntegration(t) + }) + + t.Run("Should clean up every exhausted pre-acceptance attempt", func(t *testing.T) { + t.Parallel() + runAutoTitleRoleExhaustionIntegration(t) + }) + + t.Run("Should stop fallback forever after an accepted attempt fails", func(t *testing.T) { + t.Parallel() + runAutoTitleRolePostAcceptanceFailureIntegration(t) + }) +} + +func runAutoTitleRoleExhaustionIntegration(t *testing.T) { + t.Helper() + + acpmock.RequireDriver(t) + + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{Mutate: func(cfg *aghconfig.Config) { + for _, provider := range []string{"unreachable-primary", "unreachable-fallback"} { + cfg.Providers[provider] = aghconfig.ProviderConfig{ + Command: "/missing/" + provider, + Harness: aghconfig.ProviderHarnessACP, + AuthMode: aghconfig.ProviderAuthModeNone, + NoneSecurity: aghconfig.ProviderNoneSecurityLocalTransport, + } + } + cfg.Roles.AutoTitle.Provider = "unreachable-primary" + cfg.Roles.AutoTitle.Model = "primary-model" + cfg.Roles.AutoTitle.FallbackChain = []aghconfig.RoleFallback{{ + Provider: "unreachable-fallback", + Model: "fallback-model", + }} + cfg.Roles.MemoryExtractor.Enabled = false + }}, + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "auto_title_fixture.json"), + FixtureAgent: "auto-title-agent", + AgentName: "auto-title-agent", + }}, + }) + registration, ok := harness.MockAgentRegistration("auto-title-agent") + if !ok { + t.Fatal("MockAgentRegistration(auto-title-agent) = missing, want present") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + root := createFixtureBackedSession(t, ctx, harness, "auto-title-agent", "") + if _, err := harness.PromptSession(ctx, root.ID, "Implement checkout retry fencing"); err != nil { + t.Fatalf("PromptSession(exhausted role) error = %v", err) + } + + logsPath := "/api/logs?workspace_id=" + url.QueryEscape(harness.WorkspaceID) + + "&type=role.fallback.used&limit=10" + waitForRuntimeCondition(t, "exhausted role cleanup", 10*time.Second, func() bool { + var logs aghcontract.LogsListResponse + if err := harness.UDSJSON(ctx, http.MethodGet, logsPath, nil, &logs); err != nil || len(logs.Events) != 1 { + return false + } + sessions := readAutoTitleRoleSessions(t, ctx, harness) + return len(sessions) == 1 && sessions[0].ID == root.ID && sessions[0].State == string(sessionpkg.StateActive) + }) + + records, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(exhausted role) error = %v", err) + } + if got := len(acpmock.PromptDiagnostics(records)); got != 1 { + t.Fatalf("exhausted role prompt count = %d, want only the root prompt", got) + } + current, err := harness.GetSession(ctx, root.ID) + if err != nil { + t.Fatalf("GetSession(exhausted role root) error = %v", err) + } + if current.Name != "" { + t.Fatalf("exhausted role root title = %q, want unchanged", current.Name) + } +} + +func runAutoTitleRolePostAcceptanceFailureIntegration(t *testing.T) { + t.Helper() + + acpmock.RequireDriver(t) + + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{Mutate: func(cfg *aghconfig.Config) { + cfg.Roles.AutoTitle.Provider = acpmock.ProviderName + cfg.Roles.AutoTitle.Model = "primary-title-model" + cfg.Roles.AutoTitle.FallbackChain = []aghconfig.RoleFallback{{ + Provider: acpmock.ProviderName, + Model: "fallback-title-model", + }} + cfg.Roles.MemoryExtractor.Enabled = false + }}, + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "auto_title_fixture.json"), + FixtureAgent: "auto-title-agent", + AgentName: "auto-title-agent", + }}, + }) + registration, ok := harness.MockAgentRegistration("auto-title-agent") + if !ok { + t.Fatal("MockAgentRegistration(auto-title-agent) = missing, want present") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + root := createFixtureBackedSession(t, ctx, harness, "auto-title-agent", "") + if _, err := harness.PromptSession(ctx, root.ID, "Implement post-acceptance fencing"); err != nil { + t.Fatalf("PromptSession(post-acceptance failure) error = %v", err) + } + + var child store.SessionInfo + waitForRuntimeCondition(t, "accepted role child stopped", 10*time.Second, func() bool { + sessions := readAutoTitleRoleSessions(t, ctx, harness) + active := 0 + foundChild := false + for _, candidate := range sessions { + if candidate.State == string(sessionpkg.StateActive) { + active++ + } + if candidate.Lineage != nil && candidate.Lineage.SpawnRole == sessionpkg.SpawnRoleAutoTitle { + child = candidate + foundChild = candidate.State == string(sessionpkg.StateStopped) + } + } + return foundChild && active == 1 + }) + + var logs aghcontract.LogsListResponse + logsPath := "/api/logs?workspace_id=" + url.QueryEscape(harness.WorkspaceID) + + "&type=role.fallback.used&limit=10" + if err := harness.UDSJSON(ctx, http.MethodGet, logsPath, nil, &logs); err != nil { + t.Fatalf("UDS post-acceptance fallback logs error = %v", err) + } + if len(logs.Events) != 0 { + t.Fatalf("post-acceptance fallback events = %#v, want none", logs.Events) + } + + records, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(post-acceptance failure) error = %v", err) + } + childPrompts := 0 + for _, record := range acpmock.ProtocolDiagnostics(acpmock.DiagnosticsForAGHSession(records, child.ID)) { + if record.ProtocolMethod == acpsdk.AgentMethodSessionPrompt { + childPrompts++ + } + } + if childPrompts != 1 { + t.Fatalf("post-acceptance synthetic prompt attempts = %d, want exactly 1", childPrompts) + } + current, err := harness.GetSession(ctx, root.ID) + if err != nil { + t.Fatalf("GetSession(post-acceptance root) error = %v", err) + } + if current.Name != "" { + t.Fatalf("post-acceptance root title = %q, want unchanged", current.Name) + } +} + +func readAutoTitleRoleSessions( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, +) []store.SessionInfo { + t.Helper() + + db, err := globaldb.OpenGlobalDB(ctx, harness.HomePaths.DatabaseFile) + if err != nil { + t.Fatalf("OpenGlobalDB(auto-title role) error = %v", err) + } + sessions, listErr := db.ListSessions(ctx, store.SessionListQuery{ + WorkspaceID: harness.WorkspaceID, + Limit: 100, + }) + closeErr := db.Close(ctx) + if listErr != nil { + t.Fatalf("ListSessions(auto-title role) error = %v", listErr) + } + if closeErr != nil { + t.Fatalf("Close(auto-title role global db) error = %v", closeErr) + } + return sessions +} + +func runAutoTitleRoleFallbackIntegration(t *testing.T) { + t.Helper() + + acpmock.RequireDriver(t) + + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{Mutate: func(cfg *aghconfig.Config) { + cfg.Providers["unreachable-role-provider"] = aghconfig.ProviderConfig{ + Command: "/missing/agh-role-provider", + Harness: aghconfig.ProviderHarnessACP, + AuthMode: aghconfig.ProviderAuthModeNone, + NoneSecurity: aghconfig.ProviderNoneSecurityLocalTransport, + } + cfg.Roles.AutoTitle.Provider = "unreachable-role-provider" + cfg.Roles.AutoTitle.Model = "unreachable-model" + cfg.Roles.AutoTitle.FallbackChain = []aghconfig.RoleFallback{{ + Provider: acpmock.ProviderName, + Model: "fallback-title-model", + }} + cfg.Roles.MemoryExtractor.Enabled = false + }}, + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "auto_title_fixture.json"), + FixtureAgent: "auto-title-agent", + AgentName: "auto-title-agent", + }}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + session := createFixtureBackedSession(t, ctx, harness, "auto-title-agent", "") + if _, err := harness.PromptSession(ctx, session.ID, "Implement checkout retry fencing"); err != nil { + t.Fatalf("PromptSession(role fallback) error = %v", err) + } + waitForRuntimeCondition(t, "fallback auto-title applied", 10*time.Second, func() bool { + current, err := harness.GetSession(ctx, session.ID) + return err == nil && current.Name == "Checkout Retry Fencing" + }) + + var roleResponse aghcontract.RoleStatusResponse + rolePath := "/api/roles/auto_title?workspace=" + url.QueryEscape(harness.WorkspaceRoot) + if err := harness.UDSJSON(ctx, http.MethodGet, rolePath, nil, &roleResponse); err != nil { + t.Fatalf("UDS auto_title role status error = %v", err) + } + if len(roleResponse.Role.Diagnostics) != 0 { + t.Fatalf("auto_title diagnostics = %#v, want success path unaffected", roleResponse.Role.Diagnostics) + } + + var logs aghcontract.LogsListResponse + logsPath := "/api/logs?workspace_id=" + url.QueryEscape(harness.WorkspaceID) + + "&type=role.fallback.used&limit=10" + waitForRuntimeCondition(t, "durable role fallback event", 10*time.Second, func() bool { + logs = aghcontract.LogsListResponse{} + return harness.UDSJSON(ctx, http.MethodGet, logsPath, nil, &logs) == nil && len(logs.Events) == 1 + }) + event := logs.Events[0] + if event.Type != "role.fallback.used" || event.WorkspaceID != harness.WorkspaceID || event.SessionID != session.ID { + t.Fatalf("fallback event correlation = %#v, want workspace and parent session", event) + } + var content roleFallbackEventPayload + if err := json.Unmarshal(event.Content, &content); err != nil { + t.Fatalf("json.Unmarshal(fallback event content) error = %v", err) + } + if content.Role != string(aghconfig.RoleAutoTitle) || content.Attempt != 1 || + content.Provider != acpmock.ProviderName || content.Model != "fallback-title-model" { + t.Fatalf("fallback event content = %#v, want ordered acpmock attempt", content) + } +} + +func runAutoTitleRoleLiveToggleIntegration(t *testing.T) { + t.Helper() + + acpmock.RequireDriver(t) + + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{Mutate: func(cfg *aghconfig.Config) { + cfg.Roles.AutoTitle.Enabled = false + cfg.Roles.MemoryExtractor.Enabled = false + }}, + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "auto_title_fixture.json"), + FixtureAgent: "auto-title-agent", + AgentName: "auto-title-agent", + }}, + }) + registration, ok := harness.MockAgentRegistration("auto-title-agent") + if !ok { + t.Fatal("MockAgentRegistration(auto-title-agent) = missing, want present") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + disabled := createFixtureBackedSession(t, ctx, harness, "auto-title-agent", "") + waitForRuntimeCondition(t, "disabled auto-title session active", 10*time.Second, func() bool { + current, getErr := harness.GetSession(ctx, disabled.ID) + return getErr == nil && current.State == sessionpkg.StateActive + }) + if _, err := harness.PromptSession(ctx, disabled.ID, "Implement checkout retry fencing"); err != nil { + t.Fatalf("PromptSession(disabled auto-title) error = %v", err) + } + assertAutoTitlePromptCountRemains(t, registration.DiagnosticsPath, 1, 750*time.Millisecond) + currentDisabled, err := harness.GetSession(ctx, disabled.ID) + if err != nil { + t.Fatalf("GetSession(disabled auto-title) error = %v", err) + } + if got, want := currentDisabled.Name, ""; got != want { + t.Fatalf("disabled session name = %q, want %q", got, want) + } + + var applyResult map[string]any + if err := harness.CLI.RunJSON( + ctx, + &applyResult, + "config", + "set", + "roles.auto_title.enabled", + "true", + "-o", + "json", + ); err != nil { + t.Fatalf("CLI config set roles.auto_title.enabled=true error = %v", err) + } + if applied, ok := applyResult["applied"].(bool); !ok || !applied { + t.Fatalf("CLI config set apply result = %#v, want applied=true", applyResult) + } + + enabled := createFixtureBackedSession(t, ctx, harness, "auto-title-agent", "") + waitForRuntimeCondition(t, "enabled auto-title session active", 10*time.Second, func() bool { + current, getErr := harness.GetSession(ctx, enabled.ID) + return getErr == nil && current.State == sessionpkg.StateActive + }) + if _, err := harness.PromptSession(ctx, enabled.ID, "Implement checkout retry fencing"); err != nil { + t.Fatalf("PromptSession(enabled auto-title) error = %v", err) + } + waitForRuntimeCondition(t, "enabled auto-title applied", 10*time.Second, func() bool { + current, getErr := harness.GetSession(ctx, enabled.ID) + return getErr == nil && current.Name == "Checkout Retry Fencing" + }) + + records, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(auto-title) error = %v", err) + } + if got, want := len(acpmock.PromptDiagnostics(records)), 3; got != want { + t.Fatalf("prompt diagnostics = %#v, want two user prompts and one live-enabled title prompt", records) + } +} + +func assertAutoTitlePromptCountRemains( + t testing.TB, + diagnosticsPath string, + want int, + duration time.Duration, +) { + t.Helper() + deadline := time.NewTimer(duration) + defer deadline.Stop() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + + for { + records, err := acpmock.ReadDiagnostics(diagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(auto-title disabled) error = %v", err) + } + if got := len(acpmock.PromptDiagnostics(records)); got != want { + t.Fatalf("disabled auto-title prompt count = %d, want stable %d", got, want) + } + select { + case <-deadline.C: + return + case <-ticker.C: + } + } +} diff --git a/internal/daemon/auto_title_runtime.go b/internal/daemon/auto_title_runtime.go index d869fb6bf..a1a93d37a 100644 --- a/internal/daemon/auto_title_runtime.go +++ b/internal/daemon/auto_title_runtime.go @@ -44,6 +44,7 @@ type autoTitleRuntime struct { type autoTitleJob struct { sessionID string agentName string + workspaceID string turnID string assistantText string } @@ -105,6 +106,7 @@ func (r *autoTitleRuntime) HandleSessionMessagePersisted( job := autoTitleJob{ sessionID: strings.TrimSpace(payload.SessionID), agentName: strings.TrimSpace(payload.AgentName), + workspaceID: firstNonEmptyString(payload.WorkspaceID, payload.Workspace), turnID: strings.TrimSpace(payload.TurnID), assistantText: strings.TrimSpace(payload.Text), } @@ -227,10 +229,14 @@ func (r *autoTitleRuntime) process(ctx context.Context, job autoTitleJob) { title, err := r.generator.Generate(jobCtx, autoTitleRequest{ SessionID: job.sessionID, AgentName: job.agentName, + WorkspaceID: job.workspaceID, UserMessage: userMessage, AssistantReply: job.assistantText, }) if err != nil { + if errors.Is(err, errAutoTitleRoleDisabled) { + return + } r.warn(job, "generate title", err) return } diff --git a/internal/daemon/auto_title_runtime_test.go b/internal/daemon/auto_title_runtime_test.go index 63a564766..5c9aa54d4 100644 --- a/internal/daemon/auto_title_runtime_test.go +++ b/internal/daemon/auto_title_runtime_test.go @@ -21,21 +21,31 @@ import ( func TestBootAutoTitleRuntime(t *testing.T) { t.Parallel() - t.Run("Should leave the runtime and cleanup unconfigured when automatic titles are disabled", func(t *testing.T) { + t.Run("Should keep the runtime configured when automatic titles are disabled", func(t *testing.T) { t.Parallel() - state := &bootState{cfg: aghconfig.Config{Session: aghconfig.SessionConfig{AutoTitleEnabled: false}}} + cfg := aghconfig.DefaultWithHome(aghconfig.HomePaths{}) + cfg.Roles.AutoTitle.Enabled = false + state := &bootState{cfg: cfg} cleanup := &bootCleanup{} - if err := (&Daemon{}).bootAutoTitleRuntime(testutil.Context(t), state, nil, cleanup); err != nil { + err := (&Daemon{}).bootAutoTitleRuntime( + testutil.Context(t), state, &fakeSessionManager{}, cleanup, + ) + if err != nil { t.Fatalf("bootAutoTitleRuntime() error = %v", err) } - if state.runtimeWorkers.autoTitle != nil || len(cleanup.fns) != 0 { + if state.runtimeWorkers.autoTitle == nil || len(cleanup.fns) != 1 { t.Fatalf( - "bootAutoTitleRuntime() state = runtime:%#v cleanup:%d, want disabled", + "bootAutoTitleRuntime() state = runtime:%#v cleanup:%d, want live-config runtime", state.runtimeWorkers.autoTitle, len(cleanup.fns), ) } + t.Cleanup(func() { + if err := state.runtimeWorkers.autoTitle.Shutdown(context.Background()); err != nil { + t.Errorf("autoTitleRuntime.Shutdown() error = %v", err) + } + }) }) } @@ -188,14 +198,43 @@ func TestAutoTitleRuntime(t *testing.T) { func TestForkedAutoTitleGenerator(t *testing.T) { t.Parallel() + t.Run("Should skip spawning when the role is disabled", func(t *testing.T) { + t.Parallel() + + sessions := &autoTitleSpawnSessionsStub{} + generator := newForkedAutoTitleGenerator( + sessions, + resolvedRoleResolver(ResolvedRole{Enabled: false}), + time.Second, + slog.Default(), + ) + _, err := generator.Generate(testutil.Context(t), autoTitleRequest{ + SessionID: "sess-parent", + AgentName: "coder", + WorkspaceID: "ws-title", + }) + if !errors.Is(err, errAutoTitleRoleDisabled) { + t.Fatalf("Generate() error = %v, want %v", err, errAutoTitleRoleDisabled) + } + if sessions.spawnCalls != 0 { + t.Fatalf("Spawn() calls = %d, want 0", sessions.spawnCalls) + } + }) + t.Run("Should use one bounded hidden spawn and stop it after collecting the title", func(t *testing.T) { t.Parallel() sessions := &autoTitleSpawnSessionsStub{} - generator := newForkedAutoTitleGenerator(sessions, time.Second, slog.Default()) + generator := newForkedAutoTitleGenerator( + sessions, + resolvedRoleResolver(ResolvedRole{Enabled: true, Inherit: true, Model: "title-model"}), + time.Second, + slog.Default(), + ) title, err := generator.Generate(testutil.Context(t), autoTitleRequest{ SessionID: "sess-parent", AgentName: "coder", + WorkspaceID: "ws-title", UserMessage: "Investigate flaky checkout retries", AssistantReply: "I found the retry race.", }) @@ -206,6 +245,8 @@ func TestForkedAutoTitleGenerator(t *testing.T) { t.Fatalf("Generate() title = %q, want %q", title, "Checkout retry race") } if sessions.spawn.ParentSessionID != "sess-parent" || + sessions.spawn.AgentName != "coder" || + sessions.spawn.Model != "title-model" || sessions.spawn.SpawnRole != session.SpawnRoleAutoTitle || !sessions.spawn.AutoStopOnParent || sessions.spawn.TTL <= time.Second { t.Fatalf("Spawn() opts = %#v", sessions.spawn) @@ -222,7 +263,12 @@ func TestForkedAutoTitleGenerator(t *testing.T) { t.Parallel() sessions := newCancelAwareAutoTitleSpawnSessionsStub() - generator := newForkedAutoTitleGenerator(sessions, 5*time.Second, slog.Default()) + generator := newForkedAutoTitleGenerator( + sessions, + resolvedRoleResolver(ResolvedRole{Enabled: true, Inherit: true}), + 5*time.Second, + slog.Default(), + ) ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) go func() { @@ -353,9 +399,10 @@ type autoTitleStopCall struct { } type autoTitleSpawnSessionsStub struct { - spawn session.SpawnOpts - prompt session.SyntheticPromptOpts - stops []autoTitleStopCall + spawnCalls int + spawn session.SpawnOpts + prompt session.SyntheticPromptOpts + stops []autoTitleStopCall } type cancelAwareAutoTitleSpawnSessionsStub struct { @@ -402,6 +449,7 @@ func (s *autoTitleSpawnSessionsStub) Spawn( _ context.Context, opts session.SpawnOpts, ) (*session.Session, error) { + s.spawnCalls++ s.spawn = opts return &session.Session{ID: "sess-title-child"}, nil } diff --git a/internal/daemon/boot.go b/internal/daemon/boot.go index 13c801cda..7b06a330a 100644 --- a/internal/daemon/boot.go +++ b/internal/daemon/boot.go @@ -109,6 +109,7 @@ type bootState struct { resourceKernel *resources.Kernel resourceCodecs *resources.CodecRegistry agentCatalog *resourceCatalog[aghconfig.AgentDef] + roleResolver *roleResolver soulCatalog *resourceCatalog[soul.ResourceSpec] heartbeatCatalog *resourceCatalog[heartbeat.ResourceSpec] toolCatalog *resourceCatalog[toolspkg.Tool] @@ -323,8 +324,8 @@ func (d *Daemon) buildSituationContext(state *bootState) *situation.Service { NetworkFunc: func() situation.NetworkReader { return state.network }, - CoordinatorConfigFunc: func() situation.CoordinatorConfigResolver { - return state.deps.CoordinatorConfig + CoordinatorRoleFunc: func() situation.CoordinatorRoleResolver { + return state.deps.CoordinatorRole }, SoulSnapshotsFunc: func() situation.SoulSnapshotStore { return soulSnapshotStoreDependency(state.registry) diff --git a/internal/daemon/boot_memory_session.go b/internal/daemon/boot_memory_session.go index 89b93a5c4..7a5ad7b09 100644 --- a/internal/daemon/boot_memory_session.go +++ b/internal/daemon/boot_memory_session.go @@ -31,6 +31,7 @@ func (d *Daemon) bootMemorySessionRuntime( return fmt.Errorf("daemon: create session manager: %w", err) } state.sessions = sessions + d.configureMemoryController(state, sessions) if err := d.bootClarifyBridge(state, cleanup); err != nil { return err } @@ -86,7 +87,7 @@ func (d *Daemon) bootAutoTitleRuntime( sessions SessionManager, cleanup *bootCleanup, ) error { - if state == nil || !state.cfg.Session.AutoTitleEnabled { + if state == nil { return nil } titleSessions, ok := sessions.(autoTitleSessionManager) @@ -94,7 +95,7 @@ func (d *Daemon) bootAutoTitleRuntime( return errors.New("daemon: session manager does not implement automatic title lifecycle") } deadline := state.cfg.Memory.Extractor.Deadline - generator := newForkedAutoTitleGenerator(titleSessions, deadline, state.logger) + generator := newForkedAutoTitleGenerator(titleSessions, roleResolverForState(state), deadline, state.logger) runtime := newAutoTitleRuntime(titleSessions, generator, deadline, state.logger) if err := runtime.Start(ctx); err != nil { return fmt.Errorf("daemon: start automatic title runtime: %w", err) @@ -115,7 +116,7 @@ func (d *Daemon) bootCheckpointSummaryRuntime( } summarizer := newDaemonCheckpointSummarizer( sessions, - firstNonEmptyString(state.cfg.Memory.Dream.Agent, state.cfg.Defaults.Agent), + roleResolverForState(state), ) service := memory.NewCheckpointSummaryService( state.memoryStore, diff --git a/internal/daemon/boot_runtime_services.go b/internal/daemon/boot_runtime_services.go index dfd85fe18..53ec869be 100644 --- a/internal/daemon/boot_runtime_services.go +++ b/internal/daemon/boot_runtime_services.go @@ -3,7 +3,9 @@ package daemon import ( "context" "fmt" + "time" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/memory" ) @@ -12,12 +14,13 @@ func (d *Daemon) bootRuntimeServices( state *bootState, cleanup *bootCleanup, ) error { - if state.cfg.Memory.Enabled && state.cfg.Memory.Dream.Enabled { + if state.cfg.Memory.Enabled { state.dreamSvc = d.newDreamService( memory.WithMemoryStore(state.memoryStore), memory.WithSessionsDir(d.homePaths.SessionsDir), memory.WithMinHours(state.cfg.Memory.Dream.MinHours), memory.WithMinSessions(state.cfg.Memory.Dream.MinSessions), + memory.WithDreamGateConfig(dreamGateConfigFromConfig(state.cfg.Memory.Dream)), memory.WithLogger(state.logger), memory.WithWorkspaceResolver(state.workspaceResolver), ) @@ -63,6 +66,19 @@ func (d *Daemon) bootRuntimeServices( return d.bootMemorySessionRuntime(ctx, state, cleanup) } +func dreamGateConfigFromConfig(cfg aghconfig.DreamConfig) memory.DreamGateConfig { + return memory.DreamGateConfig{ + MinCandidates: cfg.Gates.MinUnpromoted, + MinRecallCount: cfg.Gates.MinRecallCount, + MinScore: cfg.Gates.MinScore, + HalfLife: time.Duration(cfg.Scoring.RecencyHalfLifeDays) * 24 * time.Hour, + FrequencyWeight: cfg.Scoring.Weights.Frequency, + RelevanceWeight: cfg.Scoring.Weights.Relevance, + RecencyWeight: cfg.Scoring.Weights.Recency, + FreshnessWeight: cfg.Scoring.Weights.Freshness, + } +} + func (d *Daemon) bootRuntimeMemoryMonitor( ctx context.Context, state *bootState, diff --git a/internal/daemon/boot_runtime_services_test.go b/internal/daemon/boot_runtime_services_test.go new file mode 100644 index 000000000..bf8ae2600 --- /dev/null +++ b/internal/daemon/boot_runtime_services_test.go @@ -0,0 +1,50 @@ +package daemon + +import ( + "reflect" + "testing" + "time" + + aghconfig "github.com/compozy/agh/internal/config" + "github.com/compozy/agh/internal/memory" +) + +func TestDreamGateConfigFromConfig(t *testing.T) { + t.Parallel() + + t.Run("Should preserve configured dream gate and scoring values", func(t *testing.T) { + t.Parallel() + + cfg := aghconfig.DreamConfig{ + Gates: aghconfig.MemoryDreamGatesConfig{ + MinUnpromoted: 3, + MinRecallCount: 4, + MinScore: 0.65, + }, + Scoring: aghconfig.MemoryDreamScoringConfig{ + RecencyHalfLifeDays: 21, + Weights: aghconfig.MemoryDreamScoringWeightsConfig{ + Frequency: 0.1, + Relevance: 0.2, + Recency: 0.3, + Freshness: 0.4, + }, + }, + } + + got := dreamGateConfigFromConfig(cfg) + want := memory.DreamGateConfig{ + MinCandidates: 3, + MinRecallCount: 4, + MinScore: 0.65, + HalfLife: 21 * 24 * time.Hour, + FrequencyWeight: 0.1, + RelevanceWeight: 0.2, + RecencyWeight: 0.3, + FreshnessWeight: 0.4, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("dreamGateConfigFromConfig() = %#v, want %#v", got, want) + } + }) +} diff --git a/internal/daemon/checkpoint_summary_summarizer.go b/internal/daemon/checkpoint_summary_summarizer.go index a951675c8..95aa86b07 100644 --- a/internal/daemon/checkpoint_summary_summarizer.go +++ b/internal/daemon/checkpoint_summary_summarizer.go @@ -8,6 +8,7 @@ import ( "time" "github.com/compozy/agh/internal/acp" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/memory" "github.com/compozy/agh/internal/session" ) @@ -22,18 +23,18 @@ type checkpointSummarySessionManager interface { type daemonCheckpointSummarizer struct { sessions checkpointSummarySessionManager - agent string + roles RoleResolver } var _ memory.CheckpointSummarizer = (*daemonCheckpointSummarizer)(nil) func newDaemonCheckpointSummarizer( sessions checkpointSummarySessionManager, - agent string, + roles RoleResolver, ) *daemonCheckpointSummarizer { return &daemonCheckpointSummarizer{ sessions: sessions, - agent: strings.TrimSpace(agent), + roles: roles, } } @@ -47,42 +48,47 @@ func (s *daemonCheckpointSummarizer) Summarize( if ctx == nil { return "", errors.New("daemon: checkpoint summary context is required") } + if s.roles == nil { + return "", errors.New("daemon: checkpoint summary role resolver is not configured") + } + correlation := roleInvocationCorrelation{ + WorkspaceID: strings.TrimSpace(request.WorkspaceID), + SessionID: strings.TrimSpace(request.SessionID), + AgentName: strings.TrimSpace(request.AgentName), + } + roleCtx := withRoleInvocationCorrelation(ctx, correlation) + role, err := s.roles.Resolve(roleCtx, request.WorkspaceRoot, aghconfig.RoleCheckpointSummary) + if err != nil { + return "", fmt.Errorf("daemon: resolve checkpoint summary role: %w", err) + } + if !role.Enabled { + return "", memory.ErrCheckpointSummaryDisabled + } prompt, err := memory.RenderCheckpointSummaryPrompt(request) if err != nil { return "", err } - summarySession, err := s.sessions.Create(ctx, session.CreateOpts{ - AgentName: s.agent, - Name: checkpointSummarySessionName, - Workspace: strings.TrimSpace(request.WorkspaceRoot), - Type: session.SessionTypeDream, + summarySession, err := invokeRoleWithFallback(ctx, role, correlation, func( + attemptCtx context.Context, + route roleAttemptRoute, + ) (*session.Session, bool, error) { + created, createErr := s.sessions.Create(attemptCtx, session.CreateOpts{ + AgentName: route.AgentName, + Provider: route.Provider, + Model: route.Model, + ReasoningEffort: route.ReasoningEffort, + Name: checkpointSummarySessionName, + Workspace: strings.TrimSpace(request.WorkspaceRoot), + Type: session.SessionTypeDream, + }) + return created, created != nil, createErr }) + if summarySession != nil { + defer s.stopCheckpointSummarySession(ctx, summarySession.ID, &err) + } if err != nil { return "", fmt.Errorf("daemon: create checkpoint summary session: %w", err) } - defer func() { - stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkpointSummaryStopTimeout) - defer cancel() - cause := session.CauseCompleted - detail := "checkpoint summary completed" - if err != nil { - cause = session.CauseFailed - detail = err.Error() - } - stopErr := s.sessions.StopWithCause( - stopCtx, - summarySession.ID, - cause, - detail, - ) - if stopErr != nil { - err = errors.Join(err, fmt.Errorf( - "daemon: stop checkpoint summary session %q: %w", - summarySession.ID, - stopErr, - )) - } - }() events, err := s.sessions.Prompt(ctx, summarySession.ID, prompt) if err != nil { @@ -95,6 +101,28 @@ func (s *daemonCheckpointSummarizer) Summarize( return strings.TrimSpace(output), nil } +func (s *daemonCheckpointSummarizer) stopCheckpointSummarySession( + ctx context.Context, + sessionID string, + operationErr *error, +) { + stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkpointSummaryStopTimeout) + defer cancel() + cause := session.CauseCompleted + detail := "checkpoint summary completed" + if *operationErr != nil { + cause = session.CauseFailed + detail = (*operationErr).Error() + } + if err := s.sessions.StopWithCause(stopCtx, sessionID, cause, detail); err != nil { + *operationErr = errors.Join(*operationErr, fmt.Errorf( + "daemon: stop checkpoint summary session %q: %w", + sessionID, + err, + )) + } +} + func collectCheckpointSummaryOutput(ctx context.Context, events <-chan acp.AgentEvent) (string, error) { var output strings.Builder for { diff --git a/internal/daemon/checkpoint_summary_summarizer_test.go b/internal/daemon/checkpoint_summary_summarizer_test.go index cc3c6062d..97ea742ff 100644 --- a/internal/daemon/checkpoint_summary_summarizer_test.go +++ b/internal/daemon/checkpoint_summary_summarizer_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/compozy/agh/internal/acp" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/memory" memcontract "github.com/compozy/agh/internal/memory/contract" "github.com/compozy/agh/internal/session" @@ -17,6 +18,23 @@ import ( func TestDaemonCheckpointSummarizer(t *testing.T) { t.Parallel() + t.Run("Should skip the hidden session when the role is disabled", func(t *testing.T) { + t.Parallel() + + manager := &checkpointSummarySessionManagerStub{} + summarizer := newDaemonCheckpointSummarizer( + manager, + resolvedRoleResolver(ResolvedRole{Enabled: false}), + ) + summary, err := summarizer.Summarize(testutil.Context(t), checkpointSummaryRequestFixture()) + if !errors.Is(err, memory.ErrCheckpointSummaryDisabled) { + t.Fatalf("Summarize() error = %v, want ErrCheckpointSummaryDisabled", err) + } + if summary != "" || manager.createCalls != 0 { + t.Fatalf("Summarize() = %q with %d create calls, want skipped", summary, manager.createCalls) + } + }) + t.Run("Should collect agent output and stop the internal dream session", func(t *testing.T) { t.Parallel() @@ -24,7 +42,14 @@ func TestDaemonCheckpointSummarizer(t *testing.T) { {Type: acp.EventTypeAgentMessage, Text: "## Historical Task Snapshot\n- cobalt\n"}, {Type: acp.EventTypeAgentMessage, Text: "\n## Goal\n- preserve context"}, }} - summarizer := newDaemonCheckpointSummarizer(manager, "memory-agent") + summarizer := newDaemonCheckpointSummarizer( + manager, + resolvedRoleResolver(ResolvedRole{ + Enabled: true, + AgentName: aghconfig.BuiltinDreamingCuratorAgentName, + Builtin: true, + }), + ) request := checkpointSummaryRequestFixture() got, err := summarizer.Summarize(testutil.Context(t), request) if err != nil { @@ -36,7 +61,7 @@ func TestDaemonCheckpointSummarizer(t *testing.T) { if manager.createOpts.Name != checkpointSummarySessionName || manager.createOpts.Type != session.SessionTypeDream || manager.createOpts.Workspace != request.WorkspaceRoot || - manager.createOpts.AgentName != "memory-agent" { + manager.createOpts.AgentName != aghconfig.BuiltinDreamingCuratorAgentName { t.Fatalf("Create() opts = %#v, want checkpoint dream session", manager.createOpts) } if manager.promptID != "checkpoint-session" || @@ -57,7 +82,14 @@ func TestDaemonCheckpointSummarizer(t *testing.T) { wantErr := errors.New("prompt rejected") manager := &checkpointSummarySessionManagerStub{promptErr: wantErr} - summarizer := newDaemonCheckpointSummarizer(manager, "memory-agent") + summarizer := newDaemonCheckpointSummarizer( + manager, + resolvedRoleResolver(ResolvedRole{ + Enabled: true, + AgentName: aghconfig.BuiltinDreamingCuratorAgentName, + Builtin: true, + }), + ) _, err := summarizer.Summarize(testutil.Context(t), checkpointSummaryRequestFixture()) if !errors.Is(err, wantErr) { t.Fatalf("Summarize() error = %v, want wrapped %v", err, wantErr) @@ -88,19 +120,21 @@ func checkpointSummaryRequestFixture() memory.CheckpointSummaryRequest { } type checkpointSummarySessionManagerStub struct { - createOpts session.CreateOpts - promptID string - prompt string - stopID string - stopCause session.StopCause - events []acp.AgentEvent - promptErr error + createCalls int + createOpts session.CreateOpts + promptID string + prompt string + stopID string + stopCause session.StopCause + events []acp.AgentEvent + promptErr error } func (m *checkpointSummarySessionManagerStub) Create( _ context.Context, opts session.CreateOpts, ) (*session.Session, error) { + m.createCalls++ m.createOpts = opts return &session.Session{ID: "checkpoint-session"}, nil } diff --git a/internal/daemon/coordinator_config.go b/internal/daemon/coordinator_config.go index 6a8bef941..52e180568 100644 --- a/internal/daemon/coordinator_config.go +++ b/internal/daemon/coordinator_config.go @@ -4,89 +4,69 @@ import ( "context" "errors" "fmt" - "strings" aghconfig "github.com/compozy/agh/internal/config" workspacepkg "github.com/compozy/agh/internal/workspace" ) -// CoordinatorConfigResolver resolves coordinator policy without starting coordinator behavior. -type CoordinatorConfigResolver interface { - ResolveCoordinatorConfig(ctx context.Context, workspaceID string) (aghconfig.CoordinatorConfig, error) +// CoordinatorRoleResolver resolves coordinator routing and safety policy without starting behavior. +type CoordinatorRoleResolver interface { + ResolveCoordinatorRole(ctx context.Context, workspaceID string) (aghconfig.ResolvedCoordinatorRole, error) } type coordinatorAgentResolver interface { ResolveAgent(name string, resolved *workspacepkg.ResolvedWorkspace) (aghconfig.AgentDef, error) } -type defaultCoordinatorConfigResolver struct { - config *aghconfig.Config - workspaceResolver workspacepkg.RuntimeResolver - agents coordinatorAgentResolver +type defaultCoordinatorRoleResolver struct { + roles *roleResolver } -var _ CoordinatorConfigResolver = (*defaultCoordinatorConfigResolver)(nil) +var _ CoordinatorRoleResolver = (*defaultCoordinatorRoleResolver)(nil) -func newCoordinatorConfigResolver( +func newCoordinatorRoleResolver( cfg *aghconfig.Config, workspaceResolver workspacepkg.RuntimeResolver, agents coordinatorAgentResolver, -) CoordinatorConfigResolver { - return &defaultCoordinatorConfigResolver{ - config: cfg, - workspaceResolver: workspaceResolver, - agents: agents, +) CoordinatorRoleResolver { + return &defaultCoordinatorRoleResolver{ + roles: newRoleResolver(cfg, workspaceResolver, agents), } } -func (r *defaultCoordinatorConfigResolver) ResolveCoordinatorConfig( +func coordinatorRoleResolverFor(roles *roleResolver) CoordinatorRoleResolver { + return &defaultCoordinatorRoleResolver{roles: roles} +} + +func (r *defaultCoordinatorRoleResolver) ResolveCoordinatorRole( ctx context.Context, workspaceID string, -) (aghconfig.CoordinatorConfig, error) { +) (aghconfig.ResolvedCoordinatorRole, error) { if ctx == nil { - return aghconfig.CoordinatorConfig{}, errors.New("daemon: coordinator config context is required") - } - if r.config == nil { - return aghconfig.CoordinatorConfig{}, errors.New("daemon: coordinator config is required") - } - - cfg := r.config - var resolvedWorkspace *workspacepkg.ResolvedWorkspace - if target := strings.TrimSpace(workspaceID); target != "" { - if r.workspaceResolver == nil { - return aghconfig.CoordinatorConfig{}, errors.New( - "daemon: workspace resolver is required for workspace coordinator config", - ) - } - resolved, err := r.workspaceResolver.Resolve(ctx, target) - if err != nil { - return aghconfig.CoordinatorConfig{}, fmt.Errorf( - "daemon: resolve coordinator workspace %q: %w", - target, - err, - ) - } - cfg = &resolved.Config - resolvedWorkspace = &resolved - } - - fallback := aghconfig.DefaultCoordinatorAgentDef() - agentName := strings.TrimSpace(cfg.Autonomy.Coordinator.AgentName) - if agentName == "" { - agentName = fallback.Name + return aghconfig.ResolvedCoordinatorRole{}, errors.New("daemon: coordinator role context is required") } - if agentName != "" && r.agents != nil { - agent, err := r.agents.ResolveAgent(agentName, resolvedWorkspace) - if err == nil { - fallback = agent - } else if !errors.Is(err, workspacepkg.ErrAgentNotAvailable) { - return aghconfig.CoordinatorConfig{}, fmt.Errorf("daemon: resolve coordinator agent %q: %w", agentName, err) - } + if r == nil || r.roles == nil { + return aghconfig.ResolvedCoordinatorRole{}, errors.New("daemon: coordinator role resolver is required") } - resolved, err := cfg.ResolveCoordinatorConfig(fallback) + resolvedRole, effectiveConfig, err := r.roles.resolveEffective(ctx, workspaceID, aghconfig.RoleCoordinator) if err != nil { - return aghconfig.CoordinatorConfig{}, fmt.Errorf("daemon: resolve coordinator config: %w", err) + recordErr := r.roles.recordRoleResolveError(ctx, workspaceID, aghconfig.RoleCoordinator, err) + return aghconfig.ResolvedCoordinatorRole{}, fmt.Errorf( + "daemon: resolve coordinator role: %w", + errors.Join(err, recordErr), + ) } - return resolved, nil + effective := effectiveConfig.Roles.Coordinator + return aghconfig.ResolvedCoordinatorRole{ + Enabled: resolvedRole.Enabled, + AgentName: resolvedRole.AgentName, + Provider: resolvedRole.Provider, + Model: resolvedRole.Model, + ReasoningEffort: resolvedRole.ReasoningEffort, + Fallbacks: append([]aghconfig.RoleFallback(nil), resolvedRole.Fallbacks...), + TTL: effective.TTL, + MaxChildren: effective.MaxChildren, + MaxActiveSessionsPerWorkspace: effective.MaxActiveSessionsPerWorkspace, + }, nil } diff --git a/internal/daemon/coordinator_config_test.go b/internal/daemon/coordinator_config_test.go index 56d36c3a3..df1345761 100644 --- a/internal/daemon/coordinator_config_test.go +++ b/internal/daemon/coordinator_config_test.go @@ -10,144 +10,157 @@ import ( workspacepkg "github.com/compozy/agh/internal/workspace" ) -func TestCoordinatorConfigResolverReturnsBundledDefaultIdentity(t *testing.T) { +func TestCoordinatorRoleResolverReturnsBundledDefaultIdentity(t *testing.T) { t.Parallel() - - cfg := defaultCoordinatorResolverConfig(t) - resolver := newCoordinatorConfigResolver(&cfg, nil, nil) - - resolved, err := resolver.ResolveCoordinatorConfig(context.Background(), "") - if err != nil { - t.Fatalf("ResolveCoordinatorConfig() error = %v", err) - } - if got, want := resolved.AgentName, aghconfig.DefaultCoordinatorAgentName; got != want { - t.Fatalf("ResolveCoordinatorConfig() AgentName = %q, want %q", got, want) - } - if resolved.Enabled { - t.Fatal("ResolveCoordinatorConfig() Enabled = true, want bundled default false") - } - if got, want := resolved.DefaultTTL, aghconfig.DefaultCoordinatorTTL; got != want { - t.Fatalf("ResolveCoordinatorConfig() DefaultTTL = %s, want %s", got, want) - } + t.Run("Should return the bundled default identity", func(t *testing.T) { + t.Parallel() + + cfg := defaultCoordinatorResolverConfig(t) + resolver := newCoordinatorRoleResolver(&cfg, nil, nil) + + resolved, err := resolver.ResolveCoordinatorRole(context.Background(), "") + if err != nil { + t.Fatalf("ResolveCoordinatorRole() error = %v", err) + } + if got, want := resolved.AgentName, aghconfig.BuiltinCoordinatorAgentName; got != want { + t.Fatalf("ResolveCoordinatorRole() AgentName = %q, want %q", got, want) + } + if resolved.Enabled { + t.Fatal("ResolveCoordinatorRole() Enabled = true, want bundled default false") + } + if got, want := resolved.TTL, aghconfig.DefaultCoordinatorTTL; got != want { + t.Fatalf("ResolveCoordinatorRole() TTL = %s, want %s", got, want) + } + }) } -func TestCoordinatorConfigResolverPrefersGlobalConfigOverBundledDefault(t *testing.T) { +func TestCoordinatorRoleResolverPrefersGlobalConfigOverBundledDefault(t *testing.T) { t.Parallel() - - cfg := defaultCoordinatorResolverConfig(t) - cfg.Autonomy.Coordinator.Enabled = true - cfg.Autonomy.Coordinator.AgentName = "global-coordinator" - cfg.Autonomy.Coordinator.Provider = "codex" - cfg.Autonomy.Coordinator.Model = "global-model" - cfg.Autonomy.Coordinator.DefaultTTL = 4 * time.Hour - cfg.Autonomy.Coordinator.MaxChildren = 4 - resolver := newCoordinatorConfigResolver(&cfg, nil, nil) - - resolved, err := resolver.ResolveCoordinatorConfig(context.Background(), "") - if err != nil { - t.Fatalf("ResolveCoordinatorConfig() error = %v", err) - } - if !resolved.Enabled { - t.Fatal("ResolveCoordinatorConfig() Enabled = false, want global true") - } - if got, want := resolved.AgentName, "global-coordinator"; got != want { - t.Fatalf("ResolveCoordinatorConfig() AgentName = %q, want %q", got, want) - } - if got, want := resolved.Provider, "codex"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Provider = %q, want %q", got, want) - } - if got, want := resolved.Model, "global-model"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Model = %q, want %q", got, want) - } - if got, want := resolved.DefaultTTL, 4*time.Hour; got != want { - t.Fatalf("ResolveCoordinatorConfig() DefaultTTL = %s, want %s", got, want) - } - if got, want := resolved.MaxChildren, 4; got != want { - t.Fatalf("ResolveCoordinatorConfig() MaxChildren = %d, want %d", got, want) - } + t.Run("Should prefer global config over the bundled default", func(t *testing.T) { + t.Parallel() + + cfg := defaultCoordinatorResolverConfig(t) + cfg.Roles.Coordinator.Enabled = true + cfg.Roles.Coordinator.Agent = aghconfig.BuiltinCoordinatorAgentName + cfg.Roles.Coordinator.Provider = "codex" + cfg.Roles.Coordinator.Model = "global-model" + cfg.Roles.Coordinator.TTL = 4 * time.Hour + cfg.Roles.Coordinator.MaxChildren = 4 + resolver := newCoordinatorRoleResolver(&cfg, nil, nil) + + resolved, err := resolver.ResolveCoordinatorRole(context.Background(), "") + if err != nil { + t.Fatalf("ResolveCoordinatorRole() error = %v", err) + } + if !resolved.Enabled { + t.Fatal("ResolveCoordinatorRole() Enabled = false, want global true") + } + if got, want := resolved.AgentName, aghconfig.BuiltinCoordinatorAgentName; got != want { + t.Fatalf("ResolveCoordinatorRole() AgentName = %q, want %q", got, want) + } + if got, want := resolved.Provider, "codex"; got != want { + t.Fatalf("ResolveCoordinatorRole() Provider = %q, want %q", got, want) + } + if got, want := resolved.Model, "global-model"; got != want { + t.Fatalf("ResolveCoordinatorRole() Model = %q, want %q", got, want) + } + if got, want := resolved.TTL, 4*time.Hour; got != want { + t.Fatalf("ResolveCoordinatorRole() TTL = %s, want %s", got, want) + } + if got, want := resolved.MaxChildren, 4; got != want { + t.Fatalf("ResolveCoordinatorRole() MaxChildren = %d, want %d", got, want) + } + }) } -func TestCoordinatorConfigResolverPrefersWorkspaceConfig(t *testing.T) { +func TestCoordinatorRoleResolverPrefersWorkspaceConfig(t *testing.T) { t.Parallel() - - global := defaultCoordinatorResolverConfig(t) - global.Autonomy.Coordinator.Enabled = true - global.Autonomy.Coordinator.Provider = "claude" - global.Autonomy.Coordinator.Model = "global-model" - global.Autonomy.Coordinator.DefaultTTL = 2 * time.Hour - global.Autonomy.Coordinator.MaxChildren = 5 - - workspaceCfg := defaultCoordinatorResolverConfig(t) - workspaceCfg.Autonomy.Coordinator.Enabled = false - workspaceCfg.Autonomy.Coordinator.AgentName = "workspace-coordinator" - workspaceCfg.Autonomy.Coordinator.Provider = "codex" - workspaceCfg.Autonomy.Coordinator.Model = "workspace-model" - workspaceCfg.Autonomy.Coordinator.DefaultTTL = 3 * time.Hour - workspaceCfg.Autonomy.Coordinator.MaxChildren = 2 - - resolver := newCoordinatorConfigResolver( - &global, - &coordinatorWorkspaceResolverStub{ - resolved: workspacepkg.ResolvedWorkspace{ - Workspace: workspacepkg.Workspace{ID: "ws-1"}, - Config: workspaceCfg, + t.Run("Should prefer workspace config", func(t *testing.T) { + t.Parallel() + + global := defaultCoordinatorResolverConfig(t) + global.Roles.Coordinator.Enabled = true + global.Roles.Coordinator.Provider = "claude" + global.Roles.Coordinator.Model = "global-model" + global.Roles.Coordinator.TTL = 2 * time.Hour + global.Roles.Coordinator.MaxChildren = 5 + + workspaceCfg := defaultCoordinatorResolverConfig(t) + workspaceCfg.Roles.Coordinator.Enabled = false + workspaceCfg.Roles.Coordinator.Agent = aghconfig.BuiltinCoordinatorAgentName + workspaceCfg.Roles.Coordinator.Provider = "codex" + workspaceCfg.Roles.Coordinator.Model = "workspace-model" + workspaceCfg.Roles.Coordinator.TTL = 3 * time.Hour + workspaceCfg.Roles.Coordinator.MaxChildren = 2 + + resolver := newCoordinatorRoleResolver( + &global, + &coordinatorWorkspaceResolverStub{ + resolved: workspacepkg.ResolvedWorkspace{ + Workspace: workspacepkg.Workspace{ID: "ws-1"}, + Config: workspaceCfg, + }, }, - }, - nil, - ) - - resolved, err := resolver.ResolveCoordinatorConfig(context.Background(), "ws-1") - if err != nil { - t.Fatalf("ResolveCoordinatorConfig(workspace) error = %v", err) - } - if resolved.Enabled { - t.Fatal("ResolveCoordinatorConfig() Enabled = true, want workspace false") - } - if got, want := resolved.AgentName, "workspace-coordinator"; got != want { - t.Fatalf("ResolveCoordinatorConfig() AgentName = %q, want %q", got, want) - } - if got, want := resolved.Provider, "codex"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Provider = %q, want %q", got, want) - } - if got, want := resolved.Model, "workspace-model"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Model = %q, want %q", got, want) - } - if got, want := resolved.DefaultTTL, 3*time.Hour; got != want { - t.Fatalf("ResolveCoordinatorConfig() DefaultTTL = %s, want %s", got, want) - } - if got, want := resolved.MaxChildren, 2; got != want { - t.Fatalf("ResolveCoordinatorConfig() MaxChildren = %d, want %d", got, want) - } + nil, + ) + + resolved, err := resolver.ResolveCoordinatorRole(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ResolveCoordinatorRole(workspace) error = %v", err) + } + if resolved.Enabled { + t.Fatal("ResolveCoordinatorRole() Enabled = true, want workspace false") + } + if got, want := resolved.AgentName, aghconfig.BuiltinCoordinatorAgentName; got != want { + t.Fatalf("ResolveCoordinatorRole() AgentName = %q, want %q", got, want) + } + if got, want := resolved.Provider, "codex"; got != want { + t.Fatalf("ResolveCoordinatorRole() Provider = %q, want %q", got, want) + } + if got, want := resolved.Model, "workspace-model"; got != want { + t.Fatalf("ResolveCoordinatorRole() Model = %q, want %q", got, want) + } + if got, want := resolved.TTL, 3*time.Hour; got != want { + t.Fatalf("ResolveCoordinatorRole() TTL = %s, want %s", got, want) + } + if got, want := resolved.MaxChildren, 2; got != want { + t.Fatalf("ResolveCoordinatorRole() MaxChildren = %d, want %d", got, want) + } + }) } -func TestCoordinatorConfigResolverUsesAgentFallbackForProviderModel(t *testing.T) { +func TestCoordinatorRoleResolverUsesAgentFallbackForProviderModel(t *testing.T) { t.Parallel() - - cfg := defaultCoordinatorResolverConfig(t) - cfg.Defaults.Provider = "codex" - resolver := newCoordinatorConfigResolver( - &cfg, - nil, - coordinatorAgentResolverStub{ - agent: aghconfig.AgentDef{ - Name: aghconfig.DefaultCoordinatorAgentName, - Provider: "claude", - Model: "agent-model", - Prompt: "agent fallback", + t.Run("Should use the catalog agent for provider and model fallback", func(t *testing.T) { + t.Parallel() + + cfg := defaultCoordinatorResolverConfig(t) + cfg.Defaults.Provider = "codex" + cfg.Roles.Coordinator.Agent = "custom-coordinator" + resolver := newCoordinatorRoleResolver( + &cfg, + nil, + coordinatorAgentResolverStub{ + agent: aghconfig.AgentDef{ + Name: "custom-coordinator", + Provider: "claude", + Model: "agent-model", + Prompt: "agent fallback", + }, }, - }, - ) - - resolved, err := resolver.ResolveCoordinatorConfig(context.Background(), "") - if err != nil { - t.Fatalf("ResolveCoordinatorConfig() error = %v", err) - } - if got, want := resolved.Provider, "claude"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Provider = %q, want agent fallback %q", got, want) - } - if got, want := resolved.Model, "agent-model"; got != want { - t.Fatalf("ResolveCoordinatorConfig() Model = %q, want agent fallback %q", got, want) - } + ) + + resolved, err := resolver.ResolveCoordinatorRole(context.Background(), "") + if err != nil { + t.Fatalf("ResolveCoordinatorRole() error = %v", err) + } + if got, want := resolved.Provider, "claude"; got != want { + t.Fatalf("ResolveCoordinatorRole() Provider = %q, want agent fallback %q", got, want) + } + if got, want := resolved.Model, "agent-model"; got != want { + t.Fatalf("ResolveCoordinatorRole() Model = %q, want agent fallback %q", got, want) + } + }) } type coordinatorWorkspaceResolverStub struct { diff --git a/internal/daemon/coordinator_runtime.go b/internal/daemon/coordinator_runtime.go index d612fb349..67f733f07 100644 --- a/internal/daemon/coordinator_runtime.go +++ b/internal/daemon/coordinator_runtime.go @@ -10,11 +10,11 @@ import ( "time" "github.com/compozy/agh/internal/acp" - "github.com/compozy/agh/internal/coordinator" hookspkg "github.com/compozy/agh/internal/hooks" "github.com/compozy/agh/internal/network/participation" "github.com/compozy/agh/internal/session" + "github.com/compozy/agh/internal/store" taskpkg "github.com/compozy/agh/internal/task" ) @@ -66,9 +66,10 @@ type coordinatorRuntime struct { mu sync.Mutex store coordinatorTaskStore sessions coordinatorSessionManager - config CoordinatorConfigResolver + config CoordinatorRoleResolver hooks coordinatorHookDispatcher contextOverlay taskSessionContextOverlay + roleEvents roleEventSummaryWriter logger *slog.Logger now func() time.Time wakeInFlight map[string]struct{} @@ -88,11 +89,19 @@ func withCoordinatorTaskContextOverlay(overlay taskSessionContextOverlay) coordi } } +func withCoordinatorRoleEvents(events roleEventSummaryWriter) coordinatorRuntimeOption { + return func(runtime *coordinatorRuntime) { + if runtime != nil { + runtime.roleEvents = events + } + } +} + func newCoordinatorRuntime( ctx context.Context, store coordinatorTaskStore, sessions coordinatorSessionManager, - config CoordinatorConfigResolver, + config CoordinatorRoleResolver, hooks coordinatorHookDispatcher, logger *slog.Logger, now func() time.Time, @@ -143,19 +152,20 @@ func (d *Daemon) bootCoordinator(ctx context.Context, state *bootState, cleanup if state.sessions == nil { return errors.New("daemon: coordinator runtime requires session manager") } - if state.deps.CoordinatorConfig == nil { - return errors.New("daemon: coordinator runtime requires coordinator config resolver") + if state.deps.CoordinatorRole == nil { + return errors.New("daemon: coordinator runtime requires coordinator role resolver") } runtime, err := newCoordinatorRuntime( ctx, state.tasks.store, state.sessions, - state.deps.CoordinatorConfig, + state.deps.CoordinatorRole, state.notifier, state.logger, d.now, withCoordinatorTaskContextOverlay(state.situationContext), + withCoordinatorRoleEvents(state.registry), ) if err != nil { return err @@ -308,17 +318,29 @@ func (r *coordinatorRuntime) bootstrapRun( return nil, false, errors.New("daemon: coordinator bootstrap context is required") } - preflightConfig := defaultEnabledCoordinatorConfig() + preflightConfig := defaultEnabledCoordinatorRole() preflight := coordinator.DecideBootstrap(taskRecord, run, preflightConfig) if !preflight.ShouldBootstrap { r.dispatchDecision(ctx, preflight, nil, reason, "") return nil, false, nil } - cfg, err := r.config.ResolveCoordinatorConfig(ctx, preflight.WorkspaceID) + correlation := roleInvocationCorrelation{ + WorkspaceID: strings.TrimSpace(preflight.WorkspaceID), + Event: store.EventCorrelation{ + TaskID: strings.TrimSpace(preflight.TaskID), + RunID: strings.TrimSpace(preflight.RunID), + WorkflowID: strings.TrimSpace(preflight.WorkflowID), + SchedulerReason: strings.TrimSpace(reason), + ActorKind: string(taskpkg.ActorKindDaemon), + ActorID: "coordinator-runtime", + }, + } + ctx = withRoleInvocationCorrelation(ctx, correlation) + cfg, err := r.config.ResolveCoordinatorRole(ctx, preflight.WorkspaceID) if err != nil { r.dispatchFailed(ctx, preflight, nil, reason, err) - return nil, false, fmt.Errorf("daemon: resolve coordinator config: %w", err) + return nil, false, fmt.Errorf("daemon: resolve coordinator role: %w", err) } decision := coordinator.DecideBootstrap(taskRecord, run, cfg) if !decision.ShouldBootstrap { diff --git a/internal/daemon/coordinator_runtime_hooks.go b/internal/daemon/coordinator_runtime_hooks.go index 2197b7251..2a20cbd85 100644 --- a/internal/daemon/coordinator_runtime_hooks.go +++ b/internal/daemon/coordinator_runtime_hooks.go @@ -46,7 +46,7 @@ func (r *coordinatorRuntime) dispatchSpawned( ctx context.Context, decision coordinator.Decision, info *session.Info, - cfg aghconfig.CoordinatorConfig, + cfg aghconfig.ResolvedCoordinatorRole, reason string, ) { if r.hooks == nil || info == nil { @@ -158,7 +158,7 @@ func (r *coordinatorRuntime) dispatchDecision( func (r *coordinatorRuntime) preSpawnPayload( decision coordinator.Decision, - cfg aghconfig.CoordinatorConfig, + cfg aghconfig.ResolvedCoordinatorRole, coordinatorParticipation participation.Spec, reason string, ) hookspkg.CoordinatorPreSpawnPayload { diff --git a/internal/daemon/coordinator_runtime_integration_test.go b/internal/daemon/coordinator_runtime_integration_test.go index b2d1b271b..55c0adc8c 100644 --- a/internal/daemon/coordinator_runtime_integration_test.go +++ b/internal/daemon/coordinator_runtime_integration_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/network/participation" "github.com/compozy/agh/internal/session" taskpkg "github.com/compozy/agh/internal/task" @@ -15,6 +16,14 @@ import ( ) func TestCoordinatorBootstrapStartsOnceForUserTaskRunsIntegration(t *testing.T) { + t.Run("Should start one builtin coordinator with configured limits", func(t *testing.T) { + runCoordinatorBootstrapStartsOnceForUserTaskRunsIntegration(t) + }) +} + +func runCoordinatorBootstrapStartsOnceForUserTaskRunsIntegration(t *testing.T) { + t.Helper() + ctx := testutil.Context(t) manager, sessions := newCoordinatorTaskManagerIntegration(t, ctx) actor := coordinatorTaskActor() @@ -41,6 +50,18 @@ func TestCoordinatorBootstrapStartsOnceForUserTaskRunsIntegration(t *testing.T) if got := sessions.createCount(); got != 1 { t.Fatalf("Create count after first start = %d, want 1", got) } + createdCoordinator := sessions.createCall(0) + if createdCoordinator.AgentName != aghconfig.BuiltinCoordinatorAgentName || + createdCoordinator.Type != session.SessionTypeCoordinator { + t.Fatalf("coordinator Create() opts = %#v, want bundled coordinator identity and type", createdCoordinator) + } + if createdCoordinator.Lineage == nil || + createdCoordinator.Lineage.SpawnBudget.MaxChildren != 5 || + createdCoordinator.Lineage.SpawnBudget.MaxActivePerWorkspace != 5 || + createdCoordinator.Lineage.TTLExpiresAt == nil || + !createdCoordinator.Lineage.TTLExpiresAt.Equal(time.Date(2026, 4, 26, 14, 0, 0, 0, time.UTC)) { + t.Fatalf("coordinator Create() lineage = %#v, want configured caps and TTL", createdCoordinator.Lineage) + } second, err := manager.CreateTask(ctx, taskpkg.CreateTask{ Scope: taskpkg.ScopeWorkspace, @@ -118,7 +139,7 @@ func newCoordinatorTaskManagerIntegration( ctx, db, sessions, - &staticCoordinatorConfigResolver{cfg: coordinatorRuntimeConfig()}, + &staticCoordinatorRoleResolver{cfg: coordinatorRuntimeConfig()}, notifier, discardLogger(), func() time.Time { return now }, diff --git a/internal/daemon/coordinator_runtime_reconcile.go b/internal/daemon/coordinator_runtime_reconcile.go index a3a41c3c0..189f5b062 100644 --- a/internal/daemon/coordinator_runtime_reconcile.go +++ b/internal/daemon/coordinator_runtime_reconcile.go @@ -22,7 +22,7 @@ func (r *coordinatorRuntime) reconcileCreatedCoordinator( ctx context.Context, info *session.Info, decision coordinator.Decision, - createdCfg aghconfig.CoordinatorConfig, + createdCfg aghconfig.ResolvedCoordinatorRole, reason string, ) (*session.Info, bool, error) { r.mu.Lock() @@ -115,9 +115,9 @@ func (r *coordinatorRuntime) cleanupCreatedCoordinatorSession( func (r *coordinatorRuntime) createCoordinatorSession( ctx context.Context, decision coordinator.Decision, - cfg aghconfig.CoordinatorConfig, + cfg aghconfig.ResolvedCoordinatorRole, reason string, -) (*session.Info, aghconfig.CoordinatorConfig, bool, error) { +) (*session.Info, aghconfig.ResolvedCoordinatorRole, bool, error) { coordinatorParticipation, err := bindCoordinatorParticipation(decision) if err != nil { r.dispatchFailed(ctx, decision, nil, reason, err) diff --git a/internal/daemon/coordinator_runtime_session.go b/internal/daemon/coordinator_runtime_session.go index 696c3a90d..3f67c28da 100644 --- a/internal/daemon/coordinator_runtime_session.go +++ b/internal/daemon/coordinator_runtime_session.go @@ -4,12 +4,10 @@ import ( "context" "errors" "fmt" - "strings" aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/coordinator" - "github.com/compozy/agh/internal/network/participation" "github.com/compozy/agh/internal/session" ) @@ -17,7 +15,7 @@ import ( func (r *coordinatorRuntime) startCoordinatorSession( ctx context.Context, decision coordinator.Decision, - cfg aghconfig.CoordinatorConfig, + cfg aghconfig.ResolvedCoordinatorRole, coordinatorParticipation participation.Spec, ) (*session.Info, error) { policy := coordinator.PermissionPolicy(coordinatorParticipation) @@ -46,18 +44,39 @@ func (r *coordinatorRuntime) startCoordinatorSession( } promptOverlay = joinPromptOverlays(taskOverlay, promptOverlay) } - created, err := r.sessions.Create(ctx, session.CreateOpts{ - AgentName: cfg.AgentName, - Provider: cfg.Provider, - Model: cfg.Model, - Name: coordinatorSessionName(decision.WorkspaceID), - Workspace: decision.WorkspaceID, - ResolvedNetworkParticipation: &coordinatorParticipation, - PromptOverlay: promptOverlay, - Type: session.SessionTypeCoordinator, - Lineage: coordinator.Lineage(now, cfg, policy), + role := ResolvedRole{ + Role: aghconfig.RoleCoordinator, + AgentName: cfg.AgentName, + Provider: cfg.Provider, + Model: cfg.Model, + ReasoningEffort: cfg.ReasoningEffort, + Fallbacks: append([]aghconfig.RoleFallback(nil), cfg.Fallbacks...), + } + role.eventWriter = r.roleEvents + correlation := roleInvocationCorrelationFromContext(ctx, decision.WorkspaceID) + created, err := invokeRoleWithFallback(ctx, role, correlation, func( + attemptCtx context.Context, + route roleAttemptRoute, + ) (*session.Session, bool, error) { + spawned, createErr := r.sessions.Create(attemptCtx, session.CreateOpts{ + AgentName: route.AgentName, + Provider: route.Provider, + Model: route.Model, + ReasoningEffort: route.ReasoningEffort, + Name: coordinatorSessionName(decision.WorkspaceID), + Workspace: decision.WorkspaceID, + ResolvedNetworkParticipation: &coordinatorParticipation, + PromptOverlay: promptOverlay, + Type: session.SessionTypeCoordinator, + Lineage: coordinator.Lineage(now, cfg, policy), + }) + return spawned, spawned != nil, createErr }) if err != nil { + if created != nil { + cleanupErr := r.stopFailedCoordinatorSession(ctx, created, err) + return nil, errors.Join(fmt.Errorf("daemon: create coordinator session: %w", err), cleanupErr) + } return nil, fmt.Errorf("daemon: create coordinator session: %w", err) } if created == nil { @@ -70,6 +89,26 @@ func (r *coordinatorRuntime) startCoordinatorSession( return info, nil } +func (r *coordinatorRuntime) stopFailedCoordinatorSession( + ctx context.Context, + created *session.Session, + cause error, +) error { + if created == nil { + return nil + } + info := created.Info() + if info == nil || strings.TrimSpace(info.ID) == "" { + return errors.New("daemon: accepted coordinator session returned no cleanup identity") + } + stopCtx, cancel := detachedDaemonOperationContext(ctx, coordinatorRuntimeCleanupTimeout) + defer cancel() + if err := r.sessions.StopWithCause(stopCtx, info.ID, session.CauseFailed, cause.Error()); err != nil { + return fmt.Errorf("daemon: stop failed coordinator session %q: %w", info.ID, err) + } + return nil +} + func (r *coordinatorRuntime) activeCoordinator(ctx context.Context, workspaceID string) (*session.Info, error) { infos, err := r.sessions.ListAll(ctx) if err != nil { @@ -84,8 +123,8 @@ func (r *coordinatorRuntime) activeCoordinator(ctx context.Context, workspaceID return nil, nil } -func defaultEnabledCoordinatorConfig() aghconfig.CoordinatorConfig { - cfg := aghconfig.DefaultCoordinatorConfig() +func defaultEnabledCoordinatorRole() aghconfig.ResolvedCoordinatorRole { + cfg := aghconfig.DefaultResolvedCoordinatorRole() cfg.Enabled = true return cfg } diff --git a/internal/daemon/coordinator_runtime_test.go b/internal/daemon/coordinator_runtime_test.go index f3280d534..fd064561e 100644 --- a/internal/daemon/coordinator_runtime_test.go +++ b/internal/daemon/coordinator_runtime_test.go @@ -3,6 +3,7 @@ package daemon import ( "context" "encoding/json" + "errors" "fmt" "slices" "strings" @@ -131,6 +132,63 @@ func TestCoordinatorRuntimeBootstrapsManagedCoordinatorSession(t *testing.T) { } } +func TestCoordinatorRuntimeEmitsRoleFallbackEvents(t *testing.T) { + t.Parallel() + + t.Run("Should emit the fallback before creating the coordinator on its next route", func(t *testing.T) { + t.Parallel() + + sessions := &coordinatorFallbackSessions{ + coordinatorRuntimeSessions: &coordinatorRuntimeSessions{}, + primaryErr: errors.New("primary coordinator route unavailable"), + } + events := &roleEventRecorder{} + runtime, err := newCoordinatorRuntime( + t.Context(), + newCoordinatorRuntimeStore(coordinatorRuntimeTask(), coordinatorRuntimeRun()), + sessions, + &staticCoordinatorRoleResolver{cfg: coordinatorRuntimeConfig()}, + nil, + discardLogger(), + func() time.Time { return time.Date(2026, 4, 26, 12, 0, 0, 0, time.UTC) }, + withCoordinatorRoleEvents(events), + ) + if err != nil { + t.Fatalf("newCoordinatorRuntime() error = %v", err) + } + cfg := coordinatorRuntimeConfig() + cfg.Fallbacks = []aghconfig.RoleFallback{{Provider: "claude", Model: "sonnet"}} + info, err := runtime.startCoordinatorSession( + t.Context(), + coordinator.Decision{WorkspaceID: "ws-1"}, + cfg, + participation.LocalSpec(), + ) + if err != nil { + t.Fatalf("startCoordinatorSession() error = %v", err) + } + if info == nil || info.Provider != "claude" || info.Model != "sonnet" { + t.Fatalf("startCoordinatorSession() = %#v, want fallback route", info) + } + attempts := sessions.snapshotAttempts() + if len(attempts) != 2 || attempts[0].Provider != "codex" || attempts[1].Provider != "claude" { + t.Fatalf("coordinator route attempts = %#v, want codex then claude", attempts) + } + event := events.single(t) + if event.Type != "role.fallback.used" || event.WorkspaceID != "ws-1" { + t.Fatalf("coordinator fallback event = %#v", event) + } + var payload roleFallbackEventPayload + if err := json.Unmarshal(event.Content, &payload); err != nil { + t.Fatalf("json.Unmarshal(fallback event) error = %v", err) + } + if payload.Role != string(aghconfig.RoleCoordinator) || payload.Attempt != 1 || + payload.Provider != "claude" || payload.Model != "sonnet" { + t.Fatalf("coordinator fallback payload = %#v", payload) + } + }) +} + func TestCoordinatorRuntimeBindsLiveParticipationToCoordinatorLifecycle(t *testing.T) { t.Parallel() @@ -289,14 +347,14 @@ func TestCoordinatorRuntimeSkipsIneligibleRuns(t *testing.T) { name string task taskpkg.Task run taskpkg.Run - cfg aghconfig.CoordinatorConfig + cfg aghconfig.ResolvedCoordinatorRole wantReason string }{ { name: "disabled config", task: coordinatorRuntimeTask(), run: coordinatorRuntimeRun(), - cfg: aghconfig.DefaultCoordinatorConfig(), + cfg: aghconfig.DefaultResolvedCoordinatorRole(), wantReason: coordinator.DecisionDisabled, }, { @@ -742,7 +800,7 @@ func newCoordinatorRuntimeForTest( store *coordinatorRuntimeStore, sessions *coordinatorRuntimeSessions, hooks *recordingCoordinatorHooks, - cfg aghconfig.CoordinatorConfig, + cfg aghconfig.ResolvedCoordinatorRole, now time.Time, ) *coordinatorRuntime { t.Helper() @@ -750,7 +808,7 @@ func newCoordinatorRuntimeForTest( context.Background(), store, sessions, - &staticCoordinatorConfigResolver{cfg: cfg}, + &staticCoordinatorRoleResolver{cfg: cfg}, hooks, discardLogger(), func() time.Time { return now }, @@ -761,13 +819,13 @@ func newCoordinatorRuntimeForTest( return runtime } -func coordinatorRuntimeConfig() aghconfig.CoordinatorConfig { - cfg := aghconfig.DefaultCoordinatorConfig() +func coordinatorRuntimeConfig() aghconfig.ResolvedCoordinatorRole { + cfg := aghconfig.DefaultResolvedCoordinatorRole() cfg.Enabled = true cfg.AgentName = "coordinator" cfg.Provider = "codex" cfg.Model = "gpt-5" - cfg.DefaultTTL = 2 * time.Hour + cfg.TTL = 2 * time.Hour cfg.MaxChildren = 5 cfg.MaxActiveSessionsPerWorkspace = 5 return cfg @@ -794,22 +852,22 @@ func coordinatorRuntimeRun() taskpkg.Run { return run } -type staticCoordinatorConfigResolver struct { - cfg aghconfig.CoordinatorConfig +type staticCoordinatorRoleResolver struct { + cfg aghconfig.ResolvedCoordinatorRole err error mu sync.Mutex got []string } -func (r *staticCoordinatorConfigResolver) ResolveCoordinatorConfig( +func (r *staticCoordinatorRoleResolver) ResolveCoordinatorRole( _ context.Context, workspaceID string, -) (aghconfig.CoordinatorConfig, error) { +) (aghconfig.ResolvedCoordinatorRole, error) { r.mu.Lock() defer r.mu.Unlock() r.got = append(r.got, workspaceID) if r.err != nil { - return aghconfig.CoordinatorConfig{}, r.err + return aghconfig.ResolvedCoordinatorRole{}, r.err } return r.cfg, nil } @@ -882,6 +940,33 @@ type coordinatorRuntimeSessions struct { stopErr error } +type coordinatorFallbackSessions struct { + *coordinatorRuntimeSessions + mu sync.Mutex + primaryErr error + attempts []session.CreateOpts +} + +func (s *coordinatorFallbackSessions) Create( + ctx context.Context, + opts session.CreateOpts, +) (*session.Session, error) { + s.mu.Lock() + s.attempts = append(s.attempts, opts) + attempt := len(s.attempts) + s.mu.Unlock() + if attempt == 1 { + return nil, s.primaryErr + } + return s.coordinatorRuntimeSessions.Create(ctx, opts) +} + +func (s *coordinatorFallbackSessions) snapshotAttempts() []session.CreateOpts { + s.mu.Lock() + defer s.mu.Unlock() + return append([]session.CreateOpts(nil), s.attempts...) +} + type coordinatorRuntimePromptCall struct { id string opts session.SyntheticPromptOpts diff --git a/internal/daemon/daemon_acpmock_helpers_integration_test.go b/internal/daemon/daemon_acpmock_helpers_integration_test.go index e08fbe2ca..c0fab1ce8 100644 --- a/internal/daemon/daemon_acpmock_helpers_integration_test.go +++ b/internal/daemon/daemon_acpmock_helpers_integration_test.go @@ -11,7 +11,6 @@ import ( "path/filepath" "runtime" "testing" - "time" aghcontract "github.com/compozy/agh/internal/api/contract" e2etest "github.com/compozy/agh/internal/testutil/e2e" @@ -45,11 +44,11 @@ func createFixtureBackedSession( if err != nil { t.Fatalf("CreateSession(%q) error = %v", agentName, err) } - waitForRuntimeCondition(t, "fixture-backed session visible", 5*time.Second, func() bool { - current, err := harness.GetSession(ctx, session.ID) - return err == nil && current.ID == session.ID - }) - return session + active, err := harness.WaitForSessionActive(ctx, session.ID) + if err != nil { + t.Fatalf("WaitForSessionActive(%q) error = %v", session.ID, err) + } + return active } func createSessionHTTPFailure( @@ -60,6 +59,47 @@ func createSessionHTTPFailure( ) (int, aghcontract.ErrorPayload) { t.Helper() + response := postSessionHTTP(t, ctx, harness, request) + var payload aghcontract.ErrorPayload + decodeErr := json.NewDecoder(response.Body).Decode(&payload) + closeErr := response.Body.Close() + if decodeErr != nil { + t.Fatalf("decode HTTP create session failure error = %v", decodeErr) + } + if closeErr != nil { + t.Fatalf("close HTTP create session failure body error = %v", closeErr) + } + return response.StatusCode, payload +} + +func createSessionHTTPAccepted( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + request aghcontract.CreateSessionRequest, +) (int, aghcontract.SessionPayload) { + t.Helper() + + response := postSessionHTTP(t, ctx, harness, request) + var payload aghcontract.SessionResponse + decodeErr := json.NewDecoder(response.Body).Decode(&payload) + closeErr := response.Body.Close() + if decodeErr != nil { + t.Fatalf("decode HTTP accepted session error = %v", decodeErr) + } + if closeErr != nil { + t.Fatalf("close HTTP accepted session body error = %v", closeErr) + } + return response.StatusCode, payload.Session +} + +func postSessionHTTP( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + request aghcontract.CreateSessionRequest, +) *http.Response { + t.Helper() body, err := json.Marshal(request) if err != nil { t.Fatalf("json.Marshal(create session request) error = %v", err) @@ -78,16 +118,7 @@ func createSessionHTTPFailure( if err != nil { t.Fatalf("HTTP create session error = %v", err) } - var payload aghcontract.ErrorPayload - decodeErr := json.NewDecoder(response.Body).Decode(&payload) - closeErr := response.Body.Close() - if decodeErr != nil { - t.Fatalf("decode HTTP create session failure error = %v", decodeErr) - } - if closeErr != nil { - t.Fatalf("close HTTP create session failure body error = %v", closeErr) - } - return response.StatusCode, payload + return response } func providerModelListHTTP( diff --git a/internal/daemon/daemon_agent_definition_e2e_integration_test.go b/internal/daemon/daemon_agent_definition_e2e_integration_test.go index b2767c57d..c8063fbbb 100644 --- a/internal/daemon/daemon_agent_definition_e2e_integration_test.go +++ b/internal/daemon/daemon_agent_definition_e2e_integration_test.go @@ -10,7 +10,9 @@ import ( "io" "net/http" "net/url" + "os" "os/exec" + "path/filepath" "reflect" "strings" "testing" @@ -19,10 +21,13 @@ import ( "github.com/compozy/agh/internal/agentidentity" aghcontract "github.com/compozy/agh/internal/api/contract" aghconfig "github.com/compozy/agh/internal/config" + "github.com/compozy/agh/internal/testutil/acpmock" e2etest "github.com/compozy/agh/internal/testutil/e2e" + toolspkg "github.com/compozy/agh/internal/tools" ) func TestDaemonE2EAgentDefinitionLifecycleParity(t *testing.T) { + acpmock.RequireDriver(t) t.Parallel() t.Run("Should preserve lifecycle parity across CLI HTTP and restart", func(t *testing.T) { @@ -32,7 +37,7 @@ func TestDaemonE2EAgentDefinitionLifecycleParity(t *testing.T) { t.Run("Should categorize and update a bundled extension agent through the effective catalog", func(t *testing.T) { // not parallel: lifecycle steps share one ordered runtime state. - configSeed := agentDefinitionE2EConfigSeed() + configSeed := agentDefinitionE2EConfigSeed(t) harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ ConfigSeed: configSeed, }) @@ -140,12 +145,151 @@ func TestDaemonE2EAgentDefinitionLifecycleParity(t *testing.T) { }) } +func TestDaemonE2EReservedAgentNameSweep(t *testing.T) { + acpmock.RequireDriver(t) + t.Parallel() + + t.Run("Should reject every reserved authoring entry without changing the agent catalog", func(t *testing.T) { + t.Parallel() + runDaemonE2EReservedAgentNameSweep(t) + }) +} + +func runDaemonE2EReservedAgentNameSweep(t *testing.T) { + t.Helper() + + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{ + AgentDefs: []e2etest.AgentSeed{{ + Name: "reserved-sweep-source", Provider: acpmock.ProviderName, + Prompt: "Source for duplicate rejection.", + }}, + }, + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "multi_agent_fixture.json"), + FixtureAgent: "alpha", + AgentName: "reserved-sweep-provider", + }}, + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var before []aghcontract.AgentPayload + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &before, + "agent", + "list", + "--workspace", + harness.WorkspaceRoot, + "-o", + "json", + ); err != nil { + t.Fatalf("CLI agent list before reserved sweep error = %v", err) + } + + _, cliStderr, cliErr := harness.CLI.RunInDir( + ctx, + harness.WorkspaceRoot, + "agent", + "create", + "coordinator", + "--provider", + acpmock.ProviderName, + "--prompt", + "Reserved CLI create.", + "-o", + "json", + ) + assertReservedAgentCLIError(t, cliErr, cliStderr) + + httpCreate := agentDefinitionE2ERequestError( + t, + ctx, + harness.HTTPClient, + harness.HTTPURL("/api/agents"), + http.MethodPost, + aghcontract.CreateAgentRequest{ + Scope: aghcontract.AgentCreateScopeGlobal, + Agent: aghcontract.CreateAgentPayload{ + Name: "dreaming-curator", Provider: acpmock.ProviderName, Prompt: "Reserved HTTP create.", + }, + }, + ) + assertReservedAgentHTTPError(t, httpCreate) + + duplicate := agentDefinitionE2ERequestError( + t, + ctx, + harness.UDSClient, + harness.UDSURL("/api/agents/reserved-sweep-source/duplicate"), + http.MethodPost, + aghcontract.DuplicateAgentRequest{Name: "coordinator"}, + ) + assertReservedAgentHTTPError(t, duplicate) + native := agentDefinitionE2EToolRequestError( + t, + ctx, + harness.HTTPClient, + harness.HTTPURL("/api/tools/agh__agent_create/invoke"), + aghcontract.ToolInvokeRequest{ + WorkspaceID: harness.WorkspaceID, + Input: json.RawMessage( + `{"scope":"workspace","workspace":"` + harness.WorkspaceRoot + `","name":"coordinator","prompt":"Reserved native create."}`, + ), + }, + ) + if native.Status != http.StatusUnprocessableEntity || + native.Payload.Error.Code != toolspkg.ErrorCodeAgentNameReserved { + t.Fatalf("native reserved create = %#v, want 422 agent_name_reserved", native) + } + + for _, name := range []string{"coordinator", "dreaming-curator"} { + path := filepath.Join(harness.HomePaths.AgentsDir, name) + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("os.Stat(%q) error = %v, want os.ErrNotExist", path, err) + } + } + workspaceReservedPath := filepath.Join( + harness.WorkspaceRoot, + aghconfig.DirName, + aghconfig.AgentsDirName, + "coordinator", + ) + if _, err := os.Stat(workspaceReservedPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("os.Stat(%q) error = %v, want os.ErrNotExist", workspaceReservedPath, err) + } + var after []aghcontract.AgentPayload + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &after, + "agent", + "list", + "--workspace", + harness.WorkspaceRoot, + "-o", + "json", + ); err != nil { + t.Fatalf("CLI agent list after reserved sweep error = %v", err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("agent catalog after reserved sweep = %#v, want unchanged %#v", after, before) + } +} + func runDaemonE2EAgentDefinitionLifecycleParity(t *testing.T) { t.Helper() - configSeed := agentDefinitionE2EConfigSeed() + configSeed := agentDefinitionE2EConfigSeed(t) harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ ConfigSeed: configSeed, + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "multi_agent_fixture.json"), + FixtureAgent: "alpha", + AgentName: "parity-provider-fixture", + }}, }) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() @@ -162,7 +306,7 @@ func runDaemonE2EAgentDefinitionLifecycleParity(t *testing.T) { "--workspace", harness.WorkspaceRoot, "--provider", - "fake", + acpmock.ProviderName, "--model", "model-v1", "--tool", @@ -195,7 +339,7 @@ func runDaemonE2EAgentDefinitionLifecycleParity(t *testing.T) { updateRequest := aghcontract.UpdateAgentRequest{ Workspace: harness.WorkspaceRoot, Agent: aghcontract.CreateAgentPayload{ - Name: sourceName, Provider: "fake", Model: "model-v2", + Name: sourceName, Provider: acpmock.ProviderName, Model: "model-v2", Tools: []string{"builtin__shell"}, Prompt: "Review code.", }, ExpectedDigest: cliCreated.DefinitionDigest, @@ -415,16 +559,18 @@ func runDaemonE2EAgentDefinitionLifecycleParity(t *testing.T) { ) } -func agentDefinitionE2EConfigSeed() e2etest.ConfigSeedOptions { +func agentDefinitionE2EConfigSeed(t *testing.T) e2etest.ConfigSeedOptions { + t.Helper() + + driverPath, err := acpmock.DefaultDriverPath() + if err != nil { + t.Fatalf("resolve ACP mock driver path: %v", err) + } + return e2etest.ConfigSeedOptions{ - DefaultProvider: "fake", + DefaultProvider: acpmock.ProviderName, Providers: map[string]aghconfig.ProviderConfig{ - "fake": { - Command: "fake-agent --stdio", - Models: aghconfig.ProviderModelsConfig{ - Default: "fake-model", - }, - }, + acpmock.ProviderName: acpmock.ProviderConfig(driverPath), }, } } @@ -461,6 +607,11 @@ type agentDefinitionE2EError struct { Payload aghcontract.ErrorPayload } +type agentDefinitionE2EToolError struct { + Status int + Payload aghcontract.ToolErrorResponse +} + func agentDefinitionE2ERequestError( t *testing.T, ctx context.Context, @@ -505,6 +656,68 @@ func agentDefinitionE2ERequestError( return agentDefinitionE2EError{Status: response.StatusCode, Payload: errorPayload} } +func agentDefinitionE2EToolRequestError( + t *testing.T, + ctx context.Context, + client *http.Client, + target string, + body aghcontract.ToolInvokeRequest, +) agentDefinitionE2EToolError { + t.Helper() + + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("json.Marshal(native tool request) error = %v", err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload)) + if err != nil { + t.Fatalf("http.NewRequestWithContext(native tool request) error = %v", err) + } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + t.Fatalf("HTTP native tool request error = %v", err) + } + responsePayload, readErr := io.ReadAll(response.Body) + closeErr := response.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + t.Fatalf("read/close native tool response error = %v", err) + } + if response.StatusCode >= 200 && response.StatusCode < 300 { + t.Fatalf("native tool status = %d, want error; body=%s", response.StatusCode, responsePayload) + } + var errorPayload aghcontract.ToolErrorResponse + if err := json.Unmarshal(responsePayload, &errorPayload); err != nil { + t.Fatalf("json.Unmarshal(native tool error) = %v; body=%s", err, responsePayload) + } + return agentDefinitionE2EToolError{Status: response.StatusCode, Payload: errorPayload} +} + +func assertReservedAgentHTTPError(t *testing.T, got agentDefinitionE2EError) { + t.Helper() + + if got.Status != http.StatusUnprocessableEntity || got.Payload.Diagnostic == nil || + got.Payload.Diagnostic.Code != aghcontract.CodeAgentNameReserved { + t.Fatalf("reserved agent mutation = %#v, want 422 agent_name_reserved", got) + } +} + +func assertReservedAgentCLIError(t *testing.T, err error, stderr string) { + t.Helper() + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("reserved CLI error = %T %[1]v, want *exec.ExitError", err) + } + var payload aghcontract.ErrorPayload + if err := json.Unmarshal([]byte(strings.TrimSpace(stderr)), &payload); err != nil { + t.Fatalf("json.Unmarshal(reserved CLI stderr) = %v; stderr=%s", err, stderr) + } + if payload.Diagnostic == nil || payload.Diagnostic.Code != aghcontract.CodeAgentNameReserved { + t.Fatalf("reserved CLI payload = %#v, want agent_name_reserved", payload) + } +} + func assertAgentDefinitionE2ECLIError( t *testing.T, err error, diff --git a/internal/daemon/daemon_memory_e2e_integration_test.go b/internal/daemon/daemon_memory_e2e_integration_test.go index eaced0079..5b3f7e450 100644 --- a/internal/daemon/daemon_memory_e2e_integration_test.go +++ b/internal/daemon/daemon_memory_e2e_integration_test.go @@ -4,25 +4,128 @@ package daemon import ( "context" + "database/sql" "encoding/json" + "fmt" "net/http" "net/url" "os" "path/filepath" + "strconv" "strings" "testing" "time" - sdkmcp "github.com/mark3labs/mcp-go/mcp" - - memcontract "github.com/compozy/agh/internal/memory/contract" - + acpsdk "github.com/coder/acp-go-sdk" aghcontract "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" + memcontract "github.com/compozy/agh/internal/memory/contract" + sessionpkg "github.com/compozy/agh/internal/session" "github.com/compozy/agh/internal/testutil/acpmock" e2etest "github.com/compozy/agh/internal/testutil/e2e" toolspkg "github.com/compozy/agh/internal/tools" + sdkmcp "github.com/mark3labs/mcp-go/mcp" + _ "modernc.org/sqlite" ) +const ( + roleDreamProviderName = "role-dream" + roleDreamModel = "routed-dream-model" +) + +func TestDaemonE2ERolesLiveApplyChangesNextMemoryExtractorModel(t *testing.T) { + t.Parallel() + + t.Run("Should record the HTTP apply and route the next extractor through the applied model", func(t *testing.T) { + t.Parallel() + acpmock.RequireDriver(t) + + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{Mutate: func(cfg *aghconfig.Config) { + cfg.Roles.AutoTitle.Enabled = false + cfg.Roles.MemoryExtractor.Provider = acpmock.ProviderName + cfg.Roles.MemoryExtractor.Model = "extractor-model-v1" + }}, + MockAgents: []e2etest.MockAgentSpec{{ + FixturePath: mockFixturePath(t, "agent_roles_fixture.json"), + FixtureAgent: "role-agent", + AgentName: "memory-extractor-live-apply", + }}, + }) + registration, ok := harness.MockAgentRegistration("memory-extractor-live-apply") + if !ok { + t.Fatal("MockAgentRegistration(memory-extractor-live-apply) = missing, want present") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var roles aghcontract.SettingsRolesResponse + if err := harness.HTTPJSON(ctx, http.MethodGet, "/api/settings/roles", nil, &roles); err != nil { + t.Fatalf("HTTP GET settings roles error = %v", err) + } + roles.Config.MemoryExtractor.Model = "extractor-model-v2" + var apply aghcontract.SettingsApplyResponse + if err := harness.HTTPJSON( + ctx, + http.MethodPatch, + "/api/settings/roles", + aghcontract.UpdateSettingsRolesRequest{Config: roles.Config}, + &apply, + ); err != nil { + t.Fatalf("HTTP PATCH settings roles error = %v", err) + } + if !apply.Applied || apply.Lifecycle != aghcontract.SettingsApplyLifecycleLive || apply.ApplyRecordID == "" { + t.Fatalf("roles live apply = %#v, want applied live record", apply) + } + + var history aghcontract.ConfigApplyRecordsResponse + if err := harness.HTTPJSON(ctx, http.MethodGet, "/api/settings/apply", nil, &history); err != nil { + t.Fatalf("HTTP GET settings apply history error = %v", err) + } + foundApply := false + for _, entry := range history.Entries { + if entry.ID == apply.ApplyRecordID && entry.Actor == "httpapi" && + entry.Lifecycle == aghcontract.SettingsApplyLifecycleLive && + entry.Status == aghcontract.ConfigApplyStatusApplied { + foundApply = true + break + } + } + if !foundApply { + t.Fatalf("settings apply history = %#v, want live HTTP record %q", history.Entries, apply.ApplyRecordID) + } + + root := createFixtureBackedSession(t, ctx, harness, "memory-extractor-live-apply", "") + if _, err := harness.PromptSession(ctx, root.ID, "Record the extractor routing decision"); err != nil { + t.Fatalf("PromptSession(memory extractor live apply) error = %v", err) + } + + var childID string + waitForRuntimeCondition(t, "memory extractor uses live-applied model", 10*time.Second, func() bool { + for _, candidate := range readAutoTitleRoleSessions(t, ctx, harness) { + if candidate.Lineage != nil && candidate.Lineage.SpawnRole == sessionpkg.SpawnRoleMemoryExtractor { + childID = candidate.ID + } + } + if childID == "" { + return false + } + records, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) + if err != nil { + return false + } + for _, record := range acpmock.ProtocolDiagnostics(acpmock.DiagnosticsForAGHSession(records, childID)) { + if record.ProtocolMethod == acpsdk.AgentMethodSessionSetConfigOption && + record.ConfigOptionID == "model" && record.ConfigOptionValue == "extractor-model-v2" { + return true + } + } + return false + }) + }) +} + func TestDaemonE2EMemoryCatalogCLIHTTPParityAndLegacyPathIsolation(t *testing.T) { t.Parallel() @@ -479,6 +582,366 @@ func TestDaemonE2EMemoryRecallUsesCatalogSynthesisWithoutMutatingStoredUserMessa }) } +func TestDaemonE2EDreamRoleRoutesBuiltinIdentityAndModel(t *testing.T) { + t.Parallel() + + t.Run("Should route dream sessions through the configured builtin model", func(t *testing.T) { + t.Parallel() + runDaemonE2EDreamRoleRoutesBuiltinIdentityAndModel(t) + }) +} + +func runDaemonE2EDreamRoleRoutesBuiltinIdentityAndModel(t *testing.T) { + t.Helper() + + acpmock.RequireDriver(t) + + diagnosticsPath := filepath.Join(t.TempDir(), "builtin-dream.jsonl") + command := roleDreamMockCommand(t, diagnosticsPath) + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{ + AgentDefs: []e2etest.AgentSeed{{ + Name: "session-driver", + Provider: roleDreamProviderName, + Command: command, + Model: roleDreamModel, + Prompt: "Generate one completed session for dream eligibility.", + }}, + Mutate: func(cfg *aghconfig.Config) { + configureRoleDreamE2E(cfg, command) + cfg.Roles.Dream.Provider = roleDreamProviderName + cfg.Roles.Dream.Model = roleDreamModel + cfg.Roles.AutoTitle.Enabled = false + }, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + seedDreamEligibility(t, ctx, harness, "session-driver", diagnosticsPath) + triggerDreamEventually(t, ctx, harness, diagnosticsPath) + + dreamRecord := requireDreamPromptDiagnostic(t, diagnosticsPath) + dreamSession, err := harness.GetSession(ctx, dreamRecord.AGHSessionID) + if err != nil { + t.Fatalf("GetSession(dream) error = %v", err) + } + if dreamSession.AgentName != aghconfig.BuiltinDreamingCuratorAgentName || + dreamSession.Model != roleDreamModel || dreamSession.Type != "dream" { + t.Fatalf("dream session = %#v, want builtin dreaming-curator routed to %s", dreamSession, roleDreamModel) + } + assertDreamHiddenFromFleet(t, ctx, harness, dreamSession.ID) +} + +func TestDaemonE2EDreamRoleUsesWorkspaceAgentWithoutAutoTitle(t *testing.T) { + t.Parallel() + + t.Run("Should isolate workspace dream routing and keep auto-title disabled", func(t *testing.T) { + t.Parallel() + runDaemonE2EDreamRoleUsesWorkspaceAgentWithoutAutoTitle(t) + }) +} + +func runDaemonE2EDreamRoleUsesWorkspaceAgentWithoutAutoTitle(t *testing.T) { + t.Helper() + + acpmock.RequireDriver(t) + + diagnosticsPath := filepath.Join(t.TempDir(), "workspace-dream.jsonl") + command := roleDreamMockCommand(t, diagnosticsPath) + agentDocument := fmt.Sprintf(`--- +name: workspace-curator +provider: %s +command: %s +model: %s +permissions: approve-all +--- + +Curate durable workspace knowledge. +`, roleDreamProviderName, quotedYAMLString(command), roleDreamModel) + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + ConfigSeed: e2etest.ConfigSeedOptions{Mutate: func(cfg *aghconfig.Config) { + configureRoleDreamE2E(cfg, command) + cfg.Roles.MemoryExtractor.Enabled = false + }}, + Workspace: e2etest.WorkspaceSeedOptions{Files: map[string]string{ + ".agh/agents/workspace-curator/AGENT.md": agentDocument, + ".agh/config.toml": `[roles.dream] +agent = "workspace-curator" +provider = "role-dream" +model = "routed-dream-model" + +[roles.auto_title] +enabled = false +`, + }}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + seedDreamEligibility(t, ctx, harness, "workspace-curator", diagnosticsPath) + triggerDreamEventually(t, ctx, harness, diagnosticsPath) + + records, err := acpmock.ReadDiagnostics(diagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(workspace dream) error = %v", err) + } + dreamRecord := requireDreamPromptRecord(t, records) + dreamSession, err := harness.GetSession(ctx, dreamRecord.AGHSessionID) + if err != nil { + t.Fatalf("GetSession(workspace dream) error = %v", err) + } + if dreamSession.AgentName != "workspace-curator" || dreamSession.Model != roleDreamModel { + t.Fatalf("workspace dream session = %#v, want workspace-curator routed to %s", dreamSession, roleDreamModel) + } + if got := len(acpmock.PromptDiagnostics(records)); got != 2 { + t.Fatalf("prompt diagnostics = %#v, want one user turn plus one dream and no auto-title", records) + } + assertDreamHiddenFromFleet(t, ctx, harness, dreamSession.ID) +} + +func configureRoleDreamE2E(cfg *aghconfig.Config, command string) { + cfg.Providers[roleDreamProviderName] = acpmock.ProviderConfig(command) + cfg.Memory.Dream.MinHours = 0.000001 + cfg.Memory.Dream.MinSessions = 1 + cfg.Memory.Dream.Gates.MinUnpromoted = 1 + cfg.Memory.Dream.Gates.MinRecallCount = 1 + cfg.Memory.Dream.Gates.MinScore = 0.000001 +} +func roleDreamMockCommand(t testing.TB, diagnosticsPath string) string { + t.Helper() + driverPath, err := acpmock.DefaultDriverPath() + if err != nil { + t.Fatalf("acpmock.DefaultDriverPath() error = %v", err) + } + return acpmock.BuildCommand( + driverPath, + mockFixturePath(t, "agent_roles_fixture.json"), + "role-agent", + diagnosticsPath, + ) +} + +func quotedYAMLString(value string) string { + return strconv.Quote(value) +} + +func seedDreamEligibility( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + agentName string, + diagnosticsPath string, +) { + t.Helper() + writeMemoryViaUDS( + t, + ctx, + harness, + "dream-routing.md", + harness.WorkspaceRoot, + "Remember the configurable background dream routing signal.", + ) + session := createFixtureBackedSession(t, ctx, harness, agentName, "") + waitForRuntimeCondition(t, "dream eligibility session active", 10*time.Second, func() bool { + current, err := harness.GetSession(ctx, session.ID) + return err == nil && current.State == sessionpkg.StateActive + }) + if _, err := harness.PromptSession(ctx, session.ID, "remember the background dream routing signal"); err != nil { + t.Fatalf("PromptSession(dream eligibility) error = %v", err) + } + records, err := acpmock.ReadDiagnostics(diagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(dream eligibility) error = %v", err) + } + ownedPrompts := acpmock.PromptDiagnostics(acpmock.DiagnosticsForAGHSession(records, session.ID)) + if len(ownedPrompts) != 1 || !strings.Contains(ownedPrompts[0].Prompt, "Relevant durable memory for this turn:") { + t.Fatalf("dream eligibility prompts = %#v, want recalled durable memory", ownedPrompts) + } + if err := harness.StopSession(ctx, session.ID); err != nil { + t.Fatalf("StopSession(dream eligibility) error = %v", err) + } + waitForRuntimeCondition(t, "dream eligibility session stopped", 10*time.Second, func() bool { + current, err := harness.GetSession(ctx, session.ID) + return err == nil && current.State == sessionpkg.StateStopped + }) +} + +func triggerDreamEventually( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + diagnosticsPath string, +) { + t.Helper() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + deadline := time.NewTimer(10 * time.Second) + defer deadline.Stop() + + for { + recorded, err := dreamPromptRecorded(diagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(dream poll) error = %v", err) + } + if recorded { + return + } + var response aghcontract.MemoryDreamTriggerResponse + if err := harness.UDSJSON( + ctx, + http.MethodPost, + "/api/memory/dreams/trigger", + aghcontract.MemoryDreamTriggerRequest{WorkspaceID: harness.WorkspaceID}, + &response, + ); err != nil { + t.Fatalf("trigger memory dream error = %v", err) + } + select { + case <-ctx.Done(): + t.Fatalf( + "memory dream never triggered: %v (last reason %q; %s)", + ctx.Err(), + response.Reason, + dreamGateDiagnostics( + t, + harness.HomePaths.DatabaseFile, + harness.HomePaths.SessionsDir, + harness.WorkspaceRoot, + ), + ) + case <-deadline.C: + t.Fatalf( + "memory dream never triggered before deadline (last reason %q; %s)", + response.Reason, + dreamGateDiagnostics( + t, + harness.HomePaths.DatabaseFile, + harness.HomePaths.SessionsDir, + harness.WorkspaceRoot, + ), + ) + case <-ticker.C: + } + } +} + +func dreamPromptRecorded(diagnosticsPath string) (bool, error) { + records, err := acpmock.ReadDiagnostics(diagnosticsPath) + if err != nil { + return false, err + } + for _, record := range acpmock.PromptDiagnostics(records) { + if strings.Contains(record.Prompt, "# Dream Consolidation") { + return true, nil + } + } + return false, nil +} + +func dreamGateDiagnostics(t testing.TB, databasePath string, sessionsDir string, workspaceRoot string) string { + t.Helper() + db, err := sql.Open("sqlite", databasePath) + if err != nil { + return fmt.Sprintf("open catalog: %v", err) + } + var ( + count int + minRecallCount int + maxRecallCount int + minRecallScore float64 + maxRecallScore float64 + workspaceIDs string + ) + queryErr := db.QueryRow(` +SELECT + COUNT(*), + COALESCE(MIN(sig.recall_count), 0), + COALESCE(MAX(sig.recall_count), 0), + COALESCE(MIN(sig.recall_score), 0), + COALESCE(MAX(sig.recall_score), 0), + COALESCE(GROUP_CONCAT(DISTINCT e.workspace_id), '') +FROM memory_recall_signals sig +JOIN memory_chunks c ON c.id = sig.chunk_id +JOIN memory_catalog_entries e ON e.id = c.file_id +WHERE sig.promoted_at IS NULL AND e.injection = 1 +`).Scan(&count, &minRecallCount, &maxRecallCount, &minRecallScore, &maxRecallScore, &workspaceIDs) + closeErr := db.Close() + if queryErr != nil { + return fmt.Sprintf("query catalog: %v", queryErr) + } + if closeErr != nil { + return fmt.Sprintf("close catalog: %v", closeErr) + } + completedSessions := 0 + entries, entriesErr := os.ReadDir(sessionsDir) + if entriesErr == nil { + for _, entry := range entries { + metadata, readErr := os.ReadFile(filepath.Join(sessionsDir, entry.Name(), "meta.json")) + if readErr == nil && (strings.Contains(string(metadata), `"state":"stopped"`) || + strings.Contains(string(metadata), `"state": "stopped"`)) { + completedSessions++ + } + } + } + identity, identityErr := os.ReadFile(filepath.Join(workspaceRoot, ".agh", "workspace.toml")) + return fmt.Sprintf( + "eligible rows=%d recall_count=%d..%d recall_score=%g..%g workspaces=%q completed_session_files=%d session_scan_error=%v identity=%q identity_error=%v", + count, + minRecallCount, + maxRecallCount, + minRecallScore, + maxRecallScore, + workspaceIDs, + completedSessions, + entriesErr, + strings.TrimSpace(string(identity)), + identityErr, + ) +} + +func requireDreamPromptDiagnostic(t testing.TB, diagnosticsPath string) acpmock.DiagnosticsRecord { + t.Helper() + records, err := acpmock.ReadDiagnostics(diagnosticsPath) + if err != nil { + t.Fatalf("ReadDiagnostics(dream) error = %v", err) + } + return requireDreamPromptRecord(t, records) +} + +func requireDreamPromptRecord( + t testing.TB, + records []acpmock.DiagnosticsRecord, +) acpmock.DiagnosticsRecord { + t.Helper() + for _, record := range acpmock.PromptDiagnostics(records) { + if strings.Contains(record.Prompt, "# Dream Consolidation") { + return record + } + } + t.Fatalf("prompt diagnostics = %#v, want dream consolidation prompt", records) + return acpmock.DiagnosticsRecord{} +} + +func assertDreamHiddenFromFleet( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + dreamSessionID string, +) { + t.Helper() + var response aghcontract.SessionCatalogResponse + path := "/api/sessions?workspace=" + url.QueryEscape(harness.WorkspaceID) + "&limit=100" + if err := harness.UDSJSON(ctx, http.MethodGet, path, nil, &response); err != nil { + t.Fatalf("list fleet sessions error = %v", err) + } + for _, candidate := range response.Sessions { + if candidate.ID == dreamSessionID { + t.Fatalf("fleet sessions = %#v, want dream session %q hidden", response.Sessions, dreamSessionID) + } + } +} + func writeMemoryViaCLI( t testing.TB, ctx context.Context, diff --git a/internal/daemon/daemon_mock_agents_integration_test.go b/internal/daemon/daemon_mock_agents_integration_test.go index ef0d6fd9c..6f354cb27 100644 --- a/internal/daemon/daemon_mock_agents_integration_test.go +++ b/internal/daemon/daemon_mock_agents_integration_test.go @@ -21,6 +21,7 @@ import ( aghconfig "github.com/compozy/agh/internal/config" eventspkg "github.com/compozy/agh/internal/events" mcppkg "github.com/compozy/agh/internal/mcp" + "github.com/compozy/agh/internal/session" "github.com/compozy/agh/internal/store" "github.com/compozy/agh/internal/store/globaldb" taskpkg "github.com/compozy/agh/internal/task" @@ -153,6 +154,7 @@ func TestDaemonE2EProviderReasoningNegotiatesThroughAdvertisedACPOptions(t *test if err != nil { t.Fatalf("CreateSession(agent default) error = %v", err) } + sessionPayload = waitForReasoningSessionActive(t, ctx, harness, sessionPayload) if sessionPayload.Provider != "claude" || sessionPayload.Model != "claude-sonnet-5" || sessionPayload.ReasoningEffort != aghcontract.ReasoningEffort("max") { t.Fatalf("session runtime = %#v, want claude/claude-sonnet-5/max", sessionPayload) @@ -230,6 +232,7 @@ func TestDaemonE2EProviderReasoningNegotiatesThroughAdvertisedACPOptions(t *test if err != nil { t.Fatalf("CreateSession(%q) error = %v", tt.agentName, err) } + sessionPayload = waitForReasoningSessionActive(t, ctx, harness, sessionPayload) if sessionPayload.Provider != tt.provider || sessionPayload.Model != tt.model || sessionPayload.ReasoningEffort != tt.effort { t.Fatalf("session runtime = %#v, want %s/%s/%s", sessionPayload, tt.provider, tt.model, tt.effort) @@ -271,6 +274,9 @@ func TestDaemonE2EProviderReasoningNegotiatesThroughAdvertisedACPOptions(t *test } sessions = append(sessions, sessionPayload) } + for index, sessionPayload := range sessions { + sessions[index] = waitForReasoningSessionActive(t, ctx, harness, sessionPayload) + } if sessions[0].ID == sessions[1].ID { t.Fatalf("concurrent AGH session IDs = %q, want distinct owners", sessions[0].ID) } @@ -325,24 +331,28 @@ func TestDaemonE2EProviderReasoningNegotiatesThroughAdvertisedACPOptions(t *test } }) - t.Run("Should return reasoning_option_missing before the first prompt", func(t *testing.T) { + t.Run("Should persist reasoning_option_missing before the first prompt", func(t *testing.T) { registration, ok := harness.MockAgentRegistration("reasoning-codex-missing") if !ok { t.Fatal("MockAgentRegistration(reasoning-codex-missing) = missing") } - status, payload := createSessionHTTPFailure(t, ctx, harness, aghcontract.CreateSessionRequest{ + status, accepted := createSessionHTTPAccepted(t, ctx, harness, aghcontract.CreateSessionRequest{ AgentName: "reasoning-codex-missing", Provider: "codex", Model: "gpt-5.6-sol", ReasoningEffort: "max", WorkspacePath: harness.WorkspaceRoot, }) - if status != http.StatusUnprocessableEntity { - t.Fatalf("HTTP create session status = %d, want %d", status, http.StatusUnprocessableEntity) - } - if payload.Diagnostic == nil || payload.Diagnostic.Code != aghcontract.CodeReasoningOptionMissing { - t.Fatalf("HTTP create session diagnostic = %#v, want reasoning_option_missing", payload.Diagnostic) + if status != http.StatusCreated || accepted.State != session.StateStarting { + t.Fatalf("HTTP create session = status:%d session:%#v, want 201 starting", status, accepted) } + waitForReasoningNegotiationFailure( + t, + ctx, + harness, + accepted.ID, + `reasoning effort "max" is unavailable`, + ) records, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) if err != nil { t.Fatalf("ReadDiagnostics(reasoning-codex-missing) error = %v", err) @@ -359,27 +369,28 @@ func TestDaemonE2EProviderReasoningNegotiatesThroughAdvertisedACPOptions(t *test } }) - t.Run("Should return model_unavailable before the first prompt", func(t *testing.T) { + t.Run("Should persist model_unavailable before the first prompt", func(t *testing.T) { registration, ok := harness.MockAgentRegistration("reasoning-codex-unavailable") if !ok { t.Fatal("MockAgentRegistration(reasoning-codex-unavailable) = missing") } - status, payload := createSessionHTTPFailure(t, ctx, harness, aghcontract.CreateSessionRequest{ + status, accepted := createSessionHTTPAccepted(t, ctx, harness, aghcontract.CreateSessionRequest{ AgentName: "reasoning-codex-unavailable", Provider: "codex", Model: "gpt-5.6-terra", ReasoningEffort: "max", WorkspacePath: harness.WorkspaceRoot, }) - if status != http.StatusUnprocessableEntity { - t.Fatalf("HTTP create session status = %d, want %d", status, http.StatusUnprocessableEntity) - } - if payload.Diagnostic == nil || payload.Diagnostic.Code != aghcontract.CodeModelUnavailable { - t.Fatalf("HTTP create session diagnostic = %#v, want model_unavailable", payload.Diagnostic) - } - if got := payload.Diagnostic.Evidence["requested"]; got != "gpt-5.6-terra" { - t.Fatalf("model_unavailable requested evidence = %#v, want gpt-5.6-terra", got) + if status != http.StatusCreated || accepted.State != session.StateStarting { + t.Fatalf("HTTP create session = status:%d session:%#v, want 201 starting", status, accepted) } + waitForReasoningNegotiationFailure( + t, + ctx, + harness, + accepted.ID, + `model "gpt-5.6-terra" is unavailable`, + ) records, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) if err != nil { t.Fatalf("ReadDiagnostics(reasoning-codex-unavailable) error = %v", err) @@ -392,27 +403,28 @@ func TestDaemonE2EProviderReasoningNegotiatesThroughAdvertisedACPOptions(t *test } }) - t.Run("Should return reasoning_effort_unsupported before the first prompt", func(t *testing.T) { + t.Run("Should persist reasoning_effort_unsupported before the first prompt", func(t *testing.T) { registration, ok := harness.MockAgentRegistration("reasoning-codex-unsupported") if !ok { t.Fatal("MockAgentRegistration(reasoning-codex-unsupported) = missing") } - status, payload := createSessionHTTPFailure(t, ctx, harness, aghcontract.CreateSessionRequest{ + status, accepted := createSessionHTTPAccepted(t, ctx, harness, aghcontract.CreateSessionRequest{ AgentName: "reasoning-codex-unsupported", Provider: "codex", Model: "gpt-5.6-sol", ReasoningEffort: "minimal", WorkspacePath: harness.WorkspaceRoot, }) - if status != http.StatusUnprocessableEntity { - t.Fatalf("HTTP create session status = %d, want %d", status, http.StatusUnprocessableEntity) - } - if payload.Diagnostic == nil || payload.Diagnostic.Code != aghcontract.CodeReasoningEffortUnsupported { - t.Fatalf("HTTP create session diagnostic = %#v, want reasoning_effort_unsupported", payload.Diagnostic) - } - if got := payload.Diagnostic.Evidence["requested"]; got != "minimal" { - t.Fatalf("unsupported effort requested evidence = %#v, want minimal", got) + if status != http.StatusCreated || accepted.State != session.StateStarting { + t.Fatalf("HTTP create session = status:%d session:%#v, want 201 starting", status, accepted) } + waitForReasoningNegotiationFailure( + t, + ctx, + harness, + accepted.ID, + `reasoning effort "minimal" is unavailable`, + ) records, err := acpmock.ReadDiagnostics(registration.DiagnosticsPath) if err != nil { t.Fatalf("ReadDiagnostics(reasoning-codex-unsupported) error = %v", err) @@ -430,6 +442,57 @@ func TestDaemonE2EProviderReasoningNegotiatesThroughAdvertisedACPOptions(t *test }) } +func waitForReasoningSessionActive( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + accepted aghcontract.SessionPayload, +) aghcontract.SessionPayload { + t.Helper() + current := accepted + waitForRuntimeCondition(t, "reasoning session startup", 10*time.Second, func() bool { + resolved, err := harness.GetSession(ctx, accepted.ID) + if err != nil { + return false + } + current = resolved + return current.State == session.StateActive || current.State == session.StateStopped + }) + if current.State != session.StateActive { + t.Fatalf("reasoning session startup = %#v, want active", current) + } + return current +} + +func waitForReasoningNegotiationFailure( + t testing.TB, + ctx context.Context, + harness *e2etest.RuntimeHarness, + sessionID string, + wantSummary string, +) aghcontract.SessionPayload { + t.Helper() + var current aghcontract.SessionPayload + waitForRuntimeCondition(t, "reasoning negotiation failure", 10*time.Second, func() bool { + resolved, err := harness.GetSession(ctx, sessionID) + if err != nil { + return false + } + current = resolved + return current.State == session.StateStopped + }) + if current.Failure == nil || current.Failure.Kind != store.FailureProtocol || + !strings.Contains(current.Failure.Summary, wantSummary) { + t.Fatalf( + "reasoning negotiation failure = %#v, want kind %q containing %q", + current.Failure, + store.FailureProtocol, + wantSummary, + ) + } + return current +} + func assertReasoningProtocolSequence( t testing.TB, records []acpmock.DiagnosticsRecord, diff --git a/internal/daemon/daemon_sandbox_integration_test.go b/internal/daemon/daemon_sandbox_integration_test.go index 9e90c7018..be47f9079 100644 --- a/internal/daemon/daemon_sandbox_integration_test.go +++ b/internal/daemon/daemon_sandbox_integration_test.go @@ -446,12 +446,24 @@ func mustRunSandboxScenarioSession( if err != nil { t.Fatalf("CreateSession(%q) error = %v", name, err) } + current := created + waitForRuntimeCondition(t, "sandbox session startup", 10*time.Second, func() bool { + resolved, getErr := harness.GetSession(ctx, created.ID) + if getErr != nil { + return false + } + current = resolved + return current.State == sessionpkg.StateActive || current.State == sessionpkg.StateStopped + }) + if current.State != sessionpkg.StateActive { + t.Fatalf("sandbox session startup = %#v, want active", current) + } - stream, err := harness.PromptSession(ctx, created.ID, message) + stream, err := harness.PromptSession(ctx, current.ID, message) if err != nil { - t.Fatalf("PromptSession(%q) error = %v", created.ID, err) + t.Fatalf("PromptSession(%q) error = %v", current.ID, err) } - return created, stream + return current, stream } func registerSandboxRuntimeArtifacts( diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index e812e1816..20e54dc70 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -3967,24 +3967,35 @@ func TestSkillsRegistryConfigUsesDaemonHomeAndDisabledSkills(t *testing.T) { } } -func TestRunSkipsDreamLoopWhenMemoryOrDreamDisabled(t *testing.T) { +func TestRunConfiguresDreamRuntimeForLiveRoleLifecycle(t *testing.T) { t.Parallel() + t.Run("Should honor memory availability and live dream role state", func(t *testing.T) { + t.Parallel() + runDreamRuntimeLifecycleCases(t) + }) +} + +func runDreamRuntimeLifecycleCases(t *testing.T) { + t.Helper() + testCases := []struct { - name string - patch func(*aghconfig.Config) + name string + patch func(*aghconfig.Config) + wantRuntime bool }{ { - name: "memory disabled", + name: "Should keep the dream runtime absent when memory is disabled", patch: func(cfg *aghconfig.Config) { cfg.Memory.Enabled = false }, }, { - name: "dream disabled", + name: "Should retain the dream runtime for live role enablement", patch: func(cfg *aghconfig.Config) { - cfg.Memory.Dream.Enabled = false + cfg.Roles.Dream.Enabled = false }, + wantRuntime: true, }, } @@ -4017,11 +4028,17 @@ func TestRunSkipsDreamLoopWhenMemoryOrDreamDisabled(t *testing.T) { }() <-d.readyCh - waitForCondition(t, "dream loop skipped", func() bool { + waitForCondition(t, "dream runtime lifecycle applied", func() bool { d.mu.Lock() defer d.mu.Unlock() - return d.dreamRuntime == nil + return (d.dreamRuntime != nil) == tc.wantRuntime }) + d.mu.Lock() + dreamRuntime := d.dreamRuntime + d.mu.Unlock() + if tc.wantRuntime && !dreamRuntime.Enabled() { + t.Fatal("dream runtime Enabled() = false, want memory-backed scheduling for workspace role resolution") + } cancel() if err := <-errCh; err != nil { diff --git a/internal/daemon/heartbeat_wake_runtime_test.go b/internal/daemon/heartbeat_wake_runtime_test.go index 76e35564b..d817bbe8b 100644 --- a/internal/daemon/heartbeat_wake_runtime_test.go +++ b/internal/daemon/heartbeat_wake_runtime_test.go @@ -109,7 +109,7 @@ func TestSchedulerHeartbeatWakeIntegration(t *testing.T) { sessions := &fakeSessionManager{ infos: []*session.Info{{ ID: "sess-coordinator-wake", - AgentName: aghconfig.DefaultCoordinatorAgentName, + AgentName: aghconfig.BuiltinCoordinatorAgentName, WorkspaceID: "ws-coordinator-wake", Type: session.SessionTypeCoordinator, State: session.StateActive, @@ -137,7 +137,7 @@ func TestSchedulerHeartbeatWakeIntegration(t *testing.T) { }, Session: schedulerpkg.SessionSnapshot{ ID: "sess-coordinator-wake", - AgentName: aghconfig.DefaultCoordinatorAgentName, + AgentName: aghconfig.BuiltinCoordinatorAgentName, WorkspaceID: "ws-coordinator-wake", Type: string(session.SessionTypeCoordinator), State: string(session.StateActive), diff --git a/internal/daemon/loop_goal_command_e2e_integration_test.go b/internal/daemon/loop_goal_command_e2e_integration_test.go index c621203c1..6092843b6 100644 --- a/internal/daemon/loop_goal_command_e2e_integration_test.go +++ b/internal/daemon/loop_goal_command_e2e_integration_test.go @@ -133,7 +133,16 @@ func TestDaemonE2EGoalCommandsShouldSurviveControlsDisconnectAndRestart(t *testi "/goal Recover from a refused turn through an explicit approval grant", ) if approvalStartStatus != http.StatusAccepted || approvalStart.Snapshot == nil { - t.Fatalf("approval Goal start = status:%d result:%#v", approvalStartStatus, approvalStart) + current, currentErr := harness.GetSession(ctx, approvalSession.ID) + t.Fatalf( + "approval Goal start = status:%d outcome:%q reason:%v snapshot:%#v current:%#v current_error:%v", + approvalStartStatus, + approvalStart.Outcome, + goalReasonValue(approvalStart.ReasonCode), + approvalStart.Snapshot, + current, + currentErr, + ) } waitForGoalSnapshot(ctx, t, harness, approvalSession.ID, func(goal *aghcontract.GoalSnapshot) bool { return goal != nil && goal.RunStatus == aghcontract.LoopRunStatusNeedsApproval @@ -174,7 +183,16 @@ func TestDaemonE2EGoalCommandsShouldSurviveControlsDisconnectAndRestart(t *testi "/goal Keep one prompt active until an explicit clear", ) if clearStartStatus != http.StatusAccepted || clearStart.Snapshot == nil { - t.Fatalf("clear Goal start = status:%d result:%#v", clearStartStatus, clearStart) + current, currentErr := harness.GetSession(ctx, clearSession.ID) + t.Fatalf( + "clear Goal start = status:%d outcome:%q reason:%v snapshot:%#v current:%#v current_error:%v", + clearStartStatus, + clearStart.Outcome, + goalReasonValue(clearStart.ReasonCode), + clearStart.Snapshot, + current, + currentErr, + ) } clearRunID := clearStart.Snapshot.RunID waitForGoalTurns(ctx, t, harness, clearRunID, func(page aghcontract.GoalTurnPage) bool { diff --git a/internal/daemon/memory_controller_role.go b/internal/daemon/memory_controller_role.go new file mode 100644 index 000000000..82092881b --- /dev/null +++ b/internal/daemon/memory_controller_role.go @@ -0,0 +1,65 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "time" + + aghconfig "github.com/compozy/agh/internal/config" +) + +// memoryControllerCallOptions is the invocation contract for the in-process +// write-controller tiebreaker. +type memoryControllerCallOptions struct { + Enabled bool + Provider string + Model string + ReasoningEffort string + Timeout time.Duration + TopK int + PromptVersion string + MaxTokensOut int + Config aghconfig.Config + resolvedRole ResolvedRole +} + +func (r *roleResolver) resolveMemoryControllerCallOptions( + ctx context.Context, + workspaceID string, +) (memoryControllerCallOptions, error) { + if r == nil || r.config == nil { + return memoryControllerCallOptions{}, errors.New("daemon: memory controller role resolver is required") + } + resolved, effectiveConfig, err := r.resolveEffective(ctx, workspaceID, aghconfig.RoleMemoryController) + if err != nil { + return memoryControllerCallOptions{}, fmt.Errorf("daemon: resolve memory controller role: %w", err) + } + effective := effectiveConfig.Roles.MemoryController + runtime, err := effectiveConfig.ResolveAgent(aghconfig.AgentDef{ + Name: "memory-controller", + Provider: resolved.Provider, + Model: resolved.Model, + ReasoningEffort: resolved.ReasoningEffort, + Prompt: "AGH memory controller transient runtime.", + }) + if err != nil { + return memoryControllerCallOptions{}, fmt.Errorf("daemon: resolve memory controller runtime: %w", err) + } + resolved.Provider = runtime.Provider + resolved.Model = runtime.Model + resolved.ReasoningEffort = runtime.ReasoningEffort + resolved.eventWriter = r.events + return memoryControllerCallOptions{ + Enabled: resolved.Enabled, + Provider: resolved.Provider, + Model: resolved.Model, + ReasoningEffort: resolved.ReasoningEffort, + Timeout: effective.Timeout, + TopK: effective.TopK, + PromptVersion: effective.PromptVersion, + MaxTokensOut: effective.MaxTokensOut, + Config: *effectiveConfig, + resolvedRole: resolved, + }, nil +} diff --git a/internal/daemon/memory_controller_role_test.go b/internal/daemon/memory_controller_role_test.go new file mode 100644 index 000000000..460c5112f --- /dev/null +++ b/internal/daemon/memory_controller_role_test.go @@ -0,0 +1,140 @@ +package daemon + +import ( + "context" + "strings" + "testing" + "time" + + aghconfig "github.com/compozy/agh/internal/config" + memcontract "github.com/compozy/agh/internal/memory/contract" + "github.com/compozy/agh/internal/memory/controller" + "github.com/compozy/agh/internal/session" +) + +func TestMemoryControllerRoleCallOptions(t *testing.T) { + t.Parallel() + + t.Run("Should preserve every pinned default in the model call options", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + options, err := newRoleResolver(&cfg, nil, nil).resolveMemoryControllerCallOptions(t.Context(), "") + if err != nil { + t.Fatalf("resolveMemoryControllerCallOptions() error = %v", err) + } + if !options.Enabled || options.Provider != "pi" || options.Model != "anthropic/claude-haiku-4" || + options.Timeout != 250*time.Millisecond || options.TopK != 5 || + options.PromptVersion != "v1" || options.MaxTokensOut != 256 { + t.Fatalf("resolveMemoryControllerCallOptions() = %#v, want pinned defaults", options) + } + }) + + t.Run("Should use the effective workspace role knobs", func(t *testing.T) { + t.Parallel() + + global := roleResolverConfig() + workspace := global + workspace.Providers = map[string]aghconfig.ProviderConfig{ + "gateway": {Command: "gateway acp"}, + "backup": {Command: "backup acp"}, + } + workspace.Roles.MemoryController = aghconfig.MemoryControllerRoleConfig{ + Enabled: true, + Provider: "gateway", + Model: "workspace-controller", + ReasoningEffort: "high", + Timeout: time.Second, + TopK: 8, + PromptVersion: "v2", + MaxTokensOut: 512, + FallbackChain: []aghconfig.RoleFallback{{ + Provider: "backup", + Model: "backup-controller", + }}, + } + resolver := newRoleResolver(&global, roleWorkspaceResolverStub{configs: map[string]aghconfig.Config{ + "ws-a": workspace, + }}, nil) + options, err := resolver.resolveMemoryControllerCallOptions(t.Context(), "ws-a") + if err != nil { + t.Fatalf("resolveMemoryControllerCallOptions() error = %v", err) + } + if options.Provider != "gateway" || options.Model != "workspace-controller" || + options.ReasoningEffort != "high" || options.Timeout != time.Second || + options.TopK != 8 || options.PromptVersion != "v2" || options.MaxTokensOut != 512 || + len(options.resolvedRole.Fallbacks) != 1 || options.resolvedRole.Fallbacks[0].Provider != "backup" || + options.resolvedRole.Fallbacks[0].Model != "backup-controller" { + t.Fatalf("resolveMemoryControllerCallOptions() = %#v, want workspace options", options) + } + }) +} + +func TestMemoryControllerTiebreakerUsesTheLiveRoleCallContract(t *testing.T) { + t.Parallel() + t.Run("Should invoke the configured live role with bounded targets", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + cfg.Providers = map[string]aghconfig.ProviderConfig{ + "mock": {Command: "mock acp"}, + } + cfg.Roles.MemoryController = aghconfig.MemoryControllerRoleConfig{ + Enabled: true, + Provider: "mock", + Model: "controller-model", + Timeout: time.Second, + TopK: 2, + PromptVersion: "v1", + MaxTokensOut: 32, + } + invoker := &memoryControllerInvokerStub{result: session.TransientModelResult{ + Output: `{"op":"noop","target_id":"","confidence":0.5,"reason":"ambiguous"}`, + Accepted: true, + }} + tiebreaker := &daemonMemoryControllerTiebreaker{ + invoker: invoker, + roles: newRoleResolver(&cfg, nil, nil), + globalCWD: t.TempDir(), + } + result, err := tiebreaker.BreakTie(t.Context(), controller.TiebreakerRequest{ + Candidate: memcontract.Candidate{Scope: memcontract.ScopeGlobal, Content: "candidate"}, + Targets: []controller.Target{ + {ID: "target-a", Content: "a"}, + {ID: "target-b", Content: "b"}, + {ID: "target-c", Content: "c"}, + }, + }) + if err != nil { + t.Fatalf("BreakTie() error = %v", err) + } + if result.Op != memcontract.OpNoop || result.Call == nil || result.Call.Model != "controller-model" { + t.Fatalf("BreakTie() = %#v, want model-backed noop", result) + } + if len(invoker.calls) != 1 { + t.Fatalf("InvokeTransientModel() calls = %d, want 1", len(invoker.calls)) + } + call := invoker.calls[0] + if call.Provider != "mock" || call.Model != "controller-model" || call.MaxOutputBytes != 128 { + t.Fatalf("TransientModelCall = %#v, want resolved route and 128-byte bound", call) + } + if !strings.Contains(call.Prompt, "target-a") || !strings.Contains(call.Prompt, "target-b") || + strings.Contains(call.Prompt, "target-c") { + t.Fatalf("TransientModelCall.Prompt = %q, want top two targets only", call.Prompt) + } + }) +} + +type memoryControllerInvokerStub struct { + result session.TransientModelResult + err error + calls []session.TransientModelCall +} + +func (s *memoryControllerInvokerStub) InvokeTransientModel( + _ context.Context, + call session.TransientModelCall, +) (session.TransientModelResult, error) { + s.calls = append(s.calls, call) + return s.result, s.err +} diff --git a/internal/daemon/memory_controller_tiebreaker.go b/internal/daemon/memory_controller_tiebreaker.go new file mode 100644 index 000000000..d3cf79f56 --- /dev/null +++ b/internal/daemon/memory_controller_tiebreaker.go @@ -0,0 +1,261 @@ +package daemon + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "strings" + "time" + + aghconfig "github.com/compozy/agh/internal/config" + memcontract "github.com/compozy/agh/internal/memory/contract" + "github.com/compozy/agh/internal/memory/controller" + "github.com/compozy/agh/internal/memory/prompts" + "github.com/compozy/agh/internal/session" + workspacepkg "github.com/compozy/agh/internal/workspace" +) + +type transientModelInvoker interface { + InvokeTransientModel(context.Context, session.TransientModelCall) (session.TransientModelResult, error) +} + +type daemonMemoryControllerTiebreaker struct { + invoker transientModelInvoker + roles *roleResolver + configSnapshot func() aghconfig.Config + workspaceResolver workspacepkg.RuntimeResolver + globalCWD string +} + +var _ controller.Tiebreaker = (*daemonMemoryControllerTiebreaker)(nil) + +type memoryControllerPromptData struct { + Candidate memcontract.Candidate + Targets []controller.Target + RuleTrace []memcontract.RuleHit +} + +type memoryControllerModelOutput struct { + Op memcontract.Op `json:"op"` + TargetID string `json:"target_id"` + Confidence float32 `json:"confidence"` + Reason string `json:"reason"` +} + +type memoryControllerInvocation struct { + route roleAttemptRoute + result session.TransientModelResult +} + +func (t *daemonMemoryControllerTiebreaker) BreakTie( + ctx context.Context, + request controller.TiebreakerRequest, +) (controller.TiebreakerResult, error) { + if t == nil || t.roles == nil { + return controller.TiebreakerResult{}, errors.New("daemon: memory controller role resolver is required") + } + if t.invoker == nil { + return controller.TiebreakerResult{}, errors.New("daemon: transient model invoker is required") + } + options, prompt, maxOutputBytes, cwd, err := t.prepareCall(ctx, request) + if err != nil { + return controller.TiebreakerResult{}, err + } + + callCtx, cancel := memoryControllerCallContext(ctx, options.Timeout, options.Config.Memory.Controller.MaxLatency) + defer cancel() + startedAt := time.Now() + lastRoute := roleAttemptRoute{ + Provider: options.Provider, + Model: options.Model, + ReasoningEffort: options.ReasoningEffort, + } + invocation, invokeErr := invokeRoleWithFallback( + callCtx, + options.resolvedRole, + memoryControllerCorrelation(request.Candidate), + func(attemptCtx context.Context, route roleAttemptRoute) (memoryControllerInvocation, bool, error) { + lastRoute = route + result, callErr := t.invoker.InvokeTransientModel(attemptCtx, session.TransientModelCall{ + Config: &options.Config, + Provider: route.Provider, + Model: route.Model, + ReasoningEffort: route.ReasoningEffort, + CWD: cwd, + Prompt: prompt, + MaxOutputBytes: maxOutputBytes, + }) + return memoryControllerInvocation{route: route, result: result}, result.Accepted, callErr + }, + ) + return memoryControllerResult(invocation, lastRoute, options.PromptVersion, startedAt, invokeErr) +} + +func (t *daemonMemoryControllerTiebreaker) prepareCall( + ctx context.Context, + request controller.TiebreakerRequest, +) (memoryControllerCallOptions, string, int, string, error) { + roles := t.roles + if t.configSnapshot != nil { + cfg := t.configSnapshot() + roles = newRoleResolver(&cfg, t.roles.workspaceResolver, t.roles.agents, t.roles.events) + } + options, err := roles.resolveMemoryControllerCallOptions(ctx, request.Candidate.WorkspaceID) + if err != nil { + return memoryControllerCallOptions{}, "", 0, "", err + } + if !options.Enabled { + return memoryControllerCallOptions{}, "", 0, "", controller.ErrTiebreakerDisabled + } + prompt, err := renderMemoryControllerPrompt(request, options.TopK, options.PromptVersion) + if err != nil { + return memoryControllerCallOptions{}, "", 0, "", err + } + maxOutputBytes, err := memoryControllerOutputBytes(options.MaxTokensOut) + if err != nil { + return memoryControllerCallOptions{}, "", 0, "", err + } + cwd, err := t.resolveCWD(ctx, request.Candidate.WorkspaceID) + if err != nil { + return memoryControllerCallOptions{}, "", 0, "", err + } + return options, prompt, maxOutputBytes, cwd, nil +} + +func memoryControllerResult( + invocation memoryControllerInvocation, + lastRoute roleAttemptRoute, + promptVersion string, + startedAt time.Time, + invokeErr error, +) (controller.TiebreakerResult, error) { + call := &memcontract.LLMCall{ + Model: firstRoleValue(invocation.route.Model, lastRoute.Model), + PromptVersion: promptVersion, + Latency: time.Since(startedAt), + RawResponse: strings.TrimSpace(invocation.result.Output), + } + if invokeErr != nil { + call.Error = invokeErr.Error() + return controller.TiebreakerResult{Call: call}, invokeErr + } + parsed, err := parseMemoryControllerOutput(invocation.result.Output) + if err != nil { + call.Error = err.Error() + return controller.TiebreakerResult{Call: call}, err + } + return controller.TiebreakerResult{ + Op: parsed.Op, + TargetID: strings.TrimSpace(parsed.TargetID), + Confidence: parsed.Confidence, + Reason: strings.TrimSpace(parsed.Reason), + Call: call, + }, nil +} + +func renderMemoryControllerPrompt( + request controller.TiebreakerRequest, + topK int, + promptVersion string, +) (string, error) { + template, err := prompts.ParseTemplate(prompts.NameDecide, promptVersion) + if err != nil { + return "", fmt.Errorf("daemon: load memory controller prompt %q: %w", promptVersion, err) + } + targets := request.Targets + if topK > 0 && len(targets) > topK { + targets = targets[:topK] + } + var rendered bytes.Buffer + if err := template.Execute(&rendered, memoryControllerPromptData{ + Candidate: request.Candidate, + Targets: targets, + RuleTrace: request.RuleTrace, + }); err != nil { + return "", fmt.Errorf("daemon: render memory controller prompt: %w", err) + } + return rendered.String(), nil +} + +func parseMemoryControllerOutput(raw string) (memoryControllerModelOutput, error) { + decoder := json.NewDecoder(strings.NewReader(strings.TrimSpace(raw))) + decoder.DisallowUnknownFields() + var output memoryControllerModelOutput + if err := decoder.Decode(&output); err != nil { + return memoryControllerModelOutput{}, fmt.Errorf("daemon: decode memory controller output: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return memoryControllerModelOutput{}, errors.New( + "daemon: memory controller output must contain exactly one JSON object", + ) + } + if err := output.Op.Validate(); err != nil { + return memoryControllerModelOutput{}, fmt.Errorf("daemon: validate memory controller output op: %w", err) + } + if output.Confidence < 0 || output.Confidence > 1 { + return memoryControllerModelOutput{}, fmt.Errorf( + "daemon: memory controller confidence must be between 0 and 1: %f", + output.Confidence, + ) + } + if strings.TrimSpace(output.Reason) == "" { + return memoryControllerModelOutput{}, errors.New("daemon: memory controller reason is required") + } + return output, nil +} + +func memoryControllerOutputBytes(maxTokens int) (int, error) { + if maxTokens <= 0 || maxTokens > math.MaxInt/4 { + return 0, fmt.Errorf("daemon: invalid memory controller output-token bound: %d", maxTokens) + } + return maxTokens * 4, nil +} + +func memoryControllerCallContext( + ctx context.Context, + roleTimeout time.Duration, + controllerTimeout time.Duration, +) (context.Context, context.CancelFunc) { + timeout := roleTimeout + if controllerTimeout > 0 && (timeout <= 0 || controllerTimeout < timeout) { + timeout = controllerTimeout + } + if timeout > 0 { + return context.WithTimeout(ctx, timeout) + } + return context.WithCancel(ctx) +} + +func memoryControllerCorrelation(candidate memcontract.Candidate) roleInvocationCorrelation { + correlation := roleInvocationCorrelation{ + WorkspaceID: strings.TrimSpace(candidate.WorkspaceID), + AgentName: strings.TrimSpace(candidate.AgentName), + } + if candidate.Metadata != nil { + correlation.SessionID = strings.TrimSpace(candidate.Metadata["session_id"]) + } + return correlation +} + +func (t *daemonMemoryControllerTiebreaker) resolveCWD(ctx context.Context, workspaceID string) (string, error) { + if target := strings.TrimSpace(workspaceID); target != "" { + if t.workspaceResolver == nil { + return "", errors.New("daemon: workspace resolver is required for memory controller call") + } + resolved, err := t.workspaceResolver.Resolve(ctx, target) + if err != nil { + return "", fmt.Errorf("daemon: resolve memory controller workspace %q: %w", target, err) + } + if cwd := strings.TrimSpace(resolved.RootDir); cwd != "" { + return cwd, nil + } + } + if cwd := strings.TrimSpace(t.globalCWD); cwd != "" { + return cwd, nil + } + return "", errors.New("daemon: memory controller working directory is required") +} diff --git a/internal/daemon/memory_controller_wiring.go b/internal/daemon/memory_controller_wiring.go new file mode 100644 index 000000000..78e74e700 --- /dev/null +++ b/internal/daemon/memory_controller_wiring.go @@ -0,0 +1,43 @@ +package daemon + +import ( + "strings" + + aghconfig "github.com/compozy/agh/internal/config" + memcontract "github.com/compozy/agh/internal/memory/contract" + "github.com/compozy/agh/internal/memory/controller" +) + +func (d *Daemon) configureMemoryController(state *bootState, sessions SessionManager) { + if d == nil || state == nil || state.memoryStore == nil { + return + } + invoker, ok := sessions.(transientModelInvoker) + if !ok { + return + } + tiebreaker := &daemonMemoryControllerTiebreaker{ + invoker: invoker, + roles: roleResolverForState(state), + configSnapshot: func() aghconfig.Config { + d.mu.Lock() + defer d.mu.Unlock() + return state.cfg + }, + workspaceResolver: state.workspaceResolver, + globalCWD: d.homePaths.HomeDir, + } + state.memoryStore.SetDecisionControllerFactory(func(index controller.TargetIndex) memcontract.Controller { + d.mu.Lock() + cfg := state.cfg + d.mu.Unlock() + options := []controller.Option{controller.WithTiebreaker(tiebreaker)} + if strings.EqualFold(strings.TrimSpace(cfg.Memory.Controller.Mode), "rules") { + options = nil + } + if strings.EqualFold(strings.TrimSpace(cfg.Memory.Controller.DefaultOpOnFail), "reject") { + options = append(options, controller.WithDefaultOpOnFail(memcontract.OpReject)) + } + return controller.New(index, options...) + }) +} diff --git a/internal/daemon/memory_extractor_fork.go b/internal/daemon/memory_extractor_fork.go index 8270b0273..d8613b737 100644 --- a/internal/daemon/memory_extractor_fork.go +++ b/internal/daemon/memory_extractor_fork.go @@ -2,25 +2,20 @@ package daemon import ( "bufio" - "context" - "encoding/json" "errors" "fmt" "log/slog" - "strings" "sync" "time" "github.com/compozy/agh/internal/acp" - + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/memory" memcontract "github.com/compozy/agh/internal/memory/contract" - "github.com/compozy/agh/internal/session" - workspacepkg "github.com/compozy/agh/internal/workspace" ) @@ -134,8 +129,7 @@ func (s *daemonMemoryProposalSink) resolveWorkspace( type forkedMemoryExtractor struct { sessions memoryExtractorSessionManager - defaultAgent string - model string + roles RoleResolver deadline time.Duration logger *slog.Logger now func() time.Time @@ -149,6 +143,28 @@ func (e *forkedMemoryExtractor) Extract( if e == nil || e.sessions == nil { return nil, errors.New("daemon: memory extractor sessions are not configured") } + if e.roles == nil { + return nil, errors.New("daemon: memory extractor role resolver is not configured") + } + correlation := roleInvocationCorrelation{ + WorkspaceID: strings.TrimSpace(turn.WorkspaceID), + SessionID: strings.TrimSpace(turn.SessionID), + AgentName: strings.TrimSpace(turn.AgentID), + ParentSessionID: strings.TrimSpace(turn.ParentSessionID), + RootSessionID: strings.TrimSpace(turn.RootSessionID), + } + roleCtx := withRoleInvocationCorrelation(ctx, correlation) + role, err := e.roles.Resolve(roleCtx, turn.WorkspaceID, aghconfig.RoleMemoryExtractor) + if err != nil { + return nil, fmt.Errorf("daemon: resolve memory extractor role: %w", err) + } + if !role.Enabled { + return nil, nil + } + agentName := role.AgentName + if role.Inherit { + agentName = strings.TrimSpace(turn.AgentID) + } runCtx := context.WithoutCancel(ctx) if e.deadline > 0 { var cancel context.CancelFunc @@ -159,20 +175,14 @@ func (e *forkedMemoryExtractor) Extract( if err != nil { return nil, err } - child, err := e.sessions.Spawn(runCtx, session.SpawnOpts{ - ParentSessionID: turn.SessionID, - AgentName: firstNonEmptyString(turn.AgentID, e.defaultAgent), - Model: strings.TrimSpace(e.model), - Name: "Memory extractor", - PromptOverlay: memoryExtractorOverlay(), - SpawnRole: session.SpawnRoleMemoryExtractor, - TTL: e.extractorTTL(), - AllowStoppedParent: true, - }) + role.AgentName = agentName + child, err := e.spawnExtractorSession(runCtx, role, correlation, turn) + if child != nil { + defer e.stopChild(ctx, child.ID) + } if err != nil { return nil, fmt.Errorf("daemon: spawn memory extractor session: %w", err) } - defer e.stopChild(ctx, child.ID) events, err := e.sessions.PromptSynthetic(runCtx, child.ID, session.SyntheticPromptOpts{ Message: prompt, @@ -196,6 +206,32 @@ func (e *forkedMemoryExtractor) Extract( return candidates, nil } +func (e *forkedMemoryExtractor) spawnExtractorSession( + ctx context.Context, + role ResolvedRole, + correlation roleInvocationCorrelation, + turn memcontract.TurnRecord, +) (*session.Session, error) { + return invokeRoleWithFallback(ctx, role, correlation, func( + attemptCtx context.Context, + route roleAttemptRoute, + ) (*session.Session, bool, error) { + spawned, err := e.sessions.Spawn(attemptCtx, session.SpawnOpts{ + ParentSessionID: turn.SessionID, + AgentName: route.AgentName, + Provider: route.Provider, + Model: route.Model, + ReasoningEffort: route.ReasoningEffort, + Name: "Memory extractor", + PromptOverlay: memoryExtractorOverlay(), + SpawnRole: session.SpawnRoleMemoryExtractor, + TTL: e.extractorTTL(), + AllowStoppedParent: true, + }) + return spawned, spawned != nil, err + }) +} + func (e *forkedMemoryExtractor) Drain(context.Context) error { return nil } diff --git a/internal/daemon/memory_extractor_output.go b/internal/daemon/memory_extractor_output.go index 4ca4ed642..d9889020a 100644 --- a/internal/daemon/memory_extractor_output.go +++ b/internal/daemon/memory_extractor_output.go @@ -169,8 +169,12 @@ func candidateFromExtractedLine( return memcontract.Candidate{}, errors.New("content is required") } agentTier := memcontract.AgentTier(strings.TrimSpace(line.AgentTier)).Normalize() - if scope == memcontract.ScopeAgent && agentTier == "" { - agentTier = memcontract.AgentTierWorkspace + if scope == memcontract.ScopeAgent { + if agentTier == "" { + agentTier = memcontract.AgentTierWorkspace + } + } else { + agentTier = "" } agentName := "" if scope == memcontract.ScopeAgent { diff --git a/internal/daemon/memory_runtime.go b/internal/daemon/memory_runtime.go index b6ae7ab8d..4f91a2012 100644 --- a/internal/daemon/memory_runtime.go +++ b/internal/daemon/memory_runtime.go @@ -64,7 +64,7 @@ func newDaemonMemoryExtractor( sessions SessionManager, now func() time.Time, ) (*daemonMemoryExtractor, error) { - if state == nil || state.memoryStore == nil || !state.cfg.Memory.Enabled || !state.cfg.Memory.Extractor.Enabled { + if state == nil || state.memoryStore == nil || !state.cfg.Memory.Enabled { return nil, nil } forkSessions, ok := sessions.(memoryExtractorSessionManager) @@ -79,8 +79,7 @@ func newDaemonMemoryExtractor( workspaceRoots := &sync.Map{} forked := &forkedMemoryExtractor{ sessions: forkSessions, - defaultAgent: firstNonEmptyString(state.cfg.Defaults.Agent, state.cfg.Memory.Dream.Agent), - model: state.cfg.Memory.Extractor.Model, + roles: roleResolverForState(state), deadline: state.cfg.Memory.Extractor.Deadline, logger: state.logger, now: now, diff --git a/internal/daemon/memory_runtime_test.go b/internal/daemon/memory_runtime_test.go index b58cb3b1b..c49605025 100644 --- a/internal/daemon/memory_runtime_test.go +++ b/internal/daemon/memory_runtime_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/compozy/agh/internal/acp" + aghconfig "github.com/compozy/agh/internal/config" extensionpkg "github.com/compozy/agh/internal/extension" hookspkg "github.com/compozy/agh/internal/hooks" "github.com/compozy/agh/internal/memory" @@ -161,6 +162,40 @@ func TestCollectMemoryExtractorOutput(t *testing.T) { if candidate.Metadata["workspace_root"] != "/workspace/test" { t.Fatalf("candidate metadata = %#v, want workspace_root", candidate.Metadata) } + if candidate.AgentTier != "" || candidate.Frontmatter.AgentTier != "" { + t.Fatalf("candidate agent tiers = %q/%q, want empty outside agent scope", + candidate.AgentTier, + candidate.Frontmatter.AgentTier, + ) + } + }) + + t.Run("Should discard agent tier outside agent-scoped candidates", func(t *testing.T) { + t.Parallel() + + turn := memcontract.TurnRecord{ + SessionID: "sess-parent", + AgentID: "cto", + WorkspaceID: "ws-test", + } + candidates, err := parseMemoryExtractorCandidates( + `{"type":"reference","scope":"global","agent_tier":"global","content":"Release artifacts use checksums.","evidence":"seq=7"}`, + turn, + "/workspace/test", + time.Date(2026, 7, 24, 11, 25, 0, 0, time.UTC), + ) + if err != nil { + t.Fatalf("parseMemoryExtractorCandidates() error = %v", err) + } + if len(candidates) != 1 { + t.Fatalf("candidates = %#v, want one parsed candidate", candidates) + } + if candidates[0].AgentTier != "" || candidates[0].Frontmatter.AgentTier != "" { + t.Fatalf("candidate agent tiers = %q/%q, want empty outside agent scope", + candidates[0].AgentTier, + candidates[0].Frontmatter.AgentTier, + ) + } }) } @@ -189,15 +224,36 @@ func TestDaemonMemoryProviderService(t *testing.T) { func TestForkedMemoryExtractor(t *testing.T) { t.Parallel() + t.Run("Should skip the child session when the role is disabled", func(t *testing.T) { + t.Parallel() + + sessions := &recordingMemoryExtractorSessions{} + extractor := &forkedMemoryExtractor{ + sessions: sessions, + roles: resolvedRoleResolver(ResolvedRole{Enabled: false}), + } + candidates, err := extractor.Extract(testutil.Context(t), memcontract.TurnRecord{WorkspaceID: "ws-test"}) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if candidates != nil || sessions.spawnCalls != 0 { + t.Fatalf("Extract() = %#v with %d spawn calls, want skipped", candidates, sessions.spawnCalls) + } + }) + t.Run("Should pass the configured model to the extractor child spawn", func(t *testing.T) { t.Parallel() sessions := &recordingMemoryExtractorSessions{} extractor := &forkedMemoryExtractor{ - sessions: sessions, - defaultAgent: "memory-agent", - model: "claude-haiku-memory", - deadline: time.Second, + sessions: sessions, + roles: resolvedRoleResolver(ResolvedRole{ + Role: aghconfig.RoleMemoryExtractor, + Enabled: true, + Inherit: true, + Model: "claude-haiku-memory", + }), + deadline: time.Second, now: func() time.Time { return time.Date(2026, 5, 27, 10, 0, 0, 0, time.UTC) }, @@ -205,7 +261,7 @@ func TestForkedMemoryExtractor(t *testing.T) { turn := memcontract.TurnRecord{ SessionID: "sess-parent", RootSessionID: "sess-parent", - AgentID: "", + AgentID: "memory-agent", ActorKind: "agent_root", WorkspaceID: "ws-test", SinceMessageSeq: 1, @@ -241,14 +297,16 @@ func TestForkedMemoryExtractor(t *testing.T) { } type recordingMemoryExtractorSessions struct { - spawnOpts session.SpawnOpts - stoppedID string + spawnCalls int + spawnOpts session.SpawnOpts + stoppedID string } func (s *recordingMemoryExtractorSessions) Spawn( _ context.Context, opts session.SpawnOpts, ) (*session.Session, error) { + s.spawnCalls++ s.spawnOpts = opts return &session.Session{ID: "sess-memory-child"}, nil } diff --git a/internal/daemon/native_bundle_resource_tools.go b/internal/daemon/native_bundle_resource_tools.go index 63835bab4..70e0fafe2 100644 --- a/internal/daemon/native_bundle_resource_tools.go +++ b/internal/daemon/native_bundle_resource_tools.go @@ -2,12 +2,14 @@ package daemon import ( "context" + "errors" "fmt" "strings" "github.com/compozy/agh/internal/api/contract" core "github.com/compozy/agh/internal/api/core" bundlepkg "github.com/compozy/agh/internal/bundles" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/resources" toolspkg "github.com/compozy/agh/internal/tools" ) @@ -461,6 +463,15 @@ func resourceSourceFromInput(rawKind string, rawID string, path string) (resourc } func nativeBundleToolError(id toolspkg.ToolID, err error) error { + if errors.Is(err, aghconfig.ErrAgentNameReserved) { + return toolspkg.NewToolError( + toolspkg.ErrorCodeAgentNameReserved, + id, + err.Error(), + fmt.Errorf("%w: %w", toolspkg.ErrToolInvalidInput, err), + toolspkg.ReasonSchemaInvalid, + ) + } return nativeHTTPStatusToolError(id, err, core.StatusForBundleError(err)) } diff --git a/internal/daemon/native_config_hook_tools.go b/internal/daemon/native_config_hook_tools.go index 0e41ab738..c60404918 100644 --- a/internal/daemon/native_config_hook_tools.go +++ b/internal/daemon/native_config_hook_tools.go @@ -32,7 +32,10 @@ const ( nativeConfigHookToolsWorkspaceRootKey = "workspace_root" ) -const hookActionDisabled = "disabled" +const ( + hookActionDisabled = "disabled" + hookEnabledValue = "enabled" +) func (n *daemonNativeTools) configShow( _ context.Context, diff --git a/internal/daemon/native_config_mutation_tools.go b/internal/daemon/native_config_mutation_tools.go index 59f89523f..2da1919eb 100644 --- a/internal/daemon/native_config_mutation_tools.go +++ b/internal/daemon/native_config_mutation_tools.go @@ -53,6 +53,13 @@ func (n *daemonNativeTools) configSet( workspaceRoot, target, func(editor *aghconfig.OverlayEditor) error { + if policy.Kind == aghconfig.ConfigValueTable { + table, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("daemon: config path %q requires an object", path) + } + return editor.SetTable(policy.Segments, table) + } return editor.SetValue(policy.Segments, value) }, ); err != nil { diff --git a/internal/daemon/native_create_tools.go b/internal/daemon/native_create_tools.go index f5195d562..cae882c23 100644 --- a/internal/daemon/native_create_tools.go +++ b/internal/daemon/native_create_tools.go @@ -163,7 +163,8 @@ func (n *daemonNativeTools) agentCreate( if err != nil { return toolspkg.ToolResult{}, err } - if n.deps.AgentSkills == nil { + agentSkills := n.deps.agentSkills() + if agentSkills == nil { return toolspkg.ToolResult{}, nativeAgentCreateToolError( req.ToolID, errors.New("daemon: agent definition sync is unavailable"), @@ -180,7 +181,7 @@ func (n *daemonNativeTools) agentCreate( if err != nil { return toolspkg.ToolResult{}, nativeAgentCreateToolError(req.ToolID, err) } - if err := n.deps.AgentSkills.Sync(ctx); err != nil { + if err := agentSkills.Sync(ctx); err != nil { syncErr := errors.Join( fmt.Errorf("daemon: sync created agent definition: %w", err), n.rollbackNativeAgentCreate(ctx, agent.SourcePath), @@ -204,7 +205,11 @@ func (n *daemonNativeTools) rollbackNativeAgentCreate(ctx context.Context, sourc if err := aghconfig.DeleteAgentDefinition(agentsRoot, sourcePath); err != nil { return fmt.Errorf("daemon: roll back created agent definition: %w", err) } - if err := n.deps.AgentSkills.Sync(ctx); err != nil { + agentSkills := n.deps.agentSkills() + if agentSkills == nil { + return errors.New("daemon: agent definition sync is unavailable") + } + if err := agentSkills.Sync(ctx); err != nil { return fmt.Errorf("daemon: reconcile catalog after agent create rollback: %w", err) } return nil @@ -252,6 +257,14 @@ func (n *daemonNativeTools) agentCreateRequest( func nativeAgentCreateToolError(id toolspkg.ToolID, err error) error { switch { + case errors.Is(err, aghconfig.ErrAgentNameReserved): + return toolspkg.NewToolError( + toolspkg.ErrorCodeAgentNameReserved, + id, + err.Error(), + fmt.Errorf("%w: %w", toolspkg.ErrToolInvalidInput, err), + toolspkg.ReasonSchemaInvalid, + ) case errors.Is(err, aghconfig.ErrAgentDefinitionExists): return toolspkg.NewToolError( toolspkg.ErrorCodeConflict, diff --git a/internal/daemon/native_create_tools_test.go b/internal/daemon/native_create_tools_test.go index e3ce89b55..1b1ede986 100644 --- a/internal/daemon/native_create_tools_test.go +++ b/internal/daemon/native_create_tools_test.go @@ -290,6 +290,39 @@ func TestNativeAgentCreate(t *testing.T) { } }) + t.Run("Should resolve a publisher wired after native registry construction", func(t *testing.T) { + t.Parallel() + + homePaths := testHomePaths(t) + cfg := aghconfig.DefaultWithHome(homePaths) + cfg.Defaults.Provider = "claude" + cfg.Providers["claude"] = aghconfig.ProviderConfig{Command: "claude"} + var publisher agentSkillPublisher + registry := newDaemonNativeRegistry(t, &daemonNativeToolsDeps{ + HomePaths: homePaths, + Config: cfg, + Workspaces: nativeNetworkTestWorkspaceService(t), + AgentSkillsRuntime: func() agentSkillPublisher { + return publisher + }, + }, nativeApproveAllPolicyInputs()) + publisher = agentSkillPublisherFunc(func(context.Context) error { return nil }) + + _, err := registry.Call(t.Context(), toolspkg.Scope{}, toolspkg.CallRequest{ + ToolID: toolspkg.ToolIDAgentCreate, + Input: json.RawMessage( + `{"scope":"global","name":"late-publisher","provider":"claude","prompt":"Late publisher."}`, + ), + }) + if err != nil { + t.Fatalf("Registry.Call(agent_create late publisher) error = %v", err) + } + path := filepath.Join(homePaths.AgentsDir, "late-publisher", aghconfig.AgentDefinitionFileName) + if _, err := os.Stat(path); err != nil { + t.Fatalf("os.Stat(late-publisher agent) error = %v", err) + } + }) + t.Run("Should roll back a failed sync and allow a clean retry", func(t *testing.T) { t.Parallel() @@ -374,6 +407,27 @@ func TestNativeAgentCreate(t *testing.T) { requireToolReason(t, err, toolspkg.ErrToolConflict, toolspkg.ReasonConflictedID) }) + t.Run("Should reject a reserved agent name through the shared authoring path", func(t *testing.T) { + _, err := registry.Call(t.Context(), toolspkg.Scope{}, toolspkg.CallRequest{ + ToolID: toolspkg.ToolIDAgentCreate, + Input: json.RawMessage( + `{"scope":"global","name":"coordinator","provider":"claude","prompt":"Reserved."}`, + ), + }) + if !errors.Is(err, aghconfig.ErrAgentNameReserved) { + t.Fatalf("Registry.Call(agent_create reserved) error = %v, want ErrAgentNameReserved", err) + } + requireToolReason(t, err, toolspkg.ErrToolInvalidInput, toolspkg.ReasonSchemaInvalid) + var toolErr *toolspkg.ToolError + if !errors.As(err, &toolErr) || toolErr.Code != toolspkg.ErrorCodeAgentNameReserved { + t.Fatalf("Registry.Call(agent_create reserved) error = %#v, want agent_name_reserved", err) + } + path := filepath.Join(homePaths.AgentsDir, "coordinator") + if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("os.Stat(reserved agent directory) error = %v, want os.ErrNotExist", statErr) + } + }) + t.Run("Should inherit the configured runtime when the request omits overrides", func(t *testing.T) { result, err := registry.Call(t.Context(), toolspkg.Scope{}, toolspkg.CallRequest{ ToolID: toolspkg.ToolIDAgentCreate, diff --git a/internal/daemon/native_extension_tools.go b/internal/daemon/native_extension_tools.go index 6f3474103..97dbbc6fa 100644 --- a/internal/daemon/native_extension_tools.go +++ b/internal/daemon/native_extension_tools.go @@ -344,7 +344,7 @@ func (n *daemonNativeTools) extensionService() *daemonExtensionService { n.deps.ExtensionRegistry, runtime, n.deps.HookBindings, - n.deps.AgentSkills, + n.deps.agentSkills(), n.deps.ToolMCP, n.deps.BundleResources, n.deps.LoopResources, diff --git a/internal/daemon/native_hook_helpers.go b/internal/daemon/native_hook_helpers.go index d1757b833..149de79c6 100644 --- a/internal/daemon/native_hook_helpers.go +++ b/internal/daemon/native_hook_helpers.go @@ -163,7 +163,7 @@ func (n *daemonNativeTools) setHookEnabled( } action := hookActionDisabled if enabled { - action = "enabled" + action = hookEnabledValue } return nativeHookMutationResult(decl, action, target) } @@ -273,9 +273,9 @@ func nativeHookDeclPayload(decl hookspkg.HookDecl) map[string]any { "matcher": decl.Matcher, } if decl.Enabled != nil { - payload["enabled"] = *decl.Enabled + payload[hookEnabledValue] = *decl.Enabled } else { - payload["enabled"] = true + payload[hookEnabledValue] = true } if decl.Timeout > 0 { payload["timeout_ms"] = decl.Timeout.Milliseconds() diff --git a/internal/daemon/native_memory_admin_mutation.go b/internal/daemon/native_memory_admin_mutation.go index 7238172f7..893cd8ffc 100644 --- a/internal/daemon/native_memory_admin_mutation.go +++ b/internal/daemon/native_memory_admin_mutation.go @@ -12,7 +12,6 @@ import ( core "github.com/compozy/agh/internal/api/core" memcontract "github.com/compozy/agh/internal/memory/contract" - memorycontroller "github.com/compozy/agh/internal/memory/controller" toolspkg "github.com/compozy/agh/internal/tools" ) @@ -68,7 +67,7 @@ func (n *daemonNativeTools) memoryAdminPromote( } var decision memcontract.Decision if input.DryRun { - decision, err = memorycontroller.New(targetLocation.Store).Decide(ctx, candidate) + decision, err = targetLocation.Store.DecideCandidate(ctx, candidate) } else { decision, err = targetLocation.Store.ProposeCandidate(ctx, candidate) } diff --git a/internal/daemon/native_memory_admin_tools.go b/internal/daemon/native_memory_admin_tools.go index 94287979e..ed0dd0182 100644 --- a/internal/daemon/native_memory_admin_tools.go +++ b/internal/daemon/native_memory_admin_tools.go @@ -9,6 +9,7 @@ import ( "github.com/compozy/agh/internal/api/contract" core "github.com/compozy/agh/internal/api/core" + aghconfig "github.com/compozy/agh/internal/config" memcontract "github.com/compozy/agh/internal/memory/contract" @@ -211,12 +212,16 @@ func (n *daemonNativeTools) memoryAdminHealth( if err := decodeNativeInput(req, &input); err != nil { return toolspkg.ToolResult{}, err } + dreamAgent := strings.TrimSpace(n.deps.Config.Roles.Dream.Agent) + if dreamAgent == "" { + dreamAgent = aghconfig.BuiltinDreamingCuratorAgentName + } payload := contract.MemoryHealthPayload{ Status: "ok", Enabled: n.deps.Config.Memory.Enabled, Configured: strings.TrimSpace(n.deps.Config.Memory.GlobalDir) != "", GlobalDir: strings.TrimSpace(n.deps.Config.Memory.GlobalDir), - DreamAgent: strings.TrimSpace(n.deps.Config.Memory.Dream.Agent), + DreamAgent: dreamAgent, DreamMinHours: n.deps.Config.Memory.Dream.MinHours, DreamMinSessions: n.deps.Config.Memory.Dream.MinSessions, DreamCheckInterval: n.deps.Config.Memory.Dream.CheckInterval.String(), diff --git a/internal/daemon/native_tool_dependencies.go b/internal/daemon/native_tool_dependencies.go index 3ff170850..e844ef556 100644 --- a/internal/daemon/native_tool_dependencies.go +++ b/internal/daemon/native_tool_dependencies.go @@ -72,6 +72,7 @@ type daemonNativeToolsDeps struct { ExtensionSources extensionMarketplaceSourceLoader ExtensionEvents store.EventSummaryStore AgentSkills agentSkillPublisher + AgentSkillsRuntime func() agentSkillPublisher ToolMCP toolMCPPublisher MCPAuth func() toolspkg.MCPAuthStatusProvider ApprovalGrants toolspkg.ApprovalGrantStore @@ -83,3 +84,15 @@ type daemonNativeToolsDeps struct { Resources core.ResourceService WindowManager windowmanager.Service } + +func (d *daemonNativeToolsDeps) agentSkills() agentSkillPublisher { + if d == nil { + return nil + } + if d.AgentSkillsRuntime != nil { + if publisher := d.AgentSkillsRuntime(); publisher != nil { + return publisher + } + } + return d.AgentSkills +} diff --git a/internal/daemon/native_tools_dependencies_builder.go b/internal/daemon/native_tools_dependencies_builder.go index 94a76f5db..31f56b858 100644 --- a/internal/daemon/native_tools_dependencies_builder.go +++ b/internal/daemon/native_tools_dependencies_builder.go @@ -63,8 +63,11 @@ func (d *Daemon) nativeToolsDeps( ExtensionMarket: state.cfg.Extensions.Marketplace, ExtensionEvents: extensionEventSummaryStore(state.registry), AgentSkills: state.agentSkillResources, - ToolMCP: state.toolMCPResources, - ApprovalGrants: state.deps.ApprovalGrants, + AgentSkillsRuntime: func() agentSkillPublisher { + return state.agentSkillResources + }, + ToolMCP: state.toolMCPResources, + ApprovalGrants: state.deps.ApprovalGrants, Clarify: func() toolspkg.ClarifyBroker { return state.clarify }, diff --git a/internal/daemon/native_tools_test.go b/internal/daemon/native_tools_test.go index cdca75100..422901466 100644 --- a/internal/daemon/native_tools_test.go +++ b/internal/daemon/native_tools_test.go @@ -792,6 +792,36 @@ func TestDaemonNativeTools(t *testing.T) { requireNativeStructuredContains(t, result, []byte(`"ext-bundle"`)) }) + t.Run("Should preserve the reserved agent code from bundle activation", func(t *testing.T) { + t.Parallel() + + bundleService := &nativeBundleServiceStub{ + activateErr: fmt.Errorf("bundle agent: %w", aghconfig.ErrAgentNameReserved), + } + registry := newDaemonNativeRegistry(t, &daemonNativeToolsDeps{ + BundleService: func() core.BundleService { return bundleService }, + }, nativeApproveAllPolicyInputs()) + + _, err := registry.Call( + t.Context(), + toolspkg.Scope{Operator: true}, + toolspkg.CallRequest{ + ToolID: toolspkg.ToolIDBundlesActivate, + Input: json.RawMessage( + `{"extension_name":"ext-bundle","bundle_name":"starter","profile_name":"default","scope":"global"}`, + ), + }, + ) + if !errors.Is(err, aghconfig.ErrAgentNameReserved) { + t.Fatalf("Registry.Call(bundles_activate reserved) error = %v, want ErrAgentNameReserved", err) + } + requireToolReason(t, err, toolspkg.ErrToolInvalidInput, toolspkg.ReasonSchemaInvalid) + var toolErr *toolspkg.ToolError + if !errors.As(err, &toolErr) || toolErr.Code != toolspkg.ErrorCodeAgentNameReserved { + t.Fatalf("Registry.Call(bundles_activate reserved) error = %#v, want agent_name_reserved", err) + } + }) + t.Run("Should bind bundle and resource native tools to the caller workspace", func(t *testing.T) { t.Parallel() @@ -1903,6 +1933,20 @@ func TestDaemonNativeTools(t *testing.T) { t.Parallel() homePaths := testHomePaths(t) + globalTarget, err := aghconfig.ResolveConfigWriteTarget(homePaths, "", aghconfig.WriteScopeGlobal) + if err != nil { + t.Fatalf("ResolveConfigWriteTarget() error = %v", err) + } + if _, err := aghconfig.EditConfigOverlay( + homePaths, + "", + globalTarget, + func(editor *aghconfig.OverlayEditor) error { + return editor.SetTable([]string{"providers", "codex"}, map[string]any{"command": "codex-acp"}) + }, + ); err != nil { + t.Fatalf("EditConfigOverlay(provider fixture) error = %v", err) + } settingsService := &nativeConfigSettingsService{ result: settingspkg.ApplyResult{ Applied: true, @@ -1923,7 +1967,7 @@ func TestDaemonNativeTools(t *testing.T) { }, }, nativeApproveAllPolicyInputs()) - _, err := registry.Call( + _, err = registry.Call( t.Context(), toolspkg.Scope{}, toolspkg.CallRequest{ @@ -1942,6 +1986,76 @@ func TestDaemonNativeTools(t *testing.T) { t.Fatalf("Defaults.Agent = %q, want planner", cfg.Defaults.Agent) } + _, err = registry.Call( + t.Context(), + toolspkg.Scope{}, + toolspkg.CallRequest{ + ToolID: toolspkg.ToolIDConfigSet, + Input: json.RawMessage(`{"path":"roles.dream.model","value":"claude-haiku-4-5"}`), + }, + ) + if err != nil { + t.Fatalf("Registry.Call(config_set dream role model) error = %v", err) + } + cfg, err = aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome(after dream role model) error = %v", err) + } + if cfg.Roles.Dream.Model != "claude-haiku-4-5" { + t.Fatalf("Roles.Dream.Model = %q, want claude-haiku-4-5", cfg.Roles.Dream.Model) + } + dreamRoleResult, err := registry.Call( + t.Context(), + toolspkg.Scope{}, + toolspkg.CallRequest{ + ToolID: toolspkg.ToolIDConfigGet, + Input: json.RawMessage(`{"path":"roles.dream.model"}`), + }, + ) + if err != nil { + t.Fatalf("Registry.Call(config_get dream role model) error = %v", err) + } + requireNativeStructuredContains(t, dreamRoleResult, []byte(`"claude-haiku-4-5"`)) + + rolesTableInput := json.RawMessage( + `{"path":"roles","value":{"auto_title":{"enabled":true,"fallback_chain":[{"provider":"codex","model":"gpt-5-mini","reasoning_effort":"medium"}]}}}`, + ) + _, err = registry.Call( + t.Context(), + toolspkg.Scope{}, + toolspkg.CallRequest{ + ToolID: toolspkg.ToolIDConfigSet, + Input: rolesTableInput, + }, + ) + if err != nil { + t.Fatalf("Registry.Call(config_set roles table) error = %v; cause = %v", err, errors.Unwrap(err)) + } + cfg, err = aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome(after roles table) error = %v", err) + } + if got := cfg.Roles.AutoTitle.FallbackChain; len(got) != 1 || + got[0].Provider != "codex" || got[0].Model != "gpt-5-mini" { + t.Fatalf("Roles.AutoTitle.FallbackChain = %#v, want structured fallback", got) + } + coordinatorEnabledResult, err := registry.Call( + t.Context(), + toolspkg.Scope{}, + toolspkg.CallRequest{ + ToolID: toolspkg.ToolIDConfigGet, + Input: json.RawMessage(`{"path":"roles.coordinator.enabled"}`), + }, + ) + if err != nil { + t.Fatalf("Registry.Call(config_get coordinator enabled) error = %v", err) + } + requireNativeStructuredContains( + t, + coordinatorEnabledResult, + []byte(`"path":"roles.coordinator.enabled"`), + ) + goalResult, err := registry.Call( t.Context(), toolspkg.Scope{}, @@ -2009,8 +2123,8 @@ func TestDaemonNativeTools(t *testing.T) { requireNativeStructuredContains(t, marketplaceResult, []byte(`"applied":true`)) requireNativeStructuredContains(t, marketplaceResult, []byte(`"apply_record_id":"cfgapp-native-marketplace"`)) requireNativeStructuredContains(t, marketplaceResult, []byte(`"active_generation":7`)) - if settingsService.reloadCalls != 1 { - t.Fatalf("Settings.Reload calls = %d, want 1", settingsService.reloadCalls) + if settingsService.reloadCalls != 3 { + t.Fatalf("Settings.Reload calls = %d, want 3", settingsService.reloadCalls) } workspaceRoot := t.TempDir() @@ -2071,8 +2185,8 @@ func TestDaemonNativeTools(t *testing.T) { } requireNativeStructuredContains(t, unsetResult, []byte(`"applied":true`)) requireNativeStructuredContains(t, unsetResult, []byte(`"apply_record_id":"cfgapp-native-marketplace"`)) - if settingsService.reloadCalls != 2 { - t.Fatalf("Settings.Reload calls = %d, want 2", settingsService.reloadCalls) + if settingsService.reloadCalls != 4 { + t.Fatalf("Settings.Reload calls = %d, want 4", settingsService.reloadCalls) } result, err := registry.Call( @@ -2098,9 +2212,15 @@ func TestDaemonNativeTools(t *testing.T) { {path: "mcp_servers[0].env.TOKEN", reason: toolspkg.ReasonConfigSecretPathForbidden}, {path: "sandboxes.default.runtime_root", reason: toolspkg.ReasonConfigTrustRootForbidden}, {path: "marketplace.catalog.base_url", reason: toolspkg.ReasonConfigTrustRootForbidden}, + {path: "memory.dream.agent", reason: toolspkg.ReasonConfigPathForbidden}, + {path: "memory.dream.enabled", reason: toolspkg.ReasonConfigPathForbidden}, + {path: "memory.extractor.model", reason: toolspkg.ReasonConfigPathForbidden}, + {path: "memory.extractor.enabled", reason: toolspkg.ReasonConfigPathForbidden}, + {path: "memory.controller.llm.model", reason: toolspkg.ReasonConfigPathForbidden}, + {path: "session.auto_title_enabled", reason: toolspkg.ReasonConfigPathForbidden}, } for _, tc := range cases { - t.Run(tc.path, func(t *testing.T) { + t.Run("Should reject "+tc.path, func(t *testing.T) { _, err := registry.Call( t.Context(), toolspkg.Scope{}, @@ -7686,7 +7806,7 @@ func newNativeMemoryAdminFixture(t *testing.T) nativeMemoryAdminFixture { cfg := aghconfig.Config{} cfg.Memory.Enabled = true cfg.Memory.GlobalDir = globalDir - cfg.Memory.Dream.Agent = "dreaming-curator" + cfg.Roles.Dream.Agent = aghconfig.BuiltinDreamingCuratorAgentName cfg.Memory.Dream.CheckInterval = time.Hour dream := &nativeDreamTriggerService{enabled: true, triggered: true, reason: "queued", last: now} extractor := &nativeMemoryExtractorService{ @@ -8377,6 +8497,7 @@ type nativeBundleServiceStub struct { network bundlepkg.NetworkSettings activateCalls int lastActivate bundlepkg.ActivateRequest + activateErr error deactivateCalls int } @@ -8397,6 +8518,9 @@ func (s *nativeBundleServiceStub) Activate( ) (bundlepkg.ActivationPreview, error) { s.activateCalls++ s.lastActivate = req + if s.activateErr != nil { + return bundlepkg.ActivationPreview{}, s.activateErr + } return bundlepkg.ActivationPreview{ Activation: bundlepkg.Activation{ ID: "act-created", diff --git a/internal/daemon/prompt_sections.go b/internal/daemon/prompt_sections.go index 8f9c20baa..0ebcffeb7 100644 --- a/internal/daemon/prompt_sections.go +++ b/internal/daemon/prompt_sections.go @@ -34,7 +34,7 @@ const ( startupMemorySectionBudget = 24_000 startupSoulSectionBudget = 16_000 startupSkillsSectionBudget = 16_000 - startupToolsSectionBudget = 30_000 + startupToolsSectionBudget = 32_000 startupNetworkSectionBudget = 512 ) diff --git a/internal/daemon/role_dependencies.go b/internal/daemon/role_dependencies.go new file mode 100644 index 000000000..212c51802 --- /dev/null +++ b/internal/daemon/role_dependencies.go @@ -0,0 +1,18 @@ +package daemon + +func roleResolverForState(state *bootState) *roleResolver { + if state == nil { + return nil + } + if state.roleResolver == nil { + state.roleResolver = newRoleResolver( + &state.cfg, + state.workspaceResolver, + agentCatalogDependency(state.agentCatalog, agentSidecarCatalogs{ + soul: state.soulCatalog, heartbeat: state.heartbeatCatalog, + }), + state.registry, + ) + } + return state.roleResolver +} diff --git a/internal/daemon/role_fallback.go b/internal/daemon/role_fallback.go new file mode 100644 index 000000000..746436b73 --- /dev/null +++ b/internal/daemon/role_fallback.go @@ -0,0 +1,197 @@ +package daemon + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + aghconfig "github.com/compozy/agh/internal/config" + eventspkg "github.com/compozy/agh/internal/events" + "github.com/compozy/agh/internal/store" +) + +const ( + roleResolutionFailedCode = "role_resolution_failed" + roleEventWriteTimeout = 5 * time.Second +) + +type roleEventSummaryWriter interface { + WriteEventSummary(context.Context, store.EventSummary) error +} + +type roleInvocationCorrelation struct { + WorkspaceID string + SessionID string + AgentName string + ParentSessionID string + RootSessionID string + SpawnDepth int + Event store.EventCorrelation +} + +type roleInvocationCorrelationContextKey struct{} + +type roleAttemptRoute struct { + AgentName string + Provider string + Model string + ReasoningEffort string +} + +type roleFallbackEventPayload struct { + Role string `json:"role"` + Attempt int `json:"attempt"` + Provider string `json:"provider"` + Model string `json:"model"` +} + +type roleResolveErrorEventPayload struct { + Role string `json:"role"` + ErrorCode string `json:"error_code"` + Agent string `json:"agent,omitempty"` +} + +func withRoleInvocationCorrelation(ctx context.Context, correlation roleInvocationCorrelation) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, roleInvocationCorrelationContextKey{}, correlation) +} + +func roleInvocationCorrelationFromContext(ctx context.Context, workspaceID string) roleInvocationCorrelation { + correlation := roleInvocationCorrelation{WorkspaceID: strings.TrimSpace(workspaceID)} + if ctx == nil { + return correlation + } + stored, ok := ctx.Value(roleInvocationCorrelationContextKey{}).(roleInvocationCorrelation) + if !ok { + return correlation + } + stored.WorkspaceID = firstRoleValue(stored.WorkspaceID, workspaceID) + return stored +} + +func invokeRoleWithFallback[T any]( + ctx context.Context, + role ResolvedRole, + correlation roleInvocationCorrelation, + invoke func(context.Context, roleAttemptRoute) (T, bool, error), +) (T, error) { + var zero T + if invoke == nil { + return zero, errors.New("daemon: role invocation callback is required") + } + primary := roleAttemptRoute{ + AgentName: strings.TrimSpace(role.AgentName), + Provider: strings.TrimSpace(role.Provider), + Model: strings.TrimSpace(role.Model), + ReasoningEffort: strings.TrimSpace(role.ReasoningEffort), + } + value, accepted, err := invoke(ctx, primary) + if accepted { + return value, err + } + attemptErrors := []error{roleAttemptError(role.Role, 0, err)} + for index, fallback := range role.Fallbacks { + route := roleAttemptRoute{ + AgentName: primary.AgentName, + Provider: strings.TrimSpace(fallback.Provider), + Model: strings.TrimSpace(fallback.Model), + ReasoningEffort: strings.TrimSpace(fallback.ReasoningEffort), + } + attempt := index + 1 + if eventErr := recordRoleFallbackEvent(ctx, role, correlation, attempt, route); eventErr != nil { + return zero, errors.Join(errors.Join(attemptErrors...), eventErr) + } + value, accepted, err = invoke(ctx, route) + if accepted { + return value, err + } + attemptErrors = append(attemptErrors, roleAttemptError(role.Role, attempt, err)) + } + return zero, fmt.Errorf( + "daemon: %s role invocation exhausted %d attempt(s): %w", + role.Role, + len(role.Fallbacks)+1, + errors.Join(attemptErrors...), + ) +} + +func roleAttemptError(role aghconfig.RoleName, attempt int, err error) error { + if err == nil { + err = errors.New("invocation returned without acceptance") + } + return fmt.Errorf("%s role attempt %d failed before acceptance: %w", role, attempt, err) +} + +func recordRoleFallbackEvent( + ctx context.Context, + role ResolvedRole, + correlation roleInvocationCorrelation, + attempt int, + route roleAttemptRoute, +) error { + if role.eventWriter == nil { + return nil + } + content, err := json.Marshal(roleFallbackEventPayload{ + Role: string(role.Role), Attempt: attempt, Provider: route.Provider, Model: route.Model, + }) + if err != nil { + return fmt.Errorf("daemon: marshal role fallback event: %w", err) + } + eventCtx, cancel := detachedDaemonOperationContext(ctx, roleEventWriteTimeout) + defer cancel() + if err := role.eventWriter.WriteEventSummary(eventCtx, store.EventSummary{ + SessionID: correlation.SessionID, WorkspaceID: correlation.WorkspaceID, + Type: eventspkg.RoleFallbackUsed, AgentName: firstRoleValue(correlation.AgentName, route.AgentName), + Provider: route.Provider, Outcome: string(eventspkg.OutcomeFor(eventspkg.RoleFallbackUsed)), Content: content, + EventCorrelation: correlation.Event, ParentSessionID: correlation.ParentSessionID, + RootSessionID: correlation.RootSessionID, SpawnDepth: correlation.SpawnDepth, + Summary: fmt.Sprintf("%s role fallback attempt %d", role.Role, attempt), + }); err != nil { + return fmt.Errorf("daemon: record %s: %w", eventspkg.RoleFallbackUsed, err) + } + return nil +} + +func (r *roleResolver) recordRoleResolveError( + ctx context.Context, + workspaceID string, + role aghconfig.RoleName, + resolveErr error, +) error { + if r == nil || r.events == nil || resolveErr == nil { + return nil + } + correlation := roleInvocationCorrelationFromContext(ctx, workspaceID) + errorCode := roleResolutionFailedCode + agentName := "" + var resolutionErr *RoleResolutionError + if errors.As(resolveErr, &resolutionErr) { + errorCode = firstRoleValue(resolutionErr.Code, errorCode) + agentName = strings.TrimSpace(resolutionErr.Agent) + } + content, err := json.Marshal(roleResolveErrorEventPayload{ + Role: string(role), ErrorCode: errorCode, Agent: agentName, + }) + if err != nil { + return fmt.Errorf("daemon: marshal role resolution event: %w", err) + } + eventCtx, cancel := detachedDaemonOperationContext(ctx, roleEventWriteTimeout) + defer cancel() + if err := r.events.WriteEventSummary(eventCtx, store.EventSummary{ + SessionID: correlation.SessionID, WorkspaceID: correlation.WorkspaceID, + Type: eventspkg.RoleResolveError, AgentName: firstRoleValue(correlation.AgentName, agentName), + Outcome: string(eventspkg.OutcomeFor(eventspkg.RoleResolveError)), Content: content, + EventCorrelation: correlation.Event, ParentSessionID: correlation.ParentSessionID, + RootSessionID: correlation.RootSessionID, SpawnDepth: correlation.SpawnDepth, + Summary: fmt.Sprintf("%s role resolution failed: %s", role, errorCode), + }); err != nil { + return fmt.Errorf("daemon: record %s: %w", eventspkg.RoleResolveError, err) + } + return nil +} diff --git a/internal/daemon/role_fallback_test.go b/internal/daemon/role_fallback_test.go new file mode 100644 index 000000000..886655bfc --- /dev/null +++ b/internal/daemon/role_fallback_test.go @@ -0,0 +1,271 @@ +package daemon + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "sync/atomic" + "testing" + + aghconfig "github.com/compozy/agh/internal/config" + eventspkg "github.com/compozy/agh/internal/events" + "github.com/compozy/agh/internal/store" +) + +func TestInvokeRoleWithFallback(t *testing.T) { + t.Parallel() + + t.Run("Should stop after the first accepted fallback", func(t *testing.T) { + t.Parallel() + + role := fallbackTestRole(nil) + var attempts []string + result, err := invokeRoleWithFallback(t.Context(), role, roleInvocationCorrelation{}, func( + _ context.Context, + route roleAttemptRoute, + ) (string, bool, error) { + attempts = append(attempts, route.Provider+"/"+route.Model) + if route.Provider == "secondary" { + return "accepted", true, nil + } + return "", false, errors.New("rejected before acceptance") + }) + if err != nil { + t.Fatalf("invokeRoleWithFallback() error = %v", err) + } + if result != "accepted" { + t.Fatalf("invokeRoleWithFallback() = %q, want accepted", result) + } + if want := []string{"primary/m1", "secondary/m2"}; !reflect.DeepEqual(attempts, want) { + t.Fatalf("attempts = %#v, want %#v", attempts, want) + } + }) + + t.Run("Should try every route once in declared order without overlap", func(t *testing.T) { + t.Parallel() + + role := fallbackTestRole(nil) + routeErr := errors.New("route rejected before acceptance") + var active atomic.Int32 + var maximum atomic.Int32 + var attempts []string + _, err := invokeRoleWithFallback(t.Context(), role, roleInvocationCorrelation{}, func( + _ context.Context, + route roleAttemptRoute, + ) (struct{}, bool, error) { + current := active.Add(1) + defer active.Add(-1) + if current > maximum.Load() { + maximum.Store(current) + } + attempts = append(attempts, route.Provider) + return struct{}{}, false, routeErr + }) + if !errors.Is(err, routeErr) { + t.Fatalf("invokeRoleWithFallback() error = %v, want route rejection", err) + } + if want := []string{"primary", "secondary", "tertiary"}; !reflect.DeepEqual(attempts, want) { + t.Fatalf("attempts = %#v, want %#v", attempts, want) + } + if maximum.Load() != 1 { + t.Fatalf("maximum concurrent attempts = %d, want 1", maximum.Load()) + } + }) + + t.Run("Should return deterministic exhaustion with the last cause", func(t *testing.T) { + t.Parallel() + + lastCause := errors.New("tertiary unavailable") + attempt := 0 + _, err := invokeRoleWithFallback(t.Context(), fallbackTestRole(nil), roleInvocationCorrelation{}, func( + _ context.Context, + _ roleAttemptRoute, + ) (struct{}, bool, error) { + attempt++ + if attempt == 3 { + return struct{}{}, false, lastCause + } + return struct{}{}, false, errors.New("route unavailable") + }) + if !errors.Is(err, lastCause) { + t.Fatalf("invokeRoleWithFallback() error = %v, want last cause", err) + } + if attempt != 3 { + t.Fatalf("attempt count = %d, want 3", attempt) + } + }) + + t.Run("Should never fall back after authoritative acceptance", func(t *testing.T) { + t.Parallel() + + acceptedErr := errors.New("accepted session failed during startup") + attempts := 0 + value, err := invokeRoleWithFallback(t.Context(), fallbackTestRole(nil), roleInvocationCorrelation{}, func( + _ context.Context, + _ roleAttemptRoute, + ) (string, bool, error) { + attempts++ + return "accepted-session", true, acceptedErr + }) + if !errors.Is(err, acceptedErr) { + t.Fatalf("invokeRoleWithFallback() error = %v, want accepted failure", err) + } + if value != "accepted-session" || attempts != 1 { + t.Fatalf("value/attempts = %q/%d, want accepted-session/1", value, attempts) + } + }) + + t.Run("Should make one attempt and emit no event for an empty chain", func(t *testing.T) { + t.Parallel() + + recorder := &roleEventRecorder{} + role := fallbackTestRole(recorder) + role.Fallbacks = nil + primaryErr := errors.New("primary rejected") + attempts := 0 + _, err := invokeRoleWithFallback(t.Context(), role, roleInvocationCorrelation{}, func( + _ context.Context, + _ roleAttemptRoute, + ) (struct{}, bool, error) { + attempts++ + return struct{}{}, false, primaryErr + }) + if !errors.Is(err, primaryErr) { + t.Fatalf("invokeRoleWithFallback() error = %v, want primary rejection", err) + } + if attempts != 1 || recorder.count() != 0 { + t.Fatalf("attempts/events = %d/%d, want 1/0", attempts, recorder.count()) + } + }) +} + +func TestRoleObservabilityCoverageMatrix(t *testing.T) { + t.Parallel() + + t.Run("Should persist the fallback event before starting the attempt", func(t *testing.T) { + t.Parallel() + + recorder := &roleEventRecorder{} + role := fallbackTestRole(recorder) + correlation := roleInvocationCorrelation{ + WorkspaceID: "ws-1", + SessionID: "session-1", + Event: store.EventCorrelation{TaskID: "task-1", RunID: "run-1"}, + } + attempts := 0 + _, err := invokeRoleWithFallback(t.Context(), role, correlation, func( + _ context.Context, + _ roleAttemptRoute, + ) (struct{}, bool, error) { + attempts++ + if attempts == 1 { + return struct{}{}, false, errors.New("primary rejected") + } + if recorder.count() != 1 { + t.Fatalf("event count at fallback start = %d, want 1", recorder.count()) + } + return struct{}{}, true, nil + }) + if err != nil { + t.Fatalf("invokeRoleWithFallback() error = %v", err) + } + event := recorder.single(t) + if event.Type != eventspkg.RoleFallbackUsed || event.WorkspaceID != "ws-1" || + event.SessionID != "session-1" || event.TaskID != "task-1" || event.RunID != "run-1" { + t.Fatalf("fallback event = %#v", event) + } + var payload roleFallbackEventPayload + if err := json.Unmarshal(event.Content, &payload); err != nil { + t.Fatalf("json.Unmarshal(event.Content) error = %v", err) + } + if payload.Role != string(aghconfig.RoleDream) || payload.Attempt != 1 || + payload.Provider != "secondary" || payload.Model != "m2" { + t.Fatalf("fallback payload = %#v", payload) + } + }) + + t.Run("Should emit the canonical error event with role correlation", func(t *testing.T) { + t.Parallel() + + cfg := aghconfig.DefaultWithHome(aghconfig.HomePaths{}) + cfg.Roles.Dream.Agent = "missing-curator" + recorder := &roleEventRecorder{} + resolver := newRoleResolver(&cfg, nil, nil, recorder) + correlation := roleInvocationCorrelation{ + WorkspaceID: "ws-1", + SessionID: "session-1", + Event: store.EventCorrelation{ + TaskID: "task-1", + RunID: "run-1", + }, + } + _, err := resolver.Resolve( + withRoleInvocationCorrelation(t.Context(), correlation), + "", + aghconfig.RoleDream, + ) + var resolutionErr *RoleResolutionError + if !errors.As(err, &resolutionErr) || resolutionErr.Code != roleErrorAgentNotFound { + t.Fatalf("Resolve() error = %v, want role_agent_not_found", err) + } + event := recorder.single(t) + if event.Type != eventspkg.RoleResolveError || event.Outcome != string(eventspkg.OutcomeFailure) || + event.SessionID != "session-1" || event.WorkspaceID != "ws-1" || + event.TaskID != "task-1" || event.RunID != "run-1" { + t.Fatalf("resolution event = %#v", event) + } + var payload roleResolveErrorEventPayload + if err := json.Unmarshal(event.Content, &payload); err != nil { + t.Fatalf("json.Unmarshal(event.Content) error = %v", err) + } + if payload.Role != string(aghconfig.RoleDream) || + payload.ErrorCode != roleErrorAgentNotFound || payload.Agent != "missing-curator" { + t.Fatalf("resolution payload = %#v", payload) + } + }) +} + +func fallbackTestRole(writer roleEventSummaryWriter) ResolvedRole { + return ResolvedRole{ + Role: aghconfig.RoleDream, + AgentName: aghconfig.BuiltinDreamingCuratorAgentName, + Provider: "primary", + Model: "m1", + ReasoningEffort: "low", + Fallbacks: []aghconfig.RoleFallback{ + {Provider: "secondary", Model: "m2", ReasoningEffort: "medium"}, + {Provider: "tertiary", Model: "m3", ReasoningEffort: "high"}, + }, + eventWriter: writer, + } +} + +type roleEventRecorder struct { + mu sync.Mutex + events []store.EventSummary +} + +func (r *roleEventRecorder) WriteEventSummary(_ context.Context, event store.EventSummary) error { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, event) + return nil +} + +func (r *roleEventRecorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.events) +} + +func (r *roleEventRecorder) single(t *testing.T) store.EventSummary { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if len(r.events) != 1 { + t.Fatalf("event count = %d, want 1", len(r.events)) + } + return r.events[0] +} diff --git a/internal/daemon/role_resolver.go b/internal/daemon/role_resolver.go new file mode 100644 index 000000000..749930ff6 --- /dev/null +++ b/internal/daemon/role_resolver.go @@ -0,0 +1,357 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" + workspacepkg "github.com/compozy/agh/internal/workspace" +) + +const ( + roleErrorUnknown = contract.CodeRoleUnknown + roleErrorAgentNotFound = contract.CodeRoleAgentNotFound + roleFieldEnabled = "enabled" + roleFieldProvider = "provider" + roleFieldModel = "model" + roleFieldReasoning = "reasoning_effort" + roleFieldFallbackChain = "fallback_chain" + roleFieldTimeout = "timeout" +) + +// RoleResolutionError is a deterministic role-resolution failure. +type RoleResolutionError struct { + Code string + Role aghconfig.RoleName + Agent string + Err error +} + +func (e *RoleResolutionError) Error() string { + if e == nil { + return "role resolution failed" + } + parts := []string{e.Code, string(e.Role)} + if strings.TrimSpace(e.Agent) != "" { + parts = append(parts, strings.TrimSpace(e.Agent)) + } + if e.Err != nil { + parts = append(parts, e.Err.Error()) + } + return strings.Join(parts, ": ") +} + +func (e *RoleResolutionError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// DiagnosticCode returns the stable public code for transport projections. +func (e *RoleResolutionError) DiagnosticCode() string { + if e == nil { + return "" + } + return strings.TrimSpace(e.Code) +} + +// ResolvedRole is one invocation-time role routing decision. +type ResolvedRole struct { + Role aghconfig.RoleName + Enabled bool + AgentName string + Builtin bool + Inherit bool + AgentDef aghconfig.AgentDef + Provider string + Model string + ReasoningEffort string + Fallbacks []aghconfig.RoleFallback + Provenance map[string]string + eventWriter roleEventSummaryWriter +} + +type RoleResolver interface { + Resolve(ctx context.Context, workspaceID string, role aghconfig.RoleName) (ResolvedRole, error) +} + +type roleResolver struct { + config *aghconfig.Config + workspaceResolver workspacepkg.RuntimeResolver + agents coordinatorAgentResolver + events roleEventSummaryWriter +} + +var _ RoleResolver = (*roleResolver)(nil) + +func newRoleResolver( + cfg *aghconfig.Config, + workspaceResolver workspacepkg.RuntimeResolver, + agents coordinatorAgentResolver, + eventWriters ...roleEventSummaryWriter, +) *roleResolver { + resolver := &roleResolver{config: cfg, workspaceResolver: workspaceResolver, agents: agents} + if len(eventWriters) > 0 { + resolver.events = eventWriters[0] + } + return resolver +} + +func (r *roleResolver) Resolve( + ctx context.Context, + workspaceID string, + role aghconfig.RoleName, +) (ResolvedRole, error) { + resolved, _, err := r.resolveEffective(ctx, workspaceID, role) + if err != nil { + return ResolvedRole{}, errors.Join(err, r.recordRoleResolveError(ctx, workspaceID, role, err)) + } + resolved.eventWriter = r.events + return resolved, nil +} + +func (r *roleResolver) resolveEffective( + ctx context.Context, + workspaceID string, + role aghconfig.RoleName, +) (ResolvedRole, *aghconfig.Config, error) { + effectiveConfig, resolvedWorkspace, err := r.effectiveRoleConfig(ctx, workspaceID) + if err != nil { + return ResolvedRole{}, nil, err + } + + common, err := roleConfig(&effectiveConfig.Roles, role) + if err != nil { + return ResolvedRole{}, nil, err + } + resolved := ResolvedRole{ + Role: role, + Enabled: common.Enabled, + Fallbacks: append([]aghconfig.RoleFallback(nil), common.FallbackChain...), + Provenance: roleProvenance(role, effectiveConfig, r.config, resolvedWorkspace != nil), + Provider: strings.TrimSpace(common.Provider), + Model: strings.TrimSpace(common.Model), + ReasoningEffort: strings.TrimSpace(common.ReasoningEffort), + } + + agentName := strings.TrimSpace(common.Agent) + if agentName == "" { + agentName, resolved.Inherit = defaultRoleIdentity(role) + } + if resolved.Inherit { + correlation := roleInvocationCorrelationFromContext(ctx, workspaceID) + agentName = strings.TrimSpace(correlation.AgentName) + if agentName == "" { + return resolved, effectiveConfig, nil + } + } + if agentName != "" { + agentDef, builtin, resolveErr := r.resolveRoleAgent(role, agentName, resolvedWorkspace) + if resolveErr != nil { + return ResolvedRole{}, nil, resolveErr + } + resolved.AgentName = agentName + resolved.AgentDef = agentDef + resolved.Builtin = builtin + } + + if role == aghconfig.RoleMemoryController { + return resolved, effectiveConfig, nil + } + if err := resolveRoleRuntime(effectiveConfig, common, &resolved); err != nil { + return ResolvedRole{}, nil, fmt.Errorf("daemon: resolve %s role runtime: %w", role, err) + } + return resolved, effectiveConfig, nil +} + +func (r *roleResolver) effectiveRoleConfig( + ctx context.Context, + workspaceID string, +) (*aghconfig.Config, *workspacepkg.ResolvedWorkspace, error) { + if ctx == nil { + return nil, nil, errors.New("daemon: role resolver context is required") + } + if r == nil || r.config == nil { + return nil, nil, errors.New("daemon: role resolver config is required") + } + target := strings.TrimSpace(workspaceID) + if target == "" { + return r.config, nil, nil + } + if r.workspaceResolver == nil { + return nil, nil, errors.New("daemon: workspace resolver is required for workspace role resolution") + } + resolved, err := r.workspaceResolver.Resolve(ctx, target) + if err != nil { + return nil, nil, fmt.Errorf("daemon: resolve role workspace %q: %w", target, err) + } + return &resolved.Config, &resolved, nil +} + +func (r *roleResolver) resolveRoleAgent( + role aghconfig.RoleName, + name string, + resolvedWorkspace *workspacepkg.ResolvedWorkspace, +) (aghconfig.AgentDef, bool, error) { + if builtin, ok := aghconfig.BuiltinAgentDef(name); ok { + return builtin, true, nil + } + if r.agents == nil { + return aghconfig.AgentDef{}, false, &RoleResolutionError{ + Code: roleErrorAgentNotFound, Role: role, Agent: name, Err: workspacepkg.ErrAgentNotAvailable, + } + } + agent, err := r.agents.ResolveAgent(name, resolvedWorkspace) + if err != nil { + if errors.Is(err, workspacepkg.ErrAgentNotAvailable) { + return aghconfig.AgentDef{}, false, &RoleResolutionError{ + Code: roleErrorAgentNotFound, Role: role, Agent: name, Err: err, + } + } + return aghconfig.AgentDef{}, false, fmt.Errorf("daemon: resolve role agent %q: %w", name, err) + } + return agent, false, nil +} + +func resolveRoleRuntime(cfg *aghconfig.Config, common aghconfig.RoleConfig, resolved *ResolvedRole) error { + if resolved == nil { + return errors.New("resolved role is required") + } + route := aghconfig.CloneAgentDef(resolved.AgentDef) + route.Provider = firstRoleValue(common.Provider, route.Provider) + route.Model = firstRoleValue(common.Model, route.Model) + route.ReasoningEffort = firstRoleValue(common.ReasoningEffort, route.ReasoningEffort) + if strings.TrimSpace(route.Provider) == "" && cfg != nil { + route.Provider = strings.TrimSpace(cfg.Defaults.Provider) + } + if strings.TrimSpace(route.Provider) == "" { + if strings.TrimSpace(route.Model) != "" { + return errors.New("provider is required when model is set") + } + resolved.ReasoningEffort = strings.TrimSpace(route.ReasoningEffort) + return nil + } + if cfg == nil { + return errors.New("config is required") + } + runtime, err := cfg.ResolveAgent(route) + if err != nil { + return err + } + resolved.Provider = runtime.Provider + resolved.Model = runtime.Model + resolved.ReasoningEffort = runtime.ReasoningEffort + return nil +} + +func roleConfig(roles *aghconfig.RolesConfig, role aghconfig.RoleName) (aghconfig.RoleConfig, error) { + if roles == nil { + return aghconfig.RoleConfig{}, errors.New("roles config is required") + } + switch role { + case aghconfig.RoleCoordinator: + return roles.Coordinator.RoleConfig, nil + case aghconfig.RoleDream: + return roles.Dream, nil + case aghconfig.RoleCheckpointSummary: + return roles.CheckpointSummary, nil + case aghconfig.RoleMemoryExtractor: + return roles.MemoryExtractor, nil + case aghconfig.RoleAutoTitle: + return roles.AutoTitle, nil + case aghconfig.RoleMemoryController: + return aghconfig.RoleConfig{ + Enabled: roles.MemoryController.Enabled, + Provider: roles.MemoryController.Provider, + Model: roles.MemoryController.Model, + ReasoningEffort: roles.MemoryController.ReasoningEffort, + FallbackChain: roles.MemoryController.FallbackChain, + }, nil + default: + return aghconfig.RoleConfig{}, &RoleResolutionError{Code: roleErrorUnknown, Role: role} + } +} + +func defaultRoleIdentity(role aghconfig.RoleName) (string, bool) { + switch role { + case aghconfig.RoleCoordinator: + return aghconfig.BuiltinCoordinatorAgentName, false + case aghconfig.RoleDream, aghconfig.RoleCheckpointSummary: + return aghconfig.BuiltinDreamingCuratorAgentName, false + case aghconfig.RoleMemoryExtractor, aghconfig.RoleAutoTitle: + return "", true + case aghconfig.RoleMemoryController: + return "", false + default: + return "", false + } +} + +func roleProvenance( + role aghconfig.RoleName, + effective *aghconfig.Config, + global *aghconfig.Config, + hasWorkspace bool, +) map[string]string { + defaults := aghconfig.DefaultRolesConfig() + effectiveFields := roleFields(role, &effective.Roles) + globalFields := roleFields(role, &global.Roles) + defaultFields := roleFields(role, &defaults) + provenance := make(map[string]string, len(effectiveFields)) + for field, value := range effectiveFields { + if source := effective.RoleFieldSource(role, field); source == aghconfig.RoleFieldSourceWorkspace || + source == aghconfig.RoleFieldSourceGlobal { + provenance[field] = source + continue + } + source := aghconfig.RoleFieldSourceDefault + if hasWorkspace && !reflect.DeepEqual(value, globalFields[field]) { + source = aghconfig.RoleFieldSourceWorkspace + } else if !reflect.DeepEqual(globalFields[field], defaultFields[field]) { + source = aghconfig.RoleFieldSourceGlobal + } + provenance[field] = source + } + return provenance +} + +func roleFields(role aghconfig.RoleName, roles *aghconfig.RolesConfig) map[string]any { + common, err := roleConfig(roles, role) + if err != nil { + return nil + } + fields := map[string]any{ + roleFieldEnabled: common.Enabled, + daemonAgentField: common.Agent, + roleFieldProvider: common.Provider, + roleFieldModel: common.Model, + roleFieldReasoning: common.ReasoningEffort, + roleFieldFallbackChain: common.FallbackChain, + } + if role == aghconfig.RoleCoordinator { + fields["ttl"] = roles.Coordinator.TTL + fields["max_children"] = roles.Coordinator.MaxChildren + fields["max_active_sessions_per_workspace"] = roles.Coordinator.MaxActiveSessionsPerWorkspace + } + if role == aghconfig.RoleMemoryController { + fields[roleFieldTimeout] = roles.MemoryController.Timeout + fields["top_k"] = roles.MemoryController.TopK + fields["prompt_version"] = roles.MemoryController.PromptVersion + fields["max_tokens_out"] = roles.MemoryController.MaxTokensOut + } + return fields +} + +func firstRoleValue(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/daemon/role_resolver_integration_test.go b/internal/daemon/role_resolver_integration_test.go new file mode 100644 index 000000000..23c194453 --- /dev/null +++ b/internal/daemon/role_resolver_integration_test.go @@ -0,0 +1,328 @@ +//go:build integration && !windows + +package daemon + +import ( + "context" + "net/http" + "net/url" + "reflect" + "testing" + "time" + + aghcontract "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" + e2etest "github.com/compozy/agh/internal/testutil/e2e" +) + +func TestRoleResolverIntegration(t *testing.T) { + t.Parallel() + + t.Run("Should resolve every role from built-in defaults without a user config", func(t *testing.T) { + t.Parallel() + + homePaths := e2etest.NewHomePaths(t) + cfg, err := aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome() error = %v", err) + } + resolver := newRoleResolver(&cfg, nil, nil) + + for _, testCase := range []struct { + role aghconfig.RoleName + agent string + enabled bool + builtin bool + inherit bool + model string + }{ + {role: aghconfig.RoleCoordinator, agent: aghconfig.BuiltinCoordinatorAgentName, builtin: true}, + {role: aghconfig.RoleDream, agent: aghconfig.BuiltinDreamingCuratorAgentName, enabled: true, builtin: true}, + {role: aghconfig.RoleCheckpointSummary, agent: aghconfig.BuiltinDreamingCuratorAgentName, enabled: true, builtin: true}, + {role: aghconfig.RoleMemoryExtractor, enabled: true, inherit: true}, + {role: aghconfig.RoleAutoTitle, enabled: true, inherit: true}, + {role: aghconfig.RoleMemoryController, enabled: true, model: "anthropic/claude-haiku-4"}, + } { + t.Run("Should resolve "+string(testCase.role), func(t *testing.T) { + t.Parallel() + + resolved, resolveErr := resolver.Resolve(t.Context(), "", testCase.role) + if resolveErr != nil { + t.Fatalf("Resolve(%s) error = %v", testCase.role, resolveErr) + } + if resolved.Enabled != testCase.enabled || resolved.AgentName != testCase.agent || + resolved.Builtin != testCase.builtin || resolved.Inherit != testCase.inherit || + resolved.Model != testCase.model { + t.Fatalf("Resolve(%s) = %#v, want enabled=%t agent=%q builtin=%t inherit=%t model=%q", + testCase.role, + resolved, + testCase.enabled, + testCase.agent, + testCase.builtin, + testCase.inherit, + testCase.model, + ) + } + }) + } + }) + + t.Run("Should isolate workspace role overlays and report their provenance", func(t *testing.T) { + t.Parallel() + + homePaths := e2etest.NewHomePaths(t) + e2etest.SeedConfig(t, homePaths, e2etest.ConfigSeedOptions{ + Providers: map[string]aghconfig.ProviderConfig{ + "mock": {Command: "mock-acp"}, + }, + Mutate: func(cfg *aghconfig.Config) { + cfg.Roles.Dream.Provider = "mock" + cfg.Roles.Dream.Model = "global-model" + }, + }) + workspaceA := e2etest.SeedWorkspace(t, e2etest.WorkspaceSeedOptions{Files: map[string]string{ + ".agh/config.toml": "[roles.dream]\nmodel = \"workspace-model\"\n", + }}) + workspaceB := e2etest.SeedWorkspace(t, e2etest.WorkspaceSeedOptions{}) + workspaceC := e2etest.SeedWorkspace(t, e2etest.WorkspaceSeedOptions{Files: map[string]string{ + ".agh/config.toml": "[roles.dream]\nmodel = \"global-model\"\n", + }}) + + global, err := aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome(global) error = %v", err) + } + configA, err := aghconfig.LoadForHome(homePaths, aghconfig.WithWorkspaceRoot(workspaceA)) + if err != nil { + t.Fatalf("LoadForHome(workspace A) error = %v", err) + } + configB, err := aghconfig.LoadForHome(homePaths, aghconfig.WithWorkspaceRoot(workspaceB)) + if err != nil { + t.Fatalf("LoadForHome(workspace B) error = %v", err) + } + configC, err := aghconfig.LoadForHome(homePaths, aghconfig.WithWorkspaceRoot(workspaceC)) + if err != nil { + t.Fatalf("LoadForHome(workspace C) error = %v", err) + } + resolver := newRoleResolver(&global, roleWorkspaceResolverStub{configs: map[string]aghconfig.Config{ + "workspace-a": configA, + "workspace-b": configB, + "workspace-c": configC, + }}, nil) + + resolvedA, err := resolver.Resolve(t.Context(), "workspace-a", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(workspace A) error = %v", err) + } + resolvedB, err := resolver.Resolve(t.Context(), "workspace-b", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(workspace B) error = %v", err) + } + if resolvedA.Model != "workspace-model" || resolvedA.Provenance["model"] != "workspace" { + t.Fatalf("Resolve(workspace A) = %#v, want workspace model provenance", resolvedA) + } + if resolvedB.Model != "global-model" || resolvedB.Provenance["model"] != "global" { + t.Fatalf("Resolve(workspace B) = %#v, want global model provenance", resolvedB) + } + resolvedC, err := resolver.Resolve(t.Context(), "workspace-c", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(workspace C) error = %v", err) + } + if resolvedC.Model != "global-model" || resolvedC.Provenance["model"] != "workspace" { + t.Fatalf("Resolve(workspace C) = %#v, want explicit same-value workspace provenance", resolvedC) + } + }) + + t.Run("Should route through an agent definition loaded from disk", func(t *testing.T) { + t.Parallel() + + homePaths := e2etest.NewHomePaths(t) + e2etest.SeedConfig(t, homePaths, e2etest.ConfigSeedOptions{ + Providers: map[string]aghconfig.ProviderConfig{ + "mock": {Command: "mock-acp"}, + }, + AgentDefs: []e2etest.AgentSeed{{ + Name: "my-curator", + Provider: "mock", + Model: "agent-model", + Prompt: "Curate durable memories.", + }}, + Mutate: func(cfg *aghconfig.Config) { + cfg.Roles.Dream.Agent = "my-curator" + }, + }) + cfg, err := aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome() error = %v", err) + } + agents, err := aghconfig.LoadWorkspaceAgentDefs("", nil, homePaths) + if err != nil { + t.Fatalf("LoadWorkspaceAgentDefs() error = %v", err) + } + catalog := make(map[string]aghconfig.AgentDef, len(agents)) + for _, agent := range agents { + catalog[agent.Name] = agent + } + + resolved, err := newRoleResolver(&cfg, nil, roleAgentResolverStub{agents: catalog}).Resolve( + t.Context(), + "", + aghconfig.RoleDream, + ) + if err != nil { + t.Fatalf("Resolve(dream) error = %v", err) + } + if resolved.AgentName != "my-curator" || resolved.Model != "agent-model" || + resolved.AgentDef.Prompt != "Curate durable memories." { + t.Fatalf("Resolve(dream) = %#v, want disk-backed my-curator route", resolved) + } + + cfg.Roles.Dream.Model = "role-model" + resolved, err = newRoleResolver(&cfg, nil, roleAgentResolverStub{agents: catalog}).Resolve( + t.Context(), + "", + aghconfig.RoleDream, + ) + if err != nil { + t.Fatalf("Resolve(dream role override) error = %v", err) + } + if resolved.Model != "role-model" { + t.Fatalf("Resolve(dream role override) model = %q, want role-model", resolved.Model) + } + }) + + t.Run("Should boot while excluding a pre-existing reserved agent definition", func(t *testing.T) { + t.Parallel() + + homePaths := e2etest.NewHomePaths(t) + e2etest.WriteAgentDef(t, homePaths, e2etest.AgentSeed{ + Name: aghconfig.BuiltinCoordinatorAgentName, + Provider: "fake", + Model: "shadow-model", + Prompt: "Shadow the builtin coordinator.", + }) + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{HomePaths: homePaths}) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + workspace := url.QueryEscape(harness.WorkspaceRoot) + var agents aghcontract.AgentsResponse + if err := harness.UDSJSON( + ctx, + http.MethodGet, + "/api/agents?workspace="+workspace, + nil, + &agents, + ); err != nil { + t.Fatalf("list agents after reserved definition boot error = %v", err) + } + assertBuiltinIdentityAbsent(t, agents.Agents) + + var catalog aghcontract.AgentCatalogResponse + if err := harness.UDSJSON( + ctx, + http.MethodGet, + "/api/agents/catalog?workspace="+workspace, + nil, + &catalog, + ); err != nil { + t.Fatalf("list agent catalog after reserved definition boot error = %v", err) + } + catalogAgents := make([]aghcontract.AgentPayload, 0, len(catalog.Agents)) + for _, item := range catalog.Agents { + catalogAgents = append(catalogAgents, item.Agent) + } + assertBuiltinIdentityAbsent(t, catalogAgents) + }) + + t.Run("Should expose one truthful role projection across HTTP UDS and CLI", func(t *testing.T) { + t.Parallel() + + harness := e2etest.StartRuntimeHarness(t, &e2etest.RuntimeHarnessOptions{ + Workspace: e2etest.WorkspaceSeedOptions{Files: map[string]string{ + ".agh/config.toml": `[roles.dream] +agent = "missing-curator" +`, + }}, + }) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + path := "/api/roles?workspace=" + url.QueryEscape(harness.WorkspaceID) + + var httpRoles aghcontract.RolesResponse + if err := harness.HTTPJSON(ctx, http.MethodGet, path, nil, &httpRoles); err != nil { + t.Fatalf("HTTP GET roles error = %v", err) + } + var udsRoles aghcontract.RolesResponse + if err := harness.UDSJSON(ctx, http.MethodGet, path, nil, &udsRoles); err != nil { + t.Fatalf("UDS GET roles error = %v", err) + } + if !reflect.DeepEqual(httpRoles, udsRoles) { + t.Fatalf("HTTP roles = %#v, want UDS parity %#v", httpRoles, udsRoles) + } + if len(httpRoles.Roles) != 6 { + t.Fatalf("len(roles) = %d, want 6", len(httpRoles.Roles)) + } + for index := 1; index < len(httpRoles.Roles); index++ { + if httpRoles.Roles[index-1].Role >= httpRoles.Roles[index].Role { + t.Fatalf("roles are not sorted: %#v", httpRoles.Roles) + } + } + + var dream aghcontract.RoleStatus + for _, role := range httpRoles.Roles { + if role.Role == string(aghconfig.RoleDream) { + dream = role + } + if role.ResolutionMode == aghcontract.RoleResolutionModeInherit && role.Agent != nil { + t.Fatalf("inherit role fabricated an agent identity: %#v", role) + } + } + if dream.Role == "" || len(dream.Diagnostics) != 1 || + dream.Diagnostics[0].Code != aghcontract.CodeRoleAgentNotFound { + t.Fatalf("dream role = %#v, want role_agent_not_found diagnostic", dream) + } + + var cliDream aghcontract.RoleStatus + if err := harness.CLI.RunJSONInDir( + ctx, + harness.WorkspaceRoot, + &cliDream, + "roles", + "show", + "dream", + "--workspace", + harness.WorkspaceRoot, + "-o", + "json", + ); err != nil { + t.Fatalf("CLI roles show dream error = %v", err) + } + if !reflect.DeepEqual(cliDream, dream) { + t.Fatalf("CLI dream = %#v, want HTTP/UDS parity %#v", cliDream, dream) + } + + workspace := url.QueryEscape(harness.WorkspaceRoot) + var agents aghcontract.AgentsResponse + if err := harness.UDSJSON( + ctx, + http.MethodGet, + "/api/agents?workspace="+workspace, + nil, + &agents, + ); err != nil { + t.Fatalf("UDS GET agents error = %v", err) + } + assertBuiltinIdentityAbsent(t, agents.Agents) + }) +} + +func assertBuiltinIdentityAbsent(t *testing.T, agents []aghcontract.AgentPayload) { + t.Helper() + + for _, agent := range agents { + if aghconfig.IsReservedAgentName(agent.Name) { + t.Fatalf("agent catalog exposed reserved identity %q: %#v", agent.Name, agent) + } + } +} diff --git a/internal/daemon/role_resolver_test.go b/internal/daemon/role_resolver_test.go new file mode 100644 index 000000000..14a797b5d --- /dev/null +++ b/internal/daemon/role_resolver_test.go @@ -0,0 +1,345 @@ +package daemon + +import ( + "context" + "errors" + "strings" + "testing" + + aghconfig "github.com/compozy/agh/internal/config" + workspacepkg "github.com/compozy/agh/internal/workspace" +) + +type roleResolverFunc func(context.Context, string, aghconfig.RoleName) (ResolvedRole, error) + +func (f roleResolverFunc) Resolve( + ctx context.Context, + workspaceID string, + role aghconfig.RoleName, +) (ResolvedRole, error) { + return f(ctx, workspaceID, role) +} + +func resolvedRoleResolver(resolved ResolvedRole) RoleResolver { + return roleResolverFunc(func(context.Context, string, aghconfig.RoleName) (ResolvedRole, error) { + return resolved, nil + }) +} + +func TestRoleResolver(t *testing.T) { + t.Parallel() + + t.Run("Should resolve default builtin and inherited identities", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + resolver := newRoleResolver(&cfg, nil, nil) + + dream, err := resolver.Resolve(t.Context(), "", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(dream) error = %v", err) + } + if !dream.Builtin || dream.AgentName != aghconfig.BuiltinDreamingCuratorAgentName || + strings.TrimSpace(dream.AgentDef.Prompt) == "" { + t.Fatalf("Resolve(dream) = %#v, want embedded dreaming-curator", dream) + } + if dream.Provenance["agent"] != "default" || dream.Provenance["model"] != "default" { + t.Fatalf("Resolve(dream) provenance = %#v, want default fields", dream.Provenance) + } + + coordinator, err := resolver.Resolve(t.Context(), "", aghconfig.RoleCoordinator) + if err != nil { + t.Fatalf("Resolve(coordinator) error = %v", err) + } + if coordinator.Enabled || !coordinator.Builtin || + coordinator.AgentName != aghconfig.BuiltinCoordinatorAgentName { + t.Fatalf("Resolve(coordinator) = %#v, want disabled builtin coordinator", coordinator) + } + + for _, role := range []aghconfig.RoleName{aghconfig.RoleAutoTitle, aghconfig.RoleMemoryExtractor} { + resolved, resolveErr := resolver.Resolve(t.Context(), "", role) + if resolveErr != nil { + t.Fatalf("Resolve(%s) error = %v", role, resolveErr) + } + if !resolved.Inherit || resolved.AgentName != "" || resolved.AgentDef.Name != "" || + resolved.AgentDef.Prompt != "" { + t.Fatalf("Resolve(%s) = %#v, want invocation-time inheritance", role, resolved) + } + } + }) + + t.Run("Should resolve custom catalog agents", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + cfg.Roles.Dream.Agent = "my-curator" + agent := aghconfig.AgentDef{Name: "my-curator", Prompt: "curate exactly", Tools: []string{"read"}} + resolver := newRoleResolver(&cfg, nil, roleAgentResolverStub{ + agents: map[string]aghconfig.AgentDef{"my-curator": agent}, + }) + + resolved, err := resolver.Resolve(t.Context(), "", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(dream) error = %v", err) + } + if resolved.Builtin || resolved.Inherit || resolved.AgentName != "my-curator" { + t.Fatalf("Resolve(dream) = %#v, want custom catalog identity", resolved) + } + if resolved.AgentDef.Prompt != agent.Prompt || len(resolved.AgentDef.Tools) != 1 || + resolved.AgentDef.Tools[0] != "read" { + t.Fatalf("Resolve(dream) AgentDef = %#v, want untouched catalog definition", resolved.AgentDef) + } + + cfg.Roles.AutoTitle.Agent = "my-curator" + resolved, err = newRoleResolver(&cfg, nil, roleAgentResolverStub{ + agents: map[string]aghconfig.AgentDef{"my-curator": agent}, + }).Resolve(t.Context(), "", aghconfig.RoleAutoTitle) + if err != nil { + t.Fatalf("Resolve(auto_title) error = %v", err) + } + if resolved.Inherit || resolved.AgentName != "my-curator" { + t.Fatalf("Resolve(auto_title) = %#v, want explicit catalog identity", resolved) + } + }) + + t.Run("Should resolve inherited role overrides through the invoking agent provider chain", func(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + role aghconfig.RoleName + agentProvider string + wantProvider string + }{ + { + name: "Should prefer the invoking agent provider", + role: aghconfig.RoleAutoTitle, + agentProvider: "agent-provider", + wantProvider: "agent-provider", + }, + { + name: "Should use the default provider when the invoking agent inherits it", + role: aghconfig.RoleMemoryExtractor, + wantProvider: "default-provider", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + cfg.Defaults.Provider = "default-provider" + cfg.Providers["agent-provider"] = aghconfig.ProviderConfig{Command: "agent-acp"} + cfg.Providers["default-provider"] = aghconfig.ProviderConfig{Command: "default-acp"} + switch testCase.role { + case aghconfig.RoleAutoTitle: + cfg.Roles.AutoTitle.Model = "role-model" + case aghconfig.RoleMemoryExtractor: + cfg.Roles.MemoryExtractor.Model = "role-model" + } + resolver := newRoleResolver(&cfg, nil, roleAgentResolverStub{agents: map[string]aghconfig.AgentDef{ + "invoking-agent": { + Name: "invoking-agent", + Provider: testCase.agentProvider, + Prompt: "Handle the parent session.", + }, + }}) + ctx := withRoleInvocationCorrelation(t.Context(), roleInvocationCorrelation{ + AgentName: "invoking-agent", + }) + + resolved, err := resolver.Resolve(ctx, "", testCase.role) + if err != nil { + t.Fatalf("Resolve(%s) error = %v", testCase.role, err) + } + if !resolved.Inherit || resolved.AgentName != "invoking-agent" || + resolved.Provider != testCase.wantProvider || resolved.Model != "role-model" { + t.Fatalf( + "Resolve(%s) = %#v, want inherited invoking-agent routed through %q/role-model", + testCase.role, + resolved, + testCase.wantProvider, + ) + } + }) + } + }) + + t.Run("Should reject missing catalog agents and unknown roles deterministically", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + cfg.Roles.Dream.Agent = "ghost" + resolver := newRoleResolver(&cfg, nil, roleAgentResolverStub{}) + + _, err := resolver.Resolve(t.Context(), "", aghconfig.RoleDream) + var resolutionErr *RoleResolutionError + if !errors.As(err, &resolutionErr) || resolutionErr.Code != roleErrorAgentNotFound || + resolutionErr.Agent != "ghost" { + t.Fatalf("Resolve(ghost) error = %v, want role_agent_not_found naming ghost", err) + } + + _, err = resolver.Resolve(t.Context(), "", aghconfig.RoleName("unknown")) + resolutionErr = nil + if !errors.As(err, &resolutionErr) || resolutionErr.Code != roleErrorUnknown { + t.Fatalf("Resolve(unknown) error = %v, want role_unknown", err) + } + }) + + t.Run("Should apply role agent and provider model precedence", func(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + roleModel string + agentModel string + providerModel string + want string + }{ + {name: "role model", roleModel: "role-model", agentModel: "agent-model", providerModel: "provider-model", want: "role-model"}, + {name: "agent model", agentModel: "agent-model", providerModel: "provider-model", want: "agent-model"}, + {name: "provider default", providerModel: "provider-model", want: "provider-model"}, + } { + t.Run("Should prefer "+testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + cfg.Providers["mock"] = aghconfig.ProviderConfig{ + Command: "mock-acp", + Models: aghconfig.ProviderModelsConfig{Default: testCase.providerModel}, + } + cfg.Roles.Dream.Agent = "my-curator" + cfg.Roles.Dream.Model = testCase.roleModel + resolver := newRoleResolver(&cfg, nil, roleAgentResolverStub{ + agents: map[string]aghconfig.AgentDef{ + "my-curator": { + Name: "my-curator", Provider: "mock", Model: testCase.agentModel, Prompt: "Curate memory.", + }, + }, + }) + + resolved, err := resolver.Resolve(t.Context(), "", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(dream) error = %v", err) + } + if resolved.Model != testCase.want { + t.Fatalf("Resolve(dream) model = %q, want %q", resolved.Model, testCase.want) + } + }) + } + }) + + t.Run("Should reject providers that require an unresolved runtime model", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + const providerName = "model-required" + cfg.Providers[providerName] = aghconfig.ProviderConfig{ + Command: "pi-acp", Harness: aghconfig.ProviderHarnessPiACP, + } + cfg.Roles.Dream.Provider = providerName + resolver := newRoleResolver(&cfg, nil, nil) + + _, err := resolver.Resolve(t.Context(), "", aghconfig.RoleDream) + if err == nil || !strings.Contains( + err.Error(), + `agent model is required when provider "model-required" has no default model`, + ) { + t.Fatalf("Resolve(dream) error = %v, want missing runtime model", err) + } + }) + + t.Run("Should report per-field workspace and global provenance", func(t *testing.T) { + t.Parallel() + + global := roleResolverConfig() + global.Providers["mock"] = aghconfig.ProviderConfig{Command: "mock-acp"} + global.Roles.Dream.Provider = "mock" + global.Roles.Dream.Model = "global-model" + workspaceA := global + workspaceA.Roles.Dream.Model = "workspace-model" + workspaceB := global + resolver := newRoleResolver(&global, roleWorkspaceResolverStub{configs: map[string]aghconfig.Config{ + "ws-a": workspaceA, + "ws-b": workspaceB, + }}, nil) + + resolvedA, err := resolver.Resolve(t.Context(), "ws-a", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(ws-a) error = %v", err) + } + resolvedB, err := resolver.Resolve(t.Context(), "ws-b", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(ws-b) error = %v", err) + } + if resolvedA.Model != "workspace-model" || resolvedA.Provenance["model"] != "workspace" || + resolvedB.Model != "global-model" || resolvedB.Provenance["model"] != "global" { + t.Fatalf("workspace provenance = a:%#v b:%#v", resolvedA, resolvedB) + } + if resolvedA.Provenance["agent"] != aghconfig.RoleFieldSourceDefault || + resolvedB.Provenance["agent"] != aghconfig.RoleFieldSourceDefault { + t.Fatalf( + "agent provenance = a:%q b:%q, want default", + resolvedA.Provenance["agent"], + resolvedB.Provenance["agent"], + ) + } + }) + + t.Run("Should return a disabled role", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + cfg.Roles.Dream.Enabled = false + resolved, err := newRoleResolver(&cfg, nil, nil).Resolve(t.Context(), "", aghconfig.RoleDream) + if err != nil { + t.Fatalf("Resolve(dream) error = %v", err) + } + if resolved.Enabled { + t.Fatalf("Resolve(dream) Enabled = true, want false") + } + }) +} + +type roleAgentResolverStub struct { + agents map[string]aghconfig.AgentDef +} + +func (s roleAgentResolverStub) ResolveAgent( + name string, + _ *workspacepkg.ResolvedWorkspace, +) (aghconfig.AgentDef, error) { + agent, ok := s.agents[name] + if !ok { + return aghconfig.AgentDef{}, workspacepkg.ErrAgentNotAvailable + } + return aghconfig.CloneAgentDef(agent), nil +} + +type roleWorkspaceResolverStub struct { + configs map[string]aghconfig.Config +} + +func (s roleWorkspaceResolverStub) Resolve( + _ context.Context, + idOrPath string, +) (workspacepkg.ResolvedWorkspace, error) { + cfg, ok := s.configs[idOrPath] + if !ok { + return workspacepkg.ResolvedWorkspace{}, workspacepkg.ErrWorkspaceNotFound + } + return workspacepkg.ResolvedWorkspace{ + Workspace: workspacepkg.Workspace{ID: idOrPath}, + Config: cfg, + }, nil +} + +func (s roleWorkspaceResolverStub) ResolveOrRegister( + ctx context.Context, + path string, +) (workspacepkg.ResolvedWorkspace, error) { + return s.Resolve(ctx, path) +} + +func roleResolverConfig() aghconfig.Config { + return aghconfig.DefaultWithHome(aghconfig.HomePaths{}) +} diff --git a/internal/daemon/role_status.go b/internal/daemon/role_status.go new file mode 100644 index 000000000..1868c3577 --- /dev/null +++ b/internal/daemon/role_status.go @@ -0,0 +1,164 @@ +package daemon + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" + workspacepkg "github.com/compozy/agh/internal/workspace" +) + +// RoleStatuses projects the closed background-role roster in lexical order. +func (r *roleResolver) RoleStatuses(ctx context.Context, workspaceID string) ([]contract.RoleStatus, error) { + effective, resolvedWorkspace, err := r.effectiveRoleConfig(ctx, workspaceID) + if err != nil { + return nil, err + } + roles := aghconfig.RoleNames() + slices.Sort(roles) + statuses := make([]contract.RoleStatus, 0, len(roles)) + for _, role := range roles { + status, statusErr := r.projectRoleStatus(role, effective, resolvedWorkspace) + if statusErr != nil { + return nil, statusErr + } + statuses = append(statuses, status) + } + return statuses, nil +} + +// RoleStatus projects one configured background role without invoking it. +func (r *roleResolver) RoleStatus( + ctx context.Context, + workspaceID string, + role string, +) (contract.RoleStatus, error) { + roleName := aghconfig.RoleName(strings.TrimSpace(role)) + if !knownRole(roleName) { + return contract.RoleStatus{}, &RoleResolutionError{Code: roleErrorUnknown, Role: roleName} + } + effective, resolvedWorkspace, err := r.effectiveRoleConfig(ctx, workspaceID) + if err != nil { + return contract.RoleStatus{}, err + } + return r.projectRoleStatus(roleName, effective, resolvedWorkspace) +} + +func (r *roleResolver) projectRoleStatus( + role aghconfig.RoleName, + effective *aghconfig.Config, + resolvedWorkspace *workspacepkg.ResolvedWorkspace, +) (contract.RoleStatus, error) { + common, err := roleConfig(&effective.Roles, role) + if err != nil { + return contract.RoleStatus{}, err + } + mode, agent, diagnostics, err := r.projectRoleIdentity(role, common.Agent, resolvedWorkspace) + if err != nil { + return contract.RoleStatus{}, err + } + status := contract.RoleStatus{ + Role: string(role), + Enabled: common.Enabled, + ResolutionMode: mode, + Agent: agent, + Provider: roleStatusString(common.Provider), + Model: roleStatusString(common.Model), + ReasoningEffort: roleStatusString(common.ReasoningEffort), + FallbackChain: roleStatusFallbacks(common.FallbackChain), + Diagnostics: diagnostics, + } + if role == aghconfig.RoleMemoryController { + timeout := effective.Roles.MemoryController.Timeout.String() + status.Timeout = &timeout + } + status.Provenance = roleStatusProvenance( + roleProvenance(role, effective, r.config, resolvedWorkspace != nil), + status, + ) + return status, nil +} + +func (r *roleResolver) projectRoleIdentity( + role aghconfig.RoleName, + configuredAgent string, + resolvedWorkspace *workspacepkg.ResolvedWorkspace, +) (contract.RoleResolutionMode, *string, []contract.RoleDiagnostic, error) { + agentName := strings.TrimSpace(configuredAgent) + if agentName == "" { + var inherit bool + agentName, inherit = defaultRoleIdentity(role) + if inherit || agentName == "" { + return contract.RoleResolutionModeInherit, nil, []contract.RoleDiagnostic{}, nil + } + } + mode := contract.RoleResolutionModeCatalog + if _, builtin := aghconfig.BuiltinAgentDef(agentName); builtin { + mode = contract.RoleResolutionModeBuiltin + } + _, _, err := r.resolveRoleAgent(role, agentName, resolvedWorkspace) + if err == nil { + return mode, &agentName, []contract.RoleDiagnostic{}, nil + } + var resolutionErr *RoleResolutionError + if errors.As(err, &resolutionErr) && resolutionErr.Code == roleErrorAgentNotFound { + return mode, &agentName, []contract.RoleDiagnostic{{ + Code: contract.CodeRoleAgentNotFound, + Message: fmt.Sprintf("Agent %q is not available", agentName), + Agent: agentName, + }}, nil + } + return "", nil, nil, err +} + +func knownRole(role aghconfig.RoleName) bool { + return slices.Contains(aghconfig.RoleNames(), role) +} + +func roleStatusString(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func roleStatusFallbacks(fallbacks []aghconfig.RoleFallback) []contract.RoleFallbackStatus { + result := make([]contract.RoleFallbackStatus, 0, len(fallbacks)) + for _, fallback := range fallbacks { + result = append(result, contract.RoleFallbackStatus{ + Provider: strings.TrimSpace(fallback.Provider), + Model: strings.TrimSpace(fallback.Model), + ReasoningEffort: strings.TrimSpace(fallback.ReasoningEffort), + }) + } + return result +} + +func roleStatusProvenance(all map[string]string, status contract.RoleStatus) map[string]string { + fields := []string{roleFieldEnabled, roleFieldFallbackChain} + if status.Agent != nil { + fields = append(fields, daemonAgentField) + } + if status.Provider != nil { + fields = append(fields, roleFieldProvider) + } + if status.Model != nil { + fields = append(fields, roleFieldModel) + } + if status.ReasoningEffort != nil { + fields = append(fields, roleFieldReasoning) + } + if status.Timeout != nil { + fields = append(fields, roleFieldTimeout) + } + provenance := make(map[string]string, len(fields)) + for _, field := range fields { + provenance[field] = all[field] + } + return provenance +} diff --git a/internal/daemon/role_status_test.go b/internal/daemon/role_status_test.go new file mode 100644 index 000000000..c0ca25d4c --- /dev/null +++ b/internal/daemon/role_status_test.go @@ -0,0 +1,150 @@ +package daemon + +import ( + "errors" + "reflect" + "testing" + + "github.com/compozy/agh/internal/api/contract" + aghconfig "github.com/compozy/agh/internal/config" +) + +func TestRoleStatusProjection(t *testing.T) { + t.Parallel() + + t.Run("Should return the closed roster sorted with truthful provenance", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + statuses, err := newRoleResolver(&cfg, nil, nil).RoleStatuses(t.Context(), "") + if err != nil { + t.Fatalf("RoleStatuses() error = %v", err) + } + roles := make([]string, 0, len(statuses)) + for _, status := range statuses { + roles = append(roles, status.Role) + assertRoleStatusProvenance(t, status) + } + want := []string{ + "auto_title", + "checkpoint_summary", + "coordinator", + "dream", + "memory_controller", + "memory_extractor", + } + if !reflect.DeepEqual(roles, want) { + t.Fatalf("RoleStatuses() roles = %#v, want %#v", roles, want) + } + if statuses[0].ResolutionMode != contract.RoleResolutionModeInherit || statuses[0].Agent != nil { + t.Fatalf("auto_title status = %#v, want invocation-time inheritance", statuses[0]) + } + }) + + t.Run("Should report a missing catalog agent without failing projection", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + cfg.Roles.Dream.Agent = "missing-curator" + status, err := newRoleResolver(&cfg, nil, roleAgentResolverStub{}).RoleStatus( + t.Context(), + "", + string(aghconfig.RoleDream), + ) + if err != nil { + t.Fatalf("RoleStatus(dream) error = %v", err) + } + if len(status.Diagnostics) != 1 || status.Diagnostics[0].Code != contract.CodeRoleAgentNotFound || + status.Diagnostics[0].Agent != "missing-curator" { + t.Fatalf("RoleStatus(dream).Diagnostics = %#v", status.Diagnostics) + } + if status.Agent == nil || *status.Agent != "missing-curator" || + status.ResolutionMode != contract.RoleResolutionModeCatalog { + t.Fatalf("RoleStatus(dream) = %#v, want missing catalog projection", status) + } + }) + + t.Run("Should preserve global and workspace field provenance", func(t *testing.T) { + t.Parallel() + + global := roleResolverConfig() + global.Roles.Dream.Model = "global-model" + global.RoleSources[aghconfig.RoleDream][roleFieldModel] = aghconfig.RoleFieldSourceGlobal + resolver := newRoleResolver(&global, nil, nil) + globalStatus, err := resolver.RoleStatus(t.Context(), "", string(aghconfig.RoleDream)) + if err != nil { + t.Fatalf("RoleStatus(global dream) error = %v", err) + } + if got := globalStatus.Provenance[roleFieldModel]; got != aghconfig.RoleFieldSourceGlobal { + t.Fatalf("RoleStatus(global dream) model source = %q, want global", got) + } + + workspace := global + workspace.RoleSources = aghconfig.CloneRoleFieldSources(global.RoleSources) + workspace.Roles.Dream.Model = "workspace-model" + workspace.RoleSources[aghconfig.RoleDream][roleFieldModel] = aghconfig.RoleFieldSourceWorkspace + resolver = newRoleResolver(&global, roleWorkspaceResolverStub{configs: map[string]aghconfig.Config{ + "ws-role-status": workspace, + }}, nil) + workspaceStatus, err := resolver.RoleStatus( + t.Context(), + "ws-role-status", + string(aghconfig.RoleDream), + ) + if err != nil { + t.Fatalf("RoleStatus(workspace dream) error = %v", err) + } + if got := workspaceStatus.Provenance[roleFieldModel]; got != aghconfig.RoleFieldSourceWorkspace { + t.Fatalf("RoleStatus(workspace dream) model source = %q, want workspace", got) + } + }) + + t.Run("Should return the stable unknown role code", func(t *testing.T) { + t.Parallel() + + cfg := roleResolverConfig() + _, err := newRoleResolver(&cfg, nil, nil).RoleStatus(t.Context(), "", "judge") + var resolutionErr *RoleResolutionError + if !errors.As(err, &resolutionErr) || resolutionErr.DiagnosticCode() != contract.CodeRoleUnknown { + t.Fatalf("RoleStatus(judge) error = %v, want role_unknown", err) + } + }) +} + +func assertRoleStatusProvenance(t *testing.T, status contract.RoleStatus) { + t.Helper() + for _, field := range []struct { + name string + present bool + }{ + {name: roleFieldEnabled, present: true}, + {name: "fallback_chain", present: true}, + {name: "agent", present: status.Agent != nil}, + {name: "provider", present: status.Provider != nil}, + {name: "model", present: status.Model != nil}, + {name: "reasoning_effort", present: status.ReasoningEffort != nil}, + {name: "timeout", present: status.Timeout != nil}, + } { + source, exists := status.Provenance[field.name] + if exists != field.present { + t.Fatalf( + "RoleStatus(%s).Provenance[%s] exists = %t, want %t: %#v", + status.Role, + field.name, + exists, + field.present, + status.Provenance, + ) + } + if exists && source != aghconfig.RoleFieldSourceDefault { + t.Fatalf( + "RoleStatus(%s).Provenance[%s] = %q, want %q: %#v", + status.Role, + field.name, + source, + aghconfig.RoleFieldSourceDefault, + status.Provenance, + ) + } + } +} diff --git a/internal/daemon/runtime_dependencies.go b/internal/daemon/runtime_dependencies.go index aee61fe6c..4b7d087f6 100644 --- a/internal/daemon/runtime_dependencies.go +++ b/internal/daemon/runtime_dependencies.go @@ -11,12 +11,13 @@ func (d *Daemon) runtimeDeps( state *bootState, sessions SessionManager, ) RuntimeDeps { - initializeDreamRuntime(state, sessions) + d.initializeDreamRuntime(state, sessions) authoredContext := authoredContextRuntimeDeps(ctx, state, sessions) var memoryProviders core.MemoryProviderService if state.memoryProviderRegistry != nil { memoryProviders = daemonMemoryProviderService{registry: state.memoryProviderRegistry} } + roles := roleResolverForState(state) return RuntimeDeps{ Config: state.cfg, HomePaths: d.homePaths, @@ -53,24 +54,18 @@ func (d *Daemon) runtimeDeps( HeartbeatWake: authoredContext.HeartbeatWake, SessionHealth: authoredContext.SessionHealth, WakeEvents: authoredContext.WakeEvents, - CoordinatorConfig: newCoordinatorConfigResolver( - &state.cfg, - state.workspaceResolver, - agentCatalogDependency(state.agentCatalog, agentSidecarCatalogs{ - soul: state.soulCatalog, - heartbeat: state.heartbeatCatalog, - }), - ), - SkillsRegistry: skillsRegistryAPI(state.skillsRegistry), - ToolRegistry: state.toolRegistry, - Toolsets: state.toolsets, - ToolApprovals: state.toolApprovals, - ApprovalGrants: state.deps.ApprovalGrants, - Clarify: state.clarify, - HostedMCP: state.hostedMCP, - MCPHostAPI: newMCPHostAPIRuntimeInvoker(state.currentExtensionRuntime), - DreamTrigger: dreamTriggerFromRuntime(state.dreamRuntime), - Vault: state.providerVault, - StartedAt: state.startedAt, + CoordinatorRole: coordinatorRoleResolverFor(roles), + Roles: roles, + SkillsRegistry: skillsRegistryAPI(state.skillsRegistry), + ToolRegistry: state.toolRegistry, + Toolsets: state.toolsets, + ToolApprovals: state.toolApprovals, + ApprovalGrants: state.deps.ApprovalGrants, + Clarify: state.clarify, + HostedMCP: state.hostedMCP, + MCPHostAPI: newMCPHostAPIRuntimeInvoker(state.currentExtensionRuntime), + DreamTrigger: dreamTriggerFromRuntime(state.dreamRuntime), + Vault: state.providerVault, + StartedAt: state.startedAt, } } diff --git a/internal/daemon/runtime_deps.go b/internal/daemon/runtime_deps.go index cd0b26af4..fdfcccdb2 100644 --- a/internal/daemon/runtime_deps.go +++ b/internal/daemon/runtime_deps.go @@ -63,7 +63,8 @@ type RuntimeDeps struct { HeartbeatWake core.HeartbeatWakeService SessionHealth core.SessionHealthReader WakeEvents core.HeartbeatWakeEventReader - CoordinatorConfig CoordinatorConfigResolver + CoordinatorRole CoordinatorRoleResolver + Roles core.RolesStatusProvider SkillsRegistry core.SkillsRegistry SkillResources core.SkillResourceSyncer DreamTrigger DreamTrigger diff --git a/internal/daemon/runtime_dream_dependencies.go b/internal/daemon/runtime_dream_dependencies.go index 3280cf9dc..af967bce8 100644 --- a/internal/daemon/runtime_dream_dependencies.go +++ b/internal/daemon/runtime_dream_dependencies.go @@ -1,24 +1,70 @@ package daemon import ( + "context" + "fmt" + "strings" "time" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/memory" "github.com/compozy/agh/internal/memory/consolidation" + "github.com/compozy/agh/internal/store" + taskpkg "github.com/compozy/agh/internal/task" ) -func initializeDreamRuntime(state *bootState, sessions SessionManager) { +func (d *Daemon) initializeDreamRuntime(state *bootState, sessions SessionManager) { if state == nil || state.dreamSvc == nil { return } lockPath := memory.ConsolidationLockPath(state.globalMemoryDir) + roles := roleResolverForState(state) state.dreamRuntime = consolidation.NewRuntime( - state.cfg.Memory.Dream.Enabled, + func() bool { + d.mu.Lock() + defer d.mu.Unlock() + return state.cfg.Memory.Enabled + }, state.dreamSvc, consolidation.NewSessionSpawner( sessions, state.workspaceResolver, - &state.cfg, + state.cfg.Memory.Enabled, + func(ctx context.Context, workspaceID string) (consolidation.SessionRoute, error) { + correlation := roleInvocationCorrelation{ + WorkspaceID: strings.TrimSpace(workspaceID), + Event: store.EventCorrelation{ + SchedulerReason: "dream-consolidation", + ActorKind: string(taskpkg.ActorKindDaemon), + ActorID: "dream-runtime", + }, + } + roleCtx := withRoleInvocationCorrelation(ctx, correlation) + resolved, err := roles.Resolve(roleCtx, workspaceID, aghconfig.RoleDream) + if err != nil { + return consolidation.SessionRoute{}, fmt.Errorf("resolve dream role: %w", err) + } + return consolidation.SessionRoute{ + Enabled: resolved.Enabled, + AgentName: resolved.AgentName, + Provider: resolved.Provider, + Model: resolved.Model, + ReasoningEffort: resolved.ReasoningEffort, + Fallbacks: append([]aghconfig.RoleFallback(nil), resolved.Fallbacks...), + BeforeFallback: func( + fallbackCtx context.Context, + attempt int, + fallback aghconfig.RoleFallback, + ) error { + return recordRoleFallbackEvent(fallbackCtx, resolved, correlation, attempt, roleAttemptRoute{ + AgentName: resolved.AgentName, + Provider: fallback.Provider, + Model: fallback.Model, + ReasoningEffort: fallback.ReasoningEffort, + }) + }, + }, nil + }, ), state.cfg.Memory.Dream.CheckInterval, state.logger, diff --git a/internal/daemon/server_options.go b/internal/daemon/server_options.go index 6d2b11a7d..0bd2dde73 100644 --- a/internal/daemon/server_options.go +++ b/internal/daemon/server_options.go @@ -49,7 +49,8 @@ func httpServerOptions(deps *RuntimeDeps) []httpapi.Option { httpapi.WithModelCatalogService(deps.ModelCatalog), httpapi.WithMarketplaceCatalogService(deps.MarketplaceCatalog), httpapi.WithAgentContext(deps.AgentContext), - httpapi.WithCoordinatorConfig(deps.CoordinatorConfig), + httpapi.WithCoordinatorRole(deps.CoordinatorRole), + httpapi.WithRolesStatusProvider(deps.Roles), httpapi.WithSoulAuthoring(deps.SoulAuthoring), httpapi.WithSoulRefresher(deps.SoulRefresher), httpapi.WithHeartbeatAuthoring(deps.HeartbeatAuthor), @@ -126,7 +127,8 @@ func udsServerOptions(deps *RuntimeDeps) []udsapi.Option { udsapi.WithHeartbeatWake(deps.HeartbeatWake), udsapi.WithSessionHealthReader(deps.SessionHealth), udsapi.WithHeartbeatWakeEventReader(deps.WakeEvents), - udsapi.WithCoordinatorConfig(deps.CoordinatorConfig), + udsapi.WithCoordinatorRole(deps.CoordinatorRole), + udsapi.WithRolesStatusProvider(deps.Roles), udsapi.WithSkillsRegistry(deps.SkillsRegistry), udsapi.WithSkillResourceSyncer(deps.SkillResources), udsapi.WithMemoryStore(deps.MemoryStore), diff --git a/internal/daemon/settings_runtime_applier.go b/internal/daemon/settings_runtime_applier.go index 944e98420..15e7d14f5 100644 --- a/internal/daemon/settings_runtime_applier.go +++ b/internal/daemon/settings_runtime_applier.go @@ -86,6 +86,9 @@ func (a daemonSettingsRuntimeApplier) ApplyActiveConfig( a.state.cfg = next a.daemon.config = next a.daemon.mu.Unlock() + if a.state.workspaceResolver != nil { + a.state.workspaceResolver.InvalidateAll() + } providers.InvalidatePreStartCache() return nil diff --git a/internal/daemon/support_bundle_status.go b/internal/daemon/support_bundle_status.go index 403812174..f2b91f92b 100644 --- a/internal/daemon/support_bundle_status.go +++ b/internal/daemon/support_bundle_status.go @@ -23,7 +23,7 @@ func (d *Daemon) supportBundleSnapshotHandlers(state *bootState) *core.BaseHandl AgentCatalog: state.deps.AgentCatalog, ModelCatalog: state.deps.ModelCatalog, AgentContextService: state.deps.AgentContext, - CoordinatorConfig: state.deps.CoordinatorConfig, + CoordinatorRole: state.deps.CoordinatorRole, SoulAuthoring: state.deps.SoulAuthoring, SoulRefresher: state.deps.SoulRefresher, HeartbeatAuthoring: state.deps.HeartbeatAuthor, diff --git a/internal/diagnosticcontract/diagnostics.go b/internal/diagnosticcontract/diagnostics.go index b02cefa68..194499da9 100644 --- a/internal/diagnosticcontract/diagnostics.go +++ b/internal/diagnosticcontract/diagnostics.go @@ -52,6 +52,7 @@ const ( ) const ( + CodeAgentNameReserved = "agent_name_reserved" CodeBinaryVersionMismatch = "binary_version_mismatch" CodeBridgeHealthUnavailable = "bridge_health_unavailable" CodeBridgeNotFound = "bridge_not_found" @@ -131,6 +132,8 @@ const ( CodeProviderTransientFailure = "provider_transient_failure" CodeReasoningEffortUnsupported = "reasoning_effort_unsupported" CodeReasoningOptionMissing = "reasoning_option_missing" + CodeRoleAgentNotFound = "role_agent_not_found" + CodeRoleUnknown = "role_unknown" CodeRetryChainTooDeep = "retry_chain_too_deep" CodeSchedulerReady = "scheduler_ready" CodeSchedulerPaused = "scheduler_paused" @@ -173,6 +176,7 @@ type DiagnosticCodeSpec struct { } var diagnosticCodeSpecs = []DiagnosticCodeSpec{ + {Code: CodeAgentNameReserved, Category: CategoryConfig}, {Code: CodeBinaryVersionMismatch, Category: CategoryHome}, {Code: CodeBridgeHealthUnavailable, Category: CategoryBridge}, {Code: CodeBridgeNotFound, Category: CategoryBridge}, @@ -255,6 +259,8 @@ var diagnosticCodeSpecs = []DiagnosticCodeSpec{ {Code: CodeProviderTransientFailure, Category: CategoryProvider}, {Code: CodeReasoningEffortUnsupported, Category: CategoryProvider}, {Code: CodeReasoningOptionMissing, Category: CategoryProvider}, + {Code: CodeRoleAgentNotFound, Category: CategoryConfig}, + {Code: CodeRoleUnknown, Category: CategoryConfig}, {Code: CodeRetryChainTooDeep, Category: CategoryTask}, {Code: CodeSchedulerReady, Category: CategoryTask}, {Code: CodeSchedulerPaused, Category: CategoryTask}, diff --git a/internal/events/components.go b/internal/events/components.go index 31be3eb84..1510ec123 100644 --- a/internal/events/components.go +++ b/internal/events/components.go @@ -14,6 +14,7 @@ const ( ComponentNotification = "notification" ComponentProvider = "provider" ComponentReliability = "reliability" + ComponentRole = "role" ComponentScheduler = "scheduler" ComponentSession = "session" ComponentSkill = "skill" diff --git a/internal/events/registry.go b/internal/events/registry.go index f23fd0ded..3003352b1 100644 --- a/internal/events/registry.go +++ b/internal/events/registry.go @@ -102,7 +102,9 @@ const ( TaskWakeDelivered = "task.wake.delivered" TaskWakeSuppressed = "task.wake.suppressed" - SettingsChanged = "settings.changed" + SettingsChanged = "settings.changed" + RoleFallbackUsed = "role.fallback.used" + RoleResolveError = "role.resolve.error" SkillShadowed = "skill.shadowed" SkillLoadFailed = "skills.load_failed" @@ -272,6 +274,8 @@ var baseRegistryEntries = []Metadata{ info(TaskWakeDelivered, "task.wake", ComponentTask), warning(TaskWakeSuppressed, "task.wake", ComponentTask), global(info(SettingsChanged, "settings", ComponentConfig)), + global(info(RoleFallbackUsed, "role", ComponentRole)), + global(failure(RoleResolveError, "role", ComponentRole)), global(warning(SkillShadowed, "skill", ComponentSkill)), global(failure(SkillLoadFailed, "skills", ComponentSkill)), global(info(HookDispatchStart, "hook.dispatch", ComponentHook)), diff --git a/internal/events/registry_test.go b/internal/events/registry_test.go index 932c2a0ce..f667189e6 100644 --- a/internal/events/registry_test.go +++ b/internal/events/registry_test.go @@ -130,6 +130,32 @@ func TestRegistryMetadata(t *testing.T) { } }) + t.Run("Should expose canonical role invocation metadata", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + outcome Outcome + }{ + {name: RoleFallbackUsed, outcome: OutcomeInfo}, + {name: RoleResolveError, outcome: OutcomeFailure}, + } + for _, tc := range cases { + t.Run("Should expose "+tc.name, func(t *testing.T) { + t.Parallel() + meta, ok := Lookup(tc.name) + if !ok { + t.Fatalf("Lookup(%q) = false", tc.name) + } + if meta.Family != "role" || meta.Component != ComponentRole || + meta.Outcome != tc.outcome || !meta.GlobalScope || !meta.EmitsToLogs || + meta.NotificationEligible { + t.Fatalf("role metadata for %q = %#v", tc.name, meta) + } + }) + } + }) + t.Run("Should keep memory operation projections out of direct global writes", func(t *testing.T) { t.Parallel() diff --git a/internal/extension/manager_snapshots.go b/internal/extension/manager_snapshots.go index efce9166d..2e39f30f3 100644 --- a/internal/extension/manager_snapshots.go +++ b/internal/extension/manager_snapshots.go @@ -77,6 +77,7 @@ func cloneBundleSpecs(values []BundleSpec) []BundleSpec { Items: normalizeBundleChannels(profile.Channels.Items), }, Layouts: cloneBundleLayouts(profile.Layouts), + Agents: cloneBundleAgents(profile.Agents), Jobs: append([]BundleJob(nil), profile.Jobs...), Triggers: append([]BundleTrigger(nil), profile.Triggers...), Bridges: normalizeBundleBridges(profile.Bridges), @@ -91,6 +92,29 @@ func cloneBundleSpecs(values []BundleSpec) []BundleSpec { return cloned } +func cloneBundleAgents(values []BundleAgent) []BundleAgent { + if len(values) == 0 { + return nil + } + cloned := make([]BundleAgent, 0, len(values)) + for _, value := range values { + next := BundleAgent{ + Path: strings.TrimSpace(value.Path), + Agent: aghconfig.CloneAgentDef(value.Agent), + } + if value.Soul != nil { + soul := *value.Soul + next.Soul = &soul + } + if value.Heartbeat != nil { + heartbeat := *value.Heartbeat + next.Heartbeat = &heartbeat + } + cloned = append(cloned, next) + } + return cloned +} + func (m *Manager) reportBridgeRuntimeIssue(bridgeInstanceID string, status bridgepkg.BridgeStatus, reason error) { if m == nil || m.bridgeTelemetrySink == nil { return diff --git a/internal/extension/manager_test.go b/internal/extension/manager_test.go index 0b07b8ecc..3a2eda4fc 100644 --- a/internal/extension/manager_test.go +++ b/internal/extension/manager_test.go @@ -16,6 +16,7 @@ import ( automationpkg "github.com/compozy/agh/internal/automation" bridgepkg "github.com/compozy/agh/internal/bridges" + aghconfig "github.com/compozy/agh/internal/config" extensioncontract "github.com/compozy/agh/internal/extension/contract" extensionprotocol "github.com/compozy/agh/internal/extensionprotocol" hookspkg "github.com/compozy/agh/internal/hooks" @@ -1363,6 +1364,20 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) { Path: "layouts/two-up.json", Layout: testBundleLayoutResource("two-up"), }}, + Agents: []BundleAgent{{ + Path: "agents/coder", + Agent: aghconfig.AgentDef{ + Name: "coder", + Provider: "codex", + Tools: []string{"read"}, + Prompt: "Run bundle work.", + SourcePath: "/extension/agents/coder/AGENT.md", + }, + Soul: &BundleAgentSidecar{ + SourcePath: "agents/coder/SOUL.md", + Body: "Original soul.", + }, + }}, Jobs: []BundleJob{{ Name: "job-one", AgentName: "coder", @@ -1414,6 +1429,8 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) { clone.Skills[0].MCPServers[0].Env["ROOT"] = "/tmp/changed" clone.Skills[0].Provenance.Hash = "hash-changed" clone.Bundles[0].Profiles[0].Layouts[0].Layout.ParticipantSlots[0] = "changed" + clone.Bundles[0].Profiles[0].Agents[0].Agent.Tools[0] = "changed" + clone.Bundles[0].Profiles[0].Agents[0].Soul.Body = "Changed soul." clone.Bundles[0].Profiles[0].Jobs[0].Task.Title = "changed" clone.Bundles[0].Profiles[0].Bridges[0].DisplayName = "changed" clone.GrantedResourceKinds[0] = resources.ResourceKind("changed") @@ -1461,6 +1478,12 @@ func TestManagerCloneExtensionReturnsIsolatedSnapshot(t *testing.T) { windowmanager.WindowID("primary"); got != want { t.Fatalf("original bundle layout participant slot = %q, want %q", got, want) } + if got := ext.bundles[0].Profiles[0].Agents[0].Agent.Tools[0]; got != "read" { + t.Fatalf("original bundle agent tools mutated to %q", got) + } + if got := ext.bundles[0].Profiles[0].Agents[0].Soul.Body; got != "Original soul." { + t.Fatalf("original bundle agent soul mutated to %q", got) + } if ext.bundles[0].Profiles[0].Jobs[0].Task.Title != "Job task" { t.Fatalf("original bundle job task mutated to %#v", ext.bundles[0].Profiles[0].Jobs[0].Task) } diff --git a/internal/memory/batch.go b/internal/memory/batch.go index 0132f5cf8..b624be9e1 100644 --- a/internal/memory/batch.go +++ b/internal/memory/batch.go @@ -12,7 +12,6 @@ import ( "time" memcontract "github.com/compozy/agh/internal/memory/contract" - "github.com/compozy/agh/internal/memory/controller" ) const ( @@ -203,7 +202,7 @@ func (s *Store) decideBatch( } else { candidate.Content = body } - decision, err := controller.New(s).Decide(ctx, candidate) + decision, err := s.DecideCandidate(ctx, candidate) if err != nil { return memcontract.Decision{}, err } diff --git a/internal/memory/checkpoint_summary.go b/internal/memory/checkpoint_summary.go index 055b277ab..e74a38c29 100644 --- a/internal/memory/checkpoint_summary.go +++ b/internal/memory/checkpoint_summary.go @@ -33,21 +33,26 @@ const checkpointSummaryIntro = "# Workspace Checkpoint Summary\n\n" + "This block is reference-only continuity context from completed sessions. " + "Treat historical asks as stale unless the current user explicitly renews them." -var checkpointSummaryHeadings = []string{ - checkpointSummaryFirstHeading, - "## Goal", - "## Constraints & Preferences", - "## Completed Actions", - "## Active State", - "## Historical In-Progress State", - "## Blocked", - "## Key Decisions", - "## Resolved Questions", - "## Historical Pending User Asks", - "## Relevant Files", - "## Historical Remaining Work", - checkpointSummaryLastHeading, -} +var ( + // ErrCheckpointSummaryDisabled reports that the effective workspace role disables checkpoint generation. + ErrCheckpointSummaryDisabled = errors.New("memory: checkpoint summary role is disabled") + + checkpointSummaryHeadings = []string{ + checkpointSummaryFirstHeading, + "## Goal", + "## Constraints & Preferences", + "## Completed Actions", + "## Active State", + "## Historical In-Progress State", + "## Blocked", + "## Key Decisions", + "## Resolved Questions", + "## Historical Pending User Asks", + "## Relevant Files", + "## Historical Remaining Work", + checkpointSummaryLastHeading, + } +) // CheckpointSummaryRequest is the bounded input supplied to a checkpoint summarizer. type CheckpointSummaryRequest struct { diff --git a/internal/memory/checkpoint_summary_test.go b/internal/memory/checkpoint_summary_test.go index 237a2f27d..aac804869 100644 --- a/internal/memory/checkpoint_summary_test.go +++ b/internal/memory/checkpoint_summary_test.go @@ -19,6 +19,27 @@ import ( func TestCheckpointSummaryServiceUpdate(t *testing.T) { t.Parallel() + t.Run("Should normalize a resolved registration id to the stable workspace identity", func(t *testing.T) { + t.Parallel() + + env := newCheckpointSummaryTestEnv(t) + const registrationID = "ws_runtime_registration" + env.resolver.resolved.ID = registrationID + record := env.sessionEndRecord("sess-registration", "selected SQLite") + record.WorkspaceID = registrationID + summarizer := &checkpointSummarizerStub{outputs: []string{ + checkpointSummaryFixture("The session selected SQLite."), + }} + service := NewCheckpointSummaryService(env.store, env.resolver, summarizer) + + if _, err := service.Update(testutil.Context(t), record); err != nil { + t.Fatalf("Update(registration id) error = %v", err) + } + if got := summarizer.requests[0].WorkspaceID; got != env.workspaceID { + t.Fatalf("summarizer workspace id = %q, want stable identity %q", got, env.workspaceID) + } + }) + t.Run("Should update one workspace record and consume the prior summary", func(t *testing.T) { t.Parallel() @@ -86,6 +107,38 @@ func TestCheckpointSummaryServiceUpdate(t *testing.T) { } }) + t.Run("Should leave the checkpoint unchanged when the workspace role is disabled", func(t *testing.T) { + t.Parallel() + + env := newCheckpointSummaryTestEnv(t) + service := NewCheckpointSummaryService( + env.store, + env.resolver, + &checkpointSummarizerStub{errAt: map[int]error{0: ErrCheckpointSummaryDisabled}}, + ) + + result, err := service.Update( + testutil.Context(t), + env.sessionEndRecord("sess-disabled", "must not persist"), + ) + if err != nil { + t.Fatalf("Update(disabled role) error = %v", err) + } + if result.Applied || result.Decision.ID != "" { + t.Fatalf("Update(disabled role) = %#v, want no-op", result) + } + exists, err := env.store.ForWorkspace(env.workspaceRoot).Exists( + memcontract.ScopeWorkspace, + CheckpointSummaryFilename, + ) + if err != nil { + t.Fatalf("Exists(checkpoint) error = %v", err) + } + if exists { + t.Fatal("checkpoint exists after disabled-role update") + } + }) + t.Run("Should restore the prior checkpoint through the decision WAL", func(t *testing.T) { t.Parallel() @@ -233,6 +286,53 @@ func TestCheckpointSummaryServiceUpdate(t *testing.T) { func TestCheckpointSummaryServiceCompactionCoverage(t *testing.T) { t.Parallel() + t.Run("Should preserve the existing hint when the workspace role is disabled", func(t *testing.T) { + t.Parallel() + + env := newCheckpointSummaryTestEnv(t) + seedService := NewCheckpointSummaryService( + env.store, + env.resolver, + &checkpointSummarizerStub{outputs: []string{ + checkpointSummaryFixture("Stable checkpoint fact."), + }}, + ) + if _, err := seedService.Update( + testutil.Context(t), + env.sessionEndRecord("sess-prior", "stable"), + ); err != nil { + t.Fatalf("Update(prior) error = %v", err) + } + workspaceStore := env.store.ForWorkspace(env.workspaceRoot) + before, err := workspaceStore.Read(memcontract.ScopeWorkspace, CheckpointSummaryFilename) + if err != nil { + t.Fatalf("Read(before disabled compaction) error = %v", err) + } + + service := NewCheckpointSummaryService( + env.store, + env.resolver, + &checkpointSummarizerStub{errAt: map[int]error{0: ErrCheckpointSummaryDisabled}}, + ) + result, err := service.Compact( + testutil.Context(t), + env.preCompressRequest("sess-disabled", 1, 4, "must not persist"), + ) + if err != nil { + t.Fatalf("Compact(disabled role) error = %v", err) + } + if result.Decision.Applied || !strings.Contains(result.Hint.Markdown, "Stable checkpoint fact.") { + t.Fatalf("Compact(disabled role) = %#v, want prior hint and no decision", result) + } + after, err := workspaceStore.Read(memcontract.ScopeWorkspace, CheckpointSummaryFilename) + if err != nil { + t.Fatalf("Read(after disabled compaction) error = %v", err) + } + if !bytes.Equal(after, before) { + t.Fatalf("disabled compaction changed checkpoint\nbefore:\n%s\nafter:\n%s", before, after) + } + }) + t.Run("Should record one idempotent covered range before archive", func(t *testing.T) { t.Parallel() diff --git a/internal/memory/checkpoint_summary_update.go b/internal/memory/checkpoint_summary_update.go index c43518cea..5cc3251e7 100644 --- a/internal/memory/checkpoint_summary_update.go +++ b/internal/memory/checkpoint_summary_update.go @@ -43,12 +43,8 @@ func (s *CheckpointSummaryService) Compact( if err := s.validate(ctx, record); err != nil { return CheckpointCompactionResult{}, err } - if request.FromSequence <= 0 || request.ToSequence < request.FromSequence { - return CheckpointCompactionResult{}, fmt.Errorf( - "memory: invalid checkpoint compaction range %d..%d", - request.FromSequence, - request.ToSequence, - ) + if err := validateCheckpointCompactionRange(request); err != nil { + return CheckpointCompactionResult{}, err } workspaceID, workspaceRoot, workspaceStore, err := s.resolveCheckpointWorkspace(ctx, request.WorkspaceID) if err != nil { @@ -75,6 +71,11 @@ func (s *CheckpointSummaryService) Compact( } summary, err := s.summarizeCheckpoint(ctx, summaryRequest) if err != nil { + if errors.Is(err, ErrCheckpointSummaryDisabled) { + return CheckpointCompactionResult{Hint: memcontract.PreCompressHint{ + Markdown: firstCheckpointValue(state.coverage.ResumeSummary, state.body), + }}, nil + } return CheckpointCompactionResult{}, err } header := checkpointSummaryHeader(summaryRequest.EndedAt, state.header) @@ -106,6 +107,17 @@ func (s *CheckpointSummaryService) Compact( }, nil } +func validateCheckpointCompactionRange(request memcontract.PreCompressRequest) error { + if request.FromSequence > 0 && request.ToSequence >= request.FromSequence { + return nil + } + return fmt.Errorf( + "memory: invalid checkpoint compaction range %d..%d", + request.FromSequence, + request.ToSequence, + ) +} + func (s *CheckpointSummaryService) updateSessionEnd( ctx context.Context, record memcontract.SessionEndRecord, @@ -132,6 +144,9 @@ func (s *CheckpointSummaryService) updateSessionEnd( } summary, err := s.summarizeCheckpoint(ctx, request) if err != nil { + if errors.Is(err, ErrCheckpointSummaryDisabled) { + return DecisionApplyResult{}, nil + } return DecisionApplyResult{}, err } header := checkpointSummaryHeader(request.EndedAt, state.header) @@ -181,12 +196,18 @@ func (s *CheckpointSummaryService) resolveCheckpointWorkspace( err, ) } - workspaceID := firstCheckpointValue(resolved.WorkspaceID, resolved.ID) - if workspaceID == "" || workspaceID != strings.TrimSpace(requestedWorkspaceID) { + requestedID := strings.TrimSpace(requestedWorkspaceID) + registrationID := strings.TrimSpace(resolved.ID) + workspaceID := strings.TrimSpace(resolved.WorkspaceID) + if workspaceID == "" { + workspaceID = registrationID + } + if workspaceID == "" || (requestedID != registrationID && requestedID != workspaceID) { return "", "", nil, fmt.Errorf( - "memory: checkpoint workspace identity mismatch: resolved %q for %q", + "memory: checkpoint workspace identity mismatch: resolved registration %q stable %q for %q", + registrationID, workspaceID, - requestedWorkspaceID, + requestedID, ) } workspaceRoot := strings.TrimSpace(resolved.RootDir) diff --git a/internal/memory/consolidation/runtime.go b/internal/memory/consolidation/runtime.go index 82fcaee34..2322d2261 100644 --- a/internal/memory/consolidation/runtime.go +++ b/internal/memory/consolidation/runtime.go @@ -37,7 +37,7 @@ type SessionManager interface { // Runtime owns dream scheduling, trigger behavior, and session spawning. type Runtime struct { - enabled bool + enabled func() bool service Service spawner memory.SessionSpawner logger *slog.Logger @@ -58,13 +58,25 @@ type checkRequest struct { const ( defaultSessionStopTimeout = 10 * time.Second dreamGatesNotSatisfiedReason = "dream consolidation gates are not satisfied" - // DreamingCuratorAgentName is the dedicated default agent for memory dreaming. - DreamingCuratorAgentName = "dreaming-curator" ) +// SessionRoute is the resolved dream-session route supplied by the daemon role resolver. +type SessionRoute struct { + Enabled bool + AgentName string + Provider string + Model string + ReasoningEffort string + Fallbacks []aghconfig.RoleFallback + BeforeFallback func(context.Context, int, aghconfig.RoleFallback) error +} + +// SessionRouteResolver resolves the dream route for one effective workspace config. +type SessionRouteResolver func(ctx context.Context, workspaceID string) (SessionRoute, error) + // NewRuntime constructs a dream runtime that can be started by the daemon. func NewRuntime( - enabled bool, + enabled func() bool, service Service, spawner memory.SessionSpawner, interval time.Duration, @@ -84,45 +96,6 @@ func NewRuntime( } } -// Enabled reports whether dream consolidation is available. -func (r *Runtime) Enabled() bool { - return r != nil && r.enabled -} - -// LastConsolidatedAt returns the most recent lock timestamp. -func (r *Runtime) LastConsolidatedAt() (time.Time, error) { - if r == nil || r.lastConsolidatedAt == nil { - return time.Time{}, nil - } - return r.lastConsolidatedAt() -} - -// Trigger runs dream consolidation immediately when enabled and gates pass. -func (r *Runtime) Trigger(ctx context.Context, workspace string) (bool, string, error) { - if !r.Enabled() || r.service == nil || r.spawner == nil { - return false, "dream consolidation is disabled", nil - } - - shouldRun, err := r.service.ShouldRun() - if err != nil { - return false, "", err - } - if !shouldRun { - return false, dreamGatesNotSatisfiedReason, nil - } - if err := r.service.Run(ctx, r.spawner, strings.TrimSpace(workspace)); err != nil { - if errors.Is(err, memory.ErrLockUnavailable) { - return false, "dream consolidation is already running", nil - } - if errors.Is(err, memory.ErrDreamGateNotSatisfied) { - return false, dreamGatesNotSatisfiedReason, nil - } - return false, "", err - } - - return true, "", nil -} - // Start launches the background dream check loop when the runtime is configured. func (r *Runtime) Start(parent context.Context) { if r == nil { @@ -130,7 +103,7 @@ func (r *Runtime) Start(parent context.Context) { } r.mu.Lock() - if !r.enabled || r.service == nil || r.spawner == nil || r.checkCh != nil { + if r.service == nil || r.spawner == nil || r.checkCh != nil { r.mu.Unlock() return } @@ -216,7 +189,7 @@ func (r *Runtime) runCheck( reason string, workspaceRef string, ) { - if service == nil || spawner == nil { + if !r.Enabled() || service == nil || spawner == nil { return } if logger == nil { @@ -252,6 +225,14 @@ func (r *Runtime) runCheck( logger.Debug("daemon: dream consolidation skipped", "reason", reason, "workspace_ref", workspaceRef) return } + if errors.Is(err, memory.ErrDreamRoleDisabled) { + logger.Debug( + "daemon: dream consolidation skipped because the workspace role is disabled", + "reason", reason, + "workspace_ref", workspaceRef, + ) + return + } logger.Warn("daemon: dream consolidation failed", "reason", reason, "workspace_ref", workspaceRef, "error", err) return } @@ -262,12 +243,12 @@ func (r *Runtime) runCheck( func NewSessionSpawner( sessions SessionManager, resolver workspacepkg.RuntimeResolver, - cfg *aghconfig.Config, + memoryEnabled bool, + resolveRoute SessionRouteResolver, ) memory.SessionSpawner { - if cfg == nil || !cfg.Memory.Enabled || !cfg.Memory.Dream.Enabled || sessions == nil || resolver == nil { + if !memoryEnabled || sessions == nil || resolver == nil || resolveRoute == nil { return nil } - agentName := dreamingAgentName(cfg.Memory.Dream.Agent) return func(ctx context.Context, goal, prompt, workspace string, lastConsolidatedAt time.Time) error { workspaces, err := resolveWorkspaces(ctx, sessions, resolver, lastConsolidatedAt, workspace) @@ -275,11 +256,19 @@ func NewSessionSpawner( return err } + spawned := false for _, workspaceID := range workspaces { + route, err := resolveRoute(ctx, workspaceID) + if err != nil { + return fmt.Errorf("daemon: resolve dream role for workspace %q: %w", workspaceID, err) + } + if !route.Enabled { + continue + } if err := spawnSession( ctx, sessions, - agentName, + route, goal, prompt, workspaceID, @@ -287,20 +276,15 @@ func NewSessionSpawner( ); err != nil { return err } + spawned = true + } + if !spawned { + return memory.ErrDreamRoleDisabled } - return nil } } -func dreamingAgentName(configured string) string { - trimmed := strings.TrimSpace(configured) - if trimmed == "" || trimmed == aghconfig.DefaultAgentName { - return DreamingCuratorAgentName - } - return trimmed -} - func resolveWorkspaces( ctx context.Context, sessions SessionManager, @@ -423,7 +407,7 @@ func isPathLikeWorkspaceRef(ref string) bool { func spawnSession( ctx context.Context, sessions SessionManager, - agentName string, + route SessionRoute, goal string, prompt string, workspace string, @@ -436,24 +420,23 @@ func spawnSession( stopTimeout = defaultSessionStopTimeout } - dreamSession, err := sessions.Create(ctx, session.CreateOpts{ - AgentName: agentName, - Provider: "", - Name: strings.TrimSpace(goal), - Workspace: strings.TrimSpace(workspace), - Type: session.SessionTypeDream, - }) + dreamSession, err := createDreamSessionWithFallback(ctx, sessions, route, goal, workspace) + if dreamSession != nil { + defer func() { + stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), stopTimeout) + defer cancel() + stopErr := sessions.Stop(stopCtx, dreamSession.ID) + if stopErr != nil { + err = errors.Join(err, fmt.Errorf("daemon: stop dream session %q: %w", dreamSession.ID, stopErr)) + } + }() + } if err != nil { return fmt.Errorf("daemon: create dream session: %w", err) } - defer func() { - stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), stopTimeout) - defer cancel() - stopErr := sessions.Stop(stopCtx, dreamSession.ID) - if stopErr != nil { - err = errors.Join(err, fmt.Errorf("daemon: stop dream session %q: %w", dreamSession.ID, stopErr)) - } - }() + if dreamSession == nil { + return errors.New("daemon: create dream session returned nil") + } events, err := sessions.Prompt(ctx, dreamSession.ID, prompt) if err != nil { diff --git a/internal/memory/consolidation/runtime_spawner_test.go b/internal/memory/consolidation/runtime_spawner_test.go index 5e69e7cba..94b8d8302 100644 --- a/internal/memory/consolidation/runtime_spawner_test.go +++ b/internal/memory/consolidation/runtime_spawner_test.go @@ -47,7 +47,7 @@ func TestNewSessionSpawnerContract(t *testing.T) { memory.WithMinSessions(0), memory.WithLogger(discardLogger()), ) - spawner := NewSessionSpawner(sessions, &fakeWorkspaceResolver{}, &cfg) + spawner := newTestSessionSpawner(sessions, &fakeWorkspaceResolver{}, &cfg) if err := service.Run(context.Background(), spawner, ""); err != nil { t.Fatalf("service.Run() error = %v", err) } diff --git a/internal/memory/consolidation/runtime_test.go b/internal/memory/consolidation/runtime_test.go index b61493d6e..c664c00c8 100644 --- a/internal/memory/consolidation/runtime_test.go +++ b/internal/memory/consolidation/runtime_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -22,48 +23,64 @@ import ( func TestRuntimeTriggerReturnsAlreadyRunningWhenLockUnavailable(t *testing.T) { t.Parallel() + t.Run("Should report an already-running lock conflict as a clean skip", func(t *testing.T) { + t.Parallel() - service := &fakeDreamService{ - shouldRun: true, - runErr: memory.ErrLockUnavailable, - } - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, time.Minute, discardLogger(), nil) + service := &fakeDreamService{ + shouldRun: true, + runErr: memory.ErrLockUnavailable, + } + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + time.Minute, + discardLogger(), + nil, + ) - triggered, reason, err := runtime.Trigger(context.Background(), "ws-1") - if err != nil { - t.Fatalf("Trigger() error = %v", err) - } - if triggered { - t.Fatal("Trigger() triggered = true, want false") - } - if reason != "dream consolidation is already running" { - t.Fatalf("Trigger() reason = %q, want already-running message", reason) - } + triggered, reason, err := runtime.Trigger(context.Background(), "ws-1") + if err != nil { + t.Fatalf("Trigger() error = %v", err) + } + if triggered { + t.Fatal("Trigger() triggered = true, want false") + } + if reason != "dream consolidation is already running" { + t.Fatalf("Trigger() reason = %q, want already-running message", reason) + } + }) } func TestRuntimeTriggerReturnsGateMissWhenRunSignalGateMisses(t *testing.T) { t.Parallel() + t.Run("Should report a signal-gate miss as a clean skip", func(t *testing.T) { + t.Parallel() - service := &fakeDreamService{ - shouldRun: true, - runErr: memory.ErrDreamGateNotSatisfied, - } - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, time.Minute, discardLogger(), nil) + service := &fakeDreamService{ + shouldRun: true, + runErr: memory.ErrDreamGateNotSatisfied, + } + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + time.Minute, + discardLogger(), + nil, + ) - triggered, reason, err := runtime.Trigger(context.Background(), "ws-1") - if err != nil { - t.Fatalf("Trigger() error = %v", err) - } - if triggered { - t.Fatal("Trigger() triggered = true, want false") - } - if reason != "dream consolidation gates are not satisfied" { - t.Fatalf("Trigger() reason = %q, want gates-not-satisfied message", reason) - } + triggered, reason, err := runtime.Trigger(context.Background(), "ws-1") + if err != nil { + t.Fatalf("Trigger() error = %v", err) + } + if triggered { + t.Fatal("Trigger() triggered = true, want false") + } + if reason != "dream consolidation gates are not satisfied" { + t.Fatalf("Trigger() reason = %q, want gates-not-satisfied message", reason) + } + }) } func TestRuntimeTriggerStates(t *testing.T) { @@ -71,7 +88,7 @@ func TestRuntimeTriggerStates(t *testing.T) { t.Run("Should disabled returns disabled message", func(t *testing.T) { runtime := NewRuntime( - false, + staticEnabled(false), &fakeDreamService{shouldRun: true}, func(context.Context, string, string, string, time.Time) error { return nil @@ -95,7 +112,7 @@ func TestRuntimeTriggerStates(t *testing.T) { t.Run("Should gate miss returns not satisfied message", func(t *testing.T) { runtime := NewRuntime( - true, + staticEnabled(true), &fakeDreamService{shouldRun: false}, func(context.Context, string, string, string, time.Time) error { return nil @@ -120,7 +137,7 @@ func TestRuntimeTriggerStates(t *testing.T) { t.Run("Should service error is returned", func(t *testing.T) { expectedErr := errors.New("gate failed") runtime := NewRuntime( - true, + staticEnabled(true), &fakeDreamService{shouldRunErr: expectedErr}, func(context.Context, string, string, string, time.Time) error { return nil @@ -138,9 +155,14 @@ func TestRuntimeTriggerStates(t *testing.T) { t.Run("Should successful run trims workspace", func(t *testing.T) { service := &fakeDreamService{shouldRun: true} - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, time.Minute, discardLogger(), nil) + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + time.Minute, + discardLogger(), + nil, + ) triggered, reason, err := runtime.Trigger(context.Background(), " ws-1 ") if err != nil { @@ -162,7 +184,7 @@ func TestRuntimeLastConsolidatedAt(t *testing.T) { t.Parallel() t.Run("Should nil callback returns zero time", func(t *testing.T) { - runtime := NewRuntime(true, nil, nil, time.Minute, discardLogger(), nil) + runtime := NewRuntime(staticEnabled(true), nil, nil, time.Minute, discardLogger(), nil) got, err := runtime.LastConsolidatedAt() if err != nil { t.Fatalf("LastConsolidatedAt() error = %v", err) @@ -174,7 +196,7 @@ func TestRuntimeLastConsolidatedAt(t *testing.T) { t.Run("Should callback result is returned", func(t *testing.T) { expected := time.Date(2026, 4, 7, 12, 0, 0, 0, time.UTC) - runtime := NewRuntime(true, nil, nil, time.Minute, discardLogger(), func() (time.Time, error) { + runtime := NewRuntime(staticEnabled(true), nil, nil, time.Minute, discardLogger(), func() (time.Time, error) { return expected, nil }) @@ -190,67 +212,153 @@ func TestRuntimeLastConsolidatedAt(t *testing.T) { func TestRuntimeTickerRunsAndStopsOnCancellation(t *testing.T) { t.Parallel() + t.Run("Should run scheduled checks and stop after cancellation", func(t *testing.T) { + t.Parallel() - service := &fakeDreamService{shouldRun: true} - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, 10*time.Millisecond, discardLogger(), nil) + service := &fakeDreamService{shouldRun: true} + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + 10*time.Millisecond, + discardLogger(), + nil, + ) - ctx, cancel := context.WithCancel(context.Background()) - runtime.Start(ctx) - t.Cleanup(runtime.Shutdown) + ctx, cancel := context.WithCancel(context.Background()) + runtime.Start(ctx) + t.Cleanup(runtime.Shutdown) - waitForCondition(t, "dream ticker run", func() bool { - return service.runCount() > 0 - }) + waitForCondition(t, "dream ticker run", func() bool { + return service.runCount() > 0 + }) - cancel() - runtime.Shutdown() + cancel() + runtime.Shutdown() - runCount := service.runCount() - time.Sleep(30 * time.Millisecond) - if got := service.runCount(); got != runCount { - t.Fatalf("run count after shutdown = %d, want %d", got, runCount) - } + runCount := service.runCount() + time.Sleep(30 * time.Millisecond) + if got := service.runCount(); got != runCount { + t.Fatalf("run count after shutdown = %d, want %d", got, runCount) + } + }) } func TestRuntimeEnqueueCheckRunsQueuedRequest(t *testing.T) { t.Parallel() + t.Run("Should execute a queued workspace check", func(t *testing.T) { + t.Parallel() - service := &fakeDreamService{shouldRun: true} - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, time.Hour, discardLogger(), nil) + service := &fakeDreamService{shouldRun: true} + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + time.Hour, + discardLogger(), + nil, + ) - ctx := t.Context() - runtime.Start(ctx) - t.Cleanup(runtime.Shutdown) + ctx := t.Context() + runtime.Start(ctx) + t.Cleanup(runtime.Shutdown) - runtime.EnqueueCheck("session_stop", " ws-queued ") - waitForCondition(t, "queued dream check", func() bool { - return service.runCount() == 1 + runtime.EnqueueCheck("session_stop", " ws-queued ") + waitForCondition(t, "queued dream check", func() bool { + return service.runCount() == 1 + }) + + if got := service.lastWorkspace(); got != "ws-queued" { + t.Fatalf("queued workspace = %q, want trimmed queued workspace", got) + } }) +} - if got := service.lastWorkspace(); got != "ws-queued" { - t.Fatalf("queued workspace = %q, want trimmed queued workspace", got) - } +func TestRuntimeSkipsChecksWhileDisabled(t *testing.T) { + t.Parallel() + t.Run("Should keep the scheduler idle while disabled", func(t *testing.T) { + t.Parallel() + + service := &fakeDreamService{shouldRun: true} + runtime := NewRuntime( + staticEnabled(false), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + 10*time.Millisecond, + discardLogger(), + nil, + ) + + ctx := t.Context() + runtime.Start(ctx) + runtime.EnqueueCheck("manual", "ws-disabled") + + if got := service.runCount(); got != 0 { + t.Fatalf("run count = %d, want 0", got) + } + }) } -func TestRuntimeStartDoesNothingWhenDisabled(t *testing.T) { +func TestRuntimeReadsLiveEnabledState(t *testing.T) { t.Parallel() + t.Run("Should read enabled state for each trigger", func(t *testing.T) { + t.Parallel() - service := &fakeDreamService{shouldRun: true} - runtime := NewRuntime(false, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, 10*time.Millisecond, discardLogger(), nil) + enabled := false + service := &fakeDreamService{shouldRun: true} + runtime := NewRuntime(func() bool { return enabled }, service, func( + context.Context, + string, + string, + string, + time.Time, + ) error { + return nil + }, time.Minute, discardLogger(), nil) + + triggered, _, err := runtime.Trigger(context.Background(), "ws-live") + if err != nil { + t.Fatalf("Trigger(disabled) error = %v", err) + } + if triggered || service.runCount() != 0 { + t.Fatalf("Trigger(disabled) = %t with %d runs, want skipped", triggered, service.runCount()) + } - ctx := t.Context() - runtime.Start(ctx) - runtime.EnqueueCheck("manual", "ws-disabled") + enabled = true + triggered, _, err = runtime.Trigger(context.Background(), "ws-live") + if err != nil { + t.Fatalf("Trigger(enabled) error = %v", err) + } + if !triggered || service.runCount() != 1 { + t.Fatalf("Trigger(enabled) = %t with %d runs, want one run", triggered, service.runCount()) + } + }) - if got := service.runCount(); got != 0 { - t.Fatalf("run count = %d, want 0", got) - } + t.Run("Should run a queued check after live enablement", func(t *testing.T) { + t.Parallel() + + var enabled atomic.Bool + service := &fakeDreamService{shouldRun: true} + runtime := NewRuntime( + enabled.Load, + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + time.Hour, + discardLogger(), + nil, + ) + runtime.Start(t.Context()) + t.Cleanup(runtime.Shutdown) + + enabled.Store(true) + runtime.EnqueueCheck("live-enable", "ws-live") + waitForCondition(t, "live-enabled queued dream check", func() bool { + return service.runCount() == 1 + }) + if got := service.lastWorkspace(); got != "ws-live" { + t.Fatalf("queued workspace = %q, want ws-live", got) + } + }) } func TestRuntimeRunCheckStopsOnErrors(t *testing.T) { @@ -258,9 +366,14 @@ func TestRuntimeRunCheckStopsOnErrors(t *testing.T) { t.Run("Should lock unavailable is swallowed", func(t *testing.T) { service := &fakeDreamService{shouldRun: true, runErr: memory.ErrLockUnavailable} - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, time.Minute, discardLogger(), nil) + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + time.Minute, + discardLogger(), + nil, + ) runtime.runCheck( context.Background(), @@ -279,9 +392,14 @@ func TestRuntimeRunCheckStopsOnErrors(t *testing.T) { t.Run("Should signal gate miss is swallowed", func(t *testing.T) { service := &fakeDreamService{shouldRun: true, runErr: memory.ErrDreamGateNotSatisfied} - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - return nil - }, time.Minute, discardLogger(), nil) + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { return nil }, + time.Minute, + discardLogger(), + nil, + ) runtime.runCheck( context.Background(), @@ -301,10 +419,17 @@ func TestRuntimeRunCheckStopsOnErrors(t *testing.T) { t.Run("Should skip spawn on should-run error", func(t *testing.T) { service := &fakeDreamService{shouldRunErr: errors.New("gate failed")} spawnCalls := 0 - runtime := NewRuntime(true, service, func(context.Context, string, string, string, time.Time) error { - spawnCalls++ - return nil - }, time.Minute, discardLogger(), nil) + runtime := NewRuntime( + staticEnabled(true), + service, + func(context.Context, string, string, string, time.Time) error { + spawnCalls++ + return nil + }, + time.Minute, + discardLogger(), + nil, + ) runtime.runCheck( context.Background(), @@ -335,7 +460,7 @@ func TestNewSessionSpawnerCreatesDreamSession(t *testing.T) { }, } - spawner := NewSessionSpawner(sessions, resolver, &cfg) + spawner := newTestSessionSpawner(sessions, resolver, &cfg) if spawner == nil { t.Fatal("NewSessionSpawner() = nil, want non-nil") } @@ -379,11 +504,11 @@ func TestNewSessionSpawnerCreatesDreamSession(t *testing.T) { } } -func TestNewSessionSpawnerUsesDedicatedDreamingCuratorForDefaultAgent(t *testing.T) { +func TestNewSessionSpawnerUsesResolvedBuiltInDreamingCurator(t *testing.T) { t.Parallel() cfg := dreamConfig() - cfg.Memory.Dream.Agent = aghconfig.DefaultAgentName + cfg.Roles.Dream.Agent = aghconfig.BuiltinDreamingCuratorAgentName sessions := &fakeSessionManager{} resolver := &fakeWorkspaceResolver{ resolveResolved: workspacepkg.ResolvedWorkspace{ @@ -391,13 +516,13 @@ func TestNewSessionSpawnerUsesDedicatedDreamingCuratorForDefaultAgent(t *testing }, } - spawner := NewSessionSpawner(sessions, resolver, &cfg) + spawner := newTestSessionSpawner(sessions, resolver, &cfg) if err := spawner(context.Background(), "memory-consolidation", "prompt", "ws-default", time.Time{}); err != nil { t.Fatalf("spawner() error = %v", err) } - if got := sessions.createCall(0).AgentName; got != DreamingCuratorAgentName { - t.Fatalf("Create() agent = %q, want %q", got, DreamingCuratorAgentName) + if got := sessions.createCall(0).AgentName; got != aghconfig.BuiltinDreamingCuratorAgentName { + t.Fatalf("Create() agent = %q, want %q", got, aghconfig.BuiltinDreamingCuratorAgentName) } } @@ -412,7 +537,7 @@ func TestNewSessionSpawnerResolvesExplicitAliasWorkspace(t *testing.T) { }, } - spawner := NewSessionSpawner(sessions, resolver, &cfg) + spawner := newTestSessionSpawner(sessions, resolver, &cfg) if err := spawner( context.Background(), "memory-consolidation", @@ -445,7 +570,8 @@ func TestNewSessionSpawnerPropagatesWorkspaceResolveErrors(t *testing.T) { spawner := NewSessionSpawner( &fakeSessionManager{}, &fakeWorkspaceResolver{resolveErr: expectedErr}, - &cfg, + cfg.Memory.Enabled, + testDreamRouteResolver(&cfg), ) err := spawner(context.Background(), "memory-consolidation", "prompt", "workspace-alias", time.Time{}) @@ -454,6 +580,23 @@ func TestNewSessionSpawnerPropagatesWorkspaceResolveErrors(t *testing.T) { } } +func TestNewSessionSpawnerRejectsAWorkspaceDisabledDreamRole(t *testing.T) { + t.Parallel() + + cfg := dreamConfig() + cfg.Roles.Dream.Enabled = false + resolver := &fakeWorkspaceResolver{ + resolveResolved: workspacepkg.ResolvedWorkspace{ + Workspace: workspacepkg.Workspace{ID: "ws-disabled", RootDir: t.TempDir()}, + }, + } + spawner := newTestSessionSpawner(&fakeSessionManager{}, resolver, &cfg) + err := spawner(context.Background(), "memory-consolidation", "prompt", "ws-disabled", time.Time{}) + if !errors.Is(err, memory.ErrDreamRoleDisabled) { + t.Fatalf("spawner() error = %v, want ErrDreamRoleDisabled", err) + } +} + func TestIsPathLikeWorkspaceRefRecognizesSlashSeparatedRefs(t *testing.T) { t.Parallel() @@ -499,7 +642,7 @@ func TestNewSessionSpawnerDerivesRecentWorkspacesFromSessions(t *testing.T) { } prior := time.Date(2026, 4, 4, 8, 0, 0, 0, time.UTC) - spawner := NewSessionSpawner(sessions, &fakeWorkspaceResolver{}, &cfg) + spawner := newTestSessionSpawner(sessions, &fakeWorkspaceResolver{}, &cfg) if err := spawner(context.Background(), "memory-consolidation", "prompt", "", prior); err != nil { t.Fatalf("spawner() error = %v", err) } @@ -574,7 +717,7 @@ func TestNewSessionSpawnerReturnsNoRecentWorkspacesWhenSessionsAreOld(t *testing t.Fatalf("os.Chtimes(lock) error = %v", err) } - spawner := NewSessionSpawner(sessions, &fakeWorkspaceResolver{}, &cfg) + spawner := newTestSessionSpawner(sessions, &fakeWorkspaceResolver{}, &cfg) err := spawner(context.Background(), "memory-consolidation", "prompt", "", prior) if err == nil { t.Fatal("spawner() error = nil, want non-nil") @@ -589,7 +732,15 @@ func TestSpawnSessionWrapsPromptAndStopErrors(t *testing.T) { t.Run("Should prompt error is wrapped", func(t *testing.T) { sessions := &fakeSessionManager{promptErr: errors.New("prompt failed")} - err := spawnSession(context.Background(), sessions, "memory-agent", "goal", "prompt", "ws-1", 0) + err := spawnSession( + context.Background(), + sessions, + SessionRoute{AgentName: "memory-agent"}, + "goal", + "prompt", + "ws-1", + 0, + ) if err == nil { t.Fatal("spawnSession() error = nil, want non-nil") } @@ -601,7 +752,15 @@ func TestSpawnSessionWrapsPromptAndStopErrors(t *testing.T) { t.Run("Should stop error is joined", func(t *testing.T) { stopErr := errors.New("stop failed") sessions := &fakeSessionManager{stopErr: stopErr} - err := spawnSession(context.Background(), sessions, "memory-agent", "goal", "prompt", "ws-1", 0) + err := spawnSession( + context.Background(), + sessions, + SessionRoute{AgentName: "memory-agent"}, + "goal", + "prompt", + "ws-1", + 0, + ) if !errors.Is(err, stopErr) { t.Fatalf("spawnSession() error = %v, want stop failure", err) } @@ -611,7 +770,15 @@ func TestSpawnSessionWrapsPromptAndStopErrors(t *testing.T) { sessions := &fakeSessionManager{ promptEvents: []acp.AgentEvent{{Type: acp.EventTypeError, Error: "tool failed"}}, } - err := spawnSession(context.Background(), sessions, "memory-agent", "goal", "prompt", "ws-1", 0) + err := spawnSession( + context.Background(), + sessions, + SessionRoute{AgentName: "memory-agent"}, + "goal", + "prompt", + "ws-1", + 0, + ) if err == nil || !strings.Contains(err.Error(), "tool failed") { t.Fatalf("spawnSession() error = %v, want prompt event failure", err) } @@ -621,24 +788,141 @@ func TestSpawnSessionWrapsPromptAndStopErrors(t *testing.T) { sessions := &fakeSessionManager{} ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := spawnSession(ctx, sessions, "memory-agent", "goal", "prompt", "ws-1", 0); err != nil { + if err := spawnSession( + ctx, + sessions, + SessionRoute{AgentName: "memory-agent"}, + "goal", + "prompt", + "ws-1", + 0, + ); err != nil { t.Fatalf("spawnSession() error = %v", err) } if got, want := sessions.lastStopContextErr(), error(nil); got != want { t.Fatalf("Stop() context err = %v, want nil", got) } }) + + t.Run("Should use the first accepted fallback route", func(t *testing.T) { + t.Parallel() + + sessions := &fakeSessionManager{createResults: []fakeCreateResult{ + {err: errors.New("primary rejected")}, + {accepted: true}, + }} + events := 0 + err := spawnSession( + t.Context(), + sessions, + SessionRoute{ + AgentName: "memory-agent", + Provider: "primary", + Model: "m1", + ReasoningEffort: "low", + Fallbacks: []aghconfig.RoleFallback{ + {Provider: "secondary", Model: "m2", ReasoningEffort: "high"}, + {Provider: "tertiary", Model: "m3"}, + }, + BeforeFallback: func(_ context.Context, attempt int, fallback aghconfig.RoleFallback) error { + events++ + if attempt != 1 || fallback.Provider != "secondary" { + t.Fatalf("fallback event = %d/%#v, want first secondary route", attempt, fallback) + } + return nil + }, + }, + "goal", + "prompt", + "ws-1", + 0, + ) + if err != nil { + t.Fatalf("spawnSession() error = %v", err) + } + calls := sessions.createdOptions() + if len(calls) != 2 || calls[0].Provider != "primary" || calls[0].Model != "m1" || + calls[0].ReasoningEffort != "low" || calls[1].Provider != "secondary" || + calls[1].Model != "m2" || calls[1].ReasoningEffort != "high" { + t.Fatalf("Create() calls = %#v", calls) + } + if events != 1 || sessions.stopCount() != 1 { + t.Fatalf("fallback events/stops = %d/%d, want 1/1", events, sessions.stopCount()) + } + }) + + t.Run("Should not use fallback after session acceptance", func(t *testing.T) { + t.Parallel() + + acceptedErr := errors.New("accepted startup failed") + sessions := &fakeSessionManager{createResults: []fakeCreateResult{{accepted: true, err: acceptedErr}}} + events := 0 + err := spawnSession( + t.Context(), + sessions, + SessionRoute{ + AgentName: "memory-agent", + Provider: "primary", + Fallbacks: []aghconfig.RoleFallback{{Provider: "secondary", Model: "m2"}}, + BeforeFallback: func(context.Context, int, aghconfig.RoleFallback) error { + events++ + return nil + }, + }, + "goal", + "prompt", + "ws-1", + 0, + ) + if !errors.Is(err, acceptedErr) { + t.Fatalf("spawnSession() error = %v, want accepted startup failure", err) + } + if sessions.createCount() != 1 || events != 0 || sessions.stopCount() != 1 { + t.Fatalf( + "creates/events/stops = %d/%d/%d, want 1/0/1", + sessions.createCount(), + events, + sessions.stopCount(), + ) + } + }) } func dreamConfig() aghconfig.Config { cfg := aghconfig.DefaultWithHome(aghconfig.HomePaths{}) cfg.Memory.Enabled = true - cfg.Memory.Dream.Enabled = true - cfg.Memory.Dream.Agent = "memory-agent" + cfg.Roles.Dream.Enabled = true + cfg.Roles.Dream.Agent = "memory-agent" cfg.Memory.Dream.CheckInterval = time.Minute return cfg } +func newTestSessionSpawner( + sessions SessionManager, + resolver workspacepkg.RuntimeResolver, + cfg *aghconfig.Config, +) memory.SessionSpawner { + return NewSessionSpawner(sessions, resolver, cfg.Memory.Enabled, testDreamRouteResolver(cfg)) +} + +func testDreamRouteResolver(cfg *aghconfig.Config) SessionRouteResolver { + return func(context.Context, string) (SessionRoute, error) { + role := cfg.Roles.Dream + return SessionRoute{ + Enabled: role.Enabled, + AgentName: role.Agent, + Provider: role.Provider, + Model: role.Model, + ReasoningEffort: role.ReasoningEffort, + Fallbacks: append([]aghconfig.RoleFallback(nil), role.FallbackChain...), + }, nil + } +} + +func staticEnabled(enabled bool) func() bool { + return func() bool { return enabled } +} + func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } @@ -708,8 +992,9 @@ type fakeSessionManager struct { id string msg string } - stopCalls []string - stopCtxErr []error + stopCalls []string + stopCtxErr []error + createResults []fakeCreateResult } func (f *fakeSessionManager) Create(_ context.Context, opts session.CreateOpts) (*session.Session, error) { @@ -717,6 +1002,13 @@ func (f *fakeSessionManager) Create(_ context.Context, opts session.CreateOpts) defer f.mu.Unlock() f.createCalls = append(f.createCalls, opts) sessionID := fmt.Sprintf("dream-%d", len(f.createCalls)) + result := fakeCreateResult{accepted: true} + if index := len(f.createCalls) - 1; index < len(f.createResults) { + result = f.createResults[index] + } + if !result.accepted { + return nil, result.err + } return &session.Session{ ID: sessionID, AgentName: opts.AgentName, @@ -724,7 +1016,12 @@ func (f *fakeSessionManager) Create(_ context.Context, opts session.CreateOpts) Workspace: strings.TrimSpace(opts.Workspace), Type: opts.Type, State: session.StateActive, - }, nil + }, result.err +} + +type fakeCreateResult struct { + accepted bool + err error } func (f *fakeSessionManager) ListAll(context.Context) ([]*session.Info, error) { @@ -771,6 +1068,12 @@ func (f *fakeSessionManager) createCount() int { return len(f.createCalls) } +func (f *fakeSessionManager) createdOptions() []session.CreateOpts { + f.mu.Lock() + defer f.mu.Unlock() + return append([]session.CreateOpts(nil), f.createCalls...) +} + func (f *fakeSessionManager) createCall(index int) session.CreateOpts { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/memory/consolidation/runtime_trigger.go b/internal/memory/consolidation/runtime_trigger.go new file mode 100644 index 000000000..b3c37979b --- /dev/null +++ b/internal/memory/consolidation/runtime_trigger.go @@ -0,0 +1,52 @@ +package consolidation + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/compozy/agh/internal/memory" +) + +// Enabled reports whether dream consolidation is available. +func (r *Runtime) Enabled() bool { + return r != nil && r.enabled != nil && r.enabled() +} + +// LastConsolidatedAt returns the most recent lock timestamp. +func (r *Runtime) LastConsolidatedAt() (time.Time, error) { + if r == nil || r.lastConsolidatedAt == nil { + return time.Time{}, nil + } + return r.lastConsolidatedAt() +} + +// Trigger runs dream consolidation immediately when enabled and gates pass. +func (r *Runtime) Trigger(ctx context.Context, workspace string) (bool, string, error) { + if !r.Enabled() || r.service == nil || r.spawner == nil { + return false, "dream consolidation is disabled", nil + } + + shouldRun, err := r.service.ShouldRun() + if err != nil { + return false, "", err + } + if !shouldRun { + return false, dreamGatesNotSatisfiedReason, nil + } + if err := r.service.Run(ctx, r.spawner, strings.TrimSpace(workspace)); err != nil { + if errors.Is(err, memory.ErrLockUnavailable) { + return false, "dream consolidation is already running", nil + } + if errors.Is(err, memory.ErrDreamGateNotSatisfied) { + return false, dreamGatesNotSatisfiedReason, nil + } + if errors.Is(err, memory.ErrDreamRoleDisabled) { + return false, "dream role is disabled for the selected workspace", nil + } + return false, "", err + } + + return true, "", nil +} diff --git a/internal/memory/consolidation/session_fallback.go b/internal/memory/consolidation/session_fallback.go new file mode 100644 index 000000000..c385f391d --- /dev/null +++ b/internal/memory/consolidation/session_fallback.go @@ -0,0 +1,61 @@ +package consolidation + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/compozy/agh/internal/session" +) + +func createDreamSessionWithFallback( + ctx context.Context, + sessions SessionManager, + route SessionRoute, + goal string, + workspace string, +) (*session.Session, error) { + create := func(provider string, model string, reasoningEffort string) (*session.Session, error) { + return sessions.Create(ctx, session.CreateOpts{ + AgentName: strings.TrimSpace(route.AgentName), + Provider: strings.TrimSpace(provider), + Model: strings.TrimSpace(model), + ReasoningEffort: strings.TrimSpace(reasoningEffort), + Name: strings.TrimSpace(goal), + Workspace: strings.TrimSpace(workspace), + Type: session.SessionTypeDream, + }) + } + + created, err := create(route.Provider, route.Model, route.ReasoningEffort) + if created != nil { + return created, err + } + attemptErrors := []error{dreamAttemptError(0, err)} + for index, fallback := range route.Fallbacks { + attempt := index + 1 + if route.BeforeFallback != nil { + if eventErr := route.BeforeFallback(ctx, attempt, fallback); eventErr != nil { + return nil, errors.Join(errors.Join(attemptErrors...), eventErr) + } + } + created, err = create(fallback.Provider, fallback.Model, fallback.ReasoningEffort) + if created != nil { + return created, err + } + attemptErrors = append(attemptErrors, dreamAttemptError(attempt, err)) + } + return nil, fmt.Errorf( + "dream role invocation exhausted %d attempt(s): %w", + len(route.Fallbacks)+1, + errors.Join(attemptErrors...), + ) +} + +func dreamAttemptError(attempt int, err error) error { + if err == nil { + err = errors.New("invocation returned without acceptance") + } + return fmt.Errorf("dream role attempt %d failed before acceptance: %w", attempt, err) +} diff --git a/internal/memory/controller/controller.go b/internal/memory/controller/controller.go index cc60f8889..e90cdb92b 100644 --- a/internal/memory/controller/controller.go +++ b/internal/memory/controller/controller.go @@ -60,9 +60,11 @@ type TargetIndex interface { // Controller decides Memory v2 write outcomes with deterministic Slice 1 rules. type Controller struct { - index TargetIndex - now func() time.Time - promptVersion string + index TargetIndex + now func() time.Time + promptVersion string + tiebreaker Tiebreaker + defaultOpOnFail memcontract.Op } // Option customizes a Controller. @@ -86,10 +88,29 @@ func WithPromptVersion(version string) Option { } } +// WithTiebreaker installs the bounded model call used only for genuine ambiguity. +func WithTiebreaker(tiebreaker Tiebreaker) Option { + return func(c *Controller) { + c.tiebreaker = tiebreaker + } +} + +// WithDefaultOpOnFail selects the deterministic operation used when the +// configured tiebreaker fails. Only noop and reject are safe failure outcomes. +func WithDefaultOpOnFail(op memcontract.Op) Option { + return func(c *Controller) { + switch op.Normalize() { + case memcontract.OpNoop, memcontract.OpReject: + c.defaultOpOnFail = op.Normalize() + } + } +} + // New constructs a rule-first controller over the provided target index. func New(index TargetIndex, opts ...Option) *Controller { c := &Controller{ - index: index, + index: index, + defaultOpOnFail: memcontract.OpNoop, now: func() time.Time { return time.Now().UTC() }, @@ -116,7 +137,7 @@ func (c *Controller) Decide(ctx context.Context, candidate memcontract.Candidate return c.rejectDecision(normalized, scanResult, trace) } if opFromCandidate(normalized) == memcontract.OpDelete { - return c.deleteDecision(normalized, targets, trace) + return c.deleteDecision(ctx, normalized, targets, trace) } if exact := exactContentTarget(normalized, targets); exact != nil { return c.decision( @@ -130,7 +151,7 @@ func (c *Controller) Decide(ctx context.Context, candidate memcontract.Candidate nil, ) } - return c.writeDecision(normalized, targets, trace) + return c.writeDecision(ctx, normalized, targets, trace) } func (c *Controller) prepareDecision( @@ -176,6 +197,7 @@ func (c *Controller) rejectDecision( } func (c *Controller) writeDecision( + ctx context.Context, normalized memcontract.Candidate, targets []Target, trace []memcontract.RuleHit, @@ -202,7 +224,7 @@ func (c *Controller) writeDecision( ), )) } - return c.ambiguousDecision(normalized, surface, trace) + return c.ambiguousDecision(ctx, normalized, surface, trace) } return c.addDecision(normalized, trace) case 1: @@ -238,6 +260,6 @@ func (c *Controller) writeDecision( ), )) } - return c.ambiguousDecision(normalized, slotMatches, trace) + return c.ambiguousDecision(ctx, normalized, slotMatches, trace) } } diff --git a/internal/memory/controller/controller_targets.go b/internal/memory/controller/controller_targets.go index 68baf061b..3eb5df64a 100644 --- a/internal/memory/controller/controller_targets.go +++ b/internal/memory/controller/controller_targets.go @@ -2,6 +2,7 @@ package controller import ( "context" + "errors" "fmt" @@ -67,6 +68,7 @@ func (c *Controller) updateDecision( } func (c *Controller) deleteDecision( + ctx context.Context, candidate memcontract.Candidate, targets []Target, trace []memcontract.RuleHit, @@ -103,11 +105,43 @@ func (c *Controller) deleteDecision( &target, ) default: - return c.ambiguousDecision(candidate, matches, trace) + return c.ambiguousDecision(ctx, candidate, matches, trace) } } func (c *Controller) ambiguousDecision( + ctx context.Context, + candidate memcontract.Candidate, + targets []Target, + trace []memcontract.RuleHit, +) (memcontract.Decision, error) { + ambiguousTrace := slices.Clone(trace) + ambiguousTrace = append( + ambiguousTrace, + failedRule("ambiguous_targets", "multiple plausible targets require tiebreaker", targetIDs(targets)), + ) + if c.tiebreaker == nil { + return c.rulesOnlyAmbiguousDecision(candidate, targets, ambiguousTrace) + } + result, err := c.tiebreaker.BreakTie(ctx, TiebreakerRequest{ + Candidate: candidate, + Targets: append([]Target(nil), targets...), + RuleTrace: append([]memcontract.RuleHit(nil), ambiguousTrace...), + }) + if errors.Is(err, ErrTiebreakerDisabled) { + return c.rulesOnlyAmbiguousDecision(candidate, targets, ambiguousTrace) + } + if err != nil { + return c.tiebreakerFailureDecision(candidate, targets, ambiguousTrace, result, err) + } + decision, applyErr := c.applyTiebreakerResult(candidate, targets, ambiguousTrace, result) + if applyErr != nil { + return c.tiebreakerFailureDecision(candidate, targets, ambiguousTrace, result, applyErr) + } + return decision, nil +} + +func (c *Controller) rulesOnlyAmbiguousDecision( candidate memcontract.Candidate, targets []Target, trace []memcontract.RuleHit, @@ -118,15 +152,140 @@ func (c *Controller) ambiguousDecision( targets, targetFilename(candidate), "", - append( - trace, - failedRule("ambiguous_targets", "multiple plausible targets require tiebreaker", targetIDs(targets)), - ), + trace, "ambiguous targets; rules-only fallback selected noop", nil, ) } +func (c *Controller) applyTiebreakerResult( + candidate memcontract.Candidate, + targets []Target, + trace []memcontract.RuleHit, + result TiebreakerResult, +) (memcontract.Decision, error) { + op := result.Op.Normalize() + if err := op.Validate(); err != nil { + return memcontract.Decision{}, fmt.Errorf("memory controller: validate tiebreaker op: %w", err) + } + target := targetByID(targets, result.TargetID) + var ( + decision memcontract.Decision + err error + ) + switch op { + case memcontract.OpAdd: + decision, err = c.addDecision(candidate, trace) + case memcontract.OpUpdate: + if target == nil { + return memcontract.Decision{}, errors.New( + "memory controller: update tiebreaker result requires a valid target_id", + ) + } + decision, err = c.updateDecision(candidate, *target, trace) + case memcontract.OpDelete: + if target == nil { + return memcontract.Decision{}, errors.New( + "memory controller: delete tiebreaker result requires a valid target_id", + ) + } + decision, err = c.decision( + candidate, + memcontract.OpDelete, + []Target{*target}, + target.TargetFilename, + "", + trace, + result.Reason, + target, + ) + case memcontract.OpNoop: + selected := targets + if target != nil { + selected = []Target{*target} + } + decision, err = c.decision( + candidate, + memcontract.OpNoop, + selected, + targetFilename(candidate), + "", + trace, + result.Reason, + target, + ) + case memcontract.OpReject: + decision, err = c.decision( + candidate, + memcontract.OpReject, + nil, + "", + "", + trace, + result.Reason, + nil, + ) + } + if err != nil { + return memcontract.Decision{}, err + } + return applyTiebreakerMetadata(decision, result), nil +} + +func (c *Controller) tiebreakerFailureDecision( + candidate memcontract.Candidate, + targets []Target, + trace []memcontract.RuleHit, + result TiebreakerResult, + cause error, +) (memcontract.Decision, error) { + call := result.Call + if call == nil { + call = &memcontract.LLMCall{PromptVersion: c.promptVersion} + } else { + cloned := *call + call = &cloned + } + call.Error = boundString(cause.Error(), maxDecisionReasonBytes) + result.Call = call + result.Op = c.defaultOpOnFail + result.Reason = "tiebreaker failed; configured deterministic fallback selected " + c.defaultOpOnFail.String() + result.Confidence = confidenceForOp(c.defaultOpOnFail) + return c.applyTiebreakerResult(candidate, targets, trace, result) +} + +func applyTiebreakerMetadata( + decision memcontract.Decision, + result TiebreakerResult, +) memcontract.Decision { + decision.Source = memcontract.SourceLLM + decision.Confidence = result.Confidence + decision.Reason = boundString(result.Reason, maxDecisionReasonBytes) + if result.Call != nil { + call := *result.Call + decision.LLMTrace = &call + if strings.TrimSpace(call.PromptVersion) != "" { + decision.PromptVersion = strings.TrimSpace(call.PromptVersion) + } + } + decision.IdempotencyKey = IdempotencyKey(decision) + decision.ID = "dec_" + hashString(decision.IdempotencyKey)[:24] + return decision +} + +func targetByID(targets []Target, targetID string) *Target { + trimmed := strings.TrimSpace(targetID) + if trimmed == "" { + return nil + } + for i := range targets { + if strings.TrimSpace(targets[i].ID) == trimmed { + return &targets[i] + } + } + return nil +} + func (c *Controller) decision( candidate memcontract.Candidate, op memcontract.Op, diff --git a/internal/memory/controller/controller_test.go b/internal/memory/controller/controller_test.go index 259cc3b40..f374979bf 100644 --- a/internal/memory/controller/controller_test.go +++ b/internal/memory/controller/controller_test.go @@ -438,6 +438,85 @@ func TestControllerDecide(t *testing.T) { }) } +func TestControllerTiebreaker(t *testing.T) { + t.Parallel() + + targets := []Target{ + controllerTestTarget("target-a", "project_auth_a.md", "Auth uses device login.\n"), + controllerTestTarget("target-b", "project_auth_b.md", "Auth uses browser login.\n"), + } + candidate := controllerTestCandidate("Project Auth", "Auth uses browser login with PKCE.\n") + candidate.Origin = memcontract.OriginExtractor + + t.Run("Should apply a validated model update with replay metadata", func(t *testing.T) { + t.Parallel() + + call := &memcontract.LLMCall{Model: "controller-model", PromptVersion: "v2", Latency: time.Millisecond} + tiebreaker := &fakeTiebreaker{result: TiebreakerResult{ + Op: memcontract.OpUpdate, + TargetID: "target-b", + Confidence: 0.9, + Reason: "same durable slot", + Call: call, + }} + decision, err := New(fakeIndex{targets: targets}, WithTiebreaker(tiebreaker)).Decide( + testutil.Context(t), + candidate, + ) + if err != nil { + t.Fatalf("Decide() error = %v", err) + } + if decision.Op != memcontract.OpUpdate || !slices.Equal(decision.Targets, []string{"target-b"}) { + t.Fatalf("Decision route = %s/%v, want update target-b", decision.Op.String(), decision.Targets) + } + if decision.Source != memcontract.SourceLLM || decision.LLMTrace == nil || + decision.LLMTrace.Model != "controller-model" || decision.PromptVersion != "v2" { + t.Fatalf("Decision model evidence = %#v, want LLM v2 trace", decision) + } + if tiebreaker.calls != 1 || len(tiebreaker.request.Targets) != 2 { + t.Fatalf("Tiebreaker calls/targets = %d/%d, want 1/2", tiebreaker.calls, len(tiebreaker.request.Targets)) + } + }) + + t.Run("Should use the configured deterministic fallback after a model failure", func(t *testing.T) { + t.Parallel() + + call := &memcontract.LLMCall{Model: "controller-model", PromptVersion: "v1"} + decision, err := New( + fakeIndex{targets: targets}, + WithTiebreaker(&fakeTiebreaker{ + result: TiebreakerResult{Call: call}, + err: context.DeadlineExceeded, + }), + WithDefaultOpOnFail(memcontract.OpReject), + ).Decide(testutil.Context(t), candidate) + if err != nil { + t.Fatalf("Decide() error = %v", err) + } + if decision.Op != memcontract.OpReject || decision.Source != memcontract.SourceLLM { + t.Fatalf("Decision = %s/%s, want reject/llm", decision.Op.String(), decision.Source) + } + if decision.LLMTrace == nil || !strings.Contains(decision.LLMTrace.Error, "deadline exceeded") { + t.Fatalf("Decision.LLMTrace = %#v, want bounded failure evidence", decision.LLMTrace) + } + }) + + t.Run("Should preserve the rules-only fallback when the live role is disabled", func(t *testing.T) { + t.Parallel() + + decision, err := New( + fakeIndex{targets: targets}, + WithTiebreaker(&fakeTiebreaker{err: ErrTiebreakerDisabled}), + ).Decide(testutil.Context(t), candidate) + if err != nil { + t.Fatalf("Decide() error = %v", err) + } + if decision.Op != memcontract.OpNoop || decision.Source != memcontract.SourceRule || decision.LLMTrace != nil { + t.Fatalf("Decision = %#v, want rules-only noop without LLM trace", decision) + } + }) +} + func TestDecisionIdempotencyKey(t *testing.T) { t.Run("Should distinguish op post content prompt and frontmatter changes", func(t *testing.T) { t.Parallel() @@ -477,6 +556,22 @@ type fakeIndex struct { targets []Target } +type fakeTiebreaker struct { + result TiebreakerResult + err error + calls int + request TiebreakerRequest +} + +func (f *fakeTiebreaker) BreakTie( + _ context.Context, + request TiebreakerRequest, +) (TiebreakerResult, error) { + f.calls++ + f.request = request + return f.result, f.err +} + func (f fakeIndex) ListTargets(context.Context, memcontract.Candidate) ([]Target, error) { out := make([]Target, len(f.targets)) copy(out, f.targets) diff --git a/internal/memory/controller/tiebreaker.go b/internal/memory/controller/tiebreaker.go new file mode 100644 index 000000000..795ee06c8 --- /dev/null +++ b/internal/memory/controller/tiebreaker.go @@ -0,0 +1,35 @@ +package controller + +import ( + "context" + "errors" + + memcontract "github.com/compozy/agh/internal/memory/contract" +) + +var ( + // ErrTiebreakerDisabled tells the rule-first controller to keep its + // deterministic ambiguity fallback without recording a failed model call. + ErrTiebreakerDisabled = errors.New("memory controller: tiebreaker is disabled") +) + +// TiebreakerRequest contains the bounded ambiguity context supplied to a model. +type TiebreakerRequest struct { + Candidate memcontract.Candidate + Targets []Target + RuleTrace []memcontract.RuleHit +} + +// TiebreakerResult is the validated semantic result of one model call. +type TiebreakerResult struct { + Op memcontract.Op + TargetID string + Confidence float32 + Reason string + Call *memcontract.LLMCall +} + +// Tiebreaker resolves only genuine ambiguity left by deterministic rules. +type Tiebreaker interface { + BreakTie(context.Context, TiebreakerRequest) (TiebreakerResult, error) +} diff --git a/internal/memory/decision.go b/internal/memory/decision.go index 0e04f5c09..d5b38dd7d 100644 --- a/internal/memory/decision.go +++ b/internal/memory/decision.go @@ -10,7 +10,6 @@ import ( "time" memcontract "github.com/compozy/agh/internal/memory/contract" - "github.com/compozy/agh/internal/memory/controller" ) const ( @@ -99,7 +98,7 @@ func (s *Store) ProposeWrite( if candidate.Origin == "" { candidate.Origin = memcontract.OriginFile } - decision, err := controller.New(s).Decide(ctx, candidate) + decision, err := s.DecideCandidate(ctx, candidate) if err != nil { return DecisionApplyResult{}, err } @@ -143,7 +142,7 @@ func (s *Store) ProposeDelete( if candidate.Origin == "" { candidate.Origin = memcontract.OriginFile } - decision, err := controller.New(s).Decide(ctx, candidate) + decision, err := s.DecideCandidate(ctx, candidate) if err != nil { return DecisionApplyResult{}, err } @@ -186,7 +185,7 @@ func (s *Store) ProposeCandidate( if normalized.SubmittedAt.IsZero() { normalized.SubmittedAt = time.Now().UTC() } - decision, err := controller.New(s).Decide(ctx, normalized) + decision, err := s.DecideCandidate(ctx, normalized) if err != nil { return memcontract.Decision{}, err } diff --git a/internal/memory/decision_controller.go b/internal/memory/decision_controller.go new file mode 100644 index 000000000..d12d19922 --- /dev/null +++ b/internal/memory/decision_controller.go @@ -0,0 +1,61 @@ +package memory + +import ( + "context" + "errors" + "sync" + + memcontract "github.com/compozy/agh/internal/memory/contract" + "github.com/compozy/agh/internal/memory/controller" +) + +// DecisionControllerFactory builds the effective write controller for one +// store view. Store clones share the live factory registration. +type DecisionControllerFactory func(controller.TargetIndex) memcontract.Controller + +type decisionControllerFactoryState struct { + mu sync.RWMutex + factory DecisionControllerFactory +} + +// SetDecisionControllerFactory installs the daemon-owned live controller +// composition used by writes, batches, and dry-run decisions. +func (s *Store) SetDecisionControllerFactory(factory DecisionControllerFactory) { + if s == nil { + return + } + if s.decisionFactory == nil { + s.decisionFactory = &decisionControllerFactoryState{} + } + s.decisionFactory.mu.Lock() + s.decisionFactory.factory = factory + s.decisionFactory.mu.Unlock() +} + +// DecideCandidate computes a controller decision without applying it. +func (s *Store) DecideCandidate( + ctx context.Context, + candidate memcontract.Candidate, +) (memcontract.Decision, error) { + if ctx == nil { + return memcontract.Decision{}, errors.New("memory: decide candidate context is required") + } + if s == nil { + return memcontract.Decision{}, errors.New("memory: store is required") + } + return s.newDecisionController().Decide(ctx, candidate) +} + +func (s *Store) newDecisionController() memcontract.Controller { + if s.decisionFactory != nil { + s.decisionFactory.mu.RLock() + factory := s.decisionFactory.factory + s.decisionFactory.mu.RUnlock() + if factory != nil { + if built := factory(s); built != nil { + return built + } + } + } + return controller.New(s) +} diff --git a/internal/memory/dream.go b/internal/memory/dream.go index c31af8780..720c39f4c 100644 --- a/internal/memory/dream.go +++ b/internal/memory/dream.go @@ -24,6 +24,8 @@ const ( var ( // ErrLockUnavailable reports that a consolidation run could not obtain the lock. ErrLockUnavailable = errors.New("memory: consolidation lock is unavailable") + // ErrDreamRoleDisabled reports that no effective workspace route permits a dream run. + ErrDreamRoleDisabled = errors.New("memory: dream role is disabled") ) // SessionSpawner starts a one-shot consolidation session with the provided diff --git a/internal/memory/dream_run.go b/internal/memory/dream_run.go index e1b4a73f8..df645419e 100644 --- a/internal/memory/dream_run.go +++ b/internal/memory/dream_run.go @@ -49,7 +49,9 @@ func (s *Service) Run(ctx context.Context, spawn SessionSpawner, workspaceRef st s.logger.Debug("memory: starting consolidation run", "goal", s.goal, "workspace_id", workspace.id) - if err := spawn(ctx, s.goal, s.prompt, workspace.id, priorMtime); err != nil { + if err := spawn(ctx, s.goal, s.prompt, workspace.id, priorMtime); errors.Is(err, ErrDreamRoleDisabled) { + return s.skipDreamRun(ctx, workspace, gate, priorMtime) + } else if err != nil { return s.failDreamRun(ctx, workspace, gate, priorMtime, err, "spawn consolidation session") } if !gate.active { @@ -70,6 +72,30 @@ func (s *Service) Run(ctx context.Context, spawn SessionSpawner, workspaceRef st return s.completeRun(true, priorMtime) } +func (s *Service) skipDreamRun( + ctx context.Context, + workspace dreamRunWorkspace, + run dreamSignalGateResult, + priorMtime time.Time, +) error { + var cleanupErrs []error + if run.active && workspace.store != nil { + if err := workspace.store.deleteDreamRun(ctx, run.runID); err != nil { + cleanupErrs = append(cleanupErrs, err) + } + } + if err := s.completeRun(false, priorMtime); err != nil { + cleanupErrs = append(cleanupErrs, err) + } + if cleanupErr := errors.Join(cleanupErrs...); cleanupErr != nil { + return fmt.Errorf("memory: clean up disabled dream run: %w", cleanupErr) + } + s.logger.Debug("memory: dream consolidation skipped because the workspace role is disabled", + "workspace_id", workspace.id, + ) + return ErrDreamRoleDisabled +} + func (s *Service) handleDreamGateResult( ctx context.Context, workspace dreamRunWorkspace, diff --git a/internal/memory/dream_test.go b/internal/memory/dream_test.go index befe4b693..b9f09193d 100644 --- a/internal/memory/dream_test.go +++ b/internal/memory/dream_test.go @@ -433,6 +433,79 @@ func TestServiceRunDreamSignalGatePromotesEligibleSignals(t *testing.T) { }) } +func TestServiceRunSkipsWorkspaceDisabledDreamWithoutPromotion(t *testing.T) { + t.Parallel() + + t.Run("Should roll back the provisional run without promotion", func(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC) + env := newDreamSeedEnv(t, now) + seedDreamRecallSignals(t, env.workspaceStore, memcontract.ScopeWorkspace, env.workspaceID, 5, now) + prior := now.Add(-48 * time.Hour) + lock := &stubLock{ + tryAcquireFn: func() (time.Time, bool, error) { + return prior, true, nil + }, + } + service := NewService( + WithMemoryStore(env.baseStore), + WithWorkspaceResolver(&fakeDreamWorkspaceResolver{ + resolved: workspacepkg.ResolvedWorkspace{ + Workspace: workspacepkg.Workspace{ID: env.workspaceID, RootDir: env.workspaceRoot}, + }, + }), + WithMinHours(0), + WithMinSessions(0), + WithDreamGateConfig(DreamGateConfig{MinCandidates: 5, MinRecallCount: 2, MinScore: 0.75}), + withLock(lock), + withNow(func() time.Time { return now }), + ) + + err := service.Run(testutil.Context(t), func(context.Context, string, string, string, time.Time) error { + return ErrDreamRoleDisabled + }, env.workspaceID) + if !errors.Is(err, ErrDreamRoleDisabled) { + t.Fatalf("Run() error = %v, want ErrDreamRoleDisabled", err) + } + assertDreamPromotedCount(t, env.workspaceStore, 0) + db, dbErr := env.workspaceStore.catalog.ensureDB(testutil.Context(t)) + if dbErr != nil { + t.Fatalf("ensureDB() error = %v", dbErr) + } + var runCount int + if queryErr := db.QueryRowContext( + testutil.Context(t), + `SELECT COUNT(*) FROM memory_consolidations`, + ).Scan(&runCount); queryErr != nil { + t.Fatalf("query skipped dream run count error = %v", queryErr) + } + if runCount != 0 { + t.Fatalf("dream run count = %d, want 0", runCount) + } + assertDreamEventCount(t, env.workspaceStore, memoryEventDreamStarted, 0) + if lock.releaseCalls != 0 || len(lock.rollbackCalls) != 1 || !lock.rollbackCalls[0].Equal(prior) { + t.Fatalf("lock release/rollback = %d/%v, want 0/[prior]", lock.releaseCalls, lock.rollbackCalls) + } + }) + + t.Run("Should surface rollback failure instead of reporting a clean skip", func(t *testing.T) { + t.Parallel() + + rollbackErr := errors.New("rollback disabled dream run") + service := NewService(withLock(&stubLock{rollbackErr: rollbackErr})) + err := service.Run(testutil.Context(t), func(context.Context, string, string, string, time.Time) error { + return ErrDreamRoleDisabled + }, "") + if !errors.Is(err, rollbackErr) { + t.Fatalf("Run() error = %v, want rollback failure", err) + } + if errors.Is(err, ErrDreamRoleDisabled) { + t.Fatalf("Run() error = %v, must not suppress cleanup failure as disabled-role skip", err) + } + }) +} + func TestServiceRunDreamSignalGateUsesStableWorkspaceIdentity(t *testing.T) { t.Parallel() diff --git a/internal/memory/dream_v2.go b/internal/memory/dream_v2.go index cd6ea1c04..95bfa7b1a 100644 --- a/internal/memory/dream_v2.go +++ b/internal/memory/dream_v2.go @@ -38,7 +38,6 @@ const ( defaultDreamRecencyWeight = 0.20 defaultDreamFreshnessWeight = 0.15 dreamPromptVersion = "dream.v1" - dreamingCuratorSlug = "dreaming-curator" ) var ( diff --git a/internal/memory/dream_v2_artifact.go b/internal/memory/dream_v2_artifact.go index 899878c42..e08640a49 100644 --- a/internal/memory/dream_v2_artifact.go +++ b/internal/memory/dream_v2_artifact.go @@ -13,6 +13,7 @@ import ( "strings" "time" + aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/diagnostics" "github.com/compozy/agh/internal/fileutil" memcontract "github.com/compozy/agh/internal/memory/contract" @@ -105,7 +106,7 @@ func dreamArtifactFilename(at time.Time) string { if at.IsZero() { at = time.Now().UTC() } - return at.UTC().Format("20060102") + "-" + dreamingCuratorSlug + ".md" + return at.UTC().Format("20060102") + "-" + aghconfig.BuiltinDreamingCuratorAgentName + ".md" } func safeDreamRunFilename(runID string) string { diff --git a/internal/memory/dream_v2_run.go b/internal/memory/dream_v2_run.go index f3c6f42c7..fadc225b7 100644 --- a/internal/memory/dream_v2_run.go +++ b/internal/memory/dream_v2_run.go @@ -93,6 +93,33 @@ func (s *Store) startDreamRun( return s.upsertDreamRun(ctx, run, workspace, "running", 0, "", at) } +func (s *Store) deleteDreamRun(ctx context.Context, runID string) error { + if s == nil || s.catalog == nil { + return nil + } + trimmed := strings.TrimSpace(runID) + if trimmed == "" { + return errors.New("memory: dream run id is required") + } + if err := s.ensureDecisionCatalog(ctx); err != nil { + return err + } + return s.catalog.withCatalogWriteTx(ctx, "dream run delete", func(tx *storepkg.WriteTx) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM memory_consolidations WHERE id = ?`, trimmed); err != nil { + return fmt.Errorf("memory: delete skipped dream run %q: %w", trimmed, err) + } + if _, err := tx.ExecContext( + ctx, + `DELETE FROM memory_events WHERE target_id = ? AND op = ?`, + trimmed, + memoryEventDreamStarted, + ); err != nil { + return fmt.Errorf("memory: delete skipped dream run event %q: %w", trimmed, err) + } + return nil + }) +} + func (s *Store) completeDreamRun( ctx context.Context, run dreamSignalGateResult, diff --git a/internal/memory/prompts/extract.v1.tmpl b/internal/memory/prompts/extract.v1.tmpl index 2b092a7b6..d75732469 100644 --- a/internal/memory/prompts/extract.v1.tmpl +++ b/internal/memory/prompts/extract.v1.tmpl @@ -5,7 +5,7 @@ You are extracting candidate AGH Memory v2 facts from a completed assistant turn Return JSONL. Each line is one candidate with: ```json -{"type":"user|feedback|project|reference","scope":"global|workspace|agent","agent_tier":"workspace|global","content":"","evidence":"","entity":"","attribute":""} +{"type":"user|feedback|project|reference","scope":"global|workspace|agent","agent_tier":"","content":"","evidence":"","entity":"","attribute":""} ``` Hard rules: @@ -15,6 +15,7 @@ Hard rules: - Do not emit candidates when the same turn already made an explicit memory write. - Keep every candidate lexical-only. Do not include embeddings, vectors, or similarity scores. - Include concise evidence tied to the provided message sequence range. +- Set `agent_tier` to `workspace` or `global` only when `scope` is `agent`; otherwise leave it empty. - Apply the WHAT_NOT_TO_SAVE policy below before emitting any line. WHAT_NOT_TO_SAVE policy: diff --git a/internal/memory/store.go b/internal/memory/store.go index 7e6725008..6dd22fff9 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -50,6 +50,7 @@ type Store struct { mutationRevision *storeMutationRevision recallSignals recallSignalRecorderConfig recallRecorders *recallRecorderRegistry + decisionFactory *decisionControllerFactoryState } var _ memcontract.Backend = (*Store)(nil) diff --git a/internal/memory/store_options.go b/internal/memory/store_options.go index 328063f27..ccc399251 100644 --- a/internal/memory/store_options.go +++ b/internal/memory/store_options.go @@ -29,6 +29,7 @@ func NewStore(globalDir string, opts ...StoreOption) *Store { metricsEnabled: true, }, recallRecorders: &recallRecorderRegistry{recorders: make(map[string]*memoryrecall.SignalRecorder)}, + decisionFactory: &decisionControllerFactoryState{}, } for _, opt := range opts { if opt != nil { diff --git a/internal/network/manager.go b/internal/network/manager.go index d0ab5a1ec..ab0ed99bc 100644 --- a/internal/network/manager.go +++ b/internal/network/manager.go @@ -328,6 +328,7 @@ func (m *Manager) Shutdown(ctx context.Context) error { return nil } m.closed = true + m.cancel() sessionIDs := make([]string, 0, len(m.sessions)) for sessionID := range m.sessions { sessionIDs = append(sessionIDs, sessionID) @@ -340,7 +341,6 @@ func (m *Manager) Shutdown(ctx context.Context) error { } m.waiters = make(map[string]map[chan struct{}]struct{}) m.mu.Unlock() - m.cancel() for _, sessionID := range sessionIDs { m.router.Leave(sessionID) } diff --git a/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-amd64.gz b/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-amd64.gz index b7ecd012f..d51afd326 100644 Binary files a/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-amd64.gz and b/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-amd64.gz differ diff --git a/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-arm64.gz b/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-arm64.gz index e21ca23ef..fbfe2e61a 100644 Binary files a/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-arm64.gz and b/internal/sandbox/daytona/sidecar_assets/agh-daytona-sidecar-linux-arm64.gz differ diff --git a/internal/session/coordinator_fallback_test.go b/internal/session/coordinator_fallback_test.go index 4d67951fc..350ecccd9 100644 --- a/internal/session/coordinator_fallback_test.go +++ b/internal/session/coordinator_fallback_test.go @@ -15,6 +15,49 @@ import ( ) func TestBundledCoordinatorFallback(t *testing.T) { + t.Run("Should create a dream session without a materialized agent definition", func(t *testing.T) { + t.Parallel() + + h := newHarness(t) + configureBundledCoordinatorFallbackWorkspace(t, h) + created, err := h.manager.Create(testutil.Context(t), CreateOpts{ + AgentName: aghconfig.BuiltinDreamingCuratorAgentName, + Provider: "claude", + Name: "bundled-dream", + Workspace: h.workspaceID, + Type: SessionTypeDream, + }) + if err != nil { + t.Fatalf("Create(dream) error = %v", err) + } + t.Cleanup(func() { + if err := h.manager.Stop(testutil.Context(t), created.ID); err != nil { + t.Fatalf("Stop(%q) error = %v", created.ID, err) + } + }) + + if got, want := created.AgentName, aghconfig.BuiltinDreamingCuratorAgentName; got != want { + t.Fatalf("Create().AgentName = %q, want %q", got, want) + } + if got, want := created.Type, SessionTypeDream; got != want { + t.Fatalf("Create().Type = %q, want %q", got, want) + } + if got := h.driver.startCalls[0].SystemPrompt; !strings.Contains(got, mustDreamBuiltin(t).Prompt) { + t.Fatalf("startCalls[0].SystemPrompt = %q, want bundled dream prompt", got) + } + }) + + t.Run("Should reject a builtin identity for a mismatched session type", func(t *testing.T) { + t.Parallel() + + if _, ok := builtinSessionAgentDef(aghconfig.BuiltinDreamingCuratorAgentName, SessionTypeUser); ok { + t.Fatal("builtinSessionAgentDef(dreaming-curator, user) ok = true, want false") + } + if _, ok := builtinSessionAgentDef(aghconfig.BuiltinCoordinatorAgentName, SessionTypeDream); ok { + t.Fatal("builtinSessionAgentDef(coordinator, dream) ok = true, want false") + } + }) + t.Run("Should create a coordinator session without a materialized agent definition", func(t *testing.T) { t.Parallel() @@ -28,7 +71,7 @@ func TestBundledCoordinatorFallback(t *testing.T) { } }) - if got, want := created.AgentName, aghconfig.DefaultCoordinatorAgentName; got != want { + if got, want := created.AgentName, aghconfig.BuiltinCoordinatorAgentName; got != want { t.Fatalf("Create().AgentName = %q, want %q", got, want) } if got, want := created.Type, SessionTypeCoordinator; got != want { @@ -37,12 +80,12 @@ func TestBundledCoordinatorFallback(t *testing.T) { if got, want := len(h.driver.startCalls), 1; got != want { t.Fatalf("len(startCalls) = %d, want %d", got, want) } - if got, want := h.driver.startCalls[0].AgentName, aghconfig.DefaultCoordinatorAgentName; got != want { + if got, want := h.driver.startCalls[0].AgentName, aghconfig.BuiltinCoordinatorAgentName; got != want { t.Fatalf("startCalls[0].AgentName = %q, want %q", got, want) } if got := h.driver.startCalls[0].SystemPrompt; !strings.Contains( got, - aghconfig.DefaultCoordinatorAgentDef().Prompt, + mustCoordinatorBuiltin(t).Prompt, ) { t.Fatalf("startCalls[0].SystemPrompt = %q, want bundled coordinator prompt", got) } @@ -70,7 +113,7 @@ func TestBundledCoordinatorFallback(t *testing.T) { } }) - if got, want := resumed.AgentName, aghconfig.DefaultCoordinatorAgentName; got != want { + if got, want := resumed.AgentName, aghconfig.BuiltinCoordinatorAgentName; got != want { t.Fatalf("Resume().AgentName = %q, want %q", got, want) } if got, want := len(h.driver.startCalls), 2; got != want { @@ -78,7 +121,7 @@ func TestBundledCoordinatorFallback(t *testing.T) { } if got := h.driver.startCalls[1].SystemPrompt; !strings.Contains( got, - aghconfig.DefaultCoordinatorAgentDef().Prompt, + mustCoordinatorBuiltin(t).Prompt, ) { t.Fatalf("startCalls[1].SystemPrompt = %q, want bundled coordinator prompt", got) } @@ -93,7 +136,7 @@ func TestBundledCoordinatorFallback(t *testing.T) { configureBundledCoordinatorFallbackWorkspace(t, h) meta := validResumeMeta(h, "sess-coordinator-fallback") - meta.AgentName = aghconfig.DefaultCoordinatorAgentName + meta.AgentName = aghconfig.BuiltinCoordinatorAgentName meta.Provider = "claude" meta.SessionType = string(SessionTypeCoordinator) writeResumeEventStore(t, h.homePaths, meta.ID, []byte("not-empty")) @@ -112,7 +155,7 @@ func TestBundledCoordinatorFallback(t *testing.T) { configureBundledCoordinatorFallbackWorkspace(t, h) meta := validResumeMeta(h, "sess-coordinator-repair") - meta.AgentName = aghconfig.DefaultCoordinatorAgentName + meta.AgentName = aghconfig.BuiltinCoordinatorAgentName meta.Provider = "" meta.SessionType = string(SessionTypeCoordinator) @@ -140,7 +183,7 @@ func createBundledCoordinatorSession(t *testing.T, h *harness) *Session { t.Helper() created, err := h.manager.Create(testutil.Context(t), CreateOpts{ - AgentName: aghconfig.DefaultCoordinatorAgentName, + AgentName: aghconfig.BuiltinCoordinatorAgentName, Provider: "claude", Name: "bundled-coordinator", Workspace: h.workspaceID, @@ -171,7 +214,7 @@ func configureBundledCoordinatorFallbackWorkspace(t *testing.T, h *harness) { h.cfg.Defaults.Provider = "claude" h.resolver.upsert(&resolved) if _, err := resolveWorkspaceAgent( - aghconfig.DefaultCoordinatorAgentName, + aghconfig.BuiltinCoordinatorAgentName, &resolved, ); !errors.Is( err, @@ -180,3 +223,21 @@ func configureBundledCoordinatorFallbackWorkspace(t *testing.T, h *harness) { t.Fatalf("resolveWorkspaceAgent(coordinator) error = %v, want ErrAgentNotAvailable", err) } } + +func mustCoordinatorBuiltin(t *testing.T) aghconfig.AgentDef { + t.Helper() + def, ok := aghconfig.BuiltinAgentDef(aghconfig.BuiltinCoordinatorAgentName) + if !ok { + t.Fatal("BuiltinAgentDef(coordinator) ok = false, want true") + } + return def +} + +func mustDreamBuiltin(t *testing.T) aghconfig.AgentDef { + t.Helper() + def, ok := aghconfig.BuiltinAgentDef(aghconfig.BuiltinDreamingCuratorAgentName) + if !ok { + t.Fatal("BuiltinAgentDef(dreaming-curator) ok = false, want true") + } + return def +} diff --git a/internal/session/manager.go b/internal/session/manager.go index d0da3ff4f..6becb9967 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -250,7 +250,7 @@ func (m *Manager) List() []*Info { infos := make([]*Info, 0, len(sessions)) for _, session := range sessions { - infos = append(infos, session.Info()) + infos = append(infos, m.sessionInfoForRead(session)) } sort.Slice(infos, func(i, j int) bool { diff --git a/internal/session/manager_prompt_request.go b/internal/session/manager_prompt_request.go index 13146ac01..629508ca1 100644 --- a/internal/session/manager_prompt_request.go +++ b/internal/session/manager_prompt_request.go @@ -95,6 +95,9 @@ func (m *Manager) newPromptTurnID() string { func (m *Manager) lookupPromptSession(ctx context.Context, target string) (*Session, error) { session, err := m.lookup(target) if err == nil { + if m.sessionStartRun(target) != nil { + return nil, fmt.Errorf("%w: %s (%s)", ErrSessionNotActive, target, StateStarting) + } return session, nil } if !errors.Is(err, ErrSessionNotFound) { diff --git a/internal/session/manager_start_run.go b/internal/session/manager_start_run.go index 4efc1911c..b94a62a38 100644 --- a/internal/session/manager_start_run.go +++ b/internal/session/manager_start_run.go @@ -89,6 +89,17 @@ func (m *Manager) sessionStartRun(sessionID string) *sessionStartRun { return m.startRuns[strings.TrimSpace(sessionID)] } +func (m *Manager) sessionInfoForRead(session *Session) *Info { + if session == nil { + return nil + } + info := session.Info() + if info != nil && info.State == StateActive && m.sessionStartRun(info.ID) != nil { + info.State = StateStarting + } + return info +} + func waitForSessionStartRun(ctx context.Context, run *sessionStartRun) error { if run == nil { return nil diff --git a/internal/session/manager_start_test.go b/internal/session/manager_start_test.go index 5d1b4b08c..8b30b4092 100644 --- a/internal/session/manager_start_test.go +++ b/internal/session/manager_start_test.go @@ -21,6 +21,10 @@ func TestCreateAcceptedPersistsStartingBeforeProviderStartupCompletes(t *testing "Should persist starting and expose an empty transcript before provider activation", testCreateAcceptedPersistsStarting, ) + t.Run( + "Should remain externally starting until the committed startup run completes", + testCreateAcceptedRemainsStartingUntilCommitted, + ) } func testCreateAcceptedPersistsStarting(t *testing.T) { @@ -74,6 +78,69 @@ func testCreateAcceptedPersistsStarting(t *testing.T) { } } +func testCreateAcceptedRemainsStartingUntilCommitted(t *testing.T) { + t.Helper() + created := make(chan struct{}) + release := make(chan struct{}) + released := false + defer func() { + if !released { + close(release) + } + }() + h := newHarness(t, WithNotifier(&blockingCreatedNotifier{created: created, release: release})) + + accepted, err := h.manager.CreateAccepted(testutil.Context(t), CreateOpts{ + AgentName: "coder", + Workspace: h.workspaceID, + }) + if err != nil { + t.Fatalf("CreateAccepted() error = %v", err) + } + <-created + + status, err := h.manager.Status(testutil.Context(t), accepted.ID) + if err != nil { + t.Fatalf("Status(starting commit) error = %v", err) + } + if status.State != StateStarting { + t.Fatalf("Status(starting commit) State = %q, want %q", status.State, StateStarting) + } + listed := h.manager.List() + if len(listed) != 1 || listed[0].ID != accepted.ID || listed[0].State != StateStarting { + t.Fatalf("List(starting commit) = %#v, want one starting session", listed) + } + if _, err := h.manager.SendPrompt(testutil.Context(t), accepted.ID, SendPromptOpts{ + Message: "too early", + }); !errors.Is(err, ErrSessionNotActive) { + t.Fatalf("SendPrompt(starting commit) error = %v, want %v", err, ErrSessionNotActive) + } + + close(release) + released = true + waitForCondition(t, "committed session activation", func() bool { + current, statusErr := h.manager.Status(testutil.Context(t), accepted.ID) + return statusErr == nil && current.State == StateActive + }) + if err := h.manager.Stop(testutil.Context(t), accepted.ID); err != nil { + t.Fatalf("Stop() cleanup error = %v", err) + } +} + +type blockingCreatedNotifier struct { + created chan struct{} + release chan struct{} +} + +func (n *blockingCreatedNotifier) OnSessionCreated(context.Context, *Session) { + close(n.created) + <-n.release +} + +func (*blockingCreatedNotifier) OnSessionStopped(context.Context, *Session) {} + +func (*blockingCreatedNotifier) OnAgentEvent(context.Context, string, any) {} + func TestCreateAcceptedPersistsBackgroundStartupFailure(t *testing.T) { t.Parallel() t.Run("Should persist background startup failure after durable acceptance", testCreateAcceptedPersistsFailure) diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index da76c1b20..325acebdc 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -5202,6 +5202,49 @@ func TestACPDriverAdapterErrorPaths(t *testing.T) { } } +func TestInvokeTransientModel(t *testing.T) { + t.Parallel() + + t.Run("Should execute one bounded turn without publishing a durable session", func(t *testing.T) { + t.Parallel() + + h := newHarness(t) + provider := "claude" + result, err := h.manager.InvokeTransientModel(testutil.Context(t), TransientModelCall{ + Config: &h.cfg, + Provider: provider, + Model: h.cfg.Providers[provider].Models.Default, + CWD: h.workspace, + Prompt: "Choose the memory operation.", + MaxOutputBytes: 64, + }) + if err != nil { + t.Fatalf("InvokeTransientModel() error = %v", err) + } + if !result.Accepted || result.Output != "reply" { + t.Fatalf("InvokeTransientModel() = %#v, want accepted reply", result) + } + if sessions := h.manager.List(); len(sessions) != 0 { + t.Fatalf("List() = %#v, want no durable transient session", sessions) + } + if len(h.driver.startCalls) != 1 || len(h.driver.promptCalls) != 1 || h.driver.stopCalls != 1 { + t.Fatalf( + "driver start/prompt/stop calls = %d/%d/%d, want 1/1/1", + len(h.driver.startCalls), + len(h.driver.promptCalls), + h.driver.stopCalls, + ) + } + if h.driver.startCalls[0].Permissions != aghconfig.PermissionModeDenyAll { + t.Fatalf( + "Start().Permissions = %q, want %q", + h.driver.startCalls[0].Permissions, + aghconfig.PermissionModeDenyAll, + ) + } + }) +} + type harness struct { manager *Manager driver *fakeDriver diff --git a/internal/session/manager_workspace.go b/internal/session/manager_workspace.go index 007c5d09a..3a94fb7a7 100644 --- a/internal/session/manager_workspace.go +++ b/internal/session/manager_workspace.go @@ -179,7 +179,7 @@ func (m *Manager) resolveWorkspaceAgentArtifactsForSession( return AgentArtifacts{}, err } - fallback, ok := fallbackSessionAgentDef(agentName, sessionType) + fallback, ok := builtinSessionAgentDef(agentName, sessionType) if !ok { return AgentArtifacts{}, err } @@ -210,7 +210,7 @@ func resolveWorkspaceSessionAgentForType( if !errors.Is(err, workspacepkg.ErrAgentNotAvailable) { return aghconfig.ResolvedAgent{}, err } - fallback, ok := fallbackSessionAgentDef(agentName, sessionType) + fallback, ok := builtinSessionAgentDef(agentName, sessionType) if !ok { return aghconfig.ResolvedAgent{}, err } @@ -224,12 +224,18 @@ func resolveWorkspaceSessionAgentForType( return resolved, nil } -func fallbackSessionAgentDef(agentName string, sessionType Type) (aghconfig.AgentDef, bool) { - if normalizeSessionType(sessionType) != SessionTypeCoordinator { +func builtinSessionAgentDef(agentName string, sessionType Type) (aghconfig.AgentDef, bool) { + expectedName := "" + switch normalizeSessionType(sessionType) { + case SessionTypeCoordinator: + expectedName = aghconfig.BuiltinCoordinatorAgentName + case SessionTypeDream: + expectedName = aghconfig.BuiltinDreamingCuratorAgentName + default: return aghconfig.AgentDef{}, false } - if strings.TrimSpace(agentName) != aghconfig.DefaultCoordinatorAgentName { + if strings.TrimSpace(agentName) != expectedName { return aghconfig.AgentDef{}, false } - return aghconfig.DefaultCoordinatorAgentDef(), true + return aghconfig.BuiltinAgentDef(agentName) } diff --git a/internal/session/provider_runtime_pi.go b/internal/session/provider_runtime_pi.go index e77ad2b23..b6570d06a 100644 --- a/internal/session/provider_runtime_pi.go +++ b/internal/session/provider_runtime_pi.go @@ -60,24 +60,34 @@ func (m *Manager) materializePiRuntime( if session == nil { return "", errors.New("session: pi runtime requires a session") } + runtimeDir := filepath.Join(session.sessionDir, "provider-runtime", "pi") + if err := materializePiRuntimeAt(runtimeDir, resolved, injectedTargetEnvs); err != nil { + return "", err + } + return runtimeDir, nil +} + +func materializePiRuntimeAt( + runtimeDir string, + resolved aghconfig.ResolvedAgent, + injectedTargetEnvs map[string]struct{}, +) error { runtimeProvider := strings.TrimSpace(resolved.RuntimeProvider) if runtimeProvider == "" { runtimeProvider = strings.TrimSpace(resolved.Provider) } model := strings.TrimSpace(resolved.Model) if runtimeProvider == "" { - return "", errors.New("session: pi runtime provider is required") + return errors.New("session: pi runtime provider is required") } if model == "" { - return "", errors.New("session: pi model is required") + return errors.New("session: pi model is required") } - - runtimeDir := filepath.Join(session.sessionDir, "provider-runtime", "pi") if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - return "", fmt.Errorf("session: create pi runtime directory %q: %w", runtimeDir, err) + return fmt.Errorf("session: create pi runtime directory %q: %w", runtimeDir, err) } if err := os.Chmod(runtimeDir, 0o700); err != nil { - return "", fmt.Errorf("session: protect pi runtime directory %q: %w", runtimeDir, err) + return fmt.Errorf("session: protect pi runtime directory %q: %w", runtimeDir, err) } settings := piSettingsFile{ DefaultProvider: runtimeProvider, @@ -85,7 +95,7 @@ func (m *Manager) materializePiRuntime( EnabledModels: []string{model}, } if err := writeProviderJSON(filepath.Join(runtimeDir, "settings.json"), settings); err != nil { - return "", err + return err } models := piModelsFile{ @@ -99,7 +109,7 @@ func (m *Manager) materializePiRuntime( }, } if err := writeProviderJSON(filepath.Join(runtimeDir, "models.json"), models); err != nil { - return "", err + return err } - return runtimeDir, nil + return nil } diff --git a/internal/session/query.go b/internal/session/query.go index 726c77f00..b3460f2ae 100644 --- a/internal/session/query.go +++ b/internal/session/query.go @@ -99,7 +99,7 @@ func (m *Manager) Status(ctx context.Context, id string) (*Info, error) { } if session, ok := m.Get(target); ok { - return normalizeExpiredSessionAttach(session.Info(), m.now()), nil + return normalizeExpiredSessionAttach(m.sessionInfoForRead(session), m.now()), nil } meta, err := m.readMetaWithContext(ctx, target) diff --git a/internal/session/spawn.go b/internal/session/spawn.go index 4d3dccc3c..94649ef69 100644 --- a/internal/session/spawn.go +++ b/internal/session/spawn.go @@ -39,6 +39,7 @@ type SpawnOpts struct { AgentName string Provider string Model string + ReasoningEffort string Name string Workspace string WorkspacePath string @@ -91,6 +92,7 @@ func (m *Manager) Spawn(ctx context.Context, opts SpawnOpts) (*Session, error) { AgentName: normalized.AgentName, Provider: normalized.Provider, Model: normalized.Model, + ReasoningEffort: normalized.ReasoningEffort, Name: normalized.Name, Workspace: workspaceRef, WorkspacePath: workspacePath, @@ -156,6 +158,7 @@ func normalizeSpawnOpts(opts SpawnOpts) (SpawnOpts, error) { normalized.AgentName = strings.TrimSpace(normalized.AgentName) normalized.Provider = strings.TrimSpace(normalized.Provider) normalized.Model = strings.TrimSpace(normalized.Model) + normalized.ReasoningEffort = strings.TrimSpace(normalized.ReasoningEffort) normalized.Name = strings.TrimSpace(normalized.Name) normalized.Workspace = strings.TrimSpace(normalized.Workspace) normalized.WorkspacePath = strings.TrimSpace(normalized.WorkspacePath) diff --git a/internal/session/transient_model.go b/internal/session/transient_model.go new file mode 100644 index 000000000..044d42fa8 --- /dev/null +++ b/internal/session/transient_model.go @@ -0,0 +1,212 @@ +package session + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/compozy/agh/internal/acp" + aghconfig "github.com/compozy/agh/internal/config" + "github.com/compozy/agh/internal/providerenv" +) + +const transientModelStopTimeout = 5 * time.Second + +// TransientModelCall describes one provider-backed model turn that must not +// create a durable AGH session. +type TransientModelCall struct { + Config *aghconfig.Config + Provider string + Model string + ReasoningEffort string + CWD string + SystemPrompt string + Prompt string + MaxOutputBytes int +} + +// TransientModelResult reports whether the provider accepted the prompt. +// Once accepted, higher layers must not attempt a route fallback. +type TransientModelResult struct { + Output string + Accepted bool +} + +// InvokeTransientModel performs one ACP turn without publishing a session row. +func (m *Manager) InvokeTransientModel( + ctx context.Context, + call TransientModelCall, +) (result TransientModelResult, operationErr error) { + if ctx == nil { + return result, errors.New("session: transient model context is required") + } + if m == nil || m.driver == nil { + return result, errors.New("session: transient model driver is required") + } + if call.Config == nil { + return result, errors.New("session: transient model config is required") + } + if strings.TrimSpace(call.Prompt) == "" { + return result, errors.New("session: transient model prompt is required") + } + if call.MaxOutputBytes <= 0 { + return result, errors.New("session: transient model output bound must be positive") + } + + resolved, err := call.Config.ResolveAgent(aghconfig.AgentDef{ + Name: "memory-controller", + Provider: strings.TrimSpace(call.Provider), + Model: strings.TrimSpace(call.Model), + ReasoningEffort: strings.TrimSpace(call.ReasoningEffort), + Permissions: string(aghconfig.PermissionModeDenyAll), + Prompt: "AGH memory controller transient runtime.", + }) + if err != nil { + return result, fmt.Errorf("session: resolve transient model runtime: %w", err) + } + + startOpts := acp.StartOpts{ + AgentName: "memory-controller", + Command: resolved.Command, + Cwd: strings.TrimSpace(call.CWD), + Env: sessionStartEnvForProvider(os.Environ(), nil, resolved.EnvPolicy), + Permissions: aghconfig.PermissionModeDenyAll, + SystemPrompt: strings.TrimSpace(call.SystemPrompt), + PreferredModel: resolved.Model, + ReasoningEffort: resolved.ReasoningEffort, + } + startOpts, cleanup, err := m.prepareTransientModelProvider(ctx, resolved, startOpts) + if err != nil { + if cleanup != nil { + err = errors.Join(err, cleanup()) + } + return result, err + } + defer func() { + operationErr = errors.Join(operationErr, cleanup()) + }() + + process, err := m.driver.Start(ctx, startOpts) + if err != nil { + return result, fmt.Errorf("session: start transient model: %w", err) + } + defer func() { + stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), transientModelStopTimeout) + defer cancel() + if stopErr := m.driver.Stop(stopCtx, process); stopErr != nil { + operationErr = errors.Join(operationErr, fmt.Errorf("session: stop transient model: %w", stopErr)) + } + }() + + events, err := m.driver.Prompt(ctx, process, acp.PromptRequest{ + TurnID: newID("turn"), + Message: call.Prompt, + }) + if err != nil { + return result, fmt.Errorf("session: prompt transient model: %w", err) + } + result.Accepted = true + result.Output, err = collectTransientModelOutput(ctx, events, call.MaxOutputBytes) + if err != nil { + return result, err + } + return result, nil +} + +func (m *Manager) prepareTransientModelProvider( + ctx context.Context, + resolved aghconfig.ResolvedAgent, + opts acp.StartOpts, +) (acp.StartOpts, func() error, error) { + opts.Env = setProviderStartEnv(opts.Env, resolved) + var err error + if resolved.HomePolicy == aghconfig.ProviderHomePolicyIsolated { + opts.Env, err = providerenv.ApplyHomePolicy( + m.homePaths, + strings.TrimSpace(resolved.Provider), + resolved.HomePolicy, + opts.Env, + ) + if err != nil { + return acp.StartOpts{}, nil, fmt.Errorf("session: apply transient provider home policy: %w", err) + } + } + if resolved.Harness == aghconfig.ProviderHarnessPiACP && + resolved.AuthMode == aghconfig.ProviderAuthModeNativeCLI { + opts.Env, err = providerenv.ApplyPiAgentDirPolicy( + m.homePaths, + strings.TrimSpace(resolved.Provider), + resolved.HomePolicy, + opts.Env, + ) + if err != nil { + return acp.StartOpts{}, nil, fmt.Errorf("session: apply transient pi auth directory policy: %w", err) + } + } + + bindings, err := m.injectProviderSecrets(ctx, resolved, opts.Env) + if err != nil { + return acp.StartOpts{}, nil, err + } + opts.Env = bindings.env + runtimeDir := "" + cleanup := func() error { + runProviderSecretRedactions(bindings.redactionCleanups) + if runtimeDir == "" { + return nil + } + if err := os.RemoveAll(runtimeDir); err != nil { + return fmt.Errorf("session: remove transient provider runtime %q: %w", runtimeDir, err) + } + return nil + } + if resolved.Harness == aghconfig.ProviderHarnessPiACP && + resolved.AuthMode == aghconfig.ProviderAuthModeBoundSecret { + runtimeDir, err = os.MkdirTemp(m.homePaths.HomeDir, ".memory-controller-") + if err != nil { + return acp.StartOpts{}, cleanup, fmt.Errorf("session: create transient provider runtime: %w", err) + } + piDir := filepath.Join(runtimeDir, "pi") + if err := materializePiRuntimeAt(piDir, resolved, bindings.injectedTargetEnvs); err != nil { + return acp.StartOpts{}, cleanup, err + } + opts.Env = setSessionStartEnvValue(opts.Env, "PI_CODING_AGENT_DIR", piDir) + } + opts.ProviderName = strings.TrimSpace(resolved.Provider) + providerConfig := providerConfigFromResolvedAgent(resolved) + opts.ProviderConfig = &providerConfig + probeEnv := providerProbeEnvForStart(m, resolved, opts.Env) + opts.ProviderAuthEnv = &probeEnv + return opts, cleanup, nil +} + +func collectTransientModelOutput( + ctx context.Context, + events <-chan acp.AgentEvent, + maxBytes int, +) (string, error) { + var output strings.Builder + for { + select { + case <-ctx.Done(): + return "", fmt.Errorf("session: collect transient model output: %w", ctx.Err()) + case event, ok := <-events: + if !ok { + return output.String(), nil + } + switch event.Type { + case acp.EventTypeAgentMessage: + if output.Len()+len(event.Text) > maxBytes { + return "", errors.New("session: transient model output exceeds byte limit") + } + output.WriteString(event.Text) + case acp.EventTypeError: + return "", fmt.Errorf("session: transient model error: %s", strings.TrimSpace(event.Error)) + } + } + } +} diff --git a/internal/settings/config_apply_classification.go b/internal/settings/config_apply_classification.go index 908bb7eda..0bc682f44 100644 --- a/internal/settings/config_apply_classification.go +++ b/internal/settings/config_apply_classification.go @@ -46,6 +46,15 @@ func (s *service) classifyGeneralRequest(ctx context.Context, req SectionUpdateR return lifecycleForChangedPaths(changed, lifecycle.RestartRequired) } +func (s *service) classifyRolesRequest(ctx context.Context, req SectionUpdateRequest) lifecycle.Lifecycle { + loaded, err := s.loadRolesSectionUpdate(ctx, req.Scope, req.WorkspaceID) + if err != nil { + return lifecycle.Live + } + changed := diffRolesSettings(&loaded.config.Roles, req.Roles) + return lifecycleForChangedPaths(changed, lifecycle.Live) +} + func (s *service) classifyNetworkRequest(ctx context.Context, req SectionUpdateRequest) lifecycle.Lifecycle { cfg, _, err := s.loadGlobalSectionUpdate(ctx, req.Section, req.Scope, req.WorkspaceID) if err != nil { diff --git a/internal/settings/config_apply_helpers.go b/internal/settings/config_apply_helpers.go index 30c09e6bb..995193cef 100644 --- a/internal/settings/config_apply_helpers.go +++ b/internal/settings/config_apply_helpers.go @@ -2,14 +2,11 @@ package settings import ( "context" - "maps" - "strings" aghconfig "github.com/compozy/agh/internal/config" "github.com/compozy/agh/internal/config/lifecycle" - hookspkg "github.com/compozy/agh/internal/hooks" ) @@ -28,6 +25,11 @@ func (s *service) classifySectionApplyRequest( return lifecycle.RestartRequired } return s.classifyGeneralRequest(ctx, req) + case SectionRoles: + if req.Roles == nil { + return lifecycle.Live + } + return s.classifyRolesRequest(ctx, req) case SectionHooksExtensions: if req.HooksExtensions == nil { return lifecycle.RestartRequired @@ -118,6 +120,8 @@ func cloneActiveConfig(cfg *aghconfig.Config) aghconfig.Config { cloned.Sandboxes = mapsClone(cfg.Sandboxes) cloned.MCPServers = append([]aghconfig.MCPServer(nil), cfg.MCPServers...) cloned.Hooks.Declarations = append([]hookspkg.HookDecl(nil), cfg.Hooks.Declarations...) + cloned.Roles = aghconfig.CloneRolesConfig(&cfg.Roles) + cloned.RoleSources = aghconfig.CloneRoleFieldSources(cfg.RoleSources) cloned.WindowManager = cloneWindowManagerConfig(cfg.WindowManager) return cloned } diff --git a/internal/settings/config_apply_service.go b/internal/settings/config_apply_service.go index 38991852c..5a798453a 100644 --- a/internal/settings/config_apply_service.go +++ b/internal/settings/config_apply_service.go @@ -31,7 +31,7 @@ func (s *service) ApplySection(ctx context.Context, req SectionUpdateRequest) (A configLifecycle := s.classifySectionApplyRequest(ctx, req) result, err := s.UpdateSection(ctx, req) if err != nil { - return s.recordFailedApply(ctx, req.Section, req.Scope, "", configLifecycle, err) + return s.recordFailedApply(ctx, req.Section, req.Scope, req.WorkspaceID, configLifecycle, err) } if result.Section == SectionNetwork { return s.recordNetworkSectionApply(ctx, result) diff --git a/internal/settings/config_apply_service_test.go b/internal/settings/config_apply_service_test.go index 352117047..2764f2d26 100644 --- a/internal/settings/config_apply_service_test.go +++ b/internal/settings/config_apply_service_test.go @@ -3,6 +3,7 @@ package settings import ( "context" "fmt" + "path/filepath" "reflect" "slices" "strings" @@ -16,6 +17,7 @@ import ( "github.com/compozy/agh/internal/modelcatalog" "github.com/compozy/agh/internal/store" "github.com/compozy/agh/internal/store/globaldb" + workspacepkg "github.com/compozy/agh/internal/workspace" ) func TestConfigApplyServiceRecordsLiveApplyAndAdvancesGeneration(t *testing.T) { @@ -87,6 +89,172 @@ func TestConfigApplyServiceRecordsLiveApplyAndAdvancesGeneration(t *testing.T) { } }) + t.Run("Should persist role routing as a live apply with history", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + homePaths := testHomePaths(t) + writeFile(t, homePaths.ConfigFile, baseSettingsConfig()) + db, err := globaldb.OpenGlobalDB(ctx, homePaths.DatabaseFile) + if err != nil { + t.Fatalf("OpenGlobalDB() error = %v", err) + } + t.Cleanup(func() { + if err := db.Close(ctx); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + + service := testService(t, homePaths, Dependencies{ + ApplyRecords: NewConfigApplyRecordRepository(db.DB(), nil), + }) + cfg, err := aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome() error = %v", err) + } + roles := cfg.Roles + roles.MemoryExtractor.Model = "anthropic/claude-sonnet-4" + + result, err := service.ApplySection(WithMutationSource(ctx, "http"), SectionUpdateRequest{ + SectionRequest: SectionRequest{Section: SectionRoles}, + Roles: &roles, + }) + if err != nil { + t.Fatalf("ApplySection(roles) error = %v", err) + } + if !result.Applied || result.Record.Lifecycle != lifecycle.Live || + result.Record.Status != lifecycle.StatusApplied { + t.Fatalf("ApplySection(roles) = %#v, want applied live record", result) + } + + persisted, err := aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome(persisted) error = %v", err) + } + if got, want := persisted.Roles.MemoryExtractor.Model, "anthropic/claude-sonnet-4"; got != want { + t.Fatalf("persisted roles.memory_extractor.model = %q, want %q", got, want) + } + active, err := service.ActiveConfig(ctx) + if err != nil { + t.Fatalf("ActiveConfig() error = %v", err) + } + if active.Roles.MemoryExtractor.Model != persisted.Roles.MemoryExtractor.Model { + t.Fatalf( + "active roles.memory_extractor.model = %q, want persisted %q", + active.Roles.MemoryExtractor.Model, + persisted.Roles.MemoryExtractor.Model, + ) + } + active.Roles.MemoryExtractor.FallbackChain = append( + active.Roles.MemoryExtractor.FallbackChain, + aghconfig.RoleFallback{Provider: "mutated", Model: "mutated"}, + ) + active.RoleSources[aghconfig.RoleMemoryExtractor]["model"] = "mutated" + isolated, err := service.ActiveConfig(ctx) + if err != nil { + t.Fatalf("ActiveConfig(after caller mutation) error = %v", err) + } + if slices.ContainsFunc(isolated.Roles.MemoryExtractor.FallbackChain, func( + fallback aghconfig.RoleFallback, + ) bool { + return fallback.Provider == "mutated" + }) || isolated.RoleSources[aghconfig.RoleMemoryExtractor]["model"] == "mutated" { + t.Fatalf("ActiveConfig() retained caller-owned role state: %#v", isolated) + } + records, err := service.ListApplyRecords(ctx, ApplyRecordFilter{}) + if err != nil { + t.Fatalf("ListApplyRecords() error = %v", err) + } + if len(records) != 1 || records[0].Actor != "http" || records[0].Lifecycle != lifecycle.Live { + t.Fatalf("role apply records = %#v, want one HTTP live record", records) + } + }) + + t.Run("Should replace workspace role fallbacks through the scoped section contract", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + homePaths := testHomePaths(t) + writeFile(t, homePaths.ConfigFile, baseSettingsConfig()) + workspaceRoot := filepath.Join(t.TempDir(), "workspace") + workspaceID := "ws-roles" + db, err := globaldb.OpenGlobalDB(ctx, homePaths.DatabaseFile) + if err != nil { + t.Fatalf("OpenGlobalDB() error = %v", err) + } + t.Cleanup(func() { + if err := db.Close(ctx); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) + + service := testService(t, homePaths, Dependencies{ + ApplyRecords: NewConfigApplyRecordRepository(db.DB(), nil), + WorkspaceResolver: fakeWorkspaceResolver{resolved: map[string]workspacepkg.ResolvedWorkspace{ + workspaceID: { + Workspace: workspacepkg.Workspace{ID: workspaceID, RootDir: workspaceRoot}, + }, + }}, + }) + cfg, err := aghconfig.LoadForHome(homePaths, aghconfig.WithWorkspaceRoot(workspaceRoot)) + if err != nil { + t.Fatalf("LoadForHome(workspace) error = %v", err) + } + roles := cfg.Roles + roles.AutoTitle.FallbackChain = []aghconfig.RoleFallback{{ + Provider: "codex", Model: "gpt-5-mini", ReasoningEffort: "medium", + }} + + result, err := service.ApplySection(WithMutationSource(ctx, "uds"), SectionUpdateRequest{ + SectionRequest: SectionRequest{ + Section: SectionRoles, Scope: ScopeWorkspace, WorkspaceID: workspaceID, + }, + Roles: &roles, + }) + if err != nil { + t.Fatalf("ApplySection(workspace roles) error = %v", err) + } + if !result.Applied || result.Scope != ScopeWorkspace || result.WorkspaceID != workspaceID || + result.WriteTarget != WriteTargetWorkspaceConfig { + t.Fatalf("ApplySection(workspace roles) = %#v, want applied workspace config", result) + } + + persisted, err := aghconfig.LoadForHome(homePaths, aghconfig.WithWorkspaceRoot(workspaceRoot)) + if err != nil { + t.Fatalf("LoadForHome(persisted workspace) error = %v", err) + } + if !reflect.DeepEqual(persisted.Roles.AutoTitle.FallbackChain, roles.AutoTitle.FallbackChain) { + t.Fatalf( + "workspace fallback chain = %#v, want %#v", + persisted.Roles.AutoTitle.FallbackChain, + roles.AutoTitle.FallbackChain, + ) + } + + envelope, err := service.GetSection(ctx, SectionRequest{ + Section: SectionRoles, Scope: ScopeWorkspace, WorkspaceID: workspaceID, + }) + if err != nil { + t.Fatalf("GetSection(workspace roles) error = %v", err) + } + if envelope.Scope != ScopeWorkspace || + !reflect.DeepEqual(envelope.AvailableScopes, []ScopeKind{ScopeGlobal, ScopeWorkspace}) { + t.Fatalf("GetSection(workspace roles) envelope = %#v", envelope) + } + }) + + t.Run("Should treat nil and empty role fallback chains as unchanged", func(t *testing.T) { + t.Parallel() + + current := aghconfig.DefaultRolesConfig() + desired := aghconfig.CloneRolesConfig(¤t) + desired.Dream.FallbackChain = make([]aghconfig.RoleFallback, 0) + desired.MemoryController.FallbackChain = make([]aghconfig.RoleFallback, 0) + if changed := diffRolesSettings(¤t, &desired); len(changed) != 0 { + t.Fatalf("diffRolesSettings() = %#v, want no changes", changed) + } + }) + t.Run("Should return provider snapshots without shared nested state", func(t *testing.T) { t.Parallel() @@ -818,6 +986,22 @@ func TestReloadChangedPaths(t *testing.T) { } }) + t.Run("Should classify role mutations as live", func(t *testing.T) { + t.Parallel() + + current := aghconfig.DefaultWithHome(aghconfig.HomePaths{}) + desired := current + desired.Roles.AutoTitle.Enabled = false + + want := []string{"roles.auto_title.enabled"} + if got := reloadChangedPaths(¤t, &desired); !slices.Equal(got, want) { + t.Fatalf("reloadChangedPaths() = %#v, want %#v", got, want) + } + if got := classifyReloadLifecycle(¤t, &desired); got != lifecycle.Live { + t.Fatalf("classifyReloadLifecycle() = %q, want %q", got, lifecycle.Live) + } + }) + t.Run("Should emit the restart-required automation suggestion cap path", func(t *testing.T) { t.Parallel() diff --git a/internal/settings/config_reload_diff.go b/internal/settings/config_reload_diff.go index b8d758dad..a547b9ebf 100644 --- a/internal/settings/config_reload_diff.go +++ b/internal/settings/config_reload_diff.go @@ -23,6 +23,7 @@ func reloadChangedPaths(current *aghconfig.Config, desired *aghconfig.Config) [] var changed []string changed = append(changed, diffGeneralSettings(current, generalSettingsFromConfig(desired))...) changed = append(changed, diffSkillsSettings(current.Skills, desired.Skills)...) + changed = append(changed, diffRolesSettings(¤t.Roles, &desired.Roles)...) changed = append(changed, diffMemorySettings(¤t.Memory, &desired.Memory)...) changed = append(changed, diffAutomationSettings(current, automationSettingsFromConfig(desired))...) if current.Automation.Suggestions.PendingCap != desired.Automation.Suggestions.PendingCap { diff --git a/internal/settings/models.go b/internal/settings/models.go index 8de247257..aa720838a 100644 --- a/internal/settings/models.go +++ b/internal/settings/models.go @@ -3,7 +3,6 @@ package settings import ( "context" "fmt" - "strings" "time" @@ -68,6 +67,8 @@ const ( SectionGeneral SectionName = "general" // SectionMemory exposes memory and dream settings. SectionMemory SectionName = "memory" + // SectionRoles exposes background role routing settings. + SectionRoles SectionName = "roles" // SectionSkills exposes global skills-engine settings. SectionSkills SectionName = "skills" // SectionAutomation exposes automation engine settings. @@ -173,6 +174,7 @@ type SectionUpdateRequest struct { SectionRequest General *GeneralSettings Memory *aghconfig.MemoryConfig + Roles *aghconfig.RolesConfig Skills *aghconfig.SkillsConfig Automation *AutomationSettings Network *aghconfig.NetworkConfig @@ -220,6 +222,7 @@ type SectionEnvelope struct { AvailableScopes []ScopeKind General *GeneralSection Memory *MemorySection + Roles *RolesSection Skills *SkillsSection Automation *AutomationSection Network *NetworkSection diff --git a/internal/settings/models_runtime.go b/internal/settings/models_runtime.go index 47c74a6ed..d98f147b9 100644 --- a/internal/settings/models_runtime.go +++ b/internal/settings/models_runtime.go @@ -5,7 +5,6 @@ import ( automationmodel "github.com/compozy/agh/internal/automation/model" aghconfig "github.com/compozy/agh/internal/config" - skillspkg "github.com/compozy/agh/internal/skills" ) @@ -32,6 +31,11 @@ type MemorySection struct { Actions MemoryActions } +// RolesSection is the background-role routing read model. +type RolesSection struct { + Config aghconfig.RolesConfig +} + // SkillsSection is the skills section read model. type SkillsSection struct { Config aghconfig.SkillsConfig diff --git a/internal/settings/section_config_update.go b/internal/settings/section_config_update.go index b86a85f6e..17499ba6c 100644 --- a/internal/settings/section_config_update.go +++ b/internal/settings/section_config_update.go @@ -18,6 +18,8 @@ func (s *service) updateConfigBackedSection( return s.updateGeneralSection(ctx, req) case SectionMemory: return s.updateMemorySection(ctx, req) + case SectionRoles: + return s.updateRolesSection(ctx, req) case SectionAutomation: return s.updateAutomationSection(ctx, req) case SectionNetwork: @@ -33,6 +35,39 @@ func (s *service) updateConfigBackedSection( } } +func (s *service) updateRolesSection( + ctx context.Context, + req SectionUpdateRequest, +) (MutationResult, error) { + loaded, err := s.loadRolesSectionUpdate( + ctx, + req.Scope, + req.WorkspaceID, + ) + if err != nil { + return MutationResult{}, err + } + if req.Roles == nil { + return MutationResult{}, validationError(errors.New("settings: roles section payload is required")) + } + desired := cloneRolesConfig(req.Roles) + if err := desired.Validate("roles", &loaded.config); err != nil { + return MutationResult{}, validationError(err) + } + changed := diffRolesSettings(&loaded.config.Roles, &desired) + return s.updateScopedConfigSection( + req.Section, + changed, + loaded.target, + loaded.scope, + loaded.workspaceID, + loaded.workspaceRoot, + func(editor *aghconfig.OverlayEditor) error { + return applyRolesSettings(editor, &desired) + }, + ) +} + func (s *service) updateGeneralSection( ctx context.Context, req SectionUpdateRequest, @@ -201,21 +236,92 @@ func (s *service) loadGlobalSectionUpdate( return cfg, target, nil } +type rolesSectionUpdate struct { + config aghconfig.Config + target aghconfig.WriteTarget + scope ScopeKind + workspaceID string + workspaceRoot string +} + +func (s *service) loadRolesSectionUpdate( + ctx context.Context, + scope ScopeKind, + workspaceID string, +) (rolesSectionUpdate, error) { + normalizedScope, normalizedWorkspaceID, err := s.normalizeReadScope(scope, workspaceID) + if err != nil { + return rolesSectionUpdate{}, err + } + if normalizedScope != ScopeGlobal && normalizedScope != ScopeWorkspace { + return rolesSectionUpdate{}, conflictError( + fmt.Errorf("settings: section %q does not support %s scope", SectionRoles, normalizedScope), + ) + } + cfg, resolved, err := s.loadConfig(ctx, normalizedScope, normalizedWorkspaceID) + if err != nil { + return rolesSectionUpdate{}, fmt.Errorf( + "settings: load section %q config: %w", + SectionRoles, + err, + ) + } + writeScope := aghconfig.WriteScopeGlobal + workspaceRoot := "" + if normalizedScope == ScopeWorkspace { + if resolved == nil { + return rolesSectionUpdate{}, errors.New( + "settings: resolved workspace is required for roles update", + ) + } + writeScope = aghconfig.WriteScopeWorkspace + workspaceRoot = resolved.RootDir + } + target, err := aghconfig.ResolveConfigWriteTarget(s.homePaths, workspaceRoot, writeScope) + if err != nil { + return rolesSectionUpdate{}, fmt.Errorf( + "settings: resolve section %q write target: %w", + SectionRoles, + err, + ) + } + return rolesSectionUpdate{ + config: cfg, + target: target, + scope: normalizedScope, + workspaceID: normalizedWorkspaceID, + workspaceRoot: workspaceRoot, + }, nil +} + func (s *service) updateConfigSection( section SectionName, changed []string, target aghconfig.WriteTarget, mutate func(*aghconfig.OverlayEditor) error, +) (MutationResult, error) { + return s.updateScopedConfigSection(section, changed, target, ScopeGlobal, "", "", mutate) +} + +func (s *service) updateScopedConfigSection( + section SectionName, + changed []string, + target aghconfig.WriteTarget, + scope ScopeKind, + workspaceID string, + workspaceRoot string, + mutate func(*aghconfig.OverlayEditor) error, ) (MutationResult, error) { if len(changed) == 0 { return MutationResult{ - Section: section, - Scope: ScopeGlobal, - Behavior: MutationBehaviorAppliedNow, - Applied: true, - Warnings: []string{sectionsNoChangesValue}, - Lifecycle: lifecycle.Live, - DiffClass: lifecycle.DiffClassForRoot(string(section)), + Section: section, + Scope: scope, + WorkspaceID: workspaceID, + Behavior: MutationBehaviorAppliedNow, + Applied: true, + Warnings: []string{sectionsNoChangesValue}, + Lifecycle: lifecycle.Live, + DiffClass: lifecycle.DiffClassForRoot(string(section)), }, nil } @@ -224,14 +330,15 @@ func (s *service) updateConfigSection( return MutationResult{}, err } - if _, err := aghconfig.EditConfigOverlay(s.homePaths, "", target, mutate); err != nil { + if _, err := aghconfig.EditConfigOverlay(s.homePaths, workspaceRoot, target, mutate); err != nil { return MutationResult{}, fmt.Errorf("settings: write section %q: %w", section, err) } return MutationResult{ Section: section, - Scope: ScopeGlobal, + Scope: scope, WriteTarget: target.Kind(), + WorkspaceID: workspaceID, Behavior: classification.Behavior, Applied: classification.Applied, RestartRequired: classification.RestartRequired, diff --git a/internal/settings/section_memory_apply.go b/internal/settings/section_memory_apply.go index e72fa0ff8..ced7b99a4 100644 --- a/internal/settings/section_memory_apply.go +++ b/internal/settings/section_memory_apply.go @@ -48,30 +48,6 @@ func memoryControllerSettingsUpdates(settings *aghconfig.MemoryConfig) []struct path: []string{string(SectionMemory), sectionsControllerKey, "default_op_on_fail"}, value: settings.Controller.DefaultOpOnFail, }, - { - path: []string{string(SectionMemory), sectionsControllerKey, sectionsLlmKey, sectionsEnabledKey}, - value: settings.Controller.LLM.Enabled, - }, - { - path: []string{string(SectionMemory), sectionsControllerKey, sectionsLlmKey, "model"}, - value: settings.Controller.LLM.Model, - }, - { - path: []string{string(SectionMemory), sectionsControllerKey, sectionsLlmKey, "top_k"}, - value: settings.Controller.LLM.TopK, - }, - { - path: []string{string(SectionMemory), sectionsControllerKey, sectionsLlmKey, "prompt_version"}, - value: settings.Controller.LLM.PromptVersion, - }, - { - path: []string{string(SectionMemory), sectionsControllerKey, sectionsLlmKey, sectionsTimeoutKey}, - value: settings.Controller.LLM.Timeout.String(), - }, - { - path: []string{string(SectionMemory), sectionsControllerKey, sectionsLlmKey, "max_tokens_out"}, - value: settings.Controller.LLM.MaxTokensOut, - }, { path: []string{string(SectionMemory), sectionsControllerKey, sectionsPolicyKey, "max_content_chars"}, value: settings.Controller.Policy.MaxContentChars, @@ -164,10 +140,6 @@ func memoryExtractorSettingsUpdates(settings *aghconfig.MemoryConfig) []struct { path []string value any }{ - { - path: []string{string(SectionMemory), sectionsExtractorKey, sectionsEnabledKey}, - value: settings.Extractor.Enabled, - }, {path: []string{string(SectionMemory), sectionsExtractorKey, sectionsModeKey}, value: settings.Extractor.Mode}, { path: []string{string(SectionMemory), sectionsExtractorKey, "throttle_turns"}, @@ -186,7 +158,6 @@ func memoryExtractorSettingsUpdates(settings *aghconfig.MemoryConfig) []struct { value: settings.Extractor.InboxPath, }, {path: []string{string(SectionMemory), sectionsExtractorKey, "dlq_path"}, value: settings.Extractor.DLQPath}, - {path: []string{string(SectionMemory), sectionsExtractorKey, "model"}, value: settings.Extractor.Model}, { path: []string{string(SectionMemory), sectionsExtractorKey, sectionsQueueKey, "capacity"}, value: settings.Extractor.Queue.Capacity, @@ -206,8 +177,6 @@ func memoryDreamSettingsUpdates(settings *aghconfig.MemoryConfig) []struct { path []string value any }{ - {path: []string{string(SectionMemory), sectionsDreamKey, sectionsEnabledKey}, value: settings.Dream.Enabled}, - {path: []string{string(SectionMemory), sectionsDreamKey, "agent"}, value: settings.Dream.Agent}, {path: []string{string(SectionMemory), sectionsDreamKey, "min_hours"}, value: settings.Dream.MinHours}, {path: []string{string(SectionMemory), sectionsDreamKey, "min_sessions"}, value: settings.Dream.MinSessions}, {path: []string{string(SectionMemory), sectionsDreamKey, "debounce"}, value: settings.Dream.Debounce.String()}, diff --git a/internal/settings/section_roles.go b/internal/settings/section_roles.go new file mode 100644 index 000000000..c0aa305d6 --- /dev/null +++ b/internal/settings/section_roles.go @@ -0,0 +1,180 @@ +package settings + +import ( + "slices" + + aghconfig "github.com/compozy/agh/internal/config" +) + +func cloneRolesConfig(source *aghconfig.RolesConfig) aghconfig.RolesConfig { + return aghconfig.CloneRolesConfig(source) +} + +func diffRolesSettings(current *aghconfig.RolesConfig, desired *aghconfig.RolesConfig) []string { + var changed []string + changed = append(changed, diffCoordinatorRoleSettings(current.Coordinator, desired.Coordinator)...) + changed = append(changed, diffRoleSettings(aghconfig.RoleDream, current.Dream, desired.Dream)...) + changed = append(changed, diffRoleSettings( + aghconfig.RoleCheckpointSummary, + current.CheckpointSummary, + desired.CheckpointSummary, + )...) + changed = append(changed, diffRoleSettings( + aghconfig.RoleMemoryExtractor, + current.MemoryExtractor, + desired.MemoryExtractor, + )...) + changed = append(changed, diffRoleSettings(aghconfig.RoleAutoTitle, current.AutoTitle, desired.AutoTitle)...) + return append(changed, diffMemoryControllerRoleSettings(current.MemoryController, desired.MemoryController)...) +} + +func diffCoordinatorRoleSettings( + current aghconfig.CoordinatorRoleConfig, + desired aghconfig.CoordinatorRoleConfig, +) []string { + changed := diffRoleSettings(aghconfig.RoleCoordinator, current.RoleConfig, desired.RoleConfig) + prefix := "roles." + string(aghconfig.RoleCoordinator) + "." + if current.TTL != desired.TTL { + changed = append(changed, prefix+"ttl") + } + if current.MaxChildren != desired.MaxChildren { + changed = append(changed, prefix+"max_children") + } + if current.MaxActiveSessionsPerWorkspace != desired.MaxActiveSessionsPerWorkspace { + changed = append(changed, prefix+"max_active_sessions_per_workspace") + } + return changed +} + +func diffRoleSettings( + role aghconfig.RoleName, + current aghconfig.RoleConfig, + desired aghconfig.RoleConfig, +) []string { + prefix := "roles." + string(role) + "." + changed := make([]string, 0, 6) + if current.Enabled != desired.Enabled { + changed = append(changed, prefix+"enabled") + } + if current.Agent != desired.Agent { + changed = append(changed, prefix+"agent") + } + if current.Provider != desired.Provider { + changed = append(changed, prefix+"provider") + } + if current.Model != desired.Model { + changed = append(changed, prefix+"model") + } + if current.ReasoningEffort != desired.ReasoningEffort { + changed = append(changed, prefix+"reasoning_effort") + } + if !roleFallbacksEqual(current.FallbackChain, desired.FallbackChain) { + changed = append(changed, prefix+"fallback_chain") + } + return changed +} + +func diffMemoryControllerRoleSettings( + current aghconfig.MemoryControllerRoleConfig, + desired aghconfig.MemoryControllerRoleConfig, +) []string { + prefix := "roles." + string(aghconfig.RoleMemoryController) + "." + changed := make([]string, 0, 9) + if current.Enabled != desired.Enabled { + changed = append(changed, prefix+"enabled") + } + if current.Provider != desired.Provider { + changed = append(changed, prefix+"provider") + } + if current.Model != desired.Model { + changed = append(changed, prefix+"model") + } + if current.ReasoningEffort != desired.ReasoningEffort { + changed = append(changed, prefix+"reasoning_effort") + } + if current.Timeout != desired.Timeout { + changed = append(changed, prefix+"timeout") + } + if current.TopK != desired.TopK { + changed = append(changed, prefix+"top_k") + } + if current.PromptVersion != desired.PromptVersion { + changed = append(changed, prefix+"prompt_version") + } + if current.MaxTokensOut != desired.MaxTokensOut { + changed = append(changed, prefix+"max_tokens_out") + } + if !roleFallbacksEqual(current.FallbackChain, desired.FallbackChain) { + changed = append(changed, prefix+"fallback_chain") + } + return changed +} + +func roleFallbacksEqual(current, desired []aghconfig.RoleFallback) bool { + return slices.Equal(current, desired) +} + +func applyRolesSettings(editor *aghconfig.OverlayEditor, roles *aghconfig.RolesConfig) error { + tables := []struct { + role aghconfig.RoleName + values map[string]any + }{ + {role: aghconfig.RoleCoordinator, values: coordinatorRoleTable(roles.Coordinator)}, + {role: aghconfig.RoleDream, values: roleTable(roles.Dream)}, + {role: aghconfig.RoleCheckpointSummary, values: roleTable(roles.CheckpointSummary)}, + {role: aghconfig.RoleMemoryExtractor, values: roleTable(roles.MemoryExtractor)}, + {role: aghconfig.RoleAutoTitle, values: roleTable(roles.AutoTitle)}, + {role: aghconfig.RoleMemoryController, values: memoryControllerRoleTable(roles.MemoryController)}, + } + for _, table := range tables { + if err := editor.SetTable([]string{"roles", string(table.role)}, table.values); err != nil { + return err + } + } + return nil +} + +func roleTable(role aghconfig.RoleConfig) map[string]any { + return map[string]any{ + sectionsEnabledKey: role.Enabled, + "agent": role.Agent, + sectionsProviderKey: role.Provider, + sectionsModelKey: role.Model, + sectionsReasoningEffortKey: role.ReasoningEffort, + sectionsFallbackChainKey: roleFallbackTables(role.FallbackChain), + } +} + +func coordinatorRoleTable(role aghconfig.CoordinatorRoleConfig) map[string]any { + table := roleTable(role.RoleConfig) + table["ttl"] = role.TTL.String() + table["max_children"] = role.MaxChildren + table["max_active_sessions_per_workspace"] = role.MaxActiveSessionsPerWorkspace + return table +} + +func memoryControllerRoleTable(role aghconfig.MemoryControllerRoleConfig) map[string]any { + return map[string]any{ + sectionsEnabledKey: role.Enabled, + sectionsProviderKey: role.Provider, + sectionsModelKey: role.Model, + sectionsReasoningEffortKey: role.ReasoningEffort, + "timeout": role.Timeout.String(), + "top_k": role.TopK, + "prompt_version": role.PromptVersion, + "max_tokens_out": role.MaxTokensOut, + sectionsFallbackChainKey: roleFallbackTables(role.FallbackChain), + } +} + +func roleFallbackTables(fallbacks []aghconfig.RoleFallback) []map[string]any { + tables := make([]map[string]any, 0, len(fallbacks)) + for _, fallback := range fallbacks { + tables = append(tables, map[string]any{ + sectionsProviderKey: fallback.Provider, + sectionsModelKey: fallback.Model, + sectionsReasoningEffortKey: fallback.ReasoningEffort, + }) + } + return tables +} diff --git a/internal/settings/sections.go b/internal/settings/sections.go index 065d70af1..2593f9a88 100644 --- a/internal/settings/sections.go +++ b/internal/settings/sections.go @@ -6,7 +6,6 @@ import ( "fmt" aghconfig "github.com/compozy/agh/internal/config" - workspacepkg "github.com/compozy/agh/internal/workspace" ) @@ -29,11 +28,14 @@ const ( sectionsLlmKey = "llm" sectionsMarketplaceKey = "marketplace" sectionsMaxKey = "max" + sectionsModelKey = "model" sectionsModeKey = "mode" sectionsNoChangesValue = "no changes" sectionsOperatorWriteRateLimitKey = "operator_write_rate_limit" sectionsPolicyKey = "policy" sectionsProviderKey = "provider" + sectionsReasoningEffortKey = "reasoning_effort" + sectionsFallbackChainKey = "fallback_chain" sectionsQueueKey = "queue" sectionsRecallKey = "recall" sectionsResourcesKey = "resources" @@ -100,6 +102,14 @@ func (s *service) resolveSectionScope( } func validateSectionScope(section SectionName, scope ScopeKind, agentName string) error { + if section == SectionRoles { + if scope == ScopeAgent { + return conflictError( + fmt.Errorf("settings: section %q does not support %s scope", section, scope), + ) + } + return nil + } if section != SectionSkills && scope != ScopeGlobal { return conflictError( fmt.Errorf("settings: section %q does not support %s scope", section, scope), @@ -155,6 +165,10 @@ func (s *service) populateSectionEnvelope( return err } envelope.Memory = §ion + case SectionRoles: + envelope.AvailableScopes = []ScopeKind{ScopeGlobal, ScopeWorkspace} + section := RolesSection{Config: cloneRolesConfig(&cfg.Roles)} + envelope.Roles = §ion case SectionSkills: envelope.AvailableScopes = []ScopeKind{ScopeGlobal, ScopeAgent} section, err := s.buildSkillsSection( @@ -214,6 +228,11 @@ func (s *service) finalizeSectionUpdate( if err != nil { return MutationResult{}, err } + if result.Scope == ScopeWorkspace { + if invalidator, ok := s.workspaceResolver.(interface{ Invalidate(string) }); ok { + invalidator.Invalidate(result.WorkspaceID) + } + } if emitErr := s.emitSettingsChanged(ctx, result, "patch"); emitErr != nil { return MutationResult{}, emitErr } diff --git a/internal/settings/sections_daemon.go b/internal/settings/sections_daemon.go index 32d9d1965..c98ac4b85 100644 --- a/internal/settings/sections_daemon.go +++ b/internal/settings/sections_daemon.go @@ -84,7 +84,7 @@ func applyGeneralSettings(editor *aghconfig.OverlayEditor, settings GeneralSetti path: []string{sectionsDaemonKey, sectionsReloadTimeoutsKey, "bridges"}, value: settings.Daemon.ReloadTimeouts.Bridges.String(), }, - {path: []string{"redact", "enabled"}, value: settings.Redact.Enabled}, + {path: []string{"redact", sectionsEnabledKey}, value: settings.Redact.Enabled}, } return applyValueUpdates(editor, updates) } diff --git a/internal/settings/service_test.go b/internal/settings/service_test.go index 55457bb4f..4eb7873ae 100644 --- a/internal/settings/service_test.go +++ b/internal/settings/service_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "sync" "testing" @@ -143,14 +144,39 @@ func TestGetSectionBuildsSupportedSections(t *testing.T) { if envelope.Memory == nil { t.Fatal("Memory section = nil") } - if got, want := envelope.Memory.Config.Dream.Agent, "writer"; got != want { - t.Fatalf("Memory dream agent = %q, want %q", got, want) + if got, want := envelope.Memory.Config.Dream.MinHours, 12.0; got != want { + t.Fatalf("Memory dream minimum hours = %v, want %v", got, want) } if got, want := envelope.Memory.Health.FileCount, 5; got != want { t.Fatalf("Memory file count = %d, want %d", got, want) } }, }, + { + name: SectionRoles, + label: "Should build an ownership-safe roles section", + assert: func(t *testing.T, envelope SectionEnvelope) { + t.Helper() + if envelope.Roles == nil || !envelope.Roles.Config.Dream.Enabled { + t.Fatalf("Roles section = %#v, want enabled Dream role", envelope.Roles) + } + envelope.Roles.Config.AutoTitle.FallbackChain = append( + envelope.Roles.Config.AutoTitle.FallbackChain, + aghconfig.RoleFallback{Provider: "mutated", Model: "mutated"}, + ) + reloaded, err := service.GetSection(ctx, SectionRequest{Section: SectionRoles}) + if err != nil { + t.Fatalf("GetSection(roles after mutation) error = %v", err) + } + if slices.ContainsFunc(reloaded.Roles.Config.AutoTitle.FallbackChain, func( + fallback aghconfig.RoleFallback, + ) bool { + return fallback.Provider == "mutated" + }) { + t.Fatal("Roles section retained a caller-owned fallback mutation") + } + }, + }, { name: SectionSkills, assert: func(t *testing.T, envelope SectionEnvelope) { @@ -4117,7 +4143,6 @@ func TestUpdateSectionRestartRequiredSections(t *testing.T) { } memoryConfig := aghconfig.DefaultWithHome(memoryHomePaths).Memory memoryConfig.GlobalDir = "/tmp/updated-memory" - memoryConfig.Dream.Agent = "writer" memoryConfig.Dream.MinHours = 12 memoryConfig.Dream.MinSessions = 3 memoryConfig.Dream.CheckInterval = 15 * time.Minute @@ -5214,8 +5239,6 @@ enabled = true global_dir = "/tmp/memory" [memory.dream] -enabled = true -agent = "writer" min_hours = 12 min_sessions = 2 check_interval = "15m" diff --git a/internal/situation/service.go b/internal/situation/service.go index 33e436e7d..b16c074a0 100644 --- a/internal/situation/service.go +++ b/internal/situation/service.go @@ -64,9 +64,9 @@ type NetworkReader interface { Inbox(ctx context.Context, sessionID string) ([]network.Envelope, error) } -// CoordinatorConfigResolver reads the safe coordinator limits for a workspace. -type CoordinatorConfigResolver interface { - ResolveCoordinatorConfig(ctx context.Context, workspaceID string) (aghconfig.CoordinatorConfig, error) +// CoordinatorRoleResolver reads the safe coordinator limits for a workspace. +type CoordinatorRoleResolver interface { + ResolveCoordinatorRole(ctx context.Context, workspaceID string) (aghconfig.ResolvedCoordinatorRole, error) } // SoulSnapshotStore loads immutable Soul snapshots for compact context projection. @@ -92,8 +92,8 @@ type Deps struct { TaskStoreFunc func() TaskStore Network NetworkReader NetworkFunc func() NetworkReader - CoordinatorConfig CoordinatorConfigResolver - CoordinatorConfigFunc func() CoordinatorConfigResolver + CoordinatorRole CoordinatorRoleResolver + CoordinatorRoleFunc func() CoordinatorRoleResolver SoulSnapshots SoulSnapshotStore SoulSnapshotsFunc func() SoulSnapshotStore } @@ -113,8 +113,8 @@ type Service struct { taskStoreFunc func() TaskStore network NetworkReader networkFunc func() NetworkReader - coordinatorConfig CoordinatorConfigResolver - coordinatorConfigFunc func() CoordinatorConfigResolver + coordinatorRole CoordinatorRoleResolver + coordinatorRoleFunc func() CoordinatorRoleResolver soulSnapshots SoulSnapshotStore soulSnapshotsFunc func() SoulSnapshotStore promptSections *promptSectionCache @@ -144,8 +144,8 @@ func NewService(deps Deps) *Service { taskStoreFunc: deps.TaskStoreFunc, network: deps.Network, networkFunc: deps.NetworkFunc, - coordinatorConfig: deps.CoordinatorConfig, - coordinatorConfigFunc: deps.CoordinatorConfigFunc, + coordinatorRole: deps.CoordinatorRole, + coordinatorRoleFunc: deps.CoordinatorRoleFunc, soulSnapshots: deps.SoulSnapshots, soulSnapshotsFunc: deps.SoulSnapshotsFunc, promptSections: newPromptSectionCache(), diff --git a/internal/situation/service_context_cancellation_test.go b/internal/situation/service_context_cancellation_test.go index 1a125a112..c385eeaf8 100644 --- a/internal/situation/service_context_cancellation_test.go +++ b/internal/situation/service_context_cancellation_test.go @@ -37,9 +37,9 @@ func TestContextForSessionDependencyContextErrorsContract(t *testing.T) { name: "coordinator config deadline", deps: Deps{ Now: fixedNow, - CoordinatorConfig: coordinatorResolverFunc( - func(context.Context, string) (aghconfig.CoordinatorConfig, error) { - return aghconfig.CoordinatorConfig{}, context.DeadlineExceeded + CoordinatorRole: coordinatorResolverFunc( + func(context.Context, string) (aghconfig.ResolvedCoordinatorRole, error) { + return aghconfig.ResolvedCoordinatorRole{}, context.DeadlineExceeded }, ), }, @@ -81,9 +81,9 @@ func TestContextForSessionDependencyContextErrorsContract(t *testing.T) { service := NewService(Deps{ Now: fixedNow, SkillRegistry: contextErrorSkillRegistry{err: errors.New("skill registry unavailable")}, - CoordinatorConfig: coordinatorResolverFunc( - func(context.Context, string) (aghconfig.CoordinatorConfig, error) { - return aghconfig.CoordinatorConfig{}, errors.New("coordinator unavailable") + CoordinatorRole: coordinatorResolverFunc( + func(context.Context, string) (aghconfig.ResolvedCoordinatorRole, error) { + return aghconfig.ResolvedCoordinatorRole{}, errors.New("coordinator unavailable") }, ), SoulSnapshots: contextErrorSoulSnapshotStore{err: soul.ErrSnapshotNotFound}, @@ -122,9 +122,9 @@ func TestContextForStartupDependencyContextErrorsContract(t *testing.T) { name: "coordinator config deadline", deps: Deps{ Now: fixedNow, - CoordinatorConfig: coordinatorResolverFunc( - func(context.Context, string) (aghconfig.CoordinatorConfig, error) { - return aghconfig.CoordinatorConfig{}, context.DeadlineExceeded + CoordinatorRole: coordinatorResolverFunc( + func(context.Context, string) (aghconfig.ResolvedCoordinatorRole, error) { + return aghconfig.ResolvedCoordinatorRole{}, context.DeadlineExceeded }, ), }, diff --git a/internal/situation/service_network.go b/internal/situation/service_network.go index 859eab26f..893a4bc0d 100644 --- a/internal/situation/service_network.go +++ b/internal/situation/service_network.go @@ -111,14 +111,14 @@ func (s *Service) networkValue() NetworkReader { return s.network } -func (s *Service) coordinatorConfigValue() CoordinatorConfigResolver { +func (s *Service) coordinatorRoleValue() CoordinatorRoleResolver { if s == nil { return nil } - if s.coordinatorConfigFunc != nil { - return s.coordinatorConfigFunc() + if s.coordinatorRoleFunc != nil { + return s.coordinatorRoleFunc() } - return s.coordinatorConfig + return s.coordinatorRole } func (s *Service) soulSnapshotsValue() SoulSnapshotStore { diff --git a/internal/situation/service_resolution.go b/internal/situation/service_resolution.go index 5bf93a2a1..8cb9dc14d 100644 --- a/internal/situation/service_resolution.go +++ b/internal/situation/service_resolution.go @@ -171,8 +171,8 @@ func (s *Service) limits(ctx context.Context, workspaceID string) (contract.Agen MaxActiveTaskLeases: defaultMaxActiveTaskLeases, ContextSectionLimit: s.limit(), } - if resolver := s.coordinatorConfigValue(); resolver != nil { - cfg, err := resolver.ResolveCoordinatorConfig(ctx, strings.TrimSpace(workspaceID)) + if resolver := s.coordinatorRoleValue(); resolver != nil { + cfg, err := resolver.ResolveCoordinatorRole(ctx, strings.TrimSpace(workspaceID)) if err != nil && isContextError(err) { return contract.AgentLimitsPayload{}, err } diff --git a/internal/situation/service_test.go b/internal/situation/service_test.go index cd35ccb6c..a2f83a958 100644 --- a/internal/situation/service_test.go +++ b/internal/situation/service_test.go @@ -226,9 +226,9 @@ func TestContextForSessionBoundsListsAndIncludesTaskParticipationProvenance(t *t }, }, }, - CoordinatorConfig: coordinatorResolverFunc( - func(context.Context, string) (aghconfig.CoordinatorConfig, error) { - return aghconfig.CoordinatorConfig{MaxChildren: 3}, nil + CoordinatorRole: coordinatorResolverFunc( + func(context.Context, string) (aghconfig.ResolvedCoordinatorRole, error) { + return aghconfig.ResolvedCoordinatorRole{MaxChildren: 3}, nil }, ), }) @@ -1617,12 +1617,12 @@ func (fn skillRegistryFunc) ForAgent( return fn(ctx, workspace) } -type coordinatorResolverFunc func(context.Context, string) (aghconfig.CoordinatorConfig, error) +type coordinatorResolverFunc func(context.Context, string) (aghconfig.ResolvedCoordinatorRole, error) -func (fn coordinatorResolverFunc) ResolveCoordinatorConfig( +func (fn coordinatorResolverFunc) ResolveCoordinatorRole( ctx context.Context, workspaceID string, -) (aghconfig.CoordinatorConfig, error) { +) (aghconfig.ResolvedCoordinatorRole, error) { return fn(ctx, workspaceID) } diff --git a/internal/skills/coordinator_fallback_test.go b/internal/skills/coordinator_fallback_test.go index b407c7d91..8460b320d 100644 --- a/internal/skills/coordinator_fallback_test.go +++ b/internal/skills/coordinator_fallback_test.go @@ -36,7 +36,7 @@ func TestBundledCoordinatorFallback(t *testing.T) { }}, } - skills, err := registry.ForAgent(context.Background(), resolved, aghconfig.DefaultCoordinatorAgentName) + skills, err := registry.ForAgent(context.Background(), resolved, aghconfig.BuiltinCoordinatorAgentName) if err != nil { t.Fatalf("ForAgent(coordinator) error = %v", err) } @@ -76,7 +76,7 @@ func TestBundledCoordinatorFallback(t *testing.T) { section, err := provider.PromptAgentSection( context.Background(), - aghconfig.DefaultCoordinatorAgentDef(), + mustBuiltinAgentDef(t, aghconfig.BuiltinCoordinatorAgentName), resolved, ) if err != nil { @@ -88,3 +88,12 @@ func TestBundledCoordinatorFallback(t *testing.T) { }, ) } + +func mustBuiltinAgentDef(t *testing.T, name string) aghconfig.AgentDef { + t.Helper() + def, ok := aghconfig.BuiltinAgentDef(name) + if !ok { + t.Fatalf("BuiltinAgentDef(%q) ok = false, want true", name) + } + return def +} diff --git a/internal/skills/registry_agent.go b/internal/skills/registry_agent.go index 2f2995e38..41195eee5 100644 --- a/internal/skills/registry_agent.go +++ b/internal/skills/registry_agent.go @@ -218,16 +218,16 @@ func (r *Registry) resolveAgentScope( } return aghconfig.CloneAgentDef(agent), nil } - if fallback, ok := fallbackAgentScope(agentName); ok { - return fallback, nil + if builtin, ok := aghconfig.BuiltinAgentDef(agentName); ok { + return builtin, nil } return aghconfig.AgentDef{}, fmt.Errorf("%w: %q", ErrAgentNotFound, agentName) } agentsDir := r.globalAgentsDir() if strings.TrimSpace(agentsDir) == "" { - if fallback, ok := fallbackAgentScope(agentName); ok { - return fallback, nil + if builtin, ok := aghconfig.BuiltinAgentDef(agentName); ok { + return builtin, nil } return aghconfig.AgentDef{}, fmt.Errorf("%w: %q", ErrAgentNotFound, agentName) } @@ -235,8 +235,8 @@ func (r *Registry) resolveAgentScope( agent, err := aghconfig.LoadAgentDefFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { - if fallback, ok := fallbackAgentScope(agentName); ok { - return fallback, nil + if builtin, ok := aghconfig.BuiltinAgentDef(agentName); ok { + return builtin, nil } return aghconfig.AgentDef{}, fmt.Errorf("%w: %q", ErrAgentNotFound, agentName) } @@ -261,13 +261,6 @@ func (r *Registry) resolveAgentScope( return agent, nil } -func fallbackAgentScope(agentName string) (aghconfig.AgentDef, bool) { - if strings.TrimSpace(agentName) != aghconfig.DefaultCoordinatorAgentName { - return aghconfig.AgentDef{}, false - } - return aghconfig.DefaultCoordinatorAgentDef(), true -} - func (r *Registry) loadAgentLocalSkills( ctx context.Context, root string, diff --git a/internal/store/globaldb/global_db_session.go b/internal/store/globaldb/global_db_session.go index 0b7889bf7..d873ad87c 100644 --- a/internal/store/globaldb/global_db_session.go +++ b/internal/store/globaldb/global_db_session.go @@ -246,70 +246,67 @@ func sessionListOrderClause(sortKey string) string { func (g *SessionRepo) ReconcileSessions( ctx context.Context, sessions []store.SessionInfo, -) (result store.ReconcileResult, err error) { +) (store.ReconcileResult, error) { if err := g.checkReady(ctx, "reconcile sessions"); err != nil { return store.ReconcileResult{}, err } - tx, err := g.db.BeginTx(ctx, nil) - if err != nil { - return store.ReconcileResult{}, fmt.Errorf("store: begin session reconcile transaction: %w", err) - } - defer func() { - joinCleanupError(&err, rollbackTx(tx, "session reconcile")) - }() - - existing, err := g.loadSessionIDs(ctx, tx) - if err != nil { - return store.ReconcileResult{}, err - } - - result = store.ReconcileResult{ - Indexed: make([]string, 0), - Orphaned: make([]string, 0), - } - seen := make(map[string]struct{}, len(sessions)) - - for _, session := range sessions { - if err := session.Validate(); err != nil { - return store.ReconcileResult{}, err - } - normalized := session - if normalized.CreatedAt.IsZero() { - normalized.CreatedAt = g.now() - } - if normalized.UpdatedAt.IsZero() { - normalized.UpdatedAt = normalized.CreatedAt - } - if _, ok := seen[normalized.ID]; ok { - continue + var result store.ReconcileResult + err := g.withImmediateTransaction(ctx, "reconcile sessions", func(exec globalSQLExecutor) error { + existing, err := g.loadSessionIDs(ctx, exec) + if err != nil { + return err } - seen[normalized.ID] = struct{}{} - if _, ok := existing[normalized.ID]; !ok { - result.Indexed = append(result.Indexed, normalized.ID) + + attemptResult := store.ReconcileResult{ + Indexed: make([]string, 0), + Orphaned: make([]string, 0), } - if err := g.registerSession(ctx, tx, normalized); err != nil { - return store.ReconcileResult{}, fmt.Errorf("store: reconcile session %q: %w", normalized.ID, err) + seen := make(map[string]struct{}, len(sessions)) + + for _, session := range sessions { + if err := session.Validate(); err != nil { + return err + } + normalized := session + if normalized.CreatedAt.IsZero() { + normalized.CreatedAt = g.now() + } + if normalized.UpdatedAt.IsZero() { + normalized.UpdatedAt = normalized.CreatedAt + } + if _, ok := seen[normalized.ID]; ok { + continue + } + seen[normalized.ID] = struct{}{} + if _, ok := existing[normalized.ID]; !ok { + attemptResult.Indexed = append(attemptResult.Indexed, normalized.ID) + } + if err := g.registerSession(ctx, exec, normalized); err != nil { + return fmt.Errorf("store: reconcile session %q: %w", normalized.ID, err) + } } - } - orphanedAt := store.FormatTimestamp(g.now()) - for id := range existing { - if _, ok := seen[id]; ok { - continue - } - if err := sqlcgen.New(tx).MarkSessionOrphaned(ctx, sqlcgen.MarkSessionOrphanedParams{ - State: sessionStateOrphaned, UpdatedAt: orphanedAt, ID: id, - }); err != nil { - return store.ReconcileResult{}, fmt.Errorf("store: mark orphaned session %q: %w", id, err) + orphanedAt := store.FormatTimestamp(g.now()) + queries := sqlcgen.New(exec) + for id := range existing { + if _, ok := seen[id]; ok { + continue + } + if err := queries.MarkSessionOrphaned(ctx, sqlcgen.MarkSessionOrphanedParams{ + State: sessionStateOrphaned, UpdatedAt: orphanedAt, ID: id, + }); err != nil { + return fmt.Errorf("store: mark orphaned session %q: %w", id, err) + } + attemptResult.Orphaned = append(attemptResult.Orphaned, id) } - result.Orphaned = append(result.Orphaned, id) - } - if err := tx.Commit(); err != nil { - return store.ReconcileResult{}, fmt.Errorf("store: commit session reconcile transaction: %w", err) + result = attemptResult + return nil + }) + if err != nil { + return store.ReconcileResult{}, err } - return result, nil } diff --git a/internal/store/globaldb/global_db_session_ids.go b/internal/store/globaldb/global_db_session_ids.go index 87f19d9c0..db319a4de 100644 --- a/internal/store/globaldb/global_db_session_ids.go +++ b/internal/store/globaldb/global_db_session_ids.go @@ -2,7 +2,6 @@ package globaldb import ( "context" - "database/sql" "fmt" "github.com/compozy/agh/internal/store/globaldb/sqlcgen" @@ -10,9 +9,9 @@ import ( func (g *SessionRepo) loadSessionIDs( ctx context.Context, - tx *sql.Tx, + exec globalSQLExecutor, ) (ids map[string]struct{}, err error) { - rows, err := sqlcgen.New(tx).ListSessionIDs(ctx) + rows, err := sqlcgen.New(exec).ListSessionIDs(ctx) if err != nil { return nil, fmt.Errorf("store: query existing session ids: %w", err) } diff --git a/internal/testutil/acpmock/testdata/agent_roles_fixture.json b/internal/testutil/acpmock/testdata/agent_roles_fixture.json new file mode 100644 index 000000000..26a38003f --- /dev/null +++ b/internal/testutil/acpmock/testdata/agent_roles_fixture.json @@ -0,0 +1,71 @@ +{ + "version": 2, + "agents": [ + { + "name": "role-agent", + "provider": "acpmock", + "permissions": "approve-all", + "prompt": "Exercise configurable AGH background roles.", + "config_options": [ + { + "id": "model", + "name": "Model", + "current": "routed-dream-model", + "values": [ + { + "value": "routed-dream-model", + "label": "Routed dream model" + }, + { + "value": "extractor-model-v1", + "label": "Extractor model v1" + }, + { + "value": "extractor-model-v2", + "label": "Extractor model v2" + } + ] + } + ], + "turns": [ + { + "name": "role-prompt", + "match": { + "turn_source": "user" + }, + "steps": [ + { + "kind": "assistant", + "text": "role prompt acknowledged" + } + ] + }, + { + "name": "memory-extractor-live-apply-parent", + "match": { + "turn_source": "user", + "user_text": "Record the extractor routing decision" + }, + "steps": [ + { + "kind": "assistant", + "text": "Recorded the extractor routing decision." + } + ] + }, + { + "name": "memory-extractor-live-apply-child", + "match": { + "turn_source": "synthetic" + }, + "steps": [ + { + "kind": "assistant", + "text": "{\"type\":\"project\",\"scope\":\"workspace\",\"content\":\"The memory extractor uses the live-applied role model.\",\"evidence\":\"The root turn recorded the extractor routing decision.\",\"entity\":\"memory extractor\",\"attribute\":\"model\"}" + } + ] + } + ] + } + ] +} diff --git a/internal/testutil/acpmock/testdata/auto_title_fixture.json b/internal/testutil/acpmock/testdata/auto_title_fixture.json index d8919eec8..6d07426c4 100644 --- a/internal/testutil/acpmock/testdata/auto_title_fixture.json +++ b/internal/testutil/acpmock/testdata/auto_title_fixture.json @@ -44,6 +44,19 @@ "text": "Checkout Retry Fencing" } ] + }, + { + "name": "post-acceptance-parent-response", + "match": { + "turn_source": "user", + "user_text": "Implement post-acceptance fencing" + }, + "steps": [ + { + "kind": "assistant", + "text": "Implemented post-acceptance fencing." + } + ] } ] } diff --git a/internal/testutil/e2e/config_seed.go b/internal/testutil/e2e/config_seed.go index 26bcdd7af..0bc351b31 100644 --- a/internal/testutil/e2e/config_seed.go +++ b/internal/testutil/e2e/config_seed.go @@ -61,6 +61,8 @@ type configSeedFile struct { Defaults *configSeedDefaultsSection `toml:"defaults,omitempty"` Permissions *configSeedPermissionsSection `toml:"permissions,omitempty"` Session *aghconfig.SessionConfig `toml:"session,omitempty"` + Roles *aghconfig.RolesConfig `toml:"roles,omitempty"` + Memory *aghconfig.MemoryConfig `toml:"memory,omitempty"` Network *aghconfig.NetworkConfig `toml:"network,omitempty"` Marketplace *aghconfig.MarketplaceRuntimeConfig `toml:"marketplace,omitempty"` Extensions *configSeedExtensionsSection `toml:"extensions,omitempty"` @@ -171,6 +173,8 @@ func writeSeedConfigFile(homePaths aghconfig.HomePaths, cfg *aghconfig.Config) e Sandbox: cfg.Defaults.Sandbox, }, Session: cloneSessionConfig(cfg.Session), + Roles: cloneRolesConfig(&cfg.Roles), + Memory: cloneMemoryConfig(&cfg.Memory), Network: &cfg.Network, Marketplace: &aghconfig.MarketplaceRuntimeConfig{ Catalog: cfg.Marketplace.Catalog, @@ -211,6 +215,17 @@ func cloneSessionConfig(cfg aghconfig.SessionConfig) *aghconfig.SessionConfig { return &cloned } +func cloneRolesConfig(cfg *aghconfig.RolesConfig) *aghconfig.RolesConfig { + cloned := aghconfig.CloneRolesConfig(cfg) + return &cloned +} + +func cloneMemoryConfig(cfg *aghconfig.MemoryConfig) *aghconfig.MemoryConfig { + cloned := *cfg + cloned.Controller.Policy.AllowOrigins = append([]string(nil), cfg.Controller.Policy.AllowOrigins...) + return &cloned +} + // SeedWorkspace creates an isolated workspace root and any requested files. func SeedWorkspace(t testing.TB, opts WorkspaceSeedOptions) string { t.Helper() diff --git a/internal/testutil/e2e/config_seed_test.go b/internal/testutil/e2e/config_seed_test.go index 972cff20a..780956a47 100644 --- a/internal/testutil/e2e/config_seed_test.go +++ b/internal/testutil/e2e/config_seed_test.go @@ -92,6 +92,66 @@ func TestSeedConfigPersistsNetworkOverlay(t *testing.T) { } } +func TestSeedConfigPersistsRolesOverlay(t *testing.T) { + t.Parallel() + + t.Run("Should persist role mutations in seeded config", func(t *testing.T) { + t.Parallel() + runSeedConfigPersistsRolesOverlay(t) + }) +} + +func runSeedConfigPersistsRolesOverlay(t *testing.T) { + t.Helper() + + homePaths := NewHomePaths(t) + SeedConfig(t, homePaths, ConfigSeedOptions{ + Mutate: func(cfg *aghconfig.Config) { + cfg.Roles.Dream.Model = "routed-dream-model" + cfg.Roles.AutoTitle.Enabled = false + }, + }) + + loaded, err := aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome() error = %v", err) + } + if loaded.Roles.Dream.Model != "routed-dream-model" || loaded.Roles.AutoTitle.Enabled { + t.Fatalf("LoadForHome().Roles = %#v, want routed dream and disabled auto-title", loaded.Roles) + } +} + +func TestSeedConfigPersistsMemoryOverlay(t *testing.T) { + t.Parallel() + + t.Run("Should persist memory mutations in seeded config", func(t *testing.T) { + t.Parallel() + + homePaths := NewHomePaths(t) + SeedConfig(t, homePaths, ConfigSeedOptions{ + Mutate: func(cfg *aghconfig.Config) { + cfg.Memory.Dream.MinSessions = 1 + cfg.Memory.Dream.Gates.MinUnpromoted = 1 + cfg.Memory.Dream.Gates.MinRecallCount = 1 + }, + }) + + loaded, err := aghconfig.LoadForHome(homePaths) + if err != nil { + t.Fatalf("LoadForHome() error = %v", err) + } + if got, want := loaded.Memory.Dream.MinSessions, 1; got != want { + t.Fatalf("loaded.Memory.Dream.MinSessions = %d, want %d", got, want) + } + if got, want := loaded.Memory.Dream.Gates.MinUnpromoted, 1; got != want { + t.Fatalf("loaded.Memory.Dream.Gates.MinUnpromoted = %d, want %d", got, want) + } + if got, want := loaded.Memory.Dream.Gates.MinRecallCount, 1; got != want { + t.Fatalf("loaded.Memory.Dream.Gates.MinRecallCount = %d, want %d", got, want) + } + }) +} + func TestSeedConfigPersistsMarketplaceCatalogOverlay(t *testing.T) { t.Run("Should persist the configured catalog source for a real daemon", func(t *testing.T) { t.Parallel() diff --git a/internal/testutil/e2e/runtime_harness_integration_test.go b/internal/testutil/e2e/runtime_harness_integration_test.go index 27eed4164..e82f5b622 100644 --- a/internal/testutil/e2e/runtime_harness_integration_test.go +++ b/internal/testutil/e2e/runtime_harness_integration_test.go @@ -380,6 +380,10 @@ func TestStartRuntimeHarnessCapturesTranscriptAndEventsArtifacts(t *testing.T) { if err != nil { t.Fatalf("CreateSession() error = %v", err) } + created, err = harness.WaitForSessionActive(ctx, created.ID) + if err != nil { + t.Fatalf("WaitForSessionActive(%q) error = %v", created.ID, err) + } stream, err := harness.PromptSession(ctx, created.ID, "hello harness") if err != nil { diff --git a/internal/testutil/e2e/runtime_harness_sessions.go b/internal/testutil/e2e/runtime_harness_sessions.go index bbfcf7ed8..ce7e5e5f9 100644 --- a/internal/testutil/e2e/runtime_harness_sessions.go +++ b/internal/testutil/e2e/runtime_harness_sessions.go @@ -15,8 +15,10 @@ import ( "path/filepath" "strings" + "time" aghcontract "github.com/compozy/agh/internal/api/contract" + sessionpkg "github.com/compozy/agh/internal/session" ) // RuntimeManifestPath returns the stable runtime-manifest path under the harness artifact root. @@ -229,6 +231,54 @@ func (h *RuntimeHarness) GetSession( return response.Session, nil } +// WaitForSessionActive waits for asynchronous session startup to become prompt-ready. +func (h *RuntimeHarness) WaitForSessionActive( + ctx context.Context, + sessionID string, +) (aghcontract.SessionPayload, error) { + if ctx == nil { + return aghcontract.SessionPayload{}, errors.New("runtime harness session wait context is required") + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return aghcontract.SessionPayload{}, errors.New("runtime harness session wait ID is required") + } + + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + var ( + current aghcontract.SessionPayload + lastErr error + ) + for { + resolved, err := h.GetSession(ctx, sessionID) + if err == nil { + current = resolved + switch current.State { + case sessionpkg.StateActive: + return current, nil + case sessionpkg.StateStopped: + return current, fmt.Errorf( + "runtime harness session %q startup stopped: failure=%#v", + sessionID, + current.Failure, + ) + } + } else { + lastErr = err + } + + select { + case <-ctx.Done(): + return current, errors.Join( + fmt.Errorf("runtime harness wait for session %q active: %w; last=%#v", sessionID, ctx.Err(), current), + lastErr, + ) + case <-ticker.C: + } + } +} + // StopSession stops one session through the operator surface. func (h *RuntimeHarness) StopSession(ctx context.Context, sessionID string) (err error) { path, err := h.sessionScopedAPIPath(sessionID, "/stop") diff --git a/internal/tools/errors.go b/internal/tools/errors.go index 673af6f11..12b2266d1 100644 --- a/internal/tools/errors.go +++ b/internal/tools/errors.go @@ -64,6 +64,8 @@ const ( ErrorCodeModelNotFound ErrorCode = "model_not_found" // ErrorCodeReasoningEffortUnsupported reports an unavailable model effort. ErrorCodeReasoningEffortUnsupported ErrorCode = "reasoning_effort_unsupported" + // ErrorCodeAgentNameReserved reports an authored agent identity reserved for daemon roles. + ErrorCodeAgentNameReserved ErrorCode = "agent_name_reserved" ) // ToolError carries stable reason codes with a wrapped cause. diff --git a/internal/workspace/clone.go b/internal/workspace/clone.go index e3e80f47a..a52aec897 100644 --- a/internal/workspace/clone.go +++ b/internal/workspace/clone.go @@ -64,6 +64,8 @@ func cloneConfig(src *aghconfig.Config) aghconfig.Config { Agents: src.Agents, Limits: src.Limits, Session: src.Session, + Roles: aghconfig.CloneRolesConfig(&src.Roles), + RoleSources: aghconfig.CloneRoleFieldSources(src.RoleSources), Permissions: src.Permissions, MCPServers: cloneMCPServers(src.MCPServers), Providers: cloneProviders(src.Providers), diff --git a/internal/workspace/resolver.go b/internal/workspace/resolver.go index 0e21b0f25..353d992a4 100644 --- a/internal/workspace/resolver.go +++ b/internal/workspace/resolver.go @@ -270,6 +270,16 @@ func (r *Resolver) Invalidate(workspaceID string) { r.mu.Unlock() } +// InvalidateAll clears every cached runtime snapshot after a global dependency changes. +func (r *Resolver) InvalidateAll() { + if r == nil { + return + } + r.mu.Lock() + clear(r.cache) + r.mu.Unlock() +} + func (r *Resolver) notifyChangeHook(ctx context.Context, operation string, workspaceID string) error { if r == nil || r.changeHook == nil { return nil diff --git a/internal/workspace/resolver_test.go b/internal/workspace/resolver_test.go index 0b7035c7e..a16bb73fa 100644 --- a/internal/workspace/resolver_test.go +++ b/internal/workspace/resolver_test.go @@ -530,125 +530,139 @@ func TestResolveCacheHitInvalidateAndEviction(t *testing.T) { t.Parallel() t.Run("Should isolate cached config and invalidate every watched input", func(t *testing.T) { t.Parallel() + runResolveCacheHitInvalidateAndEviction(t) + }) +} - ctx := context.Background() - homePaths := newTestHomePaths(t) - root := t.TempDir() - workspaceConfig := filepath.Join(root, aghconfig.DirName, aghconfig.ConfigName) - agentFile := filepath.Join(root, aghconfig.DirName, aghconfig.AgentsDirName, "coder", agentDefinitionFile) - skillsDir := filepath.Join(root, aghconfig.DirName, aghconfig.SkillsDirName) - skillOne := filepath.Join(skillsDir, "alpha") - skillTwo := filepath.Join(skillsDir, "beta") +func runResolveCacheHitInvalidateAndEviction(t *testing.T) { + t.Helper() - writeFile(t, workspaceConfig, "[http]\nport = 4242\n") - writeAgentDef(t, agentFile, "coder", "v1") - writeSkill(t, skillOne) + ctx := context.Background() + homePaths := newTestHomePaths(t) + root := t.TempDir() + workspaceConfig := filepath.Join(root, aghconfig.DirName, aghconfig.ConfigName) + agentFile := filepath.Join(root, aghconfig.DirName, aghconfig.AgentsDirName, "coder", agentDefinitionFile) + skillsDir := filepath.Join(root, aghconfig.DirName, aghconfig.SkillsDirName) + skillOne := filepath.Join(skillsDir, "alpha") + skillTwo := filepath.Join(skillsDir, "beta") - ws := Workspace{ID: "ws_cache", RootDir: root, Name: "repo"} - store := newMockWorkspaceStore(ws) - loadedConfig := validConfig(homePaths) - loadedConfig.WindowManager.HistoryLimit = 77 - loadedConfig.WindowManager.Snap.RepeatRatios = []float64{0.5, 0.75, 0.25} - loadedConfig.WindowManager.Shortcuts = map[string]string{"window.focus.left": "alt+KeyH"} - loader := &countingConfigLoader{cfg: loadedConfig} - currentTime := time.Unix(1_700_000_000, 0).UTC() + writeFile(t, workspaceConfig, "[http]\nport = 4242\n") + writeAgentDef(t, agentFile, "coder", "v1") + writeSkill(t, skillOne) - resolver := newTestResolver(t, store, - WithHomePaths(homePaths), - WithConfigLoader(loader.Load), - withNow(func() time.Time { return currentTime }), - WithCacheTTL(10*time.Minute), - ) + ws := Workspace{ID: "ws_cache", RootDir: root, Name: "repo"} + store := newMockWorkspaceStore(ws) + loadedConfig := validConfig(homePaths) + loadedConfig.WindowManager.HistoryLimit = 77 + loadedConfig.WindowManager.Snap.RepeatRatios = []float64{0.5, 0.75, 0.25} + loadedConfig.WindowManager.Shortcuts = map[string]string{"window.focus.left": "alt+KeyH"} + loader := &countingConfigLoader{cfg: loadedConfig} + currentTime := time.Unix(1_700_000_000, 0).UTC() - first, err := resolver.Resolve(ctx, ws.ID) - if err != nil { - t.Fatalf("Resolve(first) error = %v", err) - } - if got := loader.Calls(); got != 1 { - t.Fatalf("config loader calls after first resolve = %d, want 1", got) - } - if first.Config.WindowManager.HistoryLimit != 77 || - first.Config.WindowManager.Shortcuts["window.focus.left"] != "alt+KeyH" { - t.Fatalf("Resolve(first) WindowManager = %#v, want loaded config", first.Config.WindowManager) - } - first.Config.WindowManager.Snap.RepeatRatios[0] = 0.4 - first.Config.WindowManager.Shortcuts["window.focus.left"] = "meta+KeyH" + resolver := newTestResolver(t, store, + WithHomePaths(homePaths), + WithConfigLoader(loader.Load), + withNow(func() time.Time { return currentTime }), + WithCacheTTL(10*time.Minute), + ) - currentTime = currentTime.Add(1 * time.Minute) - second, err := resolver.Resolve(ctx, ws.ID) - if err != nil { - t.Fatalf("Resolve(second) error = %v", err) - } - if got := loader.Calls(); got != 1 { - t.Fatalf("config loader calls after cache hit = %d, want 1", got) - } - if !second.ResolvedAt.Equal(first.ResolvedAt) { - t.Fatalf("ResolvedAt on cache hit = %v, want %v", second.ResolvedAt, first.ResolvedAt) - } - if got := second.Config.WindowManager.Snap.RepeatRatios; len(got) != 3 || got[0] != 0.5 { - t.Fatalf("Resolve(cache hit) repeat ratios = %#v, want isolated loaded ratios", got) - } - if got := second.Config.WindowManager.Shortcuts["window.focus.left"]; got != "alt+KeyH" { - t.Fatalf("Resolve(cache hit) shortcut = %q, want isolated alt+KeyH", got) - } + first, err := resolver.Resolve(ctx, ws.ID) + if err != nil { + t.Fatalf("Resolve(first) error = %v", err) + } + if got := loader.Calls(); got != 1 { + t.Fatalf("config loader calls after first resolve = %d, want 1", got) + } + if first.Config.WindowManager.HistoryLimit != 77 || + first.Config.WindowManager.Shortcuts["window.focus.left"] != "alt+KeyH" { + t.Fatalf("Resolve(first) WindowManager = %#v, want loaded config", first.Config.WindowManager) + } + first.Config.WindowManager.Snap.RepeatRatios[0] = 0.4 + first.Config.WindowManager.Shortcuts["window.focus.left"] = "meta+KeyH" - modTime := time.Unix(1_700_000_100, 0).UTC() - writeFile(t, workspaceConfig, "[http]\nport = 4343\n") - touchPath(t, workspaceConfig, modTime) - currentTime = currentTime.Add(1 * time.Minute) - if _, err := resolver.Resolve(ctx, ws.ID); err != nil { - t.Fatalf("Resolve(after config change) error = %v", err) - } - if got := loader.Calls(); got != 2 { - t.Fatalf("config loader calls after config invalidation = %d, want 2", got) - } + currentTime = currentTime.Add(1 * time.Minute) + second, err := resolver.Resolve(ctx, ws.ID) + if err != nil { + t.Fatalf("Resolve(second) error = %v", err) + } + if got := loader.Calls(); got != 1 { + t.Fatalf("config loader calls after cache hit = %d, want 1", got) + } + if !second.ResolvedAt.Equal(first.ResolvedAt) { + t.Fatalf("ResolvedAt on cache hit = %v, want %v", second.ResolvedAt, first.ResolvedAt) + } + if got := second.Config.WindowManager.Snap.RepeatRatios; len(got) != 3 || got[0] != 0.5 { + t.Fatalf("Resolve(cache hit) repeat ratios = %#v, want isolated loaded ratios", got) + } + if got := second.Config.WindowManager.Shortcuts["window.focus.left"]; got != "alt+KeyH" { + t.Fatalf("Resolve(cache hit) shortcut = %q, want isolated alt+KeyH", got) + } - modTime = modTime.Add(1 * time.Minute) - writeAgentDef(t, agentFile, "coder", "v2") - touchPath(t, agentFile, modTime) - currentTime = currentTime.Add(1 * time.Minute) - afterAgent, err := resolver.Resolve(ctx, ws.ID) - if err != nil { - t.Fatalf("Resolve(after agent change) error = %v", err) - } - if got := loader.Calls(); got != 3 { - t.Fatalf("config loader calls after agent invalidation = %d, want 3", got) - } - if got := agentModel(afterAgent.Agents, "coder"); got != "v2" { - t.Fatalf("agent model after agent invalidation = %q, want %q", got, "v2") - } + modTime := time.Unix(1_700_000_100, 0).UTC() + writeFile(t, workspaceConfig, "[http]\nport = 4343\n") + touchPath(t, workspaceConfig, modTime) + currentTime = currentTime.Add(1 * time.Minute) + if _, err := resolver.Resolve(ctx, ws.ID); err != nil { + t.Fatalf("Resolve(after config change) error = %v", err) + } + if got := loader.Calls(); got != 2 { + t.Fatalf("config loader calls after config invalidation = %d, want 2", got) + } - writeSkill(t, skillTwo) - touchPath(t, skillsDir, modTime.Add(1*time.Minute)) - currentTime = currentTime.Add(1 * time.Minute) - afterSkill, err := resolver.Resolve(ctx, ws.ID) - if err != nil { - t.Fatalf("Resolve(after skill change) error = %v", err) - } - if got := loader.Calls(); got != 4 { - t.Fatalf("config loader calls after skill invalidation = %d, want 4", got) - } - if got := skillNames(afterSkill.Skills); !slices.Equal(got, []string{"alpha", "beta"}) { - t.Fatalf("skill names after skill invalidation = %#v, want %#v", got, []string{"alpha", "beta"}) - } + modTime = modTime.Add(1 * time.Minute) + writeAgentDef(t, agentFile, "coder", "v2") + touchPath(t, agentFile, modTime) + currentTime = currentTime.Add(1 * time.Minute) + afterAgent, err := resolver.Resolve(ctx, ws.ID) + if err != nil { + t.Fatalf("Resolve(after agent change) error = %v", err) + } + if got := loader.Calls(); got != 3 { + t.Fatalf("config loader calls after agent invalidation = %d, want 3", got) + } + if got := agentModel(afterAgent.Agents, "coder"); got != "v2" { + t.Fatalf("agent model after agent invalidation = %q, want %q", got, "v2") + } - resolver.Invalidate(ws.ID) - currentTime = currentTime.Add(1 * time.Minute) - if _, err := resolver.Resolve(ctx, ws.ID); err != nil { - t.Fatalf("Resolve(after invalidate) error = %v", err) - } - if got := loader.Calls(); got != 5 { - t.Fatalf("config loader calls after Invalidate = %d, want 5", got) - } + writeSkill(t, skillTwo) + touchPath(t, skillsDir, modTime.Add(1*time.Minute)) + currentTime = currentTime.Add(1 * time.Minute) + afterSkill, err := resolver.Resolve(ctx, ws.ID) + if err != nil { + t.Fatalf("Resolve(after skill change) error = %v", err) + } + if got := loader.Calls(); got != 4 { + t.Fatalf("config loader calls after skill invalidation = %d, want 4", got) + } + if got := skillNames(afterSkill.Skills); !slices.Equal(got, []string{"alpha", "beta"}) { + t.Fatalf("skill names after skill invalidation = %#v, want %#v", got, []string{"alpha", "beta"}) + } - currentTime = currentTime.Add(11 * time.Minute) - if _, err := resolver.Resolve(ctx, ws.ID); err != nil { - t.Fatalf("Resolve(after TTL expiry) error = %v", err) - } - if got := loader.Calls(); got != 6 { - t.Fatalf("config loader calls after TTL eviction = %d, want 6", got) - } - }) + resolver.Invalidate(ws.ID) + currentTime = currentTime.Add(1 * time.Minute) + if _, err := resolver.Resolve(ctx, ws.ID); err != nil { + t.Fatalf("Resolve(after invalidate) error = %v", err) + } + if got := loader.Calls(); got != 5 { + t.Fatalf("config loader calls after Invalidate = %d, want 5", got) + } + + resolver.InvalidateAll() + currentTime = currentTime.Add(1 * time.Minute) + if _, err := resolver.Resolve(ctx, ws.ID); err != nil { + t.Fatalf("Resolve(after InvalidateAll) error = %v", err) + } + if got := loader.Calls(); got != 6 { + t.Fatalf("config loader calls after InvalidateAll = %d, want 6", got) + } + + currentTime = currentTime.Add(11 * time.Minute) + if _, err := resolver.Resolve(ctx, ws.ID); err != nil { + t.Fatalf("Resolve(after TTL expiry) error = %v", err) + } + if got := loader.Calls(); got != 7 { + t.Fatalf("config loader calls after TTL eviction = %d, want 7", got) + } } func TestResolveMissingRootReturnsErrWorkspaceRootMissing(t *testing.T) { @@ -955,7 +969,6 @@ func TestResolveRecordsMalformedAgentDiagnostics(t *testing.T) { filepath.Join(root, aghconfig.DirName, aghconfig.AgentsDirName, "embedded-tab", agentDefinitionFile), "---\nna\tme: broken\n---\nPrompt.", ) - store := newMockWorkspaceStore(Workspace{ID: "ws_agents", RootDir: root, Name: "repo"}) loader := &countingConfigLoader{cfg: validConfig(homePaths)} resolver := newTestResolver(t, store, @@ -991,6 +1004,64 @@ func TestResolveRecordsMalformedAgentDiagnostics(t *testing.T) { } } +func TestResolveRejectsReservedAgentIdentities(t *testing.T) { + t.Parallel() + + homePaths := newTestHomePaths(t) + root := t.TempDir() + writeAgentDef( + t, + filepath.Join( + root, + aghconfig.DirName, + aghconfig.AgentsDirName, + aghconfig.BuiltinCoordinatorAgentName, + agentDefinitionFile, + ), + aghconfig.BuiltinCoordinatorAgentName, + "shadowed-coordinator", + ) + writeAgentDef( + t, + filepath.Join(homePaths.AgentsDir, aghconfig.BuiltinDreamingCuratorAgentName, agentDefinitionFile), + aghconfig.BuiltinDreamingCuratorAgentName, + "shadowed-curator", + ) + writeAgentDef( + t, + filepath.Join(root, aghconfig.DirName, aghconfig.AgentsDirName, "alias", agentDefinitionFile), + aghconfig.BuiltinCoordinatorAgentName, + "aliased-coordinator", + ) + + store := newMockWorkspaceStore(Workspace{ID: "ws_reserved_agents", RootDir: root, Name: "repo"}) + loader := &countingConfigLoader{cfg: validConfig(homePaths)} + resolver := newTestResolver( + t, + store, + WithHomePaths(homePaths), + WithConfigLoader(loader.Load), + ) + resolved, err := resolver.Resolve(context.Background(), "ws_reserved_agents") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got := agentModel(resolved.Agents, aghconfig.BuiltinCoordinatorAgentName); got != "" { + t.Fatalf("reserved coordinator model = %q, want excluded", got) + } + if got := agentModel(resolved.Agents, aghconfig.BuiltinDreamingCuratorAgentName); got != "" { + t.Fatalf("reserved dreaming-curator model = %q, want excluded", got) + } + if got, want := len(resolved.AgentDiagnostics), 3; got != want { + t.Fatalf("len(AgentDiagnostics) = %d, want %d: %#v", got, want, resolved.AgentDiagnostics) + } + for _, diagnostic := range resolved.AgentDiagnostics { + if diagnostic.ErrorKind != reservedAgentDiagnosticKind { + t.Fatalf("reserved agent diagnostic = %#v, want %q", diagnostic, reservedAgentDiagnosticKind) + } + } +} + func TestResolveConfigFromRootOnly(t *testing.T) { ctx := context.Background() homePaths := newTestHomePaths(t) @@ -1633,13 +1704,16 @@ func TestCloneConfigProducesDeepCopy(t *testing.T) { Timeout: time.Minute, }, }, - Autonomy: aghconfig.AutonomyConfig{ - Coordinator: aghconfig.CoordinatorConfig{ - Enabled: true, - AgentName: "coordinator", - Provider: "codex", - Model: "gpt-4o", - DefaultTTL: 45 * time.Minute, + Roles: aghconfig.RolesConfig{ + Coordinator: aghconfig.CoordinatorRoleConfig{ + RoleConfig: aghconfig.RoleConfig{ + Enabled: true, + Agent: "coordinator", + Provider: "codex", + Model: "gpt-4o", + FallbackChain: []aghconfig.RoleFallback{{Provider: "claude", Model: "sonnet"}}, + }, + TTL: 45 * time.Minute, MaxChildren: 5, MaxActiveSessionsPerWorkspace: 5, }, @@ -1690,12 +1764,18 @@ func TestCloneConfigProducesDeepCopy(t *testing.T) { } cloned := cloneConfig(&original) + if !cloned.Roles.Coordinator.Enabled || cloned.Roles.Coordinator.Agent != "coordinator" || + cloned.Roles.Coordinator.TTL != 45*time.Minute || + !slices.Equal(cloned.Roles.Coordinator.FallbackChain, original.Roles.Coordinator.FallbackChain) { + t.Fatalf("cloned Roles.Coordinator = %#v, want preserved role config", cloned.Roles.Coordinator) + } cloned.Agents.Soul.MaxBodyBytes = 8192 cloned.Agents.Heartbeat.MaxBodyBytes = 8192 cloned.Session.Limits.Timeout = 2 * time.Minute - cloned.Autonomy.Coordinator.Enabled = false - cloned.Autonomy.Coordinator.AgentName = "mutated-coordinator" - cloned.Autonomy.Coordinator.DefaultTTL = 2 * time.Hour + cloned.Roles.Coordinator.Enabled = false + cloned.Roles.Coordinator.Agent = "mutated-coordinator" + cloned.Roles.Coordinator.TTL = 2 * time.Hour + cloned.Roles.Coordinator.FallbackChain[0].Model = "mutated-model" cloned.Providers["claude"] = aghconfig.ProviderConfig{} cloned.Skills.DisabledSkills[0] = "beta" cloned.Hooks.Declarations[0].Args[0] = "two" @@ -1712,14 +1792,17 @@ func TestCloneConfigProducesDeepCopy(t *testing.T) { if got, want := original.Session.Limits.Timeout, time.Minute; got != want { t.Fatalf("original Session.Limits.Timeout = %s, want %s", got, want) } - if got, want := original.Autonomy.Coordinator.DefaultTTL, 45*time.Minute; got != want { - t.Fatalf("original Autonomy.Coordinator.DefaultTTL = %s, want %s", got, want) + if got, want := original.Roles.Coordinator.TTL, 45*time.Minute; got != want { + t.Fatalf("original Roles.Coordinator.TTL = %s, want %s", got, want) + } + if !original.Roles.Coordinator.Enabled { + t.Fatal("original Roles.Coordinator.Enabled = false, want true") } - if !original.Autonomy.Coordinator.Enabled { - t.Fatal("original Autonomy.Coordinator.Enabled = false, want true") + if got, want := original.Roles.Coordinator.Agent, "coordinator"; got != want { + t.Fatalf("original Roles.Coordinator.Agent = %q, want %q", got, want) } - if got, want := original.Autonomy.Coordinator.AgentName, "coordinator"; got != want { - t.Fatalf("original Autonomy.Coordinator.AgentName = %q, want %q", got, want) + if got, want := original.Roles.Coordinator.FallbackChain[0].Model, "sonnet"; got != want { + t.Fatalf("original.Roles.Coordinator.FallbackChain[0].Model = %q, want %q", got, want) } provider := original.Providers["claude"] if provider.Command != "claude" || provider.MCPServers[0].Env["TOKEN"] != "one" { diff --git a/internal/workspace/scanner.go b/internal/workspace/scanner.go index 869dff640..cd3e6a54d 100644 --- a/internal/workspace/scanner.go +++ b/internal/workspace/scanner.go @@ -120,6 +120,10 @@ func scanAgentSource( if _, ok := snapshots[agentPath]; !ok { continue } + if aghconfig.IsReservedAgentName(entry.Name()) { + *dst = append(*dst, agentCandidate{path: agentPath}) + continue + } if err := scanAgentCapabilityCatalog(agentDir, snapshots); err != nil { return err } @@ -207,11 +211,21 @@ func loadAgents(ctx context.Context, candidates []agentCandidate) ([]aghconfig.A return nil, nil, err } + candidateName := filepath.Base(filepath.Dir(candidate.path)) + if aghconfig.IsReservedAgentName(candidateName) { + diagnostics = append(diagnostics, reservedAgentDiagnostic(candidate.path, candidateName)) + continue + } + agent, err := aghconfig.LoadAgentDefFile(candidate.path) if err != nil { diagnostics = append(diagnostics, agentDiagnosticFromError(candidate.path, err)) continue } + if aghconfig.IsReservedAgentName(agent.Name) { + diagnostics = append(diagnostics, reservedAgentDiagnostic(candidate.path, agent.Name)) + continue + } if _, ok := seen[agent.Name]; ok { continue @@ -224,6 +238,17 @@ func loadAgents(ctx context.Context, candidates []agentCandidate) ([]aghconfig.A return agents, diagnostics, nil } +const reservedAgentDiagnosticKind = "agent.name_reserved" + +func reservedAgentDiagnostic(path string, name string) AgentDiagnostic { + return AgentDiagnostic{ + Name: name, + Path: filepath.Clean(path), + ErrorKind: reservedAgentDiagnosticKind, + Message: fmt.Sprintf("workspace: skipped reserved agent definition %q", path), + } +} + func agentDiagnosticFromError(path string, err error) AgentDiagnostic { return AgentDiagnostic{ Name: filepath.Base(filepath.Dir(path)), diff --git a/openapi/agh.json b/openapi/agh.json index 515d044df..93d8cca10 100644 --- a/openapi/agh.json +++ b/openapi/agh.json @@ -30636,6 +30636,70 @@ }, "description": "Workspace root missing" }, + "422": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Agent name is reserved" + }, "500": { "content": { "application/json": { @@ -32883,7 +32947,7 @@ }, "description": "Workspace root missing" }, - "500": { + "422": { "content": { "application/json": { "schema": { @@ -32945,9 +33009,9 @@ } } }, - "description": "Internal server error" + "description": "Agent name is reserved" }, - "503": { + "500": { "content": { "application/json": { "schema": { @@ -33009,391 +33073,455 @@ } } }, - "description": "Workspace resolver or definition sync unavailable" - } - }, - "summary": "Replace one effective AGENT.md definition with digest CAS", - "tags": [ - "agents" - ], - "x-agh-transports": [ - "http", - "uds" - ] - } - }, - "/api/agents/{name}/duplicate": { - "post": { - "operationId": "duplicateAgent", - "parameters": [ - { - "description": "Source agent name", - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "name": { - "type": "string" - }, - "overrides": { - "nullable": true, - "properties": { - "category_path": { - "items": { - "type": "string" - }, - "type": "array" - }, - "command": { - "type": "string" - }, - "deny_tools": { - "items": { - "type": "string" - }, - "type": "array" - }, - "model": { - "type": "string" - }, - "permissions": { - "enum": [ - "deny-all", - "approve-reads", - "approve-all" - ], - "type": "string" - }, - "prompt": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "reasoning_effort": { - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max" - ], - "type": "string" - }, - "skills": { - "nullable": true, - "properties": { - "disabled": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "tools": { - "items": { - "type": "string" - }, - "type": "array" - }, - "toolsets": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "scope": { - "enum": [ - "workspace", - "global" - ], - "type": "string" - }, - "workspace": { - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - } - } - }, - "description": "JSON request body", - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "agent": { - "properties": { - "category_path": { - "items": { - "type": "string" - }, - "type": "array" - }, - "command": { - "type": "string" - }, - "definition_digest": { - "type": "string" - }, - "deny_tools": { - "items": { - "type": "string" - }, - "type": "array" - }, - "diagnostics": { - "items": { - "properties": { - "error_kind": { - "type": "string" - }, - "message": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": [ - "error_kind", - "message", - "path" - ], - "type": "object" - }, - "type": "array" - }, - "effective_runtime": { - "nullable": true, - "properties": { - "model": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "reasoning_effort": { - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max" - ], - "type": "string" - }, - "sources": { - "properties": { - "model": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "reasoning_effort": { - "type": "string" - } - }, - "required": [ - "provider" - ], - "type": "object" - } - }, - "required": [ - "provider", - "sources" - ], - "type": "object" - }, - "mcp_servers": { - "items": { - "properties": { - "args": { - "items": { - "type": "string" - }, - "type": "array" - }, - "auth": { - "nullable": true, - "properties": { - "authorization_url": { - "type": "string" - }, - "client_id": { - "type": "string" - }, - "client_secret_configured": { - "type": "boolean" - }, - "issuer_url": { - "type": "string" - }, - "metadata_url": { - "type": "string" - }, - "revocation_url": { - "type": "string" - }, - "scopes": { - "items": { - "type": "string" - }, - "type": "array" - }, - "token_url": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "client_secret_configured" - ], - "type": "object" - }, - "command": { - "type": "string" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "name": { - "type": "string" - }, - "secret_env": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "transport": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" - }, - "type": "array" - }, - "model": { - "type": "string" - }, - "name": { - "type": "string" - }, - "origin": { - "enum": [ - "global", - "workspace" - ], - "type": "string" - }, - "permissions": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "reasoning_effort": { - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max" - ], - "type": "string" - }, - "skills": { - "nullable": true, - "properties": { - "disabled": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "tools": { - "items": { - "type": "string" - }, - "type": "array" - }, - "toolsets": { - "items": { - "type": "string" - }, - "type": "array" - }, - "workspace_id": { - "type": "string" - } - }, - "required": [ - "definition_digest", - "name", - "origin", - "prompt", - "provider" - ], - "type": "object" - } - }, - "required": [ - "agent" - ], - "type": "object" - } - } - }, - "description": "Created" + "description": "Internal server error" }, - "400": { + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Workspace resolver or definition sync unavailable" + } + }, + "summary": "Replace one effective AGENT.md definition with digest CAS", + "tags": [ + "agents" + ], + "x-agh-transports": [ + "http", + "uds" + ] + } + }, + "/api/agents/{name}/duplicate": { + "post": { + "operationId": "duplicateAgent", + "parameters": [ + { + "description": "Source agent name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + }, + "overrides": { + "nullable": true, + "properties": { + "category_path": { + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "type": "string" + }, + "deny_tools": { + "items": { + "type": "string" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "permissions": { + "enum": [ + "deny-all", + "approve-reads", + "approve-all" + ], + "type": "string" + }, + "prompt": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "type": "string" + }, + "skills": { + "nullable": true, + "properties": { + "disabled": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "tools": { + "items": { + "type": "string" + }, + "type": "array" + }, + "toolsets": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "scope": { + "enum": [ + "workspace", + "global" + ], + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "agent": { + "properties": { + "category_path": { + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "type": "string" + }, + "definition_digest": { + "type": "string" + }, + "deny_tools": { + "items": { + "type": "string" + }, + "type": "array" + }, + "diagnostics": { + "items": { + "properties": { + "error_kind": { + "type": "string" + }, + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "error_kind", + "message", + "path" + ], + "type": "object" + }, + "type": "array" + }, + "effective_runtime": { + "nullable": true, + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "type": "string" + }, + "sources": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "type": "object" + } + }, + "required": [ + "provider", + "sources" + ], + "type": "object" + }, + "mcp_servers": { + "items": { + "properties": { + "args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "auth": { + "nullable": true, + "properties": { + "authorization_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_secret_configured": { + "type": "boolean" + }, + "issuer_url": { + "type": "string" + }, + "metadata_url": { + "type": "string" + }, + "revocation_url": { + "type": "string" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "token_url": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "client_secret_configured" + ], + "type": "object" + }, + "command": { + "type": "string" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "secret_env": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "transport": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "name": { + "type": "string" + }, + "origin": { + "enum": [ + "global", + "workspace" + ], + "type": "string" + }, + "permissions": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "type": "string" + }, + "skills": { + "nullable": true, + "properties": { + "disabled": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "tools": { + "items": { + "type": "string" + }, + "type": "array" + }, + "toolsets": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "definition_digest", + "name", + "origin", + "prompt", + "provider" + ], + "type": "object" + } + }, + "required": [ + "agent" + ], + "type": "object" + } + } + }, + "description": "Created" + }, + "400": { "content": { "application/json": { "schema": { @@ -33649,6 +33777,70 @@ }, "description": "Workspace root missing" }, + "422": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Agent name is reserved" + }, "500": { "content": { "application/json": { @@ -82176,37 +82368,6 @@ "default_op_on_fail": { "type": "string" }, - "llm": { - "properties": { - "enabled": { - "type": "boolean" - }, - "max_tokens_out": { - "type": "integer" - }, - "model": { - "type": "string" - }, - "prompt_version": { - "type": "string" - }, - "timeout": { - "type": "string" - }, - "top_k": { - "type": "integer" - } - }, - "required": [ - "enabled", - "max_tokens_out", - "model", - "prompt_version", - "timeout", - "top_k" - ], - "type": "object" - }, "max_latency": { "type": "string" }, @@ -82238,7 +82399,6 @@ }, "required": [ "default_op_on_fail", - "llm", "max_latency", "mode", "policy" @@ -82312,18 +82472,12 @@ }, "dream": { "properties": { - "agent": { - "type": "string" - }, "check_interval": { "type": "string" }, "debounce": { "type": "string" }, - "enabled": { - "type": "boolean" - }, "gates": { "properties": { "min_recall_count": { @@ -82395,10 +82549,8 @@ } }, "required": [ - "agent", "check_interval", "debounce", - "enabled", "gates", "min_hours", "min_sessions", @@ -82418,18 +82570,12 @@ "dlq_path": { "type": "string" }, - "enabled": { - "type": "boolean" - }, "inbox_path": { "type": "string" }, "mode": { "type": "string" }, - "model": { - "type": "string" - }, "queue": { "properties": { "capacity": { @@ -82455,10 +82601,8 @@ "required": [ "deadline", "dlq_path", - "enabled", "inbox_path", "mode", - "model", "queue", "sandbox_inbox_only", "throttle_turns" @@ -91422,6 +91566,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -101618,461 +101763,122 @@ ] } }, - "/api/runs/bulk/fail": { - "post": { - "operationId": "bulkForceFailTaskRuns", - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "metadata": {}, - "reason": { - "type": "string" - }, - "run_ids": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "run_ids" - ], - "type": "object" - } + "/api/roles": { + "get": { + "operationId": "listRoles", + "parameters": [ + { + "description": "Workspace id, name, or path used to resolve effective role configuration", + "in": "query", + "name": "workspace", + "schema": { + "type": "string" } - }, - "description": "JSON request body", - "required": true - }, + } + ], "responses": { "200": { "content": { "application/json": { "schema": { "properties": { - "results": { + "roles": { "items": { "properties": { - "error": { + "agent": { "nullable": true, - "properties": { - "diagnostic": { - "nullable": true, - "properties": { - "category": { - "type": "string" - }, - "code": { - "type": "string" - }, - "data_freshness": { - "type": "string" - }, - "doc_url": { - "type": "string" - }, - "evidence": { - "additionalProperties": {}, - "type": "object" - }, - "id": { - "type": "string" - }, - "message": { - "type": "string" - }, - "severity": { - "type": "string" - }, - "suggested_command": { - "type": "string" - }, - "title": { - "type": "string" - } + "type": "string" + }, + "diagnostics": { + "items": { + "properties": { + "agent": { + "type": "string" }, - "required": [ - "category", - "code", - "data_freshness", - "id", - "message", - "severity", - "title" - ], - "type": "object" + "code": { + "type": "string" + }, + "message": { + "type": "string" + } }, - "error": { - "type": "string" - } + "required": [ + "code", + "message" + ], + "type": "object" }, - "required": [ - "error" - ], - "type": "object" + "type": "array" }, - "ok": { + "enabled": { "type": "boolean" }, - "run": { - "nullable": true, - "properties": { - "attempt": { - "type": "integer" - }, - "claim_token_hash": { - "type": "string" - }, - "claimed_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "claimed_by": { - "nullable": true, - "properties": { - "kind": { - "enum": [ - "human", - "agent_session", - "automation", - "extension", - "network_peer", - "daemon" - ], - "type": "string" - }, - "ref": { - "type": "string" - } - }, - "required": [ - "kind", - "ref" - ], - "type": "object" - }, - "coordination_channel": { - "nullable": true, - "properties": { - "allowed_message_kinds": { - "items": { - "enum": [ - "status", - "request", - "reply", - "blocker", - "handoff", - "result", - "review_request" - ], - "type": "string" - }, - "type": "array" - }, - "display_name": { - "type": "string" - }, - "id": { - "type": "string" - }, - "last_activity_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "purpose": { - "type": "string" - }, - "run_id": { - "type": "string" - }, - "task_id": { - "type": "string" - }, - "workflow_id": { - "type": "string" - }, - "workspace_id": { - "type": "string" - } - }, - "required": [ - "allowed_message_kinds", - "display_name", - "id" - ], - "type": "object" - }, - "designation": { - "nullable": true, - "properties": { - "brief": { - "type": "string" - }, - "index": { - "type": "integer" - } + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" }, - "required": [ - "index" - ], - "type": "object" - }, - "designation_group_id": { - "type": "string" - }, - "ended_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "error": { - "type": "string" - }, - "failure_kind": { - "type": "string" - }, - "heartbeat_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "id": { - "type": "string" - }, - "idempotency_key": { - "type": "string" - }, - "lease_until": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "metadata": {}, - "origin": { - "properties": { - "kind": { - "enum": [ - "cli", - "web", - "uds", - "http", - "automation", - "extension", - "network", - "agent_session", - "daemon" - ], - "type": "string" - }, - "ref": { - "type": "string" - } + "provider": { + "type": "string" }, - "required": [ - "kind", - "ref" - ], - "type": "object" - }, - "previous_run_id": { - "type": "string" - }, - "queued_at": { - "format": "date-time", - "type": "string" - }, - "recovery_count": { - "type": "integer" - }, - "resolved_network_participation": { - "nullable": true, - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "mode": { - "enum": [ - "local" - ], - "type": "string" - }, - "source": { - "enum": [ - "explicit_request", - "task_profile", - "workspace_coordination", - "loop_definition", - "automation_job", - "built_in_local" - ], - "type": "string" - }, - "version": { - "enum": [ - "network-participation/v1" - ], - "type": "string" - } - }, - "required": [ - "mode", - "source", - "version" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "bounds": { - "additionalProperties": false, - "properties": { - "coalesce_window": { - "minLength": 1, - "type": "string" - }, - "max_input_tokens": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "max_output_tokens": { - "format": "int64", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "max_total_wall_time": { - "minLength": 1, - "type": "string" - }, - "max_wake_depth": { - "minimum": 1, - "type": "integer" - }, - "max_wake_wall_time": { - "minLength": 1, - "type": "string" - }, - "max_wakes": { - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "coalesce_window", - "max_input_tokens", - "max_output_tokens", - "max_total_wall_time", - "max_wake_depth", - "max_wake_wall_time", - "max_wakes" - ], - "type": "object" - }, - "channel_id": { - "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$", - "type": "string" - }, - "channel_strategy": { - "enum": [ - "named", - "run", - "loop_run" - ], - "type": "string" - }, - "mode": { - "enum": [ - "live" - ], - "type": "string" - }, - "source": { - "enum": [ - "explicit_request", - "task_profile", - "workspace_coordination", - "loop_definition", - "automation_job", - "built_in_local" - ], - "type": "string" - }, - "version": { - "enum": [ - "network-participation/v1" - ], - "type": "string" - }, - "workspace_id": { - "minLength": 1, - "type": "string" - } - }, - "required": [ - "bounds", - "channel_id", - "channel_strategy", - "mode", - "source", - "version", - "workspace_id" - ], - "type": "object" - } - ] - }, - "result": {}, - "session_id": { - "type": "string" - }, - "started_at": { - "format": "date-time", - "nullable": true, - "type": "string" - }, - "status": { - "enum": [ - "queued", - "claimed", - "starting", - "running", - "completed", - "failed", - "canceled", - "needs_attention" - ], - "type": "string" + "reasoning_effort": { + "type": "string" + } }, - "task_id": { - "type": "string" - } + "required": [ + "model", + "provider" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "nullable": true, + "type": "string" + }, + "provenance": { + "additionalProperties": { + "type": "string" }, - "required": [ - "attempt", - "id", - "origin", - "queued_at", - "recovery_count", - "status", - "task_id" - ], "type": "object" }, - "run_id": { + "provider": { + "nullable": true, + "type": "string" + }, + "reasoning_effort": { + "nullable": true, + "type": "string" + }, + "resolution_mode": { + "enum": [ + "builtin", + "catalog", + "inherit" + ], + "type": "string" + }, + "role": { + "type": "string" + }, + "timeout": { + "nullable": true, "type": "string" } }, "required": [ - "ok", - "run_id" + "agent", + "diagnostics", + "enabled", + "fallback_chain", + "model", + "provenance", + "provider", + "reasoning_effort", + "resolution_mode", + "role" ], "type": "object" }, @@ -102080,7 +101886,7 @@ } }, "required": [ - "results" + "roles" ], "type": "object" } @@ -102088,7 +101894,7 @@ }, "description": "OK" }, - "403": { + "404": { "content": { "application/json": { "schema": { @@ -102150,9 +101956,9 @@ } } }, - "description": "Force operation forbidden" + "description": "Workspace not found" }, - "422": { + "410": { "content": { "application/json": { "schema": { @@ -102214,9 +102020,9 @@ } } }, - "description": "Invalid bulk forced-failure request" + "description": "Workspace root missing" }, - "429": { + "500": { "content": { "application/json": { "schema": { @@ -102278,7 +102084,347 @@ } } }, - "description": "Force-operation rate limit exceeded" + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Roles status provider unavailable" + } + }, + "summary": "List effective background role configuration", + "tags": [ + "roles" + ], + "x-agh-transports": [ + "http", + "uds" + ] + } + }, + "/api/roles/{role}": { + "get": { + "operationId": "getRole", + "parameters": [ + { + "description": "Background role name", + "in": "path", + "name": "role", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Workspace id, name, or path used to resolve effective role configuration", + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "role": { + "properties": { + "agent": { + "nullable": true, + "type": "string" + }, + "diagnostics": { + "items": { + "properties": { + "agent": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "type": "array" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "nullable": true, + "type": "string" + }, + "provenance": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "provider": { + "nullable": true, + "type": "string" + }, + "reasoning_effort": { + "nullable": true, + "type": "string" + }, + "resolution_mode": { + "enum": [ + "builtin", + "catalog", + "inherit" + ], + "type": "string" + }, + "role": { + "type": "string" + }, + "timeout": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "agent", + "diagnostics", + "enabled", + "fallback_chain", + "model", + "provenance", + "provider", + "reasoning_effort", + "resolution_mode", + "role" + ], + "type": "object" + } + }, + "required": [ + "role" + ], + "type": "object" + } + } + }, + "description": "OK" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Role or workspace not found" + }, + "410": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Workspace root missing" }, "500": { "content": { @@ -102406,12 +102552,12 @@ } } }, - "description": "Task service is not configured" + "description": "Roles status provider unavailable" } }, - "summary": "Force fail a bounded set of queued or claimed task runs", + "summary": "Get effective background role configuration", "tags": [ - "tasks" + "roles" ], "x-agh-transports": [ "http", @@ -102419,9 +102565,810 @@ ] } }, - "/api/runs/bulk/release": { + "/api/runs/bulk/fail": { "post": { - "operationId": "bulkForceReleaseTaskRuns", + "operationId": "bulkForceFailTaskRuns", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "metadata": {}, + "reason": { + "type": "string" + }, + "run_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "run_ids" + ], + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "results": { + "items": { + "properties": { + "error": { + "nullable": true, + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "ok": { + "type": "boolean" + }, + "run": { + "nullable": true, + "properties": { + "attempt": { + "type": "integer" + }, + "claim_token_hash": { + "type": "string" + }, + "claimed_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "claimed_by": { + "nullable": true, + "properties": { + "kind": { + "enum": [ + "human", + "agent_session", + "automation", + "extension", + "network_peer", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "kind", + "ref" + ], + "type": "object" + }, + "coordination_channel": { + "nullable": true, + "properties": { + "allowed_message_kinds": { + "items": { + "enum": [ + "status", + "request", + "reply", + "blocker", + "handoff", + "result", + "review_request" + ], + "type": "string" + }, + "type": "array" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "last_activity_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "purpose": { + "type": "string" + }, + "run_id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "workflow_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "allowed_message_kinds", + "display_name", + "id" + ], + "type": "object" + }, + "designation": { + "nullable": true, + "properties": { + "brief": { + "type": "string" + }, + "index": { + "type": "integer" + } + }, + "required": [ + "index" + ], + "type": "object" + }, + "designation_group_id": { + "type": "string" + }, + "ended_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "error": { + "type": "string" + }, + "failure_kind": { + "type": "string" + }, + "heartbeat_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "lease_until": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "metadata": {}, + "origin": { + "properties": { + "kind": { + "enum": [ + "cli", + "web", + "uds", + "http", + "automation", + "extension", + "network", + "agent_session", + "daemon" + ], + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": [ + "kind", + "ref" + ], + "type": "object" + }, + "previous_run_id": { + "type": "string" + }, + "queued_at": { + "format": "date-time", + "type": "string" + }, + "recovery_count": { + "type": "integer" + }, + "resolved_network_participation": { + "nullable": true, + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "mode": { + "enum": [ + "local" + ], + "type": "string" + }, + "source": { + "enum": [ + "explicit_request", + "task_profile", + "workspace_coordination", + "loop_definition", + "automation_job", + "built_in_local" + ], + "type": "string" + }, + "version": { + "enum": [ + "network-participation/v1" + ], + "type": "string" + } + }, + "required": [ + "mode", + "source", + "version" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bounds": { + "additionalProperties": false, + "properties": { + "coalesce_window": { + "minLength": 1, + "type": "string" + }, + "max_input_tokens": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "max_output_tokens": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "max_total_wall_time": { + "minLength": 1, + "type": "string" + }, + "max_wake_depth": { + "minimum": 1, + "type": "integer" + }, + "max_wake_wall_time": { + "minLength": 1, + "type": "string" + }, + "max_wakes": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "coalesce_window", + "max_input_tokens", + "max_output_tokens", + "max_total_wall_time", + "max_wake_depth", + "max_wake_wall_time", + "max_wakes" + ], + "type": "object" + }, + "channel_id": { + "pattern": "^[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, + "channel_strategy": { + "enum": [ + "named", + "run", + "loop_run" + ], + "type": "string" + }, + "mode": { + "enum": [ + "live" + ], + "type": "string" + }, + "source": { + "enum": [ + "explicit_request", + "task_profile", + "workspace_coordination", + "loop_definition", + "automation_job", + "built_in_local" + ], + "type": "string" + }, + "version": { + "enum": [ + "network-participation/v1" + ], + "type": "string" + }, + "workspace_id": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "bounds", + "channel_id", + "channel_strategy", + "mode", + "source", + "version", + "workspace_id" + ], + "type": "object" + } + ] + }, + "result": {}, + "session_id": { + "type": "string" + }, + "started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "queued", + "claimed", + "starting", + "running", + "completed", + "failed", + "canceled", + "needs_attention" + ], + "type": "string" + }, + "task_id": { + "type": "string" + } + }, + "required": [ + "attempt", + "id", + "origin", + "queued_at", + "recovery_count", + "status", + "task_id" + ], + "type": "object" + }, + "run_id": { + "type": "string" + } + }, + "required": [ + "ok", + "run_id" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "results" + ], + "type": "object" + } + } + }, + "description": "OK" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Force operation forbidden" + }, + "422": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Invalid bulk forced-failure request" + }, + "429": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Force-operation rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Internal server error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Task service is not configured" + } + }, + "summary": "Force fail a bounded set of queued or claimed task runs", + "tags": [ + "tasks" + ], + "x-agh-transports": [ + "http", + "uds" + ] + } + }, + "/api/runs/bulk/release": { + "post": { + "operationId": "bulkForceReleaseTaskRuns", "requestBody": { "content": { "application/json": { @@ -115971,6 +116918,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -116240,6 +117188,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -116821,6 +117770,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -117179,6 +118129,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -118595,6 +119546,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -118943,6 +119895,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -119397,6 +120350,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -120096,6 +121050,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -121222,6 +122177,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -122259,6 +123215,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -122935,6 +123892,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -124724,37 +125682,6 @@ "default_op_on_fail": { "type": "string" }, - "llm": { - "properties": { - "enabled": { - "type": "boolean" - }, - "max_tokens_out": { - "type": "integer" - }, - "model": { - "type": "string" - }, - "prompt_version": { - "type": "string" - }, - "timeout": { - "type": "string" - }, - "top_k": { - "type": "integer" - } - }, - "required": [ - "enabled", - "max_tokens_out", - "model", - "prompt_version", - "timeout", - "top_k" - ], - "type": "object" - }, "max_latency": { "type": "string" }, @@ -124786,7 +125713,6 @@ }, "required": [ "default_op_on_fail", - "llm", "max_latency", "mode", "policy" @@ -124860,18 +125786,12 @@ }, "dream": { "properties": { - "agent": { - "type": "string" - }, "check_interval": { "type": "string" }, "debounce": { "type": "string" }, - "enabled": { - "type": "boolean" - }, "gates": { "properties": { "min_recall_count": { @@ -124943,10 +125863,8 @@ } }, "required": [ - "agent", "check_interval", "debounce", - "enabled", "gates", "min_hours", "min_sessions", @@ -124966,18 +125884,12 @@ "dlq_path": { "type": "string" }, - "enabled": { - "type": "boolean" - }, "inbox_path": { "type": "string" }, "mode": { "type": "string" }, - "model": { - "type": "string" - }, "queue": { "properties": { "capacity": { @@ -125003,10 +125915,8 @@ "required": [ "deadline", "dlq_path", - "enabled", "inbox_path", "mode", - "model", "queue", "sandbox_inbox_only", "throttle_turns" @@ -125243,6 +126153,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -125355,37 +126266,6 @@ "default_op_on_fail": { "type": "string" }, - "llm": { - "properties": { - "enabled": { - "type": "boolean" - }, - "max_tokens_out": { - "type": "integer" - }, - "model": { - "type": "string" - }, - "prompt_version": { - "type": "string" - }, - "timeout": { - "type": "string" - }, - "top_k": { - "type": "integer" - } - }, - "required": [ - "enabled", - "max_tokens_out", - "model", - "prompt_version", - "timeout", - "top_k" - ], - "type": "object" - }, "max_latency": { "type": "string" }, @@ -125417,7 +126297,6 @@ }, "required": [ "default_op_on_fail", - "llm", "max_latency", "mode", "policy" @@ -125491,18 +126370,12 @@ }, "dream": { "properties": { - "agent": { - "type": "string" - }, "check_interval": { "type": "string" }, "debounce": { "type": "string" }, - "enabled": { - "type": "boolean" - }, "gates": { "properties": { "min_recall_count": { @@ -125574,10 +126447,8 @@ } }, "required": [ - "agent", "check_interval", "debounce", - "enabled", "gates", "min_hours", "min_sessions", @@ -125597,18 +126468,12 @@ "dlq_path": { "type": "string" }, - "enabled": { - "type": "boolean" - }, "inbox_path": { "type": "string" }, "mode": { "type": "string" }, - "model": { - "type": "string" - }, "queue": { "properties": { "capacity": { @@ -125634,10 +126499,8 @@ "required": [ "deadline", "dlq_path", - "enabled", "inbox_path", "mode", - "model", "queue", "sandbox_inbox_only", "throttle_turns" @@ -125970,6 +126833,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -126481,6 +127345,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -126818,6 +127683,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -127265,6 +128131,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -127541,6 +128408,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -128893,6 +129761,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -130431,6 +131300,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -130742,7 +131612,850 @@ "description": "Internal server error" } }, - "summary": "Create or replace one settings-backed provider overlay", + "summary": "Create or replace one settings-backed provider overlay", + "tags": [ + "settings" + ], + "x-agh-transports": [ + "http", + "uds" + ] + } + }, + "/api/settings/reload": { + "post": { + "operationId": "reloadSettings", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "active_config_hash": { + "type": "string" + }, + "active_generation": { + "format": "int64", + "type": "integer" + }, + "agent_name": { + "type": "string" + }, + "applied": { + "type": "boolean" + }, + "apply_record_id": { + "type": "string" + }, + "lifecycle": { + "enum": [ + "live", + "live-add", + "live-remove-if-unused", + "restart-required", + "session-rebind" + ], + "type": "string" + }, + "next_action": { + "enum": [ + "none", + "restart-daemon", + "new-session", + "retry" + ], + "type": "string" + }, + "partial_failures": { + "items": { + "properties": { + "diagnostic": { + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "subsystem": { + "type": "string" + } + }, + "required": [ + "diagnostic", + "subsystem" + ], + "type": "object" + }, + "type": "array" + }, + "restart_required": { + "type": "boolean" + }, + "restart_scope": { + "type": "string" + }, + "scope": { + "enum": [ + "global", + "workspace", + "agent" + ], + "type": "string" + }, + "section": { + "enum": [ + "general", + "memory", + "roles", + "skills", + "automation", + "network", + "window-manager", + "observability", + "hooks-extensions", + "providers", + "mcp-servers", + "sandboxes", + "hooks" + ], + "type": "string" + }, + "skipped": { + "type": "boolean" + }, + "skipped_reason": { + "type": "string" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + }, + "workspace_id": { + "type": "string" + }, + "write_target": { + "enum": [ + "global-config", + "workspace-config", + "global-mcp-sidecar", + "workspace-mcp-sidecar", + "global-agent-file", + "workspace-agent-file" + ], + "type": "string" + } + }, + "required": [ + "active_config_hash", + "active_generation", + "applied", + "apply_record_id", + "lifecycle", + "next_action" + ], + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Invalid settings payload" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Conflicting settings change" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Internal server error" + } + }, + "summary": "Reconcile config.toml with the daemon active generation", + "tags": [ + "settings" + ], + "x-agh-transports": [ + "http", + "uds" + ] + } + }, + "/api/settings/roles": { + "get": { + "operationId": "getSettingsRoles", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "available_scopes": { + "items": { + "enum": [ + "global" + ], + "type": "string" + }, + "type": "array" + }, + "config": { + "properties": { + "auto_title": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "checkpoint_summary": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "coordinator": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "max_active_sessions_per_workspace": { + "type": "integer" + }, + "max_children": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + }, + "ttl": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "max_active_sessions_per_workspace", + "max_children", + "model", + "provider", + "reasoning_effort", + "ttl" + ], + "type": "object" + }, + "dream": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "memory_controller": { + "properties": { + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "max_tokens_out": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "prompt_version": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + }, + "timeout": { + "type": "string" + }, + "top_k": { + "type": "integer" + } + }, + "required": [ + "enabled", + "fallback_chain", + "max_tokens_out", + "model", + "prompt_version", + "provider", + "reasoning_effort", + "timeout", + "top_k" + ], + "type": "object" + }, + "memory_extractor": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + } + }, + "required": [ + "auto_title", + "checkpoint_summary", + "coordinator", + "dream", + "memory_controller", + "memory_extractor" + ], + "type": "object" + }, + "scope": { + "enum": [ + "global" + ], + "type": "string" + }, + "section": { + "enum": [ + "general", + "memory", + "roles", + "skills", + "automation", + "network", + "window-manager", + "observability", + "hooks-extensions" + ], + "type": "string" + } + }, + "required": [ + "available_scopes", + "config", + "scope", + "section" + ], + "type": "object" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Internal server error" + } + }, + "summary": "Read the background-role routing settings section", "tags": [ "settings" ], @@ -130750,11 +132463,362 @@ "http", "uds" ] - } - }, - "/api/settings/reload": { - "post": { - "operationId": "reloadSettings", + }, + "patch": { + "operationId": "updateSettingsRoles", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "config": { + "properties": { + "auto_title": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "checkpoint_summary": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "coordinator": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "max_active_sessions_per_workspace": { + "type": "integer" + }, + "max_children": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + }, + "ttl": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "max_active_sessions_per_workspace", + "max_children", + "model", + "provider", + "reasoning_effort", + "ttl" + ], + "type": "object" + }, + "dream": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "memory_controller": { + "properties": { + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "max_tokens_out": { + "type": "integer" + }, + "model": { + "type": "string" + }, + "prompt_version": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + }, + "timeout": { + "type": "string" + }, + "top_k": { + "type": "integer" + } + }, + "required": [ + "enabled", + "fallback_chain", + "max_tokens_out", + "model", + "prompt_version", + "provider", + "reasoning_effort", + "timeout", + "top_k" + ], + "type": "object" + }, + "memory_extractor": { + "properties": { + "agent": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "fallback_chain": { + "items": { + "properties": { + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + }, + "required": [ + "agent", + "enabled", + "fallback_chain", + "model", + "provider", + "reasoning_effort" + ], + "type": "object" + } + }, + "required": [ + "auto_title", + "checkpoint_summary", + "coordinator", + "dream", + "memory_controller", + "memory_extractor" + ], + "type": "object" + } + }, + "required": [ + "config" + ], + "type": "object" + } + } + }, + "description": "JSON request body", + "required": true + }, "responses": { "200": { "content": { @@ -130874,6 +132938,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -130992,6 +133057,70 @@ }, "description": "Invalid settings payload" }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "diagnostic": { + "nullable": true, + "properties": { + "category": { + "type": "string" + }, + "code": { + "type": "string" + }, + "data_freshness": { + "type": "string" + }, + "doc_url": { + "type": "string" + }, + "evidence": { + "additionalProperties": {}, + "type": "object" + }, + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "suggested_command": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "category", + "code", + "data_freshness", + "id", + "message", + "severity", + "title" + ], + "type": "object" + }, + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, "409": { "content": { "application/json": { @@ -131121,7 +133250,7 @@ "description": "Internal server error" } }, - "summary": "Reconcile config.toml with the daemon active generation", + "summary": "Update the background-role routing settings section", "tags": [ "settings" ], @@ -131587,6 +133716,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -132443,6 +134573,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -133027,6 +135158,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -133536,6 +135668,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -134399,6 +136532,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -134812,6 +136946,7 @@ "enum": [ "general", "memory", + "roles", "skills", "automation", "network", @@ -272912,6 +275047,9 @@ { "name": "resources" }, + { + "name": "roles" + }, { "name": "sessions" }, diff --git a/packages/site/content/runtime/api-reference/meta.json b/packages/site/content/runtime/api-reference/meta.json index f1b87a948..d25679594 100644 --- a/packages/site/content/runtime/api-reference/meta.json +++ b/packages/site/content/runtime/api-reference/meta.json @@ -36,6 +36,7 @@ "vault", "agent", "tasks", - "openai" + "openai", + "roles" ] } diff --git a/packages/site/content/runtime/core/autonomy/coordinator.mdx b/packages/site/content/runtime/core/autonomy/coordinator.mdx index 971cd0df0..8e8626682 100644 --- a/packages/site/content/runtime/core/autonomy/coordinator.mdx +++ b/packages/site/content/runtime/core/autonomy/coordinator.mdx @@ -36,31 +36,38 @@ This keeps orchestration useful without paying the Network tool/context/wake cos ## Configuration -Coordinator settings live under `[autonomy.coordinator]` in global or workspace `config.toml`: +Coordinator routing and coordinator-owned session limits live under `[roles.coordinator]` in global +or workspace `config.toml`: ```toml -[autonomy.coordinator] +[roles.coordinator] enabled = true -agent_name = "coordinator" +agent = "" provider = "codex" model = "gpt-5.6-sol" -default_ttl = "2h" +reasoning_effort = "high" +ttl = "2h" max_children = 5 max_active_sessions_per_workspace = 5 ``` -| Field | Default | Meaning | -| ----------------------------------- | ------------- | ------------------------------------------------------ | -| `enabled` | `false` | Allows coordinator bootstrap after run enqueue. | -| `agent_name` | `coordinator` | Agent definition used for the coordinator. | -| `provider` | unset | Optional provider override. | -| `model` | unset | Optional model override. | -| `default_ttl` | `2h` | Coordinator lifetime, from `1m` through `24h`. | -| `max_children` | `5` | Maximum child sessions the coordinator may spawn. | -| `max_active_sessions_per_workspace` | `5` | Active autonomy-managed sessions allowed in workspace. | - -Workspace config wins over global config, then bundled agent/provider defaults apply. The -coordinator remains idempotent per workspace and cannot spawn another coordinator. +| Field | Default | Meaning | +| ----------------------------------- | ------- | ------------------------------------------------------------------------------------ | +| `enabled` | `false` | Allows coordinator bootstrap after run enqueue. | +| `agent` | empty | Empty selects the virtual builtin `coordinator`; a name routes to an authored agent. | +| `provider` | empty | Optional provider override. | +| `model` | empty | Optional exact model override. | +| `reasoning_effort` | empty | Optional reasoning override. | +| `fallback_chain` | empty | Ordered provider/model routes tried only before ACP accepts the session. | +| `ttl` | `2h` | Coordinator lifetime, from `1m` through `24h`. | +| `max_children` | `5` | Maximum child sessions the coordinator may spawn. | +| `max_active_sessions_per_workspace` | `5` | Active autonomy-managed sessions allowed in one workspace. | + +Workspace config wins over global config, then builtin or authored-agent/provider defaults apply. +Changes are Live for later coordinator starts; an active coordinator keeps its accepted routing. +The builtin identity is virtual, reserved from authoring, and absent from the public agent catalog. +The coordinator remains idempotent per workspace and cannot spawn another coordinator. Inspect the +effective projection with `agh roles show coordinator --workspace -o json`. ## Operator control diff --git a/packages/site/content/runtime/core/autonomy/index.mdx b/packages/site/content/runtime/core/autonomy/index.mdx index 70ea6eb15..48d4e0ae9 100644 --- a/packages/site/content/runtime/core/autonomy/index.mdx +++ b/packages/site/content/runtime/core/autonomy/index.mdx @@ -128,7 +128,7 @@ The autonomy docs describe only the implemented local MVP. They do not promise: - [Tool CLI Reference](/runtime/cli-reference/tool) documents the operator commands that inspect the registry and the `agh__autonomy` tool family. - [Sessions](/runtime/core/sessions/lifecycle) explains session types, state, stop behavior, and persistence. - [Network Channels](/runtime/core/network/channels-and-peers) explains channel and peer behavior. -- [config.toml](/runtime/core/configuration/config-toml) documents `[autonomy.coordinator]` and `[task.orchestration]`. +- [config.toml](/runtime/core/configuration/config-toml) documents `[roles.coordinator]` and `[task.orchestration]`. - [Task Execution Profiles](/runtime/core/autonomy/execution-profiles) documents the typed task-owned overlay used for coordinator guidance, worker selection, reviewer routing, participant policy, and sandbox mode. - [Review Gate](/runtime/core/autonomy/review-gate) documents the post-terminal review gate, reviewer routing, verdict semantics, and continuation runs. - [Notification Cursors](/runtime/core/autonomy/notification-cursors) documents bridge terminal notifications, durable cursor diagnostics, and SSE resume seeding. diff --git a/packages/site/content/runtime/core/configuration/agent-md.mdx b/packages/site/content/runtime/core/configuration/agent-md.mdx index a7b3d705d..e9b9f5341 100644 --- a/packages/site/content/runtime/core/configuration/agent-md.mdx +++ b/packages/site/content/runtime/core/configuration/agent-md.mdx @@ -67,6 +67,17 @@ AGH loads visible agents from the workspace snapshot attached to a session. Discovery is first-wins. If two files define the same `name`, AGH keeps the first one found and does not merge lower-precedence definitions. +## Reserved Builtin Names + +`coordinator` and `dreaming-curator` are virtual, AGH-owned role identities. They have embedded +prompts, resolve without authored directories, and never appear in public agent catalogs. Creating, +updating, renaming, duplicating, or bundle-materializing either name fails with +`agent_name_reserved`; AGH does not create or modify an authored directory. + +`general` is different: it is the normal managed public agent created by installation and remains +editable. Other authored agents may be selected through `[roles.].agent`; doing so changes the +route to that agent and does not modify a builtin prompt. + ## Parser Rules | Rule | Behavior | diff --git a/packages/site/content/runtime/core/configuration/config-toml.mdx b/packages/site/content/runtime/core/configuration/config-toml.mdx index 1fa187052..8e6138805 100644 --- a/packages/site/content/runtime/core/configuration/config-toml.mdx +++ b/packages/site/content/runtime/core/configuration/config-toml.mdx @@ -33,8 +33,13 @@ Unknown TOML keys are errors. Sandbox profiles are implemented under `[sandboxes | `[http]` | HTTP and SSE bind address. | `host = "localhost"`, `port = 2123` | | `[window_manager]` | Live virtual-desktop, focus, snap, layout, transition, and shortcut defaults. | floating windows, adaptive stacks, 50-entry history, slide transition | | `[defaults]` | Default agent, provider, and sandbox resolution. | `agent = "general"`, `provider = ""`, `sandbox = ""` | +| `[roles.coordinator]` | Coordinator routing plus coordinator-owned session limits. | disabled, builtin `coordinator`, TTL 2 hours, 5 children, 5 active sessions/workspace | +| `[roles.dream]` | Dream-session routing. | enabled, builtin `dreaming-curator` | +| `[roles.checkpoint_summary]` | Checkpoint-summary session routing. | enabled, builtin `dreaming-curator` | +| `[roles.memory_extractor]` | Memory-extractor session routing. | enabled, inherits the invoking agent | +| `[roles.auto_title]` | Automatic-title session routing. | enabled, inherits the invoking agent | +| `[roles.memory_controller]` | In-process memory-controller model routing and call bounds. | enabled, `pi` + `anthropic/claude-haiku-4`, 250 ms, top-K 5, prompt v1, 256 output tokens | | `[limits]` | Daemon-level concurrently running agent cap. | `max_concurrent_agents = 20` | -| `[session]` | Daemon-owned automatic title generation for unnamed user sessions. | `auto_title_enabled = true` | | `[session.limits]` | Session-scoped wall-clock timeout. | `timeout = "0s"` | | `[session.supervision]` | Runtime activity heartbeat, progress, warning, and inactivity timeout controls. | heartbeat 30 seconds, progress 10 minutes, warning 15 minutes, timeout 30 minutes | | `[session.compaction]` | Pressure-triggered checkpoint coverage and persisted replay archiving. | enabled, 85% pressure, 1 attempt per turn, 10-minute failure cooldown | @@ -56,12 +61,11 @@ Unknown TOML keys are errors. Sandbox profiles are implemented under `[sandboxes | `[redact]` | Additive secret heuristics for agent-visible content, logs, and persisted events. | `enabled = true` | | `[memory]` | Persistent memory runtime and global memory directory. | enabled, `$AGH_HOME/memory` | | `[memory.controller]` | Hybrid write controller mode, latency, and fallback op. | hybrid, 300 ms, noop | -| `[memory.controller.llm]` | Controller LLM tiebreaker. | enabled, `anthropic/claude-haiku-4`, 250 ms, top_k 5 | | `[memory.controller.policy]` | Content/rate caps and allowed write origins. | 4096 chars, 60 writes/min, all canonical origins | | `[memory.recall]` | Deterministic recall: top-K, weights, freshness, signal queue. | top-K 5, raw 50, weighted fusion | | `[memory.decisions]` | Decision WAL retention and per-row body cap. | 90 days, audit summary on, 64 KiB body cap | -| `[memory.extractor]` | Post-message extractor and bounded queue. | enabled, post_message mode, capacity 1, coalesce 16 | -| `[memory.dream]` | Dreaming runtime, gates, and scoring. | enabled, agent `dreaming-curator`, 24 h, 3 sessions, 30 min ticker | +| `[memory.extractor]` | Post-message extractor policy and bounded queue. | post_message mode, capacity 1, coalesce 16 | +| `[memory.dream]` | Dreaming gates, cadence, and scoring policy. | 24 h, 3 sessions, 30 min ticker | | `[memory.session]` | Forensic session ledger materialization, archive, and unbound partition. | jsonl, `$AGH_HOME/sessions`, 24 h grace, 30-day cold archive, `_unbound` partition | | `[memory.daily]` | Daily-log retention and rotation. | 1 MiB, 5000 lines, 7-day window, 30-day cold archive, sweep at 03:00 | | `[memory.file]` | Curated memory body and prompt-index limits. | 200 lines, 25 KiB | @@ -77,7 +81,6 @@ Unknown TOML keys are errors. Sandbox profiles are implemented under `[sandboxes | `[loops.defaults.watch]` | Global/workspace seed defaults for watch loops. | no iteration cap, no-progress window 2, fan-out 2 | | `[goals]` | Goal defaults and durable session-event relay controls. | 20 turns, context ratio 0.8, relay batch 50 every 100 ms | | `[autonomy]` | Task-kernel autonomy defaults (unblock-loop breaker). | block recurrence limit 2 | -| `[autonomy.coordinator]` | Coordinator session bootstrap for workspace-scoped task runs. | disabled, agent `coordinator`, TTL 2 hours, 5 children, 1 coordinator + 5 sessions per workspace | | `[task.orchestration]` | Bounds for active workspace runs, summaries, context, scheduler health, and runtime. | 16 active runs/workspace, 4 KiB summaries, 8 KiB context, prior 5/recent 50 events | | `[task.orchestration.profile]` | Defaults and gates for task execution profiles. | inherit coordinator/worker/sandbox; provider override + sandbox `none` allowed | | `[task.orchestration.review]` | Defaults and bounds for the post-terminal review gate. | policy `none`, max rounds 3, max attempts 2, timeout 20m, failure `block_task` | @@ -168,14 +171,53 @@ provider = "claude" # Sandbox profile used when a workspace does not set sandbox_ref. sandbox = "local" +[roles.coordinator] +# Coordinator is a virtual builtin when agent is omitted. It is disabled by default. +enabled = false +ttl = "2h" +max_children = 5 +max_active_sessions_per_workspace = 5 + +[roles.dream] +# Route dreaming work to a custom authored curator instead of the builtin dreaming-curator. +enabled = true +agent = "memory-curator" + +[roles.checkpoint_summary] +# Empty agent selects the builtin dreaming-curator. +enabled = true + +[roles.memory_extractor] +# Empty agent inherits the invoking context's agent at runtime. +enabled = true + +[roles.auto_title] +# Use a small model for the bounded automatic-title pass. +enabled = true +provider = "claude" +model = "claude-haiku-4-5-20251001" +reasoning_effort = "low" + +[[roles.auto_title.fallback_chain]] +# Fallback routes are tried in declaration order before a session is accepted. +provider = "codex" +model = "gpt-5.4-mini" +reasoning_effort = "low" + +[roles.memory_controller] +# This in-process role has no agent field. +enabled = true +provider = "pi" +model = "anthropic/claude-haiku-4" +timeout = "250ms" +top_k = 5 +prompt_version = "v1" +max_tokens_out = 256 + [limits] # Positive daemon-wide agent concurrency bound. max_concurrent_agents = 20 -[session] -# Generate one durable title after the first assistant response is persisted. -auto_title_enabled = true - [session.limits] # 0 means no configured wall-clock timeout. Use Go durations such as "30m" or "2h". timeout = "0s" @@ -396,14 +438,6 @@ mode = "hybrid" # hybrid | rules | llm max_latency = "300ms" default_op_on_fail = "noop" -[memory.controller.llm] -enabled = true -model = "anthropic/claude-haiku-4" -top_k = 5 -prompt_version = "v1" -timeout = "250ms" -max_tokens_out = 256 - [memory.controller.policy] max_content_chars = 4096 max_writes_per_min = 60 @@ -436,22 +470,18 @@ keep_audit_summary = true max_post_content_bytes = 65536 [memory.extractor] -enabled = true mode = "post_message" throttle_turns = 1 deadline = "60s" sandbox_inbox_only = true inbox_path = "$AGH_HOME/memory/_inbox" dlq_path = "$AGH_HOME/memory/_system/extractor/failures" -model = "" [memory.extractor.queue] capacity = 1 coalesce_max = 16 [memory.dream] -enabled = true -agent = "dreaming-curator" min_hours = 24 min_sessions = 3 debounce = "10m" @@ -615,19 +645,6 @@ outbox_poll_interval = "100ms" # Same-kind re-block count that escalates a task to needs_attention. 0 disables the breaker. block_recurrence_limit = 2 -[autonomy.coordinator] -# Disabled by default. When enabled, a workspace-scoped coordinated task run may start -# one coordinator session for that workspace after publish, start, approval, or an -# equivalent execution boundary enqueues the run. -enabled = false -agent_name = "coordinator" -# Optional provider/model overrides. Empty values fall through to agent/provider defaults. -provider = "" -model = "" -default_ttl = "2h" -max_children = 5 -max_active_sessions_per_workspace = 5 - [task.orchestration] # Bounds for task summaries, context bundles, scheduler health, and runtime watchdogs. summary_max_bytes = 4096 @@ -799,23 +816,97 @@ not a second config source. | `agent` | string | `general` | Non-empty agent name. | Agent selected when a command or API request omits an agent. | | `provider` | string | empty | Built-in provider name or configured provider key. | Provider selected when an `AGENT.md` omits `provider`. Empty is valid until an agent without provider is resolved. | +## `[roles.]` + +`[roles]` is the closed routing roster for daemon-owned background work. It is not an extension +registry: only the six sections below are accepted. Global `$AGH_HOME/config.toml` values apply to +every workspace unless the selected workspace's `.agh/config.toml` overlays them. + +| Role | Default | Empty `agent` resolution | Extra policy owned here | +| -------------------- | ------- | -------------------------- | --------------------------------------------------------- | +| `coordinator` | off | builtin `coordinator` | `ttl`, child cap, workspace active-session cap | +| `dream` | on | builtin `dreaming-curator` | none | +| `checkpoint_summary` | on | builtin `dreaming-curator` | none | +| `memory_extractor` | on | invoking context | none | +| `auto_title` | on | invoking context | none | +| `memory_controller` | on | not applicable | call timeout, candidate count, prompt version, output cap | + +`coordinator` and `dreaming-curator` are AGH-owned virtual identities. They resolve without an +authored `AGENT.md`, do not appear in public agent catalogs, and are reserved from create, update, +duplicate, and bundle materialization. Their embedded prompts are fixed; selecting a custom +`agent` changes routing, not the builtin prompt. + +### Session-backed role fields + +These fields apply to `coordinator`, `dream`, `checkpoint_summary`, `memory_extractor`, and +`auto_title`. + +| Field | Type | Default | Valid values | Description | +| ------------------ | --------------------- | ------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `enabled` | boolean | role-specific | `true` or `false` | Admits new work for the role. Disabling a role does not stop an already accepted session. | +| `agent` | string | empty | Empty or an authored agent name | Selects an authored agent. Empty resolves to the builtin or invoking-context behavior in the roster above. | +| `provider` | string | empty | Empty or a configured provider key | Optional provider override applied after agent resolution. | +| `model` | string | empty | Empty or an exact provider model ID | Optional model override. | +| `reasoning_effort` | string | empty | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | Optional reasoning override. | +| `fallback_chain` | array of route tables | empty | Each entry requires `provider` and `model` | Ordered fallback routes; each entry may also set `reasoning_effort`. | + +Fallback chains replace as a whole when a workspace overlay declares them. A session-backed +consumer may advance only before ACP accepts a session; AGH never silently reroutes work after +acceptance. Immediately before each fallback attempt, AGH emits `role.fallback.used` with the role, +the fallback's one-based `attempt`, provider, and model. The event records an attempt, not success. + +```toml +[[roles.auto_title.fallback_chain]] +provider = "codex" +model = "gpt-5.4-mini" +reasoning_effort = "low" +``` + +### `[roles.coordinator]` + +Coordinator routing uses the shared session-backed fields plus these coordinator-owned safety +limits. Task creation alone does not start a coordinator; an executable workspace run must be +enqueued, the role must be enabled, and no healthy workspace coordinator may already exist. + +| Field | Type | Default | Valid values | Description | +| ----------------------------------- | -------- | ------- | ------------------ | ------------------------------------------------------------- | +| `ttl` | duration | `2h` | `1m` through `24h` | Managed coordinator session lifetime. | +| `max_children` | integer | `5` | `1` through `5` | Maximum concurrently held safe-spawn children. | +| `max_active_sessions_per_workspace` | integer | `5` | Positive integer | Active coordinator and spawned-session cap for one workspace. | + +### `[roles.memory_controller]` + +The memory controller is an in-process model call, so it has no `agent` field. It accepts +`enabled`, `provider`, `model`, `reasoning_effort`, and `fallback_chain` with the same routing +semantics above, plus these call-policy fields: + +| Field | Type | Default | Valid values | Description | +| ---------------- | -------- | -------------------------- | ------------------------ | -------------------------------------------------- | +| `provider` | string | `pi` | Configured provider name | ACP transport used by the transient model call. | +| `model` | string | `anthropic/claude-haiku-4` | Non-empty when enabled | Model used by the controller tiebreaker. | +| `timeout` | duration | `250ms` | Positive Go duration | Deadline for one controller model call. | +| `top_k` | integer | `5` | Positive integer | Candidate count passed to the tiebreaker. | +| `prompt_version` | string | `v1` | Non-empty pinned version | Fixed prompt version recorded for replay fidelity. | +| `max_tokens_out` | integer | `256` | Positive integer | Output-token cap for one call. | + +`timeout` is valid only for `roles.memory_controller`; the strict decoder rejects it on any +session-backed role. Role changes are classified `live` and affect later invocations without a +daemon restart. Inspect the resolved roster and per-field provenance with: + +```bash +agh roles list --workspace -o json +agh roles show auto_title --workspace -o json +``` + +HTTP and UDS expose the same `GET /api/roles` and `GET /api/roles/{role}` projections. A missing +authored agent remains visible as `role_agent_not_found`; an unknown role returns `role_unknown`. + ## `[limits]` | Field | Type | Default | Valid values | Description | | ----------------------- | ------- | ------- | ----------------- | ------------------------------------------------------------ | | `max_concurrent_agents` | integer | `20` | Positive integer. | Daemon-wide cap for concurrently running agent subprocesses. | -## `[session]` - -| Field | Type | Default | Valid values | Description | -| -------------------- | ------- | ------- | ----------------- | ---------------------------------------------------------------------------------------------------------- | -| `auto_title_enabled` | boolean | `true` | `true` or `false` | Starts one bounded title pass for an unnamed user session after its first assistant response is persisted. | - -An explicit session name always wins. If generation is disabled or fails, the session remains -unnamed; AGH does not derive a title from prompt text in the client or during prompt admission. -The setting is global or workspace desired state, is agent-mutable through `agh config set` and -the `agh__config_*` native tools, and follows the restart-required `session.*` lifecycle. - ## `[session.limits]` | Field | Type | Default | Valid values | Description | @@ -1347,17 +1438,6 @@ and the bundled local provider. | `max_latency` | duration | `300ms` | Positive Go duration. | Maximum end-to-end controller latency before fall-back behavior triggers. | | `default_op_on_fail` | string | `noop` | `noop`, `reject` | Decision op used when the controller fails or its deadline is exceeded. | -## `[memory.controller.llm]` - -| Field | Type | Default | Valid values | Description | -| ---------------- | -------- | -------------------------- | --------------------------- | ---------------------------------------------------------------- | -| `enabled` | boolean | `true` | `true` or `false` | Enables the LLM tiebreaker for ambiguous rule traces. | -| `model` | string | `anthropic/claude-haiku-4` | Non-empty model identifier. | Model used by the controller tiebreaker. | -| `top_k` | integer | `5` | Positive integer. | Candidates passed to the entity-slot tiebreaker. | -| `prompt_version` | string | `v1` | Non-empty pinned version. | Prompt template version pinned in decisions for replay fidelity. | -| `timeout` | duration | `250ms` | Positive Go duration. | LLM call deadline before fall-through to the rule trace. | -| `max_tokens_out` | integer | `256` | Positive integer. | Output cap for the tiebreaker call. | - ## `[memory.controller.policy]` | Field | Type | Default | Valid values | Description | @@ -1420,14 +1500,12 @@ locations. | Field | Type | Default | Valid values | Description | | -------------------- | -------- | --------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `enabled` | boolean | `true` | `true` or `false` | Enables the extractor runtime. | | `mode` | string | `post_message` | `post_message` | Extraction mode. AGH currently supports only post-message extraction. | | `throttle_turns` | integer | `1` | Positive integer. | Minimum same-session queued turns to coalesce before launching another extraction; drain flushes pending content. | | `deadline` | duration | `60s` | Positive Go duration. | Per-extraction deadline before the runtime drops the candidate. | | `sandbox_inbox_only` | boolean | `true` | `true` or `false` | Restricts extractor writes to `_inbox/` until the controller accepts them. | | `inbox_path` | string | `$AGH_HOME/memory/_inbox` | Daemon-managed. | Read-only display field; the daemon manages the inbox path. | | `dlq_path` | string | `$AGH_HOME/memory/_system/extractor/failures` | Daemon-managed. | Read-only display field; the daemon manages the DLQ path. | -| `model` | string | empty | Empty or model id. | Optional model override for the extractor child session. Empty uses the agent/provider default. | ## `[memory.extractor.queue]` @@ -1438,17 +1516,13 @@ locations. ## `[memory.dream]` -If `enabled = false`, the remaining dream validation is skipped. - -| Field | Type | Default | Valid values | Description | -| ---------------- | -------- | ------------------ | ------------------------- | ------------------------------------------------------------------------ | -| `enabled` | boolean | `true` | `true` or `false` | Enables the dreaming runtime and the Trigger-dream action. | -| `agent` | string | `dreaming-curator` | Non-empty when enabled. | Dedicated curator agent. Does **not** inherit `[defaults].agent`. | -| `min_hours` | float | `24` | Positive number. | Time-gate threshold before another successful run. | -| `min_sessions` | integer | `3` | Positive integer. | Sessions-gate threshold (completed sessions since last run). | -| `debounce` | duration | `10m` | Positive Go duration. | Debounce window for triggers fed by `session.post_stop`. | -| `prompt_version` | string | `v1` | Non-empty pinned version. | Prompt template version pinned to dreaming sessions for replay fidelity. | -| `check_interval` | duration | `30m` | Positive Go duration. | Background ticker interval. | +| Field | Type | Default | Valid values | Description | +| ---------------- | -------- | ------- | ------------------------- | ------------------------------------------------------------------------ | +| `min_hours` | float | `24` | Positive number. | Time-gate threshold before another successful run. | +| `min_sessions` | integer | `3` | Positive integer. | Sessions-gate threshold (completed sessions since last run). | +| `debounce` | duration | `10m` | Positive Go duration. | Debounce window for triggers fed by `session.post_stop`. | +| `prompt_version` | string | `v1` | Non-empty pinned version. | Prompt template version pinned to dreaming sessions for replay fidelity. | +| `check_interval` | duration | `30m` | Positive Go duration. | Background ticker interval. | ## `[memory.dream.gates]` @@ -1716,33 +1790,6 @@ scheduler subtables. The default below is loaded from `internal/config/autonomy. | ------------------------ | ------- | ------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `block_recurrence_limit` | integer | `2` | `0` or a positive int | Same-kind re-block count that escalates a task to `needs_attention`. `0` disables the [unblock-loop breaker](/runtime/core/autonomy/task-runs-and-leases#the-unblock-loop-breaker). | -## `[autonomy.coordinator]` - -Coordinator config controls whether AGH may start a workspace coordinator when executable task-run -work is enqueued. Task creation is not executable work. Creating or saving a task does not enqueue a -run or spawn a coordinator. Coordinator bootstrap is independent from Network participation: a -Local run can use the coordinator, and a Live channel is not a bootstrap precondition. - -| Field | Type | Default | Valid values | Description | -| ----------------------------------- | -------- | ------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | boolean | `false` | `true` or `false` | Allows coordinator bootstrap after a coordinated workspace run is enqueued. | -| `agent_name` | string | `coordinator` | Non-empty when enabled. | Agent definition used for managed coordinator sessions. | -| `provider` | string | empty | Empty or a configured provider key. | Optional provider override for the coordinator agent. | -| `model` | string | empty | Empty or provider-supported model string. | Optional model override. If set, `provider` or provider default resolution must exist. | -| `default_ttl` | duration | `2h` | `1m` through `24h`. | Lifetime assigned to managed coordinator sessions. | -| `max_children` | integer | `5` | `1` through `5`. | Maximum safe-spawn children a coordinator may hold at once. | -| `max_active_sessions_per_workspace` | integer | `5` | Positive integer. | Caps concurrent autonomy-managed sessions (coordinator + spawned workers) per workspace. Coordinator uniqueness is enforced by the daemon singleton check. | - -Provider and model resolution is intentionally narrow. The daemon applies: - -1. Workspace `.agh/config.toml` override for the resolved workspace. -2. Global `$AGH_HOME/config.toml`. -3. The bundled/default coordinator agent definition and provider defaults. - -Safe-spawn limits still apply after a coordinator starts. Coordinator children inherit the workspace -and coordination channel, must use a positive TTL, and can only narrow the coordinator's permission -set. - ## `[task.orchestration]` `[task.orchestration]` controls bounded task orchestration behavior: active workspace-run capacity, diff --git a/packages/site/content/runtime/core/configuration/lifecycle-matrix.mdx b/packages/site/content/runtime/core/configuration/lifecycle-matrix.mdx index f834eb56f..f6c136985 100644 --- a/packages/site/content/runtime/core/configuration/lifecycle-matrix.mdx +++ b/packages/site/content/runtime/core/configuration/lifecycle-matrix.mdx @@ -48,6 +48,8 @@ description: Generated AGH config lifecycle matrix for live apply, session rebin | `providers.*.models` | `live` | `live` | `none` | | `providers.*.models.*` | `live` | `live` | `none` | | `redact.*` | `restart-required` | `restart-required` | `restart-daemon` | +| `roles` | `live` | `live` | `none` | +| `roles.*` | `live` | `live` | `none` | | `sandboxes.*` | `session-rebind` | `session-rebind` | `new-session` | | `session.*` | `restart-required` | `restart-required` | `restart-daemon` | | `skills.*` | `restart-required` | `restart-required` | `restart-daemon` | diff --git a/packages/site/content/runtime/core/memory/dream.mdx b/packages/site/content/runtime/core/memory/dream.mdx index 1c4c5c64d..b7592d689 100644 --- a/packages/site/content/runtime/core/memory/dream.mdx +++ b/packages/site/content/runtime/core/memory/dream.mdx @@ -89,10 +89,14 @@ caption="Dream output is not injected directly into curated memory. Promotions a The dream agent runs as an ordinary AGH session of type `dream`. It is special in two ways: - AGH starts it with `approve-all` permissions for the dreaming workspace context. -- The session prompt is the embedded dreaming-curator prompt at version `memory.dream.prompt_version = "v1"`. +- AGH layers the dream role overlay and the versioned output-contract frame selected by + `memory.dream.prompt_version` on top of the selected agent's prompt. -The agent that runs the dream session is configured by `memory.dream.agent` and defaults to the -bundled **`dreaming-curator`** agent. It does not inherit `[defaults].agent`. +Dream-session routing lives under `[roles.dream]`. An empty `agent` selects the virtual builtin +**`dreaming-curator`** identity; a non-empty value routes the session to that authored agent. The +builtin contributes AGH's embedded curator prompt. A routed catalog agent contributes its own +`AGENT.md` body instead; AGH still applies the role overlay and contract frame. The builtin name is +reserved from authoring and does not appear in public agent catalogs. ## What Lands Where @@ -111,9 +115,13 @@ and from default recall. Operators browse them with `agh memory dream show { "changing workspace coordination affects future coordinated runs only", ]); expectIncludesAll(config, [ - "[autonomy.coordinator]", - "Task creation is not executable work", - "Coordinator bootstrap is independent from Network participation", - "Workspace `.agh/config.toml` override", - "Global `$AGH_HOME/config.toml`", - "bundled/default coordinator agent definition", + "[roles.coordinator]", + "Task creation alone does not start a coordinator", + "Role changes are classified `live`", + "Global `$AGH_HOME/config.toml` values", + "workspace's `.agh/config.toml` overlays them", + "builtin `coordinator`", ]); + expectExcludesAll(config, ["[autonomy.coordinator]", "default_ttl ="]); }); it("documents task leases and channel authority without exposing raw tokens in read paths", () => { diff --git a/packages/site/lib/__tests__/runtime-docs-truth.test.ts b/packages/site/lib/__tests__/runtime-docs-truth.test.ts index 4073ebfa0..14c600f63 100644 --- a/packages/site/lib/__tests__/runtime-docs-truth.test.ts +++ b/packages/site/lib/__tests__/runtime-docs-truth.test.ts @@ -25,6 +25,33 @@ function readRepoGoPackage(...parts: string[]): string { .join("\n"); } +function tomlSectionBody(document: string, section: string): string { + const lines = document.split("\n"); + const referenceHeading = `## \`[${section}]\``; + const headingIndex = lines.findIndex(line => line.trim() === referenceHeading); + if (headingIndex >= 0) { + const nextHeading = lines.findIndex( + (line, index) => index > headingIndex && line.trim().startsWith("## ") + ); + return lines.slice(headingIndex + 1, nextHeading < 0 ? undefined : nextHeading).join("\n"); + } + + const body: string[] = []; + let inSection = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === `[${section}]`) { + inSection = true; + continue; + } + if (inSection && /^\[[^\]]+\]$/.test(trimmed)) { + inSection = false; + } + if (inSection) body.push(line); + } + return body.join("\n"); +} + function listManualDocs(dir: string): ManualDoc[] { const docs: ManualDoc[] = []; for (const entry of readdirSync(dir)) { @@ -265,7 +292,7 @@ describe("runtime docs truth", () => { expect(memoryDocs).not.toMatch(/`GET \/api\/memory\/search`/); }); - it("documents the Memory v2 config keys that the runtime actually validates", () => { + it("documents the Memory policy and background-role keys that the runtime validates", () => { const configDoc = readRepoFile( "packages/site/content/runtime/core/configuration/config-toml.mdx" ); @@ -273,10 +300,9 @@ describe("runtime docs truth", () => { expect(configSource).toContain("MemoryWorkspaceConfig"); expect(configSource).toContain("MemoryDreamScoringWeightsConfig"); - expect(configSource).toContain("DefaultMemoryDreamAgentName"); + expect(configSource).toContain("DefaultRolesConfig"); expect(configDoc).toContain("[memory.controller]"); - expect(configDoc).toContain("[memory.controller.llm]"); expect(configDoc).toContain("[memory.controller.policy]"); expect(configDoc).toContain("[memory.recall]"); expect(configDoc).toContain("[memory.recall.weights]"); @@ -293,7 +319,17 @@ describe("runtime docs truth", () => { expect(configDoc).toContain("[memory.file]"); expect(configDoc).toContain("[memory.provider]"); expect(configDoc).toContain("[memory.workspace]"); + expect(configDoc).toContain("[roles.dream]"); + expect(configDoc).toContain("[roles.memory_extractor]"); + expect(configDoc).toContain("[roles.memory_controller]"); expect(configDoc).toContain("`dreaming-curator`"); + expect(configDoc).not.toContain("[memory.controller.llm]"); + const memoryDreamReference = tomlSectionBody(configDoc, "memory.dream"); + const memoryExtractorReference = tomlSectionBody(configDoc, "memory.extractor"); + expect(memoryDreamReference).toContain("| Field"); + expect(memoryExtractorReference).toContain("| Field"); + expect(memoryDreamReference).not.toMatch(/^\s*(agent|enabled)\s*=/m); + expect(memoryExtractorReference).not.toMatch(/^\s*(model|enabled)\s*=/m); // [memory.v2] must never appear as a current-tense TOML config header. expect(configDoc).not.toMatch(/^\s*\[memory\.v2\]/m); }); diff --git a/packages/site/lib/runtime-navigation.ts b/packages/site/lib/runtime-navigation.ts index e6a67f278..da1ea01b0 100644 --- a/packages/site/lib/runtime-navigation.ts +++ b/packages/site/lib/runtime-navigation.ts @@ -88,6 +88,7 @@ export const API_SECTIONS: CoreSection[] = [ "agent", "tasks", "openai", + "roles", ], }, ]; diff --git a/skills/agh/SKILL.md b/skills/agh/SKILL.md index b411eb813..f061215b0 100644 --- a/skills/agh/SKILL.md +++ b/skills/agh/SKILL.md @@ -20,6 +20,7 @@ Match the task to the row. Read the listed files in full before producing output | Task | MUST read | | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Start, inspect, prompt, stop, resume, or debug AGH sessions and daemon state | references/runtime-operations.md | +| Inspect or configure daemon-owned background roles, role diagnostics, builtin identities, or role fallback routing | references/runtime-operations.md + references/agent-definitions.md | | Expose one AGH workspace to an external MCP client with `agh mcp serve` | references/runtime-operations.md | | Inspect, mutate, or watch virtual desktops, managed windows, or workspace layouts through native tools, CLI, HTTP, or UDS | references/window-management.md + references/native-tools.md | | Create, update, inspect, or troubleshoot messaging bridges and bridge-delivered tool progress | references/runtime-operations.md | @@ -36,9 +37,9 @@ Match the task to the row. Read the listed files in full before producing output ## Reference Index -- references/runtime-operations.md - daemon, session, and messaging-bridge operations, lifecycle diagnostics, and runtime troubleshooting. +- references/runtime-operations.md - daemon, session, background-role, and messaging-bridge operations, lifecycle diagnostics, and runtime troubleshooting. - references/window-management.md - daemon-authoritative desktops, windows, layouts, revisions, clients, resources, hooks, recovery, and public surfaces. -- references/agent-definitions.md - AGENT.md structure, provider defaults, permissions, category paths, MCP sidecars, and safe setup workflow. +- references/agent-definitions.md - AGENT.md structure, reserved builtin role identities, provider defaults, permissions, category paths, MCP sidecars, and safe setup workflow. - references/tools-and-skills.md - AGH-native tool discovery, skill view/search, bundled resources, management-surface exceptions, and skill authoring rules. - references/native-tools.md - daemon-native toolsets, stable AGH tool IDs, when to inspect descriptors, and CLI fallbacks for agents running inside AGH. - references/network.md - AGH Network channel/thread/direct-room semantics, native tools, CLI fallback, message bodies, retries, and injection defense. diff --git a/skills/agh/references/agent-definitions.md b/skills/agh/references/agent-definitions.md index 54194c609..d0cd2a91d 100644 --- a/skills/agh/references/agent-definitions.md +++ b/skills/agh/references/agent-definitions.md @@ -65,6 +65,22 @@ AGH ensures one managed agent definition exists on first boot and during `agh in It is recreated only when missing; operator edits are preserved. +## Reserved Background-Role Identities + +`coordinator` and `dreaming-curator` are virtual AGH-owned identities, not managed or authored +agent definitions. They resolve for the coordinator, dream, and checkpoint-summary roles without an +`AGENT.md`, use embedded fixed prompts, and stay absent from public fleet/catalog reads. + +Do not create, update, rename, duplicate, or bundle-materialize either name. Every authoring surface +returns `agent_name_reserved` and leaves the filesystem and catalog unchanged. `general` is not +reserved; it remains the editable managed public agent described above. + +Background routing lives under `[roles.coordinator]`, `[roles.dream]`, +`[roles.checkpoint_summary]`, `[roles.memory_extractor]`, `[roles.auto_title]`, and +`[roles.memory_controller]`. A non-empty session-role `agent` selects an authored definition; it +does not customize an embedded builtin prompt. Read the effective projection with `agh roles list` +or `agh roles show ` before diagnosing provider or model behavior. + ## Providers And MCP Built-in provider names include claude, codex, gemini, opencode, copilot, cursor, kiro, and pi. Provider config can supply launch command, default model, API key environment, and provider-level MCP servers. diff --git a/skills/agh/references/capabilities-and-bundles.md b/skills/agh/references/capabilities-and-bundles.md index 9dc904324..0afd45353 100644 --- a/skills/agh/references/capabilities-and-bundles.md +++ b/skills/agh/references/capabilities-and-bundles.md @@ -174,10 +174,11 @@ are `enabled = true`, `pressure_threshold = 0.85`, `max_attempts_per_turn = 1`, `agh config set` and the native config tools, are restart-required under the canonical `session.*` lifecycle rule, and do not mutate the policy bound to the running daemon. -`session.auto_title_enabled` defaults to `true` and gates the daemon-owned title pass for unnamed -user sessions after their first persisted assistant response. It is agent-mutable through the same -config surfaces and restart-required under the canonical `session.*` lifecycle rule. Explicit names -win; disabled or failed generation leaves the session unnamed. +`roles.auto_title.enabled` defaults to `true` and gates the daemon-owned title pass for unnamed user +sessions after their first persisted assistant response. The remaining `roles.auto_title.*` fields +select its agent, provider, model, reasoning effort, and ordered fallback routes. Role changes are +Live desired state for later invocations at global or workspace scope. Explicit names win; disabled +or failed generation leaves the session unnamed. `[window_manager]` controls global behavior defaults for new-window placement, small-viewport fallback, focus and raise policy, drag-away grouping, bounded history, desktop transitions, gaps, diff --git a/skills/agh/references/native-tools.md b/skills/agh/references/native-tools.md index 9cc93c3ef..4b20d8b83 100644 --- a/skills/agh/references/native-tools.md +++ b/skills/agh/references/native-tools.md @@ -140,6 +140,14 @@ binding semantics. Config tools live under `agh__config_*` for show/list/get/set/unset/diff/path. Hook tools live under `agh__hooks_*` for list/info/events/runs/create/update/delete/enable/disable; hooks are typed dispatch, not an event bus. +Background-role inspection has no `agh__roles_*` native tool. Use `agh roles list|show -o json` or +the HTTP/UDS `GET /api/roles` reads. Scalar `roles..*` routing and role-policy keys are exposed +through the live `agh__config_set`/`agh__config_unset` descriptors, including coordinator limits and +memory-controller call bounds. Fallback chains are structured arrays and must be changed through +`config.toml` or the Settings Roles API/UI, not guessed into a scalar config-tool call. Inspect the +live descriptor before any mutation; successful role writes report the `live` lifecycle and affect +later invocations. + Automation catalogs use CLI, HTTP/UDS, and `agh__automation_jobs_list` / `agh__automation_triggers_list`. Their counted cursor pages filter by scope/workspace, source, enabled, Loop target, search, and event; run history stays uncounted and must be bounded. Other `agh__automation_*` tools cover detail, diff --git a/skills/agh/references/runtime-operations.md b/skills/agh/references/runtime-operations.md index 52e54ff39..6a395d826 100644 --- a/skills/agh/references/runtime-operations.md +++ b/skills/agh/references/runtime-operations.md @@ -23,7 +23,7 @@ AGH sessions are daemon-owned runtimes. Common states: Session types include user sessions and daemon-managed sessions such as dream, system, coordinator, worker, and reviewer sessions. Do not infer authority from a session type alone. Use the session context and daemon tools to confirm what the current session may do. -With `session.auto_title_enabled = true`, an unnamed user session receives at most one daemon-owned durable title after its first assistant response is persisted. An explicit session name wins any race; daemon-managed session types are ineligible. Treat the persisted session name as catalog identity and leave the session unnamed when generation is disabled or fails. +With `roles.auto_title.enabled = true`, an unnamed user session receives at most one daemon-owned durable title after its first assistant response is persisted. Configure its agent, provider, model, reasoning, and fallback routes under `[roles.auto_title]`. An explicit session name wins any race; daemon-managed session types are ineligible. Treat the persisted session name as catalog identity and leave the session unnamed when generation is disabled or fails. Attachability is explicit runtime state. Use `agh session list --resumable -o json` and `agh session resume` instead of assuming a stopped or idle session can be reused. @@ -83,6 +83,40 @@ API until it becomes `active` or durably `stopped` with `failure.kind=startup_fa If an AGH-native session tool is visible, prefer the tool because it is policy-aware and easier for the daemon to audit. Use the CLI when the tool is denied, absent, or explicitly requested. +## Background Roles + +AGH routes six daemon-owned background responsibilities through the closed `[roles]` roster: +`coordinator`, `dream`, `checkpoint_summary`, `memory_extractor`, `auto_title`, and +`memory_controller`. Inspect the effective global or workspace projection with structured output: + + agh roles list -o json + agh roles list --workspace -o json + agh roles show dream --workspace -o json + +HTTP and UDS expose the same `GET /api/roles` and `GET /api/roles/{role}` payloads. Each projection +reports `enabled`, `resolution_mode`, nullable agent/provider/model/reasoning values, controller-only +`timeout`, the ordered `fallback_chain`, per-field `provenance`, and current `diagnostics`. Preserve +nulls: `resolution_mode=inherit` means the invoking context decides at invocation, not that a client +should substitute `[defaults]`. + +`coordinator` and `dreaming-curator` are virtual builtin identities and never fleet entries. A +configured authored agent that cannot be resolved produces `role_agent_not_found`; an unknown role +returns `role_unknown`. The reads are diagnostic only and never simulate a provider invocation. + +Scalar role keys can be written through `agh config set roles.. -o json` or the +live `agh__config_set` descriptor. Role writes are Live desired state at global or workspace scope +and affect later invocations without restarting the daemon. Use `config.toml` or the Settings Roles +API/UI for the ordered `fallback_chain`, which is an array of route tables and replaces as a whole in +a workspace overlay. A fallback may advance only at the owning invocation's pre-acceptance boundary; +an accepted ACP session is never silently rerouted. Immediately before each fallback attempt, AGH +emits `role.fallback.used`; the event records that the route was tried, not that it succeeded. + +Session-backed roles accept `enabled`, `agent`, `provider`, `model`, `reasoning_effort`, and +`fallback_chain`. Coordinator additionally owns `ttl`, `max_children`, and +`max_active_sessions_per_workspace`. The in-process `memory_controller` has no `agent`; it owns +`timeout`, `top_k`, `prompt_version`, and `max_tokens_out`. Do not move Loop model defaults, +TaskExecutionProfile selectors, automation resources, or subsystem policy into `[roles]`. + ### Usage cost truth The session usage endpoint diff --git a/web/e2e/__tests__/agent-categories.spec.ts b/web/e2e/__tests__/agent-categories.spec.ts index 5bdee3688..e99d3d005 100644 --- a/web/e2e/__tests__/agent-categories.spec.ts +++ b/web/e2e/__tests__/agent-categories.spec.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; +import { openAppWindow } from "../fixtures/os-navigation"; import { sessionLifecycleSelectors } from "../fixtures/selectors"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -43,24 +44,24 @@ test.use({ test("categorized agents surface on the fleet page and group inside the session-create command picker", async ({ appPage, + runtime, }) => { const ui = sessionLifecycleSelectors(appPage); await useGlobalWorkspaceIfPrompted(ui); await expect(ui.osDesktop).toBeVisible(); - await appPage.getByTestId("nav-agents").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(ui.agentRow(categorizedAgent)).toBeVisible(); - await expect(ui.agentRow(flatAgent)).toBeVisible(); - await expect(appPage.getByTestId("agent-fleet-row-link-categorized-agent")).toContainText( - "Marketing / Sales" - ); + await expect(fleet.agentRow(categorizedAgent)).toBeVisible(); + await expect(fleet.agentRow(flatAgent)).toBeVisible(); + await expect(fleet.agentRow(categorizedAgent)).toContainText("Marketing / Sales"); - await ui.agentRow(flatAgent).click(); + await fleet.agentRow(flatAgent).click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe(`/agents/${flatAgent}`); - await expect(ui.agentPageNewSession).toBeVisible(); - await ui.agentPageNewSession.click(); + await expect(fleet.agentPageNewSession).toBeVisible(); + await fleet.agentPageNewSession.click(); const trigger = appPage.getByTestId("session-create-agent-select"); await expect(trigger).toBeVisible(); @@ -75,8 +76,9 @@ test("categorized agents surface on the fleet page and group inside the session- await expect(trigger).toContainText(categorizedAgent); await appPage.getByTestId("session-create-dialog-cancel").click(); - await appPage.getByTestId("nav-agents").click(); + await appPage.goto(runtime.url("/agents"), { waitUntil: "domcontentloaded" }); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await ui.agentRow(categorizedAgent).click(); + await expect(agentsWin).toBeVisible(); + await fleet.agentRow(categorizedAgent).click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe(`/agents/${categorizedAgent}`); }); diff --git a/web/e2e/__tests__/agents.spec.ts b/web/e2e/__tests__/agents.spec.ts index 92cc9f971..58994002b 100644 --- a/web/e2e/__tests__/agents.spec.ts +++ b/web/e2e/__tests__/agents.spec.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; +import { openAppWindow } from "../fixtures/os-navigation"; import { sessionLifecycleSelectors } from "../fixtures/selectors"; import { expect, test } from "../fixtures/test"; import { ensureGlobalWorkspace, useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -32,31 +33,11 @@ test("agent navigation renders the managed default agent after first-run setup", await appPage.goto(runtime.url("/agents"), { waitUntil: "domcontentloaded" }); await useGlobalWorkspaceIfPrompted(ui); - await expect(appPage.getByTestId("agent-fleet-empty")).toHaveCount(0); - await expect(ui.agentRow("general")).toBeVisible(); -}); - -test("dashboard reports the agents endpoint failure during shell bootstrap", async ({ - page, - runtime, -}) => { - await page.route("**/api/agents**", async route => { - await route.fulfill({ - status: 500, - contentType: "application/json", - body: JSON.stringify({ error: "agents unavailable" }), - }); - }); - - await ensureGlobalWorkspace(runtime); - await page.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); - const ui = sessionLifecycleSelectors(page); - await useGlobalWorkspaceIfPrompted(ui); - - await expect(page.getByRole("heading", { name: "Unable to load dashboard" })).toBeVisible({ - timeout: 20_000, - }); - await expect(page.getByText("agents unavailable")).toBeVisible(); + const agentsWin = appPage.getByTestId("os-window-app:agents"); + await expect(agentsWin).toBeVisible(); + const fleet = sessionLifecycleSelectors(agentsWin); + await expect(agentsWin.getByTestId("agent-fleet-empty")).toHaveCount(0); + await expect(fleet.agentRow("general")).toBeVisible(); }); test.describe("seeded agent detail", () => { @@ -86,12 +67,13 @@ test.describe("seeded agent detail", () => { const ui = sessionLifecycleSelectors(appPage); await useGlobalWorkspaceIfPrompted(ui); - await appPage.getByTestId("nav-agents").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(ui.agentRow("agent-detail-primary")).toBeVisible(); - await expect(ui.agentRow("agent-detail-secondary")).toBeVisible(); - await ui.agentRow("agent-detail-primary").click(); + await expect(fleet.agentRow("agent-detail-primary")).toBeVisible(); + await expect(fleet.agentRow("agent-detail-secondary")).toBeVisible(); + await fleet.agentRow("agent-detail-primary").click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents/agent-detail-primary"); await expect(appPage.getByTestId("agent-detail-page")).toBeVisible(); @@ -113,7 +95,7 @@ test.describe("seeded agent detail", () => { await expect.poll(() => new URL(appPage.url()).searchParams.get("tab")).toBe("sessions"); await expect(appPage.getByTestId("agent-sessions-empty")).toBeVisible(); - await appPage.getByTestId("agent-page-new-session").click(); + await fleet.agentPageNewSession.click(); const trigger = appPage.getByTestId("session-create-agent-select"); await expect(trigger).toBeVisible(); await trigger.click(); @@ -130,10 +112,12 @@ test.describe("seeded agent detail", () => { test("operator edits agent settings, saves, and returns to overview", async ({ appPage }) => { const ui = sessionLifecycleSelectors(appPage); await useGlobalWorkspaceIfPrompted(ui); - await appPage.getByTestId("nav-agents").click(); - await ui.agentRow("agent-detail-primary").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); + await fleet.agentRow("agent-detail-primary").click(); await expect(appPage.getByTestId("agent-detail-page")).toBeVisible(); + await appPage.getByTestId("agent-page-overflow").click(); await appPage.getByTestId("agent-page-edit-settings").click(); await expect .poll(() => new URL(appPage.url()).pathname) @@ -176,8 +160,9 @@ test.describe("seeded agent detail", () => { test("operator duplicates an agent from the overflow menu", async ({ appPage }) => { const ui = sessionLifecycleSelectors(appPage); await useGlobalWorkspaceIfPrompted(ui); - await appPage.getByTestId("nav-agents").click(); - await ui.agentRow("agent-detail-primary").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); + await fleet.agentRow("agent-detail-primary").click(); await appPage.getByTestId("agent-page-overflow").click(); await appPage.getByTestId("agent-page-duplicate").click(); @@ -191,8 +176,10 @@ test.describe("seeded agent detail", () => { }) => { const ui = sessionLifecycleSelectors(appPage); await useGlobalWorkspaceIfPrompted(ui); - await appPage.getByTestId("nav-agents").click(); - await ui.agentRow("agent-detail-secondary").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); + await fleet.agentRow("agent-detail-secondary").click(); + await appPage.getByTestId("agent-page-overflow").click(); await appPage.getByTestId("agent-page-edit-settings").click(); await appPage.getByTestId("agent-settings-nav-danger").click(); await appPage.getByTestId("agent-settings-delete").click(); @@ -202,7 +189,7 @@ test.describe("seeded agent detail", () => { await expect(appPage.getByTestId("agent-delete-confirm")).toBeEnabled(); await appPage.getByTestId("agent-delete-confirm").click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(ui.agentRow("agent-detail-secondary")).toHaveCount(0); + await expect(fleet.agentRow("agent-detail-secondary")).toHaveCount(0); }); test("operator creates an agent with a persisted model and reasoning default", async ({ @@ -225,7 +212,7 @@ test.describe("seeded agent detail", () => { await expect(appPage.getByTestId("agent-create-runtime")).toBeVisible(); const runtimeTrigger = appPage.getByTestId("agent-create-runtime-select"); - await runtimeTrigger.locator('button[data-focus="model"]').first().click(); + await runtimeTrigger.click(); await expect(appPage.getByTestId("runtime-selector-popup")).toBeVisible(); await appPage .locator(`[data-provider="${mockAgentProvider}"][data-model="${reasoningCatalogModel}"]`) @@ -236,7 +223,7 @@ test.describe("seeded agent detail", () => { await reasoningStrip.locator('button[data-rz="high"]').click(); await appPage.keyboard.press("Escape"); await expect(runtimeTrigger).toContainText(reasoningCatalogModelLabel); - await expect(runtimeTrigger.locator('button[data-focus="reasoning"]')).toContainText("High"); + await expect(runtimeTrigger).toContainText("High"); await appPage.getByTestId("agent-create-next").click(); await appPage.getByTestId("agent-create-prompt").fill("Keep runtime selection truthful."); @@ -318,11 +305,12 @@ test.describe("fleet scan journey", () => { }) => { const ui = sessionLifecycleSelectors(appPage); await useGlobalWorkspaceIfPrompted(ui); - await appPage.getByTestId("nav-agents").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(ui.agentRow("fleet-release")).toBeVisible(); - await expect(ui.agentRow("fleet-ops")).toBeVisible(); + await expect(fleet.agentRow("fleet-release")).toBeVisible(); + await expect(fleet.agentRow("fleet-ops")).toBeVisible(); await appPage.getByTestId("listing-view-cards").click(); await expect.poll(() => new URL(appPage.url()).searchParams.get("view")).toBe("cards"); @@ -330,13 +318,14 @@ test.describe("fleet scan journey", () => { await expect(appPage.getByTestId("agent-fleet-card-fleet-release")).toBeVisible(); await appPage.getByTestId("listing-view-rows").click(); await expect.poll(() => new URL(appPage.url()).searchParams.get("view")).toBeNull(); - await expect(ui.agentRow("fleet-release")).toBeVisible(); + await expect(fleet.agentRow("fleet-release")).toBeVisible(); await appPage.getByTestId("agent-fleet-search").fill("release"); await expect.poll(() => new URL(appPage.url()).searchParams.get("q")).toBe("release"); - await expect(ui.agentRow("fleet-release")).toBeVisible(); - await expect(ui.agentRow("fleet-ops")).toHaveCount(0); + await expect(fleet.agentRow("fleet-release")).toBeVisible(); + await expect(fleet.agentRow("fleet-ops")).toHaveCount(0); + const search = appPage.getByTestId("agent-fleet-search"); await appPage.getByTestId("agent-fleet-filters-add").click(); await appPage.getByRole("option", { name: "Category" }).click(); await appPage.getByRole("option", { name: "Engineering / Release" }).click(); @@ -344,13 +333,15 @@ test.describe("fleet scan journey", () => { .poll(() => new URL(appPage.url()).searchParams.get("category")) .toBe("Engineering / Release"); - await appPage.getByTestId("agent-fleet-search").fill("ops"); + await search.fill("ops"); + await expect(search).toHaveValue("ops"); + await expect.poll(() => new URL(appPage.url()).searchParams.get("q")).toBe("ops"); await expect(appPage.getByTestId("agent-fleet-filtered-empty")).toBeVisible(); await appPage.getByTestId("agent-fleet-clear-filters").click(); await expect.poll(() => new URL(appPage.url()).search).toBe(""); - await expect(ui.agentRow("fleet-ops")).toBeVisible(); + await expect(fleet.agentRow("fleet-ops")).toBeVisible(); - await ui.agentRow("fleet-release").click(); + await fleet.agentRow("fleet-release").click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents/fleet-release"); }); }); diff --git a/web/e2e/__tests__/automation.spec.ts b/web/e2e/__tests__/automation.spec.ts index 17961e7d8..05dea6a07 100644 --- a/web/e2e/__tests__/automation.spec.ts +++ b/web/e2e/__tests__/automation.spec.ts @@ -2,11 +2,12 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import type { AutomationJob, AutomationSuggestion } from "@/systems/automation"; -import { automationOperatorSelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; +import { automationOperatorSelectors, sessionWindowSelectors } from "../fixtures/selectors"; import { browserAutomationOperatorFlowScenario, seedBrowserAutomationOperatorFlow, } from "../fixtures/runtime"; +import { openAppWindow, sessionWindow, windowTitle } from "../fixtures/os-navigation"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -48,7 +49,6 @@ test("operator can inspect automation, trigger a real run, and inspect the linke runtime, }) => { const automationUI = automationOperatorSelectors(appPage); - const sessionUI = sessionLifecycleSelectors(appPage); const seeded = await seedBrowserAutomationOperatorFlow(runtime, { agentName: automationAgentName, }); @@ -56,48 +56,48 @@ test("operator can inspect automation, trigger a real run, and inspect the linke await useGlobalWorkspaceIfPrompted(automationUI); await expect(automationUI.osDesktop).toBeVisible(); - await expect(automationUI.navJobs).toBeVisible(); - await automationUI.navJobs.click(); + const jobsWin = await openAppWindow(appPage, "Jobs", "jobs"); + const jobsUI = automationOperatorSelectors(jobsWin, appPage); await expect(appPage).toHaveURL(/\/jobs$/); - await expect(automationUI.jobsShell).toBeVisible(); - await expect(automationUI.jobsListRows).toBeVisible(); - await expect(automationUI.item(seeded.job.id)).toBeVisible(); - await automationUI.itemLink(seeded.job.id).click(); + await expect(jobsUI.jobsShell).toBeVisible(); + await expect(jobsUI.jobsListRows).toBeVisible(); + await expect(jobsUI.item(seeded.job.id)).toBeVisible(); + await jobsUI.itemLink(seeded.job.id).click(); await expect(appPage).toHaveURL(new RegExp(`/jobs/${seeded.job.id}$`)); - await expect(automationUI.detailPanel).toBeVisible(); - await expect(automationUI.detailPanel).toContainText(seeded.job.name); - await expect(automationUI.detailPanel).toContainText( - browserAutomationOperatorFlowScenario.job.prompt - ); - await expect(automationUI.runHistory).toBeVisible(); - await expect(automationUI.run(seeded.baselineRun.id)).toBeVisible(); - await expect(automationUI.run(seeded.baselineRun.id)).toContainText(/completed/i); - await expect(automationUI.runSessionLink(seeded.baselineRun.id)).toBeVisible(); - await expect(automationUI.runSessionLink(seeded.baselineRun.id)).toHaveAttribute( + await expect(jobsUI.detailPanel).toBeVisible(); + await expect(windowTitle(jobsWin)).toContainText(seeded.job.name); + await expect(jobsUI.detailPanel).toContainText(browserAutomationOperatorFlowScenario.job.prompt); + await expect(jobsUI.runHistory).toBeVisible(); + await expect(jobsUI.run(seeded.baselineRun.id)).toBeVisible(); + await expect(jobsUI.run(seeded.baselineRun.id)).toContainText(/completed/i); + await expect(jobsUI.runSessionLink(seeded.baselineRun.id)).toBeVisible(); + await expect(jobsUI.runSessionLink(seeded.baselineRun.id)).toHaveAttribute( "href", `/session/${seeded.baselineRun.session_id}` ); - await automationUI.navTriggers.click(); + const triggersWin = await openAppWindow(appPage, "Triggers", "triggers"); + const triggersUI = automationOperatorSelectors(triggersWin, appPage); await expect(appPage).toHaveURL(/\/triggers$/); - await expect(automationUI.triggersShell).toBeVisible(); - await expect(automationUI.triggersListRows).toBeVisible(); - await expect(automationUI.item(seeded.trigger.id)).toBeVisible(); - await automationUI.itemLink(seeded.trigger.id).click(); + await expect(triggersUI.triggersShell).toBeVisible(); + await expect(triggersUI.triggersListRows).toBeVisible(); + await expect(triggersUI.item(seeded.trigger.id)).toBeVisible(); + await triggersUI.itemLink(seeded.trigger.id).click(); await expect(appPage).toHaveURL(new RegExp(`/triggers/${seeded.trigger.id}$`)); - await expect(automationUI.detailPanel).toContainText(seeded.trigger.name); - await expect(automationUI.detailPanel).toContainText( + await expect(windowTitle(triggersWin)).toContainText(seeded.trigger.name); + await expect(triggersUI.detailPanel).toContainText( browserAutomationOperatorFlowScenario.trigger.webhookID ); - await expect(automationUI.editAutomationButton).toBeVisible(); - await expect(automationUI.editAutomationButton).toBeEnabled(); - await automationUI.editAutomationButton.click(); - await expect(automationUI.triggerNameInput).toHaveValue(seeded.trigger.name); - const triggerDialog = appPage.getByTestId("automation-editor-dialog"); + await triggersUI.detailOverflow.click(); + const editTrigger = appPage.getByTestId("edit-automation-btn"); + await expect(editTrigger).toBeEnabled(); + await editTrigger.click(); + await expect(triggersUI.triggerNameInput).toHaveValue(seeded.trigger.name); + const triggerDialog = triggersUI.editorDialog; await expect(triggerDialog).toHaveAttribute("data-frame", "unframed"); await expect(triggerDialog.locator('[data-slot="dialog-header"]')).toHaveAttribute( "data-variant", @@ -107,25 +107,26 @@ test("operator can inspect automation, trigger a real run, and inspect the linke "data-variant", "ruled" ); - await expect(appPage.getByTestId("trigger-retry-max")).toBeVisible(); - await appPage.getByTestId("trigger-governance-toggle").click(); - await expect(appPage.getByTestId("trigger-retry-max")).toBeHidden(); - await appPage.getByTestId("trigger-governance-toggle").click(); - await expect(appPage.getByTestId("trigger-retry-max")).toBeVisible(); + await expect(triggersUI.triggerRetryMax).toBeVisible(); + await triggersWin.getByTestId("trigger-governance-toggle").click(); + await expect(triggersUI.triggerRetryMax).toBeHidden(); + await triggersWin.getByTestId("trigger-governance-toggle").click(); + await expect(triggersUI.triggerRetryMax).toBeVisible(); await appPage.keyboard.press("Escape"); await expect(triggerDialog).toBeHidden(); - await automationUI.navJobs.click(); + await appPage.goto(runtime.url("/jobs"), { waitUntil: "domcontentloaded" }); await expect(appPage).toHaveURL(/\/jobs$/); - await expect(automationUI.jobsShell).toBeVisible(); - await automationUI.itemLink(seeded.job.id).click(); + await expect(jobsUI.jobsShell).toBeVisible(); + await jobsUI.itemLink(seeded.job.id).click(); await expect(appPage).toHaveURL(new RegExp(`/jobs/${seeded.job.id}$`)); - await expect(automationUI.editAutomationButton).toBeVisible(); - await expect(automationUI.editAutomationButton).toBeEnabled(); - await automationUI.editAutomationButton.click(); - await expect(automationUI.jobForm).toBeVisible(); - const jobDialog = appPage.getByTestId("automation-editor-dialog"); + await jobsUI.detailOverflow.click(); + const editJob = appPage.getByTestId("edit-automation-btn"); + await expect(editJob).toBeEnabled(); + await editJob.click(); + await expect(jobsUI.jobForm).toBeVisible(); + const jobDialog = jobsUI.editorDialog; await expect(jobDialog).toHaveAttribute("data-frame", "unframed"); await expect(jobDialog.locator('[data-slot="dialog-header"]')).toHaveAttribute( "data-variant", @@ -135,14 +136,14 @@ test("operator can inspect automation, trigger a real run, and inspect the linke "data-variant", "ruled" ); - await expect(automationUI.jobNameInput).toHaveValue(seeded.job.name); - await expect(automationUI.jobScheduleExpr).toHaveValue( + await expect(jobsUI.jobNameInput).toHaveValue(seeded.job.name); + await expect(jobsUI.jobScheduleExpr).toHaveValue( browserAutomationOperatorFlowScenario.job.scheduleExpr ); await appPage.keyboard.press("Escape"); - await expect(automationUI.jobForm).toBeHidden(); + await expect(jobsUI.jobForm).toBeHidden(); - await automationUI.triggerJobButton.click(); + await jobsUI.triggerJobButton.click(); await expect .poll(async () => { @@ -180,14 +181,15 @@ test("operator can inspect automation, trigger a real run, and inspect the linke throw new Error("Expected the UI-triggered automation run to include a linked session."); } - await expect(automationUI.run(uiTriggeredRun.id)).toBeVisible(); + await expect(jobsUI.run(uiTriggeredRun.id)).toBeVisible(); await browserArtifacts.captureScreenshot("automation-operator-history", appPage); - await automationUI.runSessionLink(uiTriggeredRun.id).click(); + await jobsUI.runSessionLink(uiTriggeredRun.id).click(); await expect .poll(() => new URL(appPage.url()).pathname) .toBe(automationSessionPath(uiTriggeredRun.session_id)); + const sessionUI = sessionWindowSelectors(sessionWindow(appPage, uiTriggeredRun.session_id)); await expect(sessionUI.chatView).toBeVisible(); await expect(sessionUI.chatView).toContainText(browserAutomationOperatorFlowScenario.job.prompt); await expect(sessionUI.chatView).toContainText( @@ -228,16 +230,17 @@ test("operator can accept and dismiss workspace suggestions through the real dae throw new Error("Expected the deterministic starter suggestion catalog."); } - await automationUI.navJobs.click(); + const jobsWin = await openAppWindow(appPage, "Jobs", "jobs"); + const jobsUI = automationOperatorSelectors(jobsWin, appPage); await expect(appPage).toHaveURL(/\/jobs$/); - await expect(automationUI.automationSuggestionsCard).toBeVisible(); + await expect(jobsUI.automationSuggestionsCard).toBeVisible(); - const acceptedRow = automationUI.suggestion(acceptTarget.id); + const acceptedRow = jobsUI.suggestion(acceptTarget.id); await expect(acceptedRow).toContainText(acceptTarget.payload.prompt); await acceptedRow.getByRole("button", { name: "Create job" }).click(); await expect(acceptedRow).toBeHidden(); - await expect(automationUI.item(acceptTarget.payload.id)).toBeVisible(); + await expect(jobsUI.item(acceptTarget.payload.id)).toBeVisible(); const acceptedJob = await runtime.requestJSON<{ job: AutomationJob }>( `/api/automation/jobs/${encodeURIComponent(acceptTarget.payload.id)}` ); @@ -247,14 +250,14 @@ test("operator can accept and dismiss workspace suggestions through the real dae workspace_id: workspaceID, }); - const dismissedRow = automationUI.suggestion(dismissTarget.id); + const dismissedRow = jobsUI.suggestion(dismissTarget.id); await dismissedRow.getByRole("button", { name: "Dismiss" }).click(); await expect(dismissedRow).toBeHidden(); await appPage.reload({ waitUntil: "domcontentloaded" }); - await expect(automationUI.jobsShell).toBeVisible(); - await expect(automationUI.suggestion(acceptTarget.id)).toBeHidden(); - await expect(automationUI.suggestion(dismissTarget.id)).toBeHidden(); + await expect(jobsUI.jobsShell).toBeVisible(); + await expect(jobsUI.suggestion(acceptTarget.id)).toBeHidden(); + await expect(jobsUI.suggestion(dismissTarget.id)).toBeHidden(); const accepted = await runtime.requestJSON<{ suggestions: AutomationSuggestion[] }>( `${suggestionPath}?status=accepted` diff --git a/web/e2e/__tests__/bridges.spec.ts b/web/e2e/__tests__/bridges.spec.ts index 2728416c1..5b4249af9 100644 --- a/web/e2e/__tests__/bridges.spec.ts +++ b/web/e2e/__tests__/bridges.spec.ts @@ -17,6 +17,7 @@ import type { import { captureRouteState } from "../fixtures/browser-artifact-session"; import { bridgeOperatorSelectors } from "../fixtures/selectors"; +import { openAppWindow, windowTitle } from "../fixtures/os-navigation"; import type { BrowserRuntime } from "../fixtures/runtime"; import { browserBridgeOperatorFlowScenario, @@ -96,14 +97,15 @@ test("operator can edit bridge config, enable runtime, observe status updates, a browserArtifacts, runtime, }) => { - const bridgeUI = bridgeOperatorSelectors(appPage); + const shellUI = bridgeOperatorSelectors(appPage); const seeded = await seedBrowserBridgeOperatorFlow(runtime); - await useGlobalWorkspaceIfPrompted(bridgeUI); + await useGlobalWorkspaceIfPrompted(shellUI); - await expect(bridgeUI.osDesktop).toBeVisible(); - await expect(bridgeUI.navBridges).toBeVisible(); - await bridgeUI.navBridges.click(); + await expect(shellUI.osDesktop).toBeVisible(); + const bridgesWin = await openAppWindow(appPage, "Bridges", "bridges"); + const bridgeUI = bridgeOperatorSelectors(bridgesWin); + const bridgeStatus = bridgesWin.locator('[data-slot="topbar-status"]'); await expect(appPage).toHaveURL(/\/bridges$/); await expect(bridgeUI.listPanel).toBeVisible(); @@ -135,7 +137,7 @@ test("operator can edit bridge config, enable runtime, observe status updates, a await expect(appPage).toHaveURL( new RegExp(`/bridges/${encodeURIComponent(createdPayload.bridge.id)}$`) ); - await expect(bridgeUI.detailPanel).toContainText(createdBridgeName); + await expect(windowTitle(bridgesWin)).toContainText(createdBridgeName); await browserArtifacts.captureScreenshot("bridge-create-dialog-saved", appPage); await bridgeUI.backToList.click(); @@ -151,14 +153,15 @@ test("operator can edit bridge config, enable runtime, observe status updates, a await expect(bridgeUI.item(seeded.bridge.id)).toContainText("0 routes"); await bridgeUI.item(seeded.bridge.id).click(); await expect(appPage).toHaveURL(new RegExp(`/bridges/${encodeURIComponent(seeded.bridge.id)}$`)); - await expect(bridgeUI.detailPanel).toContainText( + await expect(windowTitle(bridgesWin)).toContainText( browserBridgeOperatorFlowScenario.bridge.initialName ); - await expect(bridgeUI.detailPanel).toContainText("disabled"); + await expect(bridgeStatus).toContainText("disabled"); await expect(bridgeUI.detailPanel).toContainText(/Last delivery\s*Never/); await browserArtifacts.captureScreenshot("bridge-operator-seeded", appPage); - await bridgeUI.editBridgeButton.click(); + await bridgeUI.detailOverflow.click(); + await appPage.getByTestId("edit-bridge-btn").click(); await expect(bridgeUI.editDialog).toBeVisible(); await bridgeUI.editDisplayNameInput.fill(browserBridgeOperatorFlowScenario.bridge.editedName); @@ -169,7 +172,7 @@ test("operator can edit bridge config, enable runtime, observe status updates, a await bridgeUI.submitBridgeEdit.click(); await expect(bridgeUI.editDialog).toBeHidden(); - await expect(bridgeUI.detailPanel).toContainText( + await expect(windowTitle(bridgesWin)).toContainText( browserBridgeOperatorFlowScenario.bridge.editedName ); await expect(bridgeUI.detailPanel).toContainText( @@ -195,7 +198,7 @@ test("operator can edit bridge config, enable runtime, observe status updates, a .toBe("ready"); await expect - .poll(async () => (await bridgeUI.detailPanel.textContent()) ?? "", { + .poll(async () => (await bridgeStatus.textContent()) ?? "", { timeout: 45_000, }) .toContain("ready"); @@ -244,9 +247,7 @@ test("operator can edit bridge config, enable runtime, observe status updates, a /Last delivery\s*Never/.test((await bridgeUI.detailPanel.textContent()) ?? "") ) .toBe(false); - await expect - .poll(async () => (await bridgeUI.detailPanel.textContent()) ?? "") - .toContain("ready"); + await expect.poll(async () => (await bridgeStatus.textContent()) ?? "").toContain("ready"); const routeSnapshots = await collectBridgeRouteSnapshots(runtime, seeded.bridge.id); expect(routeSnapshots.http.routes.some(route => route.session_id === ingress.sessionId)).toBe( true @@ -268,15 +269,17 @@ test("operator creates a bridge, rotates secrets, diagnoses auth failure, and re browserArtifacts, runtime, }) => { - const bridgeUI = bridgeOperatorSelectors(appPage); + const shellUI = bridgeOperatorSelectors(appPage); const seeded = await seedBrowserBridgeOperatorFlow(runtime, { displayName: "Telegram Seed Provider Anchor", }); const providerKey = `${seeded.provider.extension_name}::${seeded.provider.platform}`; const createdName = "Telegram Browser Lifecycle"; - await useGlobalWorkspaceIfPrompted(bridgeUI); - await bridgeUI.navBridges.click(); + await useGlobalWorkspaceIfPrompted(shellUI); + const bridgesWin = await openAppWindow(appPage, "Bridges", "bridges"); + const bridgeUI = bridgeOperatorSelectors(bridgesWin); + const bridgeStatus = bridgesWin.locator('[data-slot="topbar-status"]'); await expect(appPage).toHaveURL(/\/bridges$/); await expect(bridgeUI.listPanel).toBeVisible(); @@ -315,7 +318,7 @@ test("operator creates a bridge, rotates secrets, diagnoses auth failure, and re await expect(bridgeUI.createDialog).toBeHidden(); const createdBridge = await waitForBridgeByName(runtime, createdName); await expect(appPage).toHaveURL(new RegExp(`/bridges/${encodeURIComponent(createdBridge.id)}$`)); - await expect(bridgeUI.detailPanel).toContainText(createdName); + await expect(windowTitle(bridgesWin)).toContainText(createdName); await expect(bridgeUI.detailPanel).toContainText("UNBOUND"); await browserArtifacts.captureScreenshot("bridge-created-unbound", appPage); @@ -364,23 +367,21 @@ test("operator creates a bridge, rotates secrets, diagnoses auth failure, and re await bridgeUI.enableBridgeButton.click(); await waitForBridgeStatus(runtime, createdBridge.id, "ready"); await expect - .poll(async () => (await bridgeUI.detailPanel.textContent()) ?? "", { timeout: 45_000 }) + .poll(async () => (await bridgeStatus.textContent()) ?? "", { timeout: 45_000 }) .toContain("ready"); await bridgeUI.detailOverflow.click(); - await bridgeUI.disableBridgeButton.click(); + await appPage.getByTestId("disable-bridge-btn").click(); await waitForBridgeStatus(runtime, createdBridge.id, "disabled"); - await expect - .poll(async () => (await bridgeUI.detailPanel.textContent()) ?? "") - .toContain("disabled"); + await expect.poll(async () => (await bridgeStatus.textContent()) ?? "").toContain("disabled"); await bridgeUI.enableBridgeButton.click(); await waitForBridgeStatus(runtime, createdBridge.id, "ready"); await bridgeUI.detailOverflow.click(); - await bridgeUI.restartBridgeButton.click(); + await appPage.getByTestId("restart-bridge-btn").click(); await waitForBridgeStatus(runtime, createdBridge.id, "ready"); await expect - .poll(async () => (await bridgeUI.detailPanel.textContent()) ?? "", { timeout: 45_000 }) + .poll(async () => (await bridgeStatus.textContent()) ?? "", { timeout: 45_000 }) .toContain("ready"); await browserArtifacts.captureScreenshot("bridge-ready-after-restart", appPage); @@ -402,11 +403,11 @@ test("operator creates a bridge, rotates secrets, diagnoses auth failure, and re await expect(bridgeUI.restartRequired).toBeVisible(); await bridgeUI.detailOverflow.click(); - await bridgeUI.restartBridgeButton.click(); + await appPage.getByTestId("restart-bridge-btn").click(); const authRequired = await waitForBridgeStatus(runtime, createdBridge.id, "auth_required"); expect(authRequired.health.status).toBe("auth_required"); await expect - .poll(async () => (await bridgeUI.detailPanel.textContent()) ?? "") + .poll(async () => (await bridgeStatus.textContent()) ?? "") .toContain("auth required"); await browserArtifacts.captureScreenshot("bridge-auth-required-after-secret-delete", appPage); @@ -418,10 +419,10 @@ test("operator creates a bridge, rotates secrets, diagnoses auth failure, and re bridgeUI.secretBinding(browserBridgeOperatorFlowScenario.secretBinding.name) ).toContainText("BOUND"); await bridgeUI.detailOverflow.click(); - await bridgeUI.restartBridgeButton.click(); + await appPage.getByTestId("restart-bridge-btn").click(); await waitForBridgeStatus(runtime, createdBridge.id, "ready"); await expect - .poll(async () => (await bridgeUI.detailPanel.textContent()) ?? "", { timeout: 45_000 }) + .poll(async () => (await bridgeStatus.textContent()) ?? "", { timeout: 45_000 }) .toContain("ready"); await assertBridgeDetailResponsive(appPage, bridgeUI, createdBridge.id); diff --git a/web/e2e/__tests__/combined-flows.spec.ts b/web/e2e/__tests__/combined-flows.spec.ts index e95943caa..22c66a0ac 100644 --- a/web/e2e/__tests__/combined-flows.spec.ts +++ b/web/e2e/__tests__/combined-flows.spec.ts @@ -2,7 +2,8 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { bridgeOperatorSelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; +import { openAppWindow, sessionWindow, windowTitle } from "../fixtures/os-navigation"; +import { bridgeOperatorSelectors, sessionWindowSelectors } from "../fixtures/selectors"; import { browserBridgeOperatorFlowScenario, seedBrowserBridgeOperatorFlow, @@ -48,22 +49,22 @@ test("@nightly operator can follow a bridge-created route into the shipped sessi runtime, }) => { const bridgeUI = bridgeOperatorSelectors(appPage); - const sessionUI = sessionLifecycleSelectors(appPage); const seeded = await seedBrowserBridgeOperatorFlow(runtime); await useGlobalWorkspaceIfPrompted(bridgeUI); await expect(bridgeUI.osDesktop).toBeVisible(); - await bridgeUI.navBridges.click(); + const bridgesWin = await openAppWindow(appPage, "Bridges", "bridges"); + const bridge = bridgeOperatorSelectors(bridgesWin); await expect(appPage).toHaveURL(/\/bridges$/); - await expect(bridgeUI.listPanel).toBeVisible(); + await expect(bridge.listPanel).toBeVisible(); - await bridgeUI.item(seeded.bridge.id).click(); - await expect(bridgeUI.detailPanel).toContainText( + await bridge.item(seeded.bridge.id).click(); + await expect(windowTitle(bridgesWin)).toContainText( browserBridgeOperatorFlowScenario.bridge.initialName ); - await bridgeUI.enableBridgeButton.click(); + await bridge.enableBridgeButton.click(); await expect .poll(async () => { const payload = await runtime.requestJSON<{ @@ -77,15 +78,17 @@ test("@nightly operator can follow a bridge-created route into the shipped sessi const ingress = await triggerBrowserBridgeIngress(runtime, seeded); - await expect(bridgeUI.route(ingress.sessionId)).toBeVisible(); - await expect(bridgeUI.detailPanel).toContainText(ingress.sessionId); + await expect(bridge.route(ingress.sessionId)).toBeVisible(); + await expect(bridge.detailPanel).toContainText(ingress.sessionId); await browserArtifacts.captureScreenshot("combined-flow-bridge-route", appPage); await appPage.goto(runtime.url(`/session/${encodeURIComponent(ingress.sessionId)}`), { waitUntil: "domcontentloaded", }); - await expect(sessionUI.chatHeader).toBeVisible(); + const sessionWin = sessionWindow(appPage, ingress.sessionId); + const sessionUI = sessionWindowSelectors(sessionWin); + await expect(sessionWin).toBeVisible(); await expect(sessionUI.chatView).toContainText(browserBridgeOperatorFlowScenario.ingress.text); await expect(sessionUI.chatView).toContainText( browserBridgeOperatorFlowScenario.ingress.assistant diff --git a/web/e2e/__tests__/dashboard.spec.ts b/web/e2e/__tests__/dashboard.spec.ts index 2ad89d398..166b99c5b 100644 --- a/web/e2e/__tests__/dashboard.spec.ts +++ b/web/e2e/__tests__/dashboard.spec.ts @@ -1,3 +1,8 @@ +// Suite: daemon-served Home application +// Invariant: Home renders the current overview truth, degrades honestly, and preserves +// HTTP/UDS/CLI parity through real browser flows. +// Boundary IN: daemon-served Web, overview transports, workspace switching, and QA artifacts. +// Boundary OUT: overview aggregation internals (owned by internal/observe suites). import { execFile } from "node:child_process"; import { mkdtemp, readFile } from "node:fs/promises"; import os from "node:os"; @@ -5,18 +10,21 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; + +import type { OperationResponse } from "@/lib/api-contract"; -import { sessionLifecycleTestIds } from "../fixtures/selectors"; import { reloadDaemonServedPage } from "../fixtures/navigation"; +import { ensureAppWindow, openAppWindow, switchWorkspace } from "../fixtures/os-navigation"; import type { BrowserRuntime, WorkspacePayload } from "../fixtures/runtime"; +import { waitForSeedSessionActive } from "../fixtures/runtime"; +import { sessionLifecycleTestIds } from "../fixtures/selectors"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; const execFileAsync = promisify(execFile); -const activeSessionStates = new Set(["active", "starting", "stopping"]); -const dashboardAgentAlpha = "dashboard-agent-alpha"; -const dashboardAgentBeta = "dashboard-agent-beta"; +const homeAgentAlpha = "home-agent-alpha"; +const homeAgentBeta = "home-agent-beta"; const browserLifecycleFixture = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", @@ -31,6 +39,9 @@ const browserLifecycleFixture = path.resolve( const sensitivePattern = /agh_claim_|claim_token["':\s]|telegram-bot-token|pkce|oauth|webhook_secret|provider[_-]?credentials?["'\s]*[:=]/i; +type HomeOverview = OperationResponse<"getObserveOverview", 200>["overview"]; +type HomeAgentCatalog = OperationResponse<"listAgentCatalog", 200>; + interface SessionSummary { id: string; state: string; @@ -39,7 +50,6 @@ interface SessionSummary { interface SettingsRestartAction { operation_id: string; - status: string; status_url: string; } @@ -47,27 +57,12 @@ interface SettingsRestartStatus { status: string; } -interface DashboardSnapshot { - agents: { agents: unknown[] }; - cli?: { - agents?: unknown; - daemon?: unknown; - sessions?: unknown; - workspaces?: unknown; - }; - daemonHTTP: unknown; - daemonUDS?: unknown; - health: { - health: { - status?: string; - uptime_seconds?: number; - version?: string; - }; - }; - sessions: { sessions: SessionSummary[] }; +interface HomeSnapshot { + catalog: HomeAgentCatalog; + cli?: HomeOverview; + http: HomeOverview; + uds?: HomeOverview; workspace: WorkspacePayload; - workspaceDetail: { agents?: unknown[]; sessions?: SessionSummary[] }; - workspaces: { workspaces: WorkspacePayload[] }; } test.use({ @@ -75,12 +70,12 @@ test.use({ seed: { mockAgents: [ { - agentName: dashboardAgentAlpha, + agentName: homeAgentAlpha, fixtureAgent: "browser-lifecycle-agent", fixturePath: browserLifecycleFixture, }, { - agentName: dashboardAgentBeta, + agentName: homeAgentBeta, fixtureAgent: "browser-lifecycle-agent", fixturePath: browserLifecycleFixture, }, @@ -89,55 +84,55 @@ test.use({ }, }); -test("operator sees truthful Dashboard health, metrics, navigation, artifacts, and parity evidence", async ({ +test("operator sees truthful Home overview, navigation, artifacts, and transport parity", async ({ appPage, browserArtifacts, runtime, }) => { - const workspace = await prepareDashboardRuntime(runtime); + const workspace = await prepareHomeRuntime(runtime); await useGlobalWorkspaceIfPrompted(workspaceShell(appPage)); await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); + const home = await ensureAppWindow(appPage, "Home", "dashboard"); + const snapshot = await captureHomeSnapshot(runtime, workspace); - const snapshot = await captureDashboardSnapshot(runtime, workspace); - const expectedActiveSessions = snapshot.sessions.sessions.filter(session => - activeSessionStates.has(session.state) - ).length; - const expectedAgents = snapshot.workspaceDetail.agents?.length ?? snapshot.agents.agents.length; - const expectedDaemonStatus = dashboardStatusKey(snapshot.health.health.status); - - await expect(appPage.getByTestId("home-shell")).toBeVisible(); - await expect(appPage.getByTestId("home-connection-indicator")).toHaveAttribute( + await expect(home.getByTestId("home-body")).toBeVisible(); + await expect(home.getByTestId("home-connection-indicator")).toHaveAttribute( "data-status", "connected" ); - await expect(appPage.getByTestId("home-daemon-card")).toHaveAttribute( - "data-status", - expectedDaemonStatus, - { timeout: 15_000 } - ); - await expect(appPage.getByTestId("home-daemon-status-label")).toHaveText(/Healthy|Degraded/); - expect(snapshot.health.health.version?.trim()).not.toBe(""); - await expect(appPage.getByTestId("home-daemon-version")).toContainText( - `v${snapshot.health.health.version}` + await expect(home.locator('[data-slot="home-page-meta"]')).toBeVisible(); + await expect(home.locator('[data-slot="home-kpi-strip"] [data-slot="metric"]')).toHaveCount(4); + await expect(homeMetricValue(home, "Needs you")).toHaveText( + String(snapshot.http.attention.total) ); - await expect(metricValue(appPage, "home-metric-active-sessions")).toHaveText( - String(expectedActiveSessions) + await expect(homeMetricValue(home, "Completed today")).toHaveText( + String(snapshot.http.today.runs_completed + snapshot.http.today.tasks_closed) ); - await expect(metricValue(appPage, "home-metric-workspaces")).toHaveText( - String(snapshot.workspaces.workspaces.length) + await expect(homeMetricValue(home, "Usage")).toHaveText( + formatTokenCount(snapshot.http.usage.total_tokens) ); - await expect(metricValue(appPage, "home-metric-agents")).toHaveText(String(expectedAgents)); - await expect(metricValue(appPage, "home-metric-uptime")).not.toHaveText("—"); - await expect(appPage.getByTestId("home-metric-active-sessions")).toContainText( - `in ${workspace.name}` + await expect + .poll(async () => Number(await homeMetricValue(home, "Working now").textContent())) + .toBeGreaterThan(0); + await expect(home.locator('[data-slot="home-run-card"]')).toBeVisible(); + await expect(home.locator('[data-slot="home-agent-row"]')).toHaveCount( + snapshot.catalog.agents.length ); - - await assertDashboardNavigation(appPage, runtime); - await assertDashboardViewportMatrix(appPage, browserArtifacts, runtime); - await assertDashboardFocus(appPage); + await expect( + home.locator('[data-slot="home-agent-row"]').filter({ hasText: homeAgentAlpha }) + ).toBeVisible(); + await expect(home.locator('[data-slot="home-pulse"]')).toBeVisible(); + await expect(home.locator('[data-slot="home-outcomes"]')).toBeVisible(); + await expect(home.locator('[data-slot="home-usage"]')).toBeVisible(); + await expect(home.locator('[data-slot="home-activity"]')).toBeVisible(); + + assertOverviewParity(snapshot); + await assertHomeNavigation(appPage, home); + await assertHomeViewportMatrix(appPage, browserArtifacts, home); + await assertHomeFocus(appPage); await runtime.artifactCollector.captureJSON("browser_api_snapshots", snapshot); - await browserArtifacts.captureScreenshot("dashboard-truthful", appPage); + await browserArtifacts.captureScreenshot("home-truthful", appPage); const manifest = await browserArtifacts.persist(appPage); expect(manifest.artifacts).toEqual( expect.arrayContaining([ @@ -154,360 +149,298 @@ test("operator sees truthful Dashboard health, metrics, navigation, artifacts, a expect(routeState).toMatchObject({ home_view_visible: true, home_connection_status: "connected", - home_daemon_status: expectedDaemonStatus, - home_active_sessions_value: String(expectedActiveSessions), - home_workspaces_value: String(snapshot.workspaces.workspaces.length), - home_agents_value: String(expectedAgents), + home_metric_count: 4, + home_needs_you_value: String(snapshot.http.attention.total), + home_completed_today_value: String( + snapshot.http.today.runs_completed + snapshot.http.today.tasks_closed + ), + home_usage_value: formatTokenCount(snapshot.http.usage.total_tokens), + home_agent_count: snapshot.catalog.agents.length, }); await assertNoSensitiveLeak(appPage, runtime, snapshot); }); -test("dashboard degrades one failed metric without hiding daemon health", async ({ - appPage, +test("Home reports an overview failure without misreporting daemon connectivity", async ({ + page, runtime, }) => { - await prepareDashboardRuntime(runtime); - await useGlobalWorkspaceIfPrompted(workspaceShell(appPage)); - const status = await runtime.requestJSON("/api/status"); - const expectedDaemonStatus = dashboardStatusKey(status.health.status); - await appPage.route("**/api/sessions**", async route => { + await prepareHomeRuntime(runtime); + await page.route("**/api/observe/overview**", async route => { await route.fulfill({ contentType: "application/json", status: 503, - body: JSON.stringify({ error: "sessions unavailable" }), + body: JSON.stringify({ error: "overview unavailable" }), }); }); - await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); + await page.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); + await useGlobalWorkspaceIfPrompted(workspaceShell(page)); + const home = await ensureAppWindow(page, "Home", "dashboard"); - await expect(appPage.getByTestId("home-daemon-card")).toHaveAttribute( + await expect(home.getByTestId("home-connection-indicator")).toHaveAttribute( "data-status", - expectedDaemonStatus, - { timeout: 15_000 } + "connected" ); - await expect(metricValue(appPage, "home-metric-active-sessions")).toHaveText("—"); - await expect(appPage.getByTestId("home-metric-active-sessions")).toContainText("unavailable"); - await expect(appPage.getByTestId("home-error")).toBeHidden(); + await expect(home.getByRole("heading", { name: "Unable to load the home overview" })).toBeVisible( + { timeout: 20_000 } + ); + await expect(home.locator('[data-slot="home-kpi-strip"]')).toHaveCount(0); }); -test("dashboard shows reconnecting state and recovers when health requests resume", async ({ - appPage, +test("Home preserves its loaded overview and recovers when health requests resume", async ({ + page, runtime, }) => { - await prepareDashboardRuntime(runtime); - await useGlobalWorkspaceIfPrompted(workspaceShell(appPage)); - const status = await runtime.requestJSON("/api/status"); - const expectedDaemonStatus = dashboardStatusKey(status.health.status); - await appPage.route("**/api/status", async route => { + await prepareHomeRuntime(runtime); + await page.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); + await useGlobalWorkspaceIfPrompted(workspaceShell(page)); + const home = await ensureAppWindow(page, "Home", "dashboard"); + await expect(home.getByTestId("home-connection-indicator")).toHaveAttribute( + "data-status", + "connected" + ); + + await page.route("**/api/status", async route => { await route.abort("failed"); }); - await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); - - await expect(appPage.getByTestId("home-connection-indicator")).toHaveAttribute( + await expect(home.getByTestId("home-connection-indicator")).toHaveAttribute( "data-status", /reconnecting|disconnected|error/, { timeout: 15_000 } ); - const disconnectedCard = appPage.getByTestId("home-daemon-disconnected"); - if (await disconnectedCard.isVisible().catch(() => false)) { - await expect(disconnectedCard).toContainText("agh daemon"); - } else { - await expect(appPage.getByTestId("home-daemon-card")).toHaveAttribute( - "data-status", - "disconnected" - ); - await expect(appPage.getByTestId("home-daemon-status-label")).toHaveText("Connection error"); - } + await expect(home.getByTestId("home-body")).toBeVisible(); + await expect(home.locator('[data-slot="home-kpi-strip"] [data-slot="metric"]')).toHaveCount(4); - await appPage.unroute("**/api/status"); - await expect(appPage.getByTestId("home-connection-indicator")).toHaveAttribute( + await page.unroute("**/api/status"); + await expect(home.getByTestId("home-connection-indicator")).toHaveAttribute( "data-status", "connected", { timeout: 20_000 } ); - await expect(appPage.getByTestId("home-daemon-card")).toHaveAttribute( - "data-status", - expectedDaemonStatus - ); + await expect(home.getByTestId("home-body")).toBeVisible(); }); -test("dashboard refreshes after daemon restart action without stale health", async ({ +test("Home recovers after a daemon restart without retaining a stale overview", async ({ appPage, browserArtifacts, runtime, }) => { - const workspace = await prepareDashboardRuntime(runtime); + await prepareHomeRuntime(runtime); await useGlobalWorkspaceIfPrompted(workspaceShell(appPage)); await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); + const home = await ensureAppWindow(appPage, "Home", "dashboard"); + const beforeRestart = await requestOverview(runtime); - const beforeRestart = await captureDashboardSnapshot(runtime, workspace); - const expectedBeforeRestartStatus = dashboardStatusKey(beforeRestart.health.health.status); - await expect(appPage.getByTestId("home-daemon-card")).toHaveAttribute( - "data-status", - expectedBeforeRestartStatus, - { timeout: 15_000 } + await expect(homeMetricValue(home, "Needs you")).toHaveText( + String(beforeRestart.attention.total) ); - await expect(metricValue(appPage, "home-metric-workspaces")).toHaveText( - String(beforeRestart.workspaces.workspaces.length) - ); - const restart = await runtime.requestJSON( "/api/settings/actions/restart", - { - method: "POST", - body: "{}", - } + { method: "POST", body: "{}" } ); expect(restart.operation_id).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i ); - await reloadDaemonServedPage(appPage, runtime, "/", { readyTestId: "home-shell" }); - await expect(appPage.getByTestId("home-shell")).toBeVisible({ timeout: 15_000 }); - await browserArtifacts.captureScreenshot("dashboard-restart-polling", appPage); - await expect - .poll(async () => await pollRestartStatus(runtime, restart.status_url), { - timeout: 45_000, - }) + .poll(async () => await pollRestartStatus(runtime, restart.status_url), { timeout: 45_000 }) .toBe("ready"); - await reloadDaemonServedPage(appPage, runtime, "/", { readyTestId: "home-shell" }); + await reloadDaemonServedPage(appPage, runtime, "/", { readyTestId: "home-body" }); + const afterRestart = await requestOverview(runtime); - const afterRestart = await captureDashboardSnapshot(runtime, workspace); - const expectedAfterRestartStatus = dashboardStatusKey(afterRestart.health.health.status); - await expect(appPage.getByTestId("home-connection-indicator")).toHaveAttribute( + await expect(home.getByTestId("home-connection-indicator")).toHaveAttribute( "data-status", "connected" ); - await expect(appPage.getByTestId("home-daemon-card")).toHaveAttribute( - "data-status", - expectedAfterRestartStatus - ); - await expect(metricValue(appPage, "home-metric-workspaces")).toHaveText( - String(afterRestart.workspaces.workspaces.length) - ); - expect(afterRestart.daemonHTTP).toBeDefined(); - await browserArtifacts.captureScreenshot("dashboard-restart-ready", appPage); + await expect(homeMetricValue(home, "Needs you")).toHaveText(String(afterRestart.attention.total)); + expect(afterRestart.schema_version).toBe(beforeRestart.schema_version); + expect(afterRestart.generated_at).not.toBe(beforeRestart.generated_at); + await browserArtifacts.captureScreenshot("home-restart-ready", appPage); }); -test("workspace-scoped Dashboard metrics change when the active workspace changes", async ({ - appPage, - runtime, -}) => { +test("Home scope follows the active workspace", async ({ appPage, runtime }) => { if (!runtime.paths?.homeDir) { - throw new Error("Dashboard workspace switching requires launch-mode runtime paths."); + throw new Error("Home workspace switching requires launch-mode runtime paths."); } - const alpha = await prepareDashboardRuntime(runtime); - const betaRoot = await mkdtemp(path.join(os.tmpdir(), "agh-dashboard-workspace-beta-")); + const alpha = await prepareHomeRuntime(runtime); + const betaRoot = await mkdtemp(path.join(os.tmpdir(), "agh-home-workspace-beta-")); const beta = await runtime.resolveWorkspace(betaRoot); await useGlobalWorkspaceIfPrompted(workspaceShell(appPage)); await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); - await selectDashboardWorkspace(appPage, beta.id); - - const betaSnapshot = await captureDashboardSnapshot(runtime, beta); - const betaActiveSessions = betaSnapshot.sessions.sessions.filter(session => - activeSessionStates.has(session.state) - ).length; - expect(betaActiveSessions).toBe(0); - await expect(appPage.getByTestId(`workspace-avatar-${beta.id}`)).toHaveAttribute( - "aria-pressed", - "true" - ); - await expect(metricValue(appPage, "home-metric-active-sessions")).toHaveText( - String(betaActiveSessions) - ); - await expect(appPage.getByTestId("home-metric-active-sessions")).toContainText(`in ${beta.name}`); - - await selectDashboardWorkspace(appPage, alpha.id); - const alphaSnapshot = await captureDashboardSnapshot(runtime, alpha); - const alphaActiveSessions = alphaSnapshot.sessions.sessions.filter(session => - activeSessionStates.has(session.state) - ).length; - expect(alphaActiveSessions).toBeGreaterThan(0); - await expect(appPage.getByTestId(`workspace-avatar-${alpha.id}`)).toHaveAttribute( - "aria-pressed", - "true" - ); - await expect(metricValue(appPage, "home-metric-active-sessions")).toHaveText( - String(alphaActiveSessions) - ); - await expect(appPage.getByTestId("home-metric-active-sessions")).toContainText( - `in ${alpha.name}` + let home = await ensureAppWindow(appPage, "Home", "dashboard"); + await switchWorkspace(appPage, beta.id, beta.name); + home = await ensureAppWindow(appPage, "Home", "dashboard"); + + await expect(home.locator('[data-slot="home-page-meta"]')).toContainText( + `workspace ${beta.name}` ); -}); + await expect(homeMetricValue(home, "Working now")).toHaveText("0"); -async function selectDashboardWorkspace(appPage: Page, workspaceID: string): Promise { - const switcher = appPage.getByTestId("workspace-switcher"); - await switcher.click(); - await appPage.getByTestId(`workspace-command-item-${workspaceID}`).click(); - await expect(switcher).toHaveAttribute("aria-expanded", "false"); -} + await switchWorkspace(appPage, alpha.id, alpha.name); + home = await ensureAppWindow(appPage, "Home", "dashboard"); + await expect(home.locator('[data-slot="home-page-meta"]')).not.toContainText("workspace"); + await expect + .poll(async () => Number(await homeMetricValue(home, "Working now").textContent())) + .toBeGreaterThan(0); +}); -async function prepareDashboardRuntime(runtime: BrowserRuntime): Promise { +async function prepareHomeRuntime(runtime: BrowserRuntime): Promise { if (!runtime.paths?.homeDir) { - throw new Error("Dashboard E2E requires launch-mode runtime paths."); + throw new Error("Home E2E requires launch-mode runtime paths."); } const workspace = await runtime.resolveWorkspace(runtime.paths.homeDir); const sessions = await runtime.requestJSON<{ sessions: SessionSummary[] }>( `/api/sessions?workspace=${encodeURIComponent(workspace.id)}` ); - const hasDashboardSession = sessions.sessions.some( - session => session.workspace_id === workspace.id && activeSessionStates.has(session.state) + const pending = sessions.sessions.find( + session => + session.workspace_id === workspace.id && + (session.state === "active" || session.state === "starting") ); - if (!hasDashboardSession) { - await runtime.requestJSON<{ session: SessionSummary }>("/api/sessions", { - method: "POST", - body: JSON.stringify({ - agent_name: dashboardAgentAlpha, - workspace: workspace.id, - }), - }); - } - - await expect - .poll(async () => { - const payload = await runtime.requestJSON<{ sessions: SessionSummary[] }>( - `/api/sessions?workspace=${encodeURIComponent(workspace.id)}` - ); - return payload.sessions.filter(session => activeSessionStates.has(session.state)).length; - }) - .toBeGreaterThan(0); - + const sessionId = + pending?.id ?? + ( + await runtime.requestJSON<{ session: SessionSummary }>("/api/sessions", { + method: "POST", + body: JSON.stringify({ agent_name: homeAgentAlpha, workspace: workspace.id }), + }) + ).session.id; + await waitForSeedSessionActive(runtime, sessionId); return workspace; } -async function pollRestartStatus(runtime: BrowserRuntime, statusURL: string): Promise { - try { - const payload = await runtime.requestJSON(statusURL); - return payload.status; - } catch { - // Daemon restarts can briefly drop the transport before the status endpoint comes back. - return "restarting"; - } -} - -async function captureDashboardSnapshot( +async function captureHomeSnapshot( runtime: BrowserRuntime, workspace: WorkspacePayload -): Promise { - const [health, daemonHTTP, daemonUDS, workspaces, workspaceDetail, sessions, agents, cli] = - await Promise.all([ - runtime.requestJSON("/api/status"), - runtime.requestJSON("/api/status"), - runtime.requestOperatorJSON?.("/api/status"), - runtime.requestJSON("/api/workspaces"), - runtime.requestJSON( - `/api/workspaces/${encodeURIComponent(workspace.id)}` - ), - runtime.requestJSON( - `/api/sessions?workspace=${encodeURIComponent(workspace.id)}` - ), - runtime.requestJSON( - `/api/agents?workspace=${encodeURIComponent(workspace.id)}` - ), - captureCLISnapshot(runtime, workspace), - ]); +): Promise { + const [http, uds, cli, catalog] = await Promise.all([ + requestOverview(runtime), + runtime.requestOperatorJSON?.<{ overview: HomeOverview }>(overviewPath()), + captureCLIOverview(runtime), + runtime.requestJSON( + `/api/agents/catalog?workspace=${encodeURIComponent(workspace.id)}&limit=6` + ), + ]); + return { catalog, http, uds: uds?.overview, cli, workspace }; +} - return { - agents, - cli, - daemonHTTP, - daemonUDS, - health, - sessions, - workspace, - workspaceDetail, - workspaces, - }; +function overviewPath(workspace?: string): string { + const search = new URLSearchParams({ usage_window: "30" }); + if (workspace) search.set("workspace", workspace); + return `/api/observe/overview?${search.toString()}`; } -async function captureCLISnapshot(runtime: BrowserRuntime, workspace: WorkspacePayload) { - if (!runtime.paths) { - return undefined; - } - return { - daemon: await runCLIJSON(runtime, ["status", "-o", "json"]), - workspaces: await runCLIJSON(runtime, ["workspace", "list", "-o", "json"]), - sessions: await runCLIJSON(runtime, [ - "session", - "list", - "--workspace", - workspace.id, - "-o", - "json", - ]), - agents: await runCLIJSON(runtime, ["agent", "list", "--workspace", workspace.id, "-o", "json"]), - }; +async function requestOverview(runtime: BrowserRuntime, workspace?: string): Promise { + const response = await runtime.requestJSON<{ overview: HomeOverview }>(overviewPath(workspace)); + return response.overview; } -async function runCLIJSON(runtime: BrowserRuntime, args: string[]) { +async function captureCLIOverview(runtime: BrowserRuntime): Promise { + if (!runtime.paths) return undefined; + return await runCLIJSON(runtime, [ + "observe", + "overview", + "--usage-window", + "30", + "-o", + "json", + ]); +} + +async function runCLIJSON(runtime: BrowserRuntime, args: string[]): Promise { if (!runtime.paths) { throw new Error(`CLI snapshot ${args.join(" ")} requires launch-mode runtime paths.`); } const { stdout } = await execFileAsync(runtime.paths.cliShim, args, { - env: { - ...process.env, - AGH_HOME: runtime.paths.homeDir, - HOME: runtime.paths.homeDir, - }, + env: { ...process.env, AGH_HOME: runtime.paths.homeDir, HOME: runtime.paths.homeDir }, maxBuffer: 10 * 1024 * 1024, }); - return JSON.parse(stdout) as unknown; + return JSON.parse(stdout) as T; } -function workspaceShell(page: import("@playwright/test").Page) { +function assertOverviewParity(snapshot: HomeSnapshot): void { + const expected = comparableOverview(snapshot.http); + if (snapshot.uds) expect(comparableOverview(snapshot.uds)).toEqual(expected); + if (snapshot.cli) expect(comparableOverview(snapshot.cli)).toEqual(expected); +} + +function comparableOverview(overview: HomeOverview) { return { - osDesktop: page.getByTestId(sessionLifecycleTestIds.osDesktop), - workspaceOnboarding: page.getByTestId(sessionLifecycleTestIds.workspaceOnboarding), - workspaceUseGlobal: page.getByTestId(sessionLifecycleTestIds.workspaceUseGlobal), + schema_version: overview.schema_version, + attention: overview.attention, + today: overview.today, + outcomes: overview.outcomes, + usage: overview.usage, + pulse: overview.pulse, + network: overview.network, + system: overview.system, }; } -function dashboardStatusKey(status: string | undefined): "healthy" | "degraded" { - const normalized = status?.trim().toLowerCase(); - if (normalized === "ok" || normalized === "healthy" || normalized === "running") { - return "healthy"; - } - return "degraded"; +function homeMetric(root: Locator, label: string): Locator { + return root + .locator('[data-slot="home-kpi-strip"] [data-slot="metric"]') + .filter({ hasText: label }) + .first(); } -function metricValue(page: import("@playwright/test").Page, testId: string) { - return page.getByTestId(testId).locator('[data-slot="metric-value"]'); +function homeMetricValue(root: Locator, label: string): Locator { + return homeMetric(root, label).locator('[data-slot="metric-value"]'); } -async function assertDashboardNavigation( - page: import("@playwright/test").Page, - runtime: BrowserRuntime -): Promise { - await page.getByTestId("nav-agents").click(); - await expect.poll(() => new URL(page.url()).pathname).toBe("/agents"); - await page.getByTestId(`agent-fleet-row-link-${dashboardAgentAlpha}`).click(); - await expect.poll(() => new URL(page.url()).pathname).toBe(`/agents/${dashboardAgentAlpha}`); +function formatTokenCount(total: number): string { + if (total >= 1_000_000) { + const millions = total / 1_000_000; + return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`; + } + if (total >= 1_000) return `${Math.round(total / 1_000)}K`; + return String(total); +} - await page.getByTestId("nav-network").click(); - await expect.poll(() => new URL(page.url()).pathname).toBe("/network"); +async function pollRestartStatus(runtime: BrowserRuntime, statusURL: string): Promise { + try { + return (await runtime.requestJSON(statusURL)).status; + } catch { + // Transport loss is the observable restart state until the operation endpoint returns. + return "restarting"; + } +} + +function workspaceShell(page: Page) { + return { + osDesktop: page.getByTestId(sessionLifecycleTestIds.osDesktop), + workspaceOnboarding: page.getByTestId(sessionLifecycleTestIds.workspaceOnboarding), + workspaceUseGlobal: page.getByTestId(sessionLifecycleTestIds.workspaceUseGlobal), + }; +} - await page.getByTestId("nav-tasks").click(); +async function assertHomeNavigation(page: Page, home: Locator): Promise { + await home.getByRole("link", { name: /Needs you/i }).click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/tasks"); + await expect.poll(() => new URL(page.url()).searchParams.get("mode")).toBe("inbox"); - await page.getByTestId("nav-settings").click(); - await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/general"); + const focusedHome = await openAppWindow(page, "Home", "dashboard"); + await focusedHome + .locator('[data-slot="home-agents"]') + .getByRole("link", { name: new RegExp(homeAgentAlpha) }) + .click(); + await expect.poll(() => new URL(page.url()).pathname).toBe(`/agents/${homeAgentAlpha}`); - await page.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); - await expect(page.getByTestId("home-shell")).toBeVisible(); + const returnedHome = await openAppWindow(page, "Home", "dashboard"); + await expect(returnedHome.getByTestId("home-body")).toBeVisible(); } -async function assertDashboardViewportMatrix( - page: import("@playwright/test").Page, +async function assertHomeViewportMatrix( + page: Page, browserArtifacts: { - captureScreenshot(name?: string, page?: import("@playwright/test").Page): Promise; + captureScreenshot(name?: string, page?: Page): Promise; }, - runtime: BrowserRuntime + home: Locator ): Promise { const viewports = [ { width: 375, height: 812, name: "mobile" }, @@ -517,41 +450,25 @@ async function assertDashboardViewportMatrix( for (const viewport of viewports) { await page.setViewportSize({ width: viewport.width, height: viewport.height }); - await page.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); - await expect(page.getByTestId("home-daemon-card")).toBeVisible(); - await expect(page.getByTestId("home-metric-active-sessions")).toBeVisible(); - await expect(page.getByTestId("home-metric-workspaces")).toBeVisible(); - await expect(page.getByTestId("home-metric-agents")).toBeVisible(); - await expect(page.getByTestId("home-metric-uptime")).toBeVisible(); - await browserArtifacts.captureScreenshot(`dashboard-${viewport.name}`, page); + await expect(home.getByTestId("home-body")).toBeVisible(); + await expect(home.locator('[data-slot="home-kpi-strip"] [data-slot="metric"]')).toHaveCount(4); + await expect(home.locator('[data-slot="home-run-card"]').first()).toBeVisible(); + await expect(home.locator('[data-slot="home-agents"]')).toBeVisible(); + await browserArtifacts.captureScreenshot(`home-${viewport.name}`, page); } } -async function assertDashboardFocus(page: import("@playwright/test").Page): Promise { - const dashboardNav = page.getByTestId("nav-dashboard"); - await expect(dashboardNav).toHaveAccessibleName("Dashboard"); - await expect - .poll( - async () => { - try { - await dashboardNav.focus(); - return await dashboardNav.evaluate(element => element === document.activeElement); - } catch { - return false; - } - }, - { - timeout: 20_000, - intervals: [100, 250, 500, 1_000], - } - ) - .toBe(true); +async function assertHomeFocus(page: Page): Promise { + const homeLauncher = page.getByRole("button", { name: "Home", exact: true }); + await expect(homeLauncher).toHaveAccessibleName("Home"); + await homeLauncher.focus(); + await expect(homeLauncher).toBeFocused(); } async function assertNoSensitiveLeak( - page: import("@playwright/test").Page, + page: Page, runtime: BrowserRuntime, - snapshot: DashboardSnapshot + snapshot: HomeSnapshot ): Promise { await expect(page.locator("body")).not.toContainText(sensitivePattern); const artifactPayloads = [ @@ -561,9 +478,7 @@ async function assertNoSensitiveLeak( await readFile(runtime.artifactCollector.artifactPath("browser_route_state"), "utf8"), await readFile(runtime.artifactCollector.artifactPath("browser_api_snapshots"), "utf8"), ]; - for (const payload of artifactPayloads) { - expect(payload).not.toMatch(sensitivePattern); - } + for (const payload of artifactPayloads) expect(payload).not.toMatch(sensitivePattern); if (runtime.paths?.daemonLog) { expect(await readFile(runtime.paths.daemonLog, "utf8")).not.toMatch(sensitivePattern); } diff --git a/web/e2e/__tests__/jobs-hardening.spec.ts b/web/e2e/__tests__/jobs-hardening.spec.ts index 7f11a5fb3..7f01b1bab 100644 --- a/web/e2e/__tests__/jobs-hardening.spec.ts +++ b/web/e2e/__tests__/jobs-hardening.spec.ts @@ -10,7 +10,8 @@ import { promisify } from "node:util"; import type { Page } from "@playwright/test"; import { reloadDaemonServedPage } from "../fixtures/navigation"; -import { automationOperatorSelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; +import { automationOperatorSelectors, sessionWindowSelectors } from "../fixtures/selectors"; +import { sessionWindow, windowTitle } from "../fixtures/os-navigation"; import type { BrowserRuntime } from "../fixtures/runtime"; import { browserAutomationOperatorFlowScenario } from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; @@ -167,11 +168,13 @@ test("operator creates edits disables enables triggers and deletes a dynamic job browserArtifacts, runtime, }) => { - const ui = automationOperatorSelectors(appPage); - const sessionUI = sessionLifecycleSelectors(appPage); + const jobsWin = appPage.getByTestId("os-window-app:jobs"); + const ui = automationOperatorSelectors(jobsWin, appPage); + const shellUI = automationOperatorSelectors(appPage); + const jobStatus = jobsWin.locator('[data-slot="topbar-status"]'); const workspace = await createWorkspace(runtime); const workspaceJob = await createJob(runtime, workspaceJobRequest(workspace)); - await useGlobalWorkspaceIfPrompted(ui); + await useGlobalWorkspaceIfPrompted(shellUI); await appPage.goto(runtime.url("/jobs"), { waitUntil: "domcontentloaded" }); await expect(ui.jobsShell).toBeVisible(); @@ -181,9 +184,9 @@ test("operator creates edits disables enables triggers and deletes a dynamic job await expect(appPage).toHaveURL(new RegExp(`/jobs/${workspaceJob.id}$`)); await expect(ui.detailPanel).toContainText("Scope: WORKSPACE"); - await appPage - .getByRole("navigation", { name: "Breadcrumb" }) - .getByRole("link", { exact: true, name: "Jobs" }) + await jobsWin + .getByRole("navigation", { name: "Window path" }) + .getByRole("button", { exact: true, name: "Jobs" }) .click(); await expect(appPage).toHaveURL(/\/jobs$/); await expect(ui.jobsShell).toBeVisible(); @@ -213,10 +216,11 @@ test("operator creates edits disables enables triggers and deletes a dynamic job const created = await waitForJobByName(runtime, initialName); // Saving from the editor navigates straight to the new job's detail route. await expect(appPage).toHaveURL(new RegExp(`/jobs/${created.id}$`)); - await expect(ui.detailPanel).toContainText(initialName); + await expect(windowTitle(jobsWin)).toContainText(initialName); await expect(ui.detailPanel).toContainText("REGISTERED"); - await ui.editAutomationButton.click(); + await ui.detailOverflow.click(); + await appPage.getByTestId("edit-automation-btn").click(); await expect(ui.editorDialog).toBeVisible(); const editedName = `${initialName}-edited`; await ui.jobNameInput.fill(editedName); @@ -231,12 +235,12 @@ test("operator creates edits disables enables triggers and deletes a dynamic job await ui.submitJobForm.click(); expect((await updateResponse).ok()).toBe(true); await expect(ui.editorDialog).toBeHidden(); - await expect(ui.detailPanel).toContainText(editedName); + await expect(windowTitle(jobsWin)).toContainText(editedName); await ui.detailOverflow.click(); await ui.toggleAutomationButton.click(); await expect.poll(async () => (await getJob(runtime, created.id)).job.enabled).toBe(false); - await expect(ui.detailPanel).toContainText("DISABLED"); + await expect(jobStatus).toContainText("DISABLED"); let disabledHealth = await getAutomationHealth(runtime); expect( disabledHealth.automation.scheduled_jobs?.some( @@ -247,7 +251,7 @@ test("operator creates edits disables enables triggers and deletes a dynamic job await ui.detailOverflow.click(); await ui.toggleAutomationButton.click(); await expect.poll(async () => (await getJob(runtime, created.id)).job.enabled).toBe(true); - await expect(ui.detailPanel).toContainText("ENABLED"); + await expect(jobStatus).toContainText("ENABLED"); await expect .poll(async () => schedulerState(runtime, created.id).then(state => state?.registered)) .toBe(true); @@ -282,7 +286,12 @@ test("operator creates edits disables enables triggers and deletes a dynamic job }); await ui.runSessionLink(completedRun.id).click(); - await expect(sessionUI.chatHeader).toBeVisible(); + const completedSessionId = completedRun.session_id; + if (!completedSessionId) { + throw new Error("Expected the completed job run to expose a linked session."); + } + const sessionUI = sessionWindowSelectors(sessionWindow(appPage, completedSessionId)); + await expect(sessionUI.chatView).toBeVisible(); await expect(sessionUI.chatView).toContainText(browserAutomationOperatorFlowScenario.job.prompt); await appPage.goto(runtime.url("/jobs"), { waitUntil: "domcontentloaded" }); await ui.itemLink(created.id).click(); @@ -329,7 +338,9 @@ test("scheduled job survives daemon restart and does not duplicate fire ids", as browserArtifacts, runtime, }) => { - const ui = automationOperatorSelectors(appPage); + const jobsWin = appPage.getByTestId("os-window-app:jobs"); + const ui = automationOperatorSelectors(jobsWin, appPage); + const shellUI = automationOperatorSelectors(appPage); const job = await createJob( runtime, jobRequest({ @@ -338,7 +349,7 @@ test("scheduled job survives daemon restart and does not duplicate fire ids", as schedule: { mode: "every", interval: "1s" }, }) ); - await useGlobalWorkspaceIfPrompted(ui); + await useGlobalWorkspaceIfPrompted(shellUI); await appPage.goto(runtime.url("/jobs"), { waitUntil: "domcontentloaded" }); await expect(ui.jobsListRows).toBeVisible(); await expect(ui.item(job.id)).toBeVisible({ timeout: 20_000 }); @@ -394,7 +405,9 @@ test("failed job run is diagnosable from browser and CLI without leaking secrets browserArtifacts, runtime, }) => { - const ui = automationOperatorSelectors(appPage); + const jobsWin = appPage.getByTestId("os-window-app:jobs"); + const ui = automationOperatorSelectors(jobsWin, appPage); + const shellUI = automationOperatorSelectors(appPage); const job = await createJob( runtime, jobRequest({ @@ -403,7 +416,7 @@ test("failed job run is diagnosable from browser and CLI without leaking secrets prompt: "trigger crash mid-stream", }) ); - await useGlobalWorkspaceIfPrompted(ui); + await useGlobalWorkspaceIfPrompted(shellUI); await appPage.goto(runtime.url("/jobs"), { waitUntil: "domcontentloaded" }); await expect(ui.item(job.id)).toBeVisible(); await ui.itemLink(job.id).click(); @@ -417,7 +430,7 @@ test("failed job run is diagnosable from browser and CLI without leaking secrets expect(await trigger.text()).not.toMatch(sensitivePattern); const failedRun = await waitForLatestRun(runtime, job.id, "failed"); await appPage.reload({ waitUntil: "domcontentloaded" }); - await expect(ui.detailPanel).toContainText(job.name, { timeout: 20_000 }); + await expect(windowTitle(jobsWin)).toContainText(job.name, { timeout: 20_000 }); await expect(ui.run(failedRun.id)).toBeVisible(); await expect(ui.run(failedRun.id)).toContainText("FAILED"); await expect(ui.run(failedRun.id)).toContainText(/disconnect|prompt|session|failed/i); @@ -698,7 +711,8 @@ async function assertJobsLifecycleViewportMatrix( runtime: BrowserRuntime, jobID: string ): Promise { - const ui = automationOperatorSelectors(appPage); + const jobsWin = appPage.getByTestId("os-window-app:jobs"); + const ui = automationOperatorSelectors(jobsWin, appPage); for (const width of [375, 768, 1280]) { await appPage.setViewportSize({ width, height: 820 }); await appPage.goto(runtime.url("/jobs"), { waitUntil: "domcontentloaded" }); @@ -707,7 +721,8 @@ async function assertJobsLifecycleViewportMatrix( await ui.itemLink(jobID).click(); await expect(ui.runHistory).toBeVisible(); await browserArtifacts.captureScreenshot(`jobs-lifecycle-history-viewport-${width}`, appPage); - await ui.editAutomationButton.click(); + await ui.detailOverflow.click(); + await appPage.getByTestId("edit-automation-btn").click(); await expect(ui.editorDialog).toBeVisible(); await expect(ui.jobForm).toBeVisible(); await expect(ui.jobScheduleExpr).toBeVisible(); @@ -724,7 +739,8 @@ async function assertJobsViewportMatrix( runtime: BrowserRuntime, jobID: string ): Promise { - const ui = automationOperatorSelectors(appPage); + const jobsWin = appPage.getByTestId("os-window-app:jobs"); + const ui = automationOperatorSelectors(jobsWin, appPage); for (const width of [375, 768, 1280]) { await appPage.setViewportSize({ width, height: 820 }); await appPage.goto(runtime.url("/jobs"), { waitUntil: "domcontentloaded" }); diff --git a/web/e2e/__tests__/knowledge.spec.ts b/web/e2e/__tests__/knowledge.spec.ts index b2c7df0c8..afeab52af 100644 --- a/web/e2e/__tests__/knowledge.spec.ts +++ b/web/e2e/__tests__/knowledge.spec.ts @@ -8,7 +8,12 @@ import { promisify } from "node:util"; import type { Page } from "@playwright/test"; -import { knowledgeOperatorSelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; +import { sessionWindow } from "../fixtures/os-navigation"; +import { + knowledgeOperatorSelectors, + sessionLifecycleSelectors, + sessionWindowSelectors, +} from "../fixtures/selectors"; import type { BrowserRuntime } from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; import { ensureGlobalWorkspace, useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -120,14 +125,15 @@ test("operator creates edits reverts searches recalls and deletes workspace know throw new Error("Knowledge browser E2E requires launch-mode runtime paths."); } - const knowledgeUI = knowledgeOperatorSelectors(appPage); - const sessionUI = sessionLifecycleSelectors(appPage); await ensureGlobalWorkspace(runtime); const workspace = await runtime.resolveWorkspace(runtime.paths.homeDir); await appPage.reload({ waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(knowledgeUI); + await useGlobalWorkspaceIfPrompted(appPage); await appPage.goto(runtime.url("/knowledge"), { waitUntil: "domcontentloaded" }); + const kWin = appPage.getByTestId("os-window-app:knowledge"); + await expect(kWin).toBeVisible(); + const knowledgeUI = knowledgeOperatorSelectors(kWin); await expect(knowledgeUI.shell).toBeVisible(); await expect(knowledgeUI.tabGlobal).toHaveAttribute("aria-pressed", "true"); await knowledgeUI.tabWorkspace.click(); @@ -167,7 +173,7 @@ test("operator creates edits reverts searches recalls and deletes workspace know await expect(knowledgeUI.createDialog).toBeHidden(); await expect(knowledgeUI.item(`workspace:${filename}`)).toBeVisible({ timeout: 20_000 }); await knowledgeUI.item(`workspace:${filename}`).click(); - await expect(knowledgeUI.detailPanel).toContainText(memoryName); + await expect(kWin.getByTestId("knowledge-detail-title")).toContainText(memoryName); await expect(knowledgeUI.contentPreview).toContainText(originalContent); await knowledgeUI.searchInput.fill("auth migration sessions"); @@ -247,14 +253,15 @@ test("operator creates edits reverts searches recalls and deletes workspace know knowledge_view_visible: true, }); - const recalledSession = await createSessionThroughBrowser( - appPage, - sessionUI, - memoryRecallAgentName + const recalledSession = await createSessionThroughBrowser(appPage, memoryRecallAgentName); + const recalledSessionUI = sessionWindowSelectors( + sessionWindow(appPage, recalledSession.session.id) ); - await sessionUI.composerTextarea.fill("remember me"); - await sessionUI.composerTextarea.press("Enter"); - await expect(sessionUI.chatView).toContainText("qa-memory acknowledged", { timeout: 30_000 }); + await recalledSessionUI.composerTextarea.fill("remember me"); + await recalledSessionUI.composerTextarea.press("Enter"); + await expect(recalledSessionUI.chatView).toContainText("qa-memory acknowledged", { + timeout: 30_000, + }); const recalledACPSessionID = await acpSessionIDForSession( runtime, recalledSession.session.workspace_id, @@ -312,14 +319,15 @@ test("operator creates edits reverts searches recalls and deletes workspace know const searchAfterDelete = await searchMemory(runtime, workspace.id, marker); expect(searchAfterDelete.results.some(result => result.memory.filename === filename)).toBe(false); - const postDeleteSession = await createSessionThroughBrowser( - appPage, - sessionUI, - memoryRecallAgentName + const postDeleteSession = await createSessionThroughBrowser(appPage, memoryRecallAgentName); + const postDeleteSessionUI = sessionWindowSelectors( + sessionWindow(appPage, postDeleteSession.session.id) ); - await sessionUI.composerTextarea.fill("remember me"); - await sessionUI.composerTextarea.press("Enter"); - await expect(sessionUI.chatView).toContainText("qa-memory acknowledged", { timeout: 30_000 }); + await postDeleteSessionUI.composerTextarea.fill("remember me"); + await postDeleteSessionUI.composerTextarea.press("Enter"); + await expect(postDeleteSessionUI.chatView).toContainText("qa-memory acknowledged", { + timeout: 30_000, + }); const postDeleteACPSessionID = await acpSessionIDForSession( runtime, postDeleteSession.session.workspace_id, @@ -343,14 +351,16 @@ test("operator creates edits reverts searches recalls and deletes workspace know async function createSessionThroughBrowser( page: Page, - ui: ReturnType, agentName: string ): Promise { await page.goto(new URL(`/agents/${agentName}`, page.url()).toString(), { waitUntil: "domcontentloaded", }); - await expect(ui.agentPageNewSession).toBeVisible(); - await ui.agentPageNewSession.click(); + const agentsWin = page.getByTestId("os-window-app:agents"); + await expect(agentsWin).toBeVisible(); + const agentsUI = sessionLifecycleSelectors(agentsWin); + await expect(agentsUI.agentPageNewSession).toBeVisible(); + await agentsUI.agentPageNewSession.click(); await expect(page.getByTestId("session-create-dialog")).toBeVisible(); const createResponsePromise = page.waitForResponse( response => response.request().method() === "POST" && response.url().endsWith("/api/sessions") @@ -362,8 +372,9 @@ async function createSessionThroughBrowser( await expect .poll(() => new URL(page.url()).pathname) .toBe(`/agents/${agentName}/sessions/${session.session.id}`); - await expect(ui.chatHeader).toBeVisible(); - await expect(ui.composerTextarea).toBeVisible(); + const sessionWin = sessionWindow(page, session.session.id); + await expect(sessionWin).toBeVisible(); + await expect(sessionWindowSelectors(sessionWin).composerTextarea).toBeVisible(); return session; } @@ -381,6 +392,7 @@ async function assertKnowledgeViewportAndDialogMatrix( await page.setViewportSize({ width: viewport.width, height: viewport.height }); await expect(ui.shell).toBeVisible(); await expect(ui.listPanel).toBeVisible(); + await expect(ui.tabWorkspace).toHaveAttribute("aria-pressed", "true"); await expect(ui.detailPanel).toBeVisible(); await browserArtifacts.captureScreenshot(`knowledge-${viewport.name}-detail`, page); } diff --git a/web/e2e/__tests__/marketplace.spec.ts b/web/e2e/__tests__/marketplace.spec.ts index 827f4c093..9a22093ce 100644 --- a/web/e2e/__tests__/marketplace.spec.ts +++ b/web/e2e/__tests__/marketplace.spec.ts @@ -7,13 +7,15 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { captureRouteState } from "../fixtures/browser-artifact-session"; import { closeMCPAuthServer, startMCPAuthServer } from "../fixtures/mcp-auth-server"; +import { sessionWindow, switchWorkspace } from "../fixtures/os-navigation"; import { marketplaceOperatorSelectors, sessionLifecycleSelectors, + sessionWindowSelectors, settingsOperatorSelectors, } from "../fixtures/selectors"; import { @@ -179,8 +181,10 @@ test.describe("Marketplace acquisition", () => { await appPage.reload({ waitUntil: "domcontentloaded" }); await useGlobalWorkspaceIfPrompted(appPage); - const marketplace = marketplaceOperatorSelectors(appPage); await appPage.goto(runtime.url("/marketplace"), { waitUntil: "domcontentloaded" }); + const marketplaceWin = appPage.getByTestId("os-window-app:marketplace"); + await expect(marketplaceWin).toBeVisible(); + const marketplace = marketplaceOperatorSelectors(marketplaceWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/marketplace/skills"); await expect(marketplace.kind("skill")).toBeVisible({ timeout: 20_000 }); await expect(marketplace.card(skillEntryID)).toBeVisible(); @@ -304,7 +308,7 @@ test.describe("Marketplace acquisition", () => { await expect(appPage.getByText("Blocked by extensions policy", { exact: true })).toBeVisible(); await extensionDetailLink.click(); await expect(marketplace.detail).toBeVisible(); - await expect(marketplace.detailAction).toHaveAttribute("aria-disabled", "true"); + await expect(marketplace.action(extensionEntryID)).toHaveAttribute("aria-disabled", "true"); await expect( marketplace.detail.getByRole("link", { name: "Settings › Extensions" }) ).toHaveAttribute("href", "/settings/extensions"); @@ -451,7 +455,7 @@ test.describe("Marketplace acquisition", () => { "PATCH", "/api/settings/hooks-extensions" ); - await appPage.getByTestId("settings-page-extensions-policy-save").click(); + await appPage.getByTestId("settings-page-extensions-save").click(); const policyResponse = await policyResponsePromise; expect(policyResponse.status(), await policyResponse.text()).toBe(200); @@ -459,8 +463,8 @@ test.describe("Marketplace acquisition", () => { waitUntil: "domcontentloaded", }); await expect(marketplace.detail).toBeVisible(); - await expect(marketplace.detailAction).not.toHaveAttribute("aria-disabled", "true"); - await expect(marketplace.detailAction).toBeEnabled(); + await expect(marketplace.action(extensionEntryID)).not.toHaveAttribute("aria-disabled", "true"); + await expect(marketplace.action(extensionEntryID)).toBeEnabled(); await appPage.goto(runtime.url("/settings/hooks"), { waitUntil: "domcontentloaded" }); await expect(appPage.getByTestId("settings-page-hooks")).toBeVisible({ timeout: 20_000 }); @@ -740,9 +744,7 @@ test.describe("Skills marketplace management", () => { throw new Error("Skills browser E2E requires launch-mode runtime paths."); } - const sessionUI = sessionLifecycleSelectors(appPage); const settingsUI = settingsOperatorSelectors(appPage); - const marketplace = marketplaceOperatorSelectors(appPage); const installedSkillCard = (name: string) => appPage .getByTestId(/^marketplace-installed-card-/) @@ -758,6 +760,9 @@ test.describe("Skills marketplace management", () => { await appPage.goto(runtime.url("/marketplace/skills?tab=installed"), { waitUntil: "domcontentloaded", }); + const marketplaceWin = appPage.getByTestId("os-window-app:marketplace"); + await expect(marketplaceWin).toBeVisible(); + const marketplace = marketplaceOperatorSelectors(marketplaceWin); await expect(marketplace.kind("skill")).toBeVisible(); await appPage.getByTestId("marketplace-kind-search-skill").fill("browser-context"); @@ -782,7 +787,7 @@ test.describe("Skills marketplace management", () => { expect(initialParity.cliInfo.enabled).toBe(true); expect(initialParity.httpContent.content).toContain("Use browser context skill evidence"); - await assertSkillsViewportMatrix(appPage, browserArtifacts); + await assertSkillsViewportMatrix(marketplaceWin, browserArtifacts); await runtime.artifactCollector.captureJSON("browser_api_snapshots", { initialParity, scenario_contract: { @@ -818,14 +823,13 @@ test.describe("Skills marketplace management", () => { skills_selected_item: contextSkillName, skills_view_visible: true, }); - const baselineSession = await createSessionThroughBrowser( - appPage, - sessionUI, - skillsContextAgentName + const baselineSession = await createSessionThroughBrowser(appPage, skillsContextAgentName); + const baselineSessionUI = sessionWindowSelectors( + sessionWindow(appPage, baselineSession.session.id) ); - await sessionUI.composerTextarea.fill("skill context before disable"); - await sessionUI.composerTextarea.press("Enter"); - await expect(sessionUI.chatView).toContainText("qa-skills-context acknowledged", { + await baselineSessionUI.composerTextarea.fill("skill context before disable"); + await baselineSessionUI.composerTextarea.press("Enter"); + await expect(baselineSessionUI.chatView).toContainText("qa-skills-context acknowledged", { timeout: 30_000, }); const baselinePrompt = await promptForSession( @@ -865,14 +869,13 @@ test.describe("Skills marketplace management", () => { expect(disabledParity.udsDetail.skill.enabled).toBe(false); expect(disabledParity.cliInfo.enabled).toBe(false); - const disabledSession = await createSessionThroughBrowser( - appPage, - sessionUI, - skillsContextAgentName + const disabledSession = await createSessionThroughBrowser(appPage, skillsContextAgentName); + const disabledSessionUI = sessionWindowSelectors( + sessionWindow(appPage, disabledSession.session.id) ); - await sessionUI.composerTextarea.fill("skill context after disable"); - await sessionUI.composerTextarea.press("Enter"); - await expect(sessionUI.chatView).toContainText("qa-skills-context acknowledged", { + await disabledSessionUI.composerTextarea.fill("skill context after disable"); + await disabledSessionUI.composerTextarea.press("Enter"); + await expect(disabledSessionUI.chatView).toContainText("qa-skills-context acknowledged", { timeout: 30_000, }); const disabledPrompt = await promptForSession( @@ -905,14 +908,13 @@ test.describe("Skills marketplace management", () => { expect((await enableResponsePromise).ok()).toBe(true); await expect(enabledLabel).toHaveText("Enabled"); - const restoredSession = await createSessionThroughBrowser( - appPage, - sessionUI, - skillsContextAgentName + const restoredSession = await createSessionThroughBrowser(appPage, skillsContextAgentName); + const restoredSessionUI = sessionWindowSelectors( + sessionWindow(appPage, restoredSession.session.id) ); - await sessionUI.composerTextarea.fill("skill context after enable"); - await sessionUI.composerTextarea.press("Enter"); - await expect(sessionUI.chatView).toContainText("qa-skills-context acknowledged", { + await restoredSessionUI.composerTextarea.fill("skill context after enable"); + await restoredSessionUI.composerTextarea.press("Enter"); + await expect(restoredSessionUI.chatView).toContainText("qa-skills-context acknowledged", { timeout: 30_000, }); const restoredPrompt = await promptForSession( @@ -976,9 +978,10 @@ test.describe("Skills marketplace management", () => { }); async function assertSkillsViewportMatrix( - page: Page, + win: Locator, browserArtifacts: { captureScreenshot: (name: string, page?: Page) => Promise } ): Promise { + const page = win.page(); const viewports = [ { name: "desktop", width: 1280, height: 900 }, { name: "tablet", width: 768, height: 900 }, @@ -986,8 +989,8 @@ test.describe("Skills marketplace management", () => { ]; for (const viewport of viewports) { await page.setViewportSize({ width: viewport.width, height: viewport.height }); - await expect(page.getByTestId("marketplace-detail")).toBeVisible(); - await expect(page.getByTestId("content-body")).toBeVisible(); + await expect(win.getByTestId("marketplace-detail")).toBeVisible(); + await expect(win.getByTestId("content-body")).toBeVisible(); await browserArtifacts.captureScreenshot(`skills-${viewport.name}-detail`, page); } await page.setViewportSize({ width: 1280, height: 900 }); @@ -1080,14 +1083,16 @@ test.describe("Skills marketplace management", () => { async function createSessionThroughBrowser( page: Page, - ui: ReturnType, agentName: string ): Promise { await page.goto(new URL(`/agents/${agentName}`, page.url()).toString(), { waitUntil: "domcontentloaded", }); - await expect(ui.agentPageNewSession).toBeVisible(); - await ui.agentPageNewSession.click(); + const agentsWin = page.getByTestId("os-window-app:agents"); + await expect(agentsWin).toBeVisible(); + const agentsUI = sessionLifecycleSelectors(agentsWin); + await expect(agentsUI.agentPageNewSession).toBeVisible(); + await agentsUI.agentPageNewSession.click(); await expect(page.getByTestId("session-create-dialog")).toBeVisible(); const createResponsePromise = page.waitForResponse( response => response.request().method() === "POST" && response.url().endsWith("/api/sessions") @@ -1099,8 +1104,9 @@ test.describe("Skills marketplace management", () => { await expect .poll(() => new URL(page.url()).pathname) .toBe(`/agents/${agentName}/sessions/${session.session.id}`); - await expect(ui.chatHeader).toBeVisible(); - await expect(ui.composerTextarea).toBeVisible(); + const sessionWin = sessionWindow(page, session.session.id); + await expect(sessionWin).toBeVisible(); + await expect(sessionWindowSelectors(sessionWin).composerTextarea).toBeVisible(); return session; } @@ -1297,8 +1303,7 @@ test.describe("MCP marketplace authorization", () => { await appPage.goto(runtime.url("/marketplace/mcps?tab=installed"), { waitUntil: "domcontentloaded", }); - await appPage.getByTestId("workspace-switcher").click(); - await appPage.getByTestId(`workspace-command-item-${workspace.id}`).click(); + await switchWorkspace(appPage, workspace.id, workspace.name); const installedCard = appPage .getByTestId(/^marketplace-installed-card-/) @@ -1306,7 +1311,9 @@ test.describe("MCP marketplace authorization", () => { .first(); await expect(installedCard).toBeVisible(); await installedCard.getByRole("link", { name: `View ${SERVER_NAME} details` }).click(); - const detail = appPage.getByTestId("marketplace-detail"); + const detail = appPage + .getByTestId("os-window-app:marketplace") + .getByTestId("marketplace-detail"); await expect(detail).toBeVisible(); await expect(detail.getByText("Needs login", { exact: true })).toBeVisible(); @@ -1380,8 +1387,7 @@ test.describe("MCP marketplace authorization", () => { await appPage.goto(runtime.url("/marketplace/mcps?tab=installed"), { waitUntil: "domcontentloaded", }); - await appPage.getByTestId("workspace-switcher").click(); - await appPage.getByTestId(`workspace-command-item-${workspace.id}`).click(); + await switchWorkspace(appPage, workspace.id, workspace.name); const installedCard = appPage .getByTestId(/^marketplace-installed-card-/) .filter({ hasText: SERVER_NAME }) @@ -1610,7 +1616,6 @@ test.describe("Extension and bundle marketplace runtime", () => { const extensionDir = await createBrowserToolProviderExtension(); const checksumFailureDir = await createChecksumFailureExtension(); const badManifestDir = await createInvalidExtensionManifest(); - const marketplace = marketplaceOperatorSelectors(appPage); const installedExtensionCard = appPage .getByTestId(/^marketplace-installed-card-/) .filter({ hasText: extensionName }) @@ -1643,6 +1648,9 @@ test.describe("Extension and bundle marketplace runtime", () => { await appPage.goto(runtime.url("/marketplace/extensions?tab=installed"), { waitUntil: "domcontentloaded", }); + const marketplaceWin = appPage.getByTestId("os-window-app:marketplace"); + await expect(marketplaceWin).toBeVisible(); + const marketplace = marketplaceOperatorSelectors(marketplaceWin); await expect(marketplace.kind("extension")).toBeVisible({ timeout: 20_000 }); await expect(installedExtensionCard).toBeVisible(); await expect(installedExtensionCard.getByRole("switch")).toBeChecked(); @@ -1979,25 +1987,26 @@ test.describe("Extension and bundle marketplace runtime", () => { await appPage.goto(runtime.url(sessionPath(toolPermissionAgent, approved.session.id)), { waitUntil: "domcontentloaded", }); - const ui = sessionLifecycleSelectors(appPage); - await expect(ui.chatHeader).toBeVisible(); - await ui.composerTextarea.fill("exercise golden"); - await ui.composerTextarea.press("Enter"); - await expect(ui.chatView).toContainText("hello from golden"); - await expect(ui.permissionPrompt).toBeVisible(); - await expect(appPage.getByText("Permission Required")).toBeVisible(); - await expect(appPage.getByRole("button", { name: /allow always/i })).toBeVisible(); - await expect(appPage.getByRole("button", { name: /reject always/i })).toBeVisible(); - await assertPermissionKeyboardPath(appPage); + const approvedWin = sessionWindow(appPage, approved.session.id); + const approvedUI = sessionWindowSelectors(approvedWin); + await expect(approvedWin).toBeVisible(); + await approvedUI.composerTextarea.fill("exercise golden"); + await approvedUI.composerTextarea.press("Enter"); + await expect(approvedUI.chatView).toContainText("hello from golden"); + await expect(approvedUI.permissionPrompt).toBeVisible(); + await expect(approvedWin.getByText("Permission Required")).toBeVisible(); + await expect(approvedWin.getByRole("button", { name: /allow always/i })).toBeVisible(); + await expect(approvedWin.getByRole("button", { name: /reject always/i })).toBeVisible(); + await assertPermissionKeyboardPath(approvedWin); const approveResponsePromise = appPage.waitForResponse( response => response.request().method() === "POST" && response.url().endsWith(sessionAPIPath(workspace.id, approved.session.id, "/approve")) ); - await appPage.getByTestId("permission-allow-always").click(); + await approvedWin.getByTestId("permission-allow-always").click(); expect((await approveResponsePromise).ok()).toBe(true); - await expect(ui.permissionPrompt).toBeHidden(); + await expect(approvedUI.permissionPrompt).toBeHidden(); const approvedSnapshot = await captureSessionSnapshot( runtime, workspace.id, @@ -2009,22 +2018,24 @@ test.describe("Extension and bundle marketplace runtime", () => { await appPage.goto(runtime.url(sessionPath(denyPermissionAgent, denied.session.id)), { waitUntil: "domcontentloaded", }); - await expect(ui.chatHeader).toBeVisible(); - await ui.composerTextarea.fill("exercise permission hardening"); - await ui.composerTextarea.press("Enter"); - await expect(ui.chatView).toContainText("Permission hardening started."); - await expect(ui.permissionPrompt).toBeVisible(); - await expect(appPage.getByTestId("permission-tool-input")).toContainText("hardening.txt"); + const deniedWin = sessionWindow(appPage, denied.session.id); + const deniedUI = sessionWindowSelectors(deniedWin); + await expect(deniedWin).toBeVisible(); + await deniedUI.composerTextarea.fill("exercise permission hardening"); + await deniedUI.composerTextarea.press("Enter"); + await expect(deniedUI.chatView).toContainText("Permission hardening started."); + await expect(deniedUI.permissionPrompt).toBeVisible(); + await expect(deniedWin.getByTestId("permission-tool-input")).toContainText("hardening.txt"); const rejectResponsePromise = appPage.waitForResponse( response => response.request().method() === "POST" && response.url().endsWith(sessionAPIPath(workspace.id, denied.session.id, "/approve")) ); - await appPage.getByTestId("permission-reject-always").click(); + await deniedWin.getByTestId("permission-reject-always").click(); expect((await rejectResponsePromise).ok()).toBe(true); - await expect(ui.permissionPrompt).toBeHidden(); - await expect(appPage.getByTestId("permission-rejected-notice")).toContainText( + await expect(deniedUI.permissionPrompt).toBeHidden(); + await expect(deniedWin.getByTestId("permission-rejected-notice")).toContainText( "Permission Rejected" ); const deniedSnapshot = await captureSessionSnapshot(runtime, workspace.id, denied.session.id); @@ -2457,16 +2468,17 @@ test.describe("Extension and bundle marketplace runtime", () => { )}${suffix}`; } - async function assertPermissionKeyboardPath(page: Page): Promise { + async function assertPermissionKeyboardPath(win: Locator): Promise { + const page = win.page(); const focusOrder = [ "permission-allow-once", "permission-allow-always", "permission-reject-once", "permission-reject-always", ]; - await page.getByTestId(focusOrder[0]).focus(); + await win.getByTestId(focusOrder[0]).focus(); for (const testID of focusOrder) { - await expect(page.locator(`[data-testid="${testID}"]`)).toBeFocused(); + await expect(win.locator(`[data-testid="${testID}"]`)).toBeFocused(); if (testID !== focusOrder.at(-1)) { await page.keyboard.press("Tab"); } diff --git a/web/e2e/__tests__/network.spec.ts b/web/e2e/__tests__/network.spec.ts index 98998ca85..3ff16c80f 100644 --- a/web/e2e/__tests__/network.spec.ts +++ b/web/e2e/__tests__/network.spec.ts @@ -6,6 +6,7 @@ import process from "node:process"; import { promisify } from "node:util"; import { networkOperatorSelectors } from "../fixtures/selectors"; +import { ensureAppWindow, openAppWindow } from "../fixtures/os-navigation"; import { browserNetworkOperatorFlowScenario, seedBrowserNetworkOperatorFlow, @@ -66,28 +67,28 @@ test.describe("network disabled state", () => { browserArtifacts, runtime, }) => { - const ui = networkOperatorSelectors(appPage); await ensureGlobalWorkspace(runtime); await appPage.goto(runtime.url("/network"), { waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(ui); + await useGlobalWorkspaceIfPrompted(appPage); + const networkWin = await ensureAppWindow(appPage, "Network", "network"); + const ui = networkOperatorSelectors(networkWin); await expect(ui.disabledState).toBeVisible(); - await expect(appPage.getByTestId("network-empty")).toContainText("Network is disabled."); - await expect(appPage.getByTestId("network-empty")).toContainText( + await expect(networkWin.getByTestId("network-empty")).toContainText("Network is disabled."); + await expect(networkWin.getByTestId("network-empty")).toContainText( "Local work remains available" ); - await expect(appPage.getByTestId("network-empty-open-settings")).toBeVisible(); + await expect(networkWin.getByTestId("network-empty-open-settings")).toBeVisible(); const status = await runtime.requestJSON("/api/network/status"); expect(status.network.enabled).toBe(false); expect(status.network.status).toBe("disabled"); await browserArtifacts.captureScreenshot("network-disabled", appPage); - await appPage.getByTestId("network-empty-open-settings").click(); + await networkWin.getByTestId("network-empty-open-settings").click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/settings/network"); - await expect(appPage.getByTestId("settings-page-network-runtime-status")).toContainText( - "disabled" - ); + const settingsWin = appPage.getByTestId("os-window-app:settings"); + await expect(settingsWin.getByTestId("settings-page-network-hero")).toContainText("disabled"); await runtime.artifactCollector.captureJSON("browser_api_snapshots", { http_status: status, @@ -103,19 +104,18 @@ test("operator verifies thread and direct network surfaces with final conversati browserArtifacts, runtime, }) => { - const ui = networkOperatorSelectors(appPage); - const workspace = await prepareNetworkRuntime(runtime, appPage); - await ui.navNetwork.click(); + const networkWin = await openAppWindow(appPage, "Network", "network"); + const ui = networkOperatorSelectors(networkWin); await expect(ui.noChannelsState).toBeVisible(); - await expect(appPage.getByTestId("network-empty")).toContainText( + await expect(networkWin.getByTestId("network-empty")).toContainText( "Executions stay Local by default" ); - await expect(appPage.getByTestId("network-empty")).toContainText("Choose Live explicitly"); - await expect(appPage.getByTestId("network-empty")).toContainText( + await expect(networkWin.getByTestId("network-empty")).toContainText("Choose Live explicitly"); + await expect(networkWin.getByTestId("network-empty")).toContainText( "Network availability never enrolls an execution" ); - await expect(appPage.getByTestId("network-empty-open-settings")).toBeVisible(); + await expect(networkWin.getByTestId("network-empty-open-settings")).toBeVisible(); await createChannelFromUI(appPage, ui, { agents: [initiatorAgentName, responderAgentName], channel: channelName, @@ -134,33 +134,32 @@ test("operator verifies thread and direct network surfaces with final conversati waitUntil: "domcontentloaded", }); - await expect(ui.navNetwork).toBeVisible(); - await expect(ui.workspace).toBeVisible(); + await expect(networkWin).toBeVisible(); + await expect(networkWin.getByTestId("network-shell")).toBeVisible(); await expect(ui.channelItem(channelName)).toBeVisible({ timeout: 15_000 }); await ui.channelItem(channelName).getByTestId(`network-channel-link-${channelName}`).click(); await expect(ui.channelHeader.getByTestId("network-channel-title")).toContainText(channelName); const channelMeta = ui.channelHeader.getByTestId("network-channel-meta"); await expect(channelMeta).toContainText("2 agents"); - await expect(channelMeta).toContainText(createdChannelPurpose); + await expect(ui.channelHeader.getByTestId("network-channel-purpose")).toContainText( + createdChannelPurpose + ); await expect(ui.threadTab).toHaveAttribute("aria-selected", "true"); await expect(ui.threadsTab).toHaveAttribute("aria-label", `Threads in #${channelName}`); await expect(ui.threadList).toHaveAttribute("aria-label", `Threads in #${channelName}`); await ui.channelInspectorToggle.click(); await expect(ui.inspector).toBeVisible(); - await expect(ui.inspectorMembersTab).toHaveAttribute("aria-selected", "true"); + await expect(ui.inspectorMembersTab).toHaveAttribute("aria-pressed", "true"); await expect(ui.inspectorPanelMembers).toBeVisible(); await ui.inspectorWorkTab.click(); - await expect(ui.inspectorWorkTab).toHaveAttribute("aria-selected", "true"); + await expect(ui.inspectorWorkTab).toHaveAttribute("aria-pressed", "true"); await expect(ui.inspectorPanelWork).toBeVisible(); - await ui.inspectorActivityTab.click(); - await expect(ui.inspectorActivityTab).toHaveAttribute("aria-selected", "true"); - await expect(ui.inspectorPanelActivity).toBeVisible(); await ui.channelInspectorToggle.click(); await expect(ui.inspector).toHaveCount(0); await expect(ui.threadItem(operatorFlow.threadId)).toBeVisible(); await expect(ui.threadList).toHaveAttribute("aria-label", `Threads in #${channelName}`); - await expect(appPage.getByTestId("network-composer-channel-thread")).toBeVisible(); + await expect(networkWin.getByTestId("network-composer-channel-thread")).toBeVisible(); await expect(ui.threadItem(operatorFlow.threadId)).toContainText( browserNetworkOperatorFlowScenario.texts.say ); @@ -180,15 +179,15 @@ test("operator verifies thread and direct network surfaces with final conversati await expect(ui.channelMessage(operatorFlow.messageIds.direct)).toHaveCount(0); await browserArtifacts.captureScreenshot("network-thread-detail", appPage); - const threadComposer = appPage.getByTestId("network-composer-textarea-thread"); + const threadComposer = networkWin.getByTestId("network-composer-textarea-thread"); await threadComposer.click(); await expect(threadComposer).toBeFocused(); await appPage.setViewportSize({ width: 375, height: 812 }); await expect(ui.channelTabs).toBeVisible(); - await expect(appPage.getByTestId("network-composer-thread")).toBeVisible(); + await expect(networkWin.getByTestId("network-composer-thread")).toBeVisible(); await browserArtifacts.captureScreenshot("network-mobile-composer", appPage); - await appPage.getByTestId("network-thread-overlay-close").click(); + await networkWin.getByTestId("network-thread-overlay-close").click(); await expect .poll(() => new URL(appPage.url()).pathname) .toBe("/network/" + workspace.id + "/" + channelName + "/threads"); @@ -197,13 +196,13 @@ test("operator verifies thread and direct network surfaces with final conversati await browserArtifacts.captureScreenshot("network-tablet-thread-list", appPage); await appPage.setViewportSize({ width: 1280, height: 900 }); - await appPage.getByTestId("network-tab-activity").click(); + await networkWin.getByTestId("network-tab-activity").click(); await expect .poll(() => new URL(appPage.url()).pathname) .toBe(`/network/${workspace.id}/${channelName}/activity`); await expect(ui.activityFeed).toHaveAttribute("aria-label", `Activity in #${channelName}`); await expect( - appPage.getByTestId(`network-activity-entry-thread:${operatorFlow.threadId}`) + networkWin.getByTestId(`network-activity-entry-thread:${operatorFlow.threadId}`) ).toBeVisible(); await browserArtifacts.captureScreenshot("network-activity", appPage); @@ -232,8 +231,8 @@ test("operator verifies thread and direct network surfaces with final conversati await expect(ui.channelMessage(operatorFlow.messageIds.say)).toHaveCount(0); await ui.inspectorToggle.click(); - await expect(appPage.getByTestId("network-inspector")).toBeVisible(); - await appPage.getByTestId("network-inspector-tab-work").click(); + await expect(networkWin.getByTestId("network-inspector")).toBeVisible(); + await networkWin.getByTestId("network-inspector-tab-work").click(); await expect(ui.workInspector).toBeVisible(); await expect(ui.workInspectorRow(openWorkId)).toContainText("needs input"); await browserArtifacts.captureScreenshot("network-work-inspector", appPage); @@ -417,6 +416,10 @@ async function createChannelFromUI( for (const agent of input.agents) { await ui.agentOption(agent).click(); } + const firstAgent = input.agents[0]; + if (!firstAgent) throw new Error("channel creation requires at least one agent"); + await ui.createAgentTrigger.click(); + await expect(ui.agentOption(firstAgent)).toBeHidden(); const createResponse = page.waitForResponse( response => response.request().method() === "POST" && diff --git a/web/e2e/__tests__/os-shell.spec.ts b/web/e2e/__tests__/os-shell.spec.ts index 3771a0433..8c210b748 100644 --- a/web/e2e/__tests__/os-shell.spec.ts +++ b/web/e2e/__tests__/os-shell.spec.ts @@ -509,6 +509,7 @@ test("E2E-010 and E2E-018: peers converge topology while presentation stays clie }) => { const workspace = await prepareShell(appPage, runtime); const second = await openPeerPage(browser, runtime); + let lastRects: Record = {}; try { const firstWindow = await openDockApp(appPage, "Tasks", "tasks"); const secondWindow = second.getByTestId("os-window-app:tasks"); @@ -583,13 +584,18 @@ test("E2E-010 and E2E-018: peers converge topology while presentation stays clie workspace.id, "app:tasks" ); - return ( - rectsClose(first, peer) && - rectsClose(first, authoritative) && - rectsClose(peer, authoritative) - ); + lastRects = { + authoritative, + first, + peer, + converged: + rectsClose(first, peer) && + rectsClose(first, authoritative) && + rectsClose(peer, authoritative), + }; + return lastRects; }) - .toBe(true); + .toMatchObject({ converged: true }); } finally { await second.context().close(); } diff --git a/web/e2e/__tests__/sandbox.spec.ts b/web/e2e/__tests__/sandbox.spec.ts index f4186e91e..78c5e71ad 100644 --- a/web/e2e/__tests__/sandbox.spec.ts +++ b/web/e2e/__tests__/sandbox.spec.ts @@ -6,9 +6,14 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import type { Page } from "@playwright/test"; - -import { sandboxOperatorSelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; +import type { Locator, Page } from "@playwright/test"; + +import { sessionWindow, switchWorkspace } from "../fixtures/os-navigation"; +import { + sandboxOperatorSelectors, + sessionLifecycleSelectors, + sessionWindowSelectors, +} from "../fixtures/selectors"; import type { BrowserRuntime } from "../fixtures/runtime"; import { seedBrowserSandboxProfiles } from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; @@ -119,19 +124,21 @@ test("operator manages a local sandbox profile and binds it to real session exec test.setTimeout(180_000); await assertLaunchRuntime(runtime); - const sandboxUI = sandboxOperatorSelectors(appPage); await useGlobalWorkspaceIfPrompted(sessionLifecycleSelectors(appPage)); await appPage.goto(runtime.url("/sandbox"), { waitUntil: "domcontentloaded" }); + const sandboxWin = appPage.getByTestId("os-window-app:sandbox"); + await expect(sandboxWin).toBeVisible({ timeout: 20_000 }); + const sandboxUI = sandboxOperatorSelectors(sandboxWin); await expect(sandboxUI.shell).toBeVisible({ timeout: 20_000 }); - await createSandboxProfileThroughUI(appPage, sandboxProfileName); + await createSandboxProfileThroughUI(sandboxWin, sandboxProfileName); await expect(sandboxUI.profile(sandboxProfileName)).toBeVisible(); await expect(sandboxUI.profile(sandboxProfileName)).toContainText("local"); await expect(sandboxUI.profileMetadata(sandboxProfileName)).toContainText("reuse"); await expect(sandboxUI.profileSource(sandboxProfileName)).toContainText("CONFIG"); await expect(sandboxUI.actionResult).toContainText(`Saved sandbox "${sandboxProfileName}"`); - await assertDuplicateNameValidation(appPage, sandboxProfileName); + await assertDuplicateNameValidation(sandboxWin, sandboxProfileName); await expect( runtime.requestJSON( `/api/settings/sandboxes/${encodeURIComponent("browser-invalid-sandbox")}`, @@ -174,21 +181,20 @@ test("operator manages a local sandbox profile and binds it to real session exec await sandboxUI.deleteProfile(sandboxProfileName).click(); await expect(sandboxUI.deleteDialog).toBeVisible(); await expect(sandboxUI.deleteUsage).toContainText("1 workspace currently reference this profile"); - await appPage.getByTestId("settings-sandboxes-delete-cancel").click(); + await sandboxWin.getByTestId("settings-sandboxes-delete-cancel").click(); const session = await createSession(runtime, allowedAgent, workspace.id); // The session lives in this freshly resolved workspace, while the active // workspace is still the global one. The session route redirects away when // the active workspace does not own the session, so activate the session's // workspace first, exactly as an operator would before opening it. - await appPage.getByTestId("workspace-switcher").click(); - await appPage.getByTestId(`workspace-command-item-${workspace.id}`).click(); - await expect(appPage.getByTestId("workspace-switcher")).toHaveAttribute("aria-expanded", "false"); + await switchWorkspace(appPage, workspace.id, workspace.name); await appPage.goto(runtime.url(sessionPath(allowedAgent, session.id)), { waitUntil: "domcontentloaded", }); - const sessionUI = sessionLifecycleSelectors(appPage); - await expect(sessionUI.chatHeader).toBeVisible(); + const allowedSessionWin = sessionWindow(appPage, session.id); + const sessionUI = sessionWindowSelectors(allowedSessionWin); + await expect(allowedSessionWin).toBeVisible(); await sessionUI.composerTextarea.fill("exercise sandbox allowed path"); await sessionUI.composerTextarea.press("Enter"); @@ -234,7 +240,7 @@ test("operator manages a local sandbox profile and binds it to real session exec expect(sandboxByName(udsSandboxes.sandboxes, sandboxProfileName)?.profile.backend).toBe("local"); await appPage.goto(runtime.url("/sandbox"), { waitUntil: "domcontentloaded" }); - await assertSandboxViewports(appPage, browserArtifacts); + await assertSandboxViewports(sandboxWin, browserArtifacts); await runtime.artifactCollector.captureJSON("browser_api_snapshots", { cli_session: cliSession, cli_workspace: cliWorkspace, @@ -284,8 +290,9 @@ test("operator sees blocked sandbox diagnostics without leaking secrets or writi waitUntil: "domcontentloaded", }); await useGlobalWorkspaceIfPrompted(appPage); - const sessionUI = sessionLifecycleSelectors(appPage); - await expect(sessionUI.chatHeader).toBeVisible(); + const blockedSessionWin = sessionWindow(appPage, session.id); + const sessionUI = sessionWindowSelectors(blockedSessionWin); + await expect(blockedSessionWin).toBeVisible(); await sessionUI.composerTextarea.fill("exercise sandbox blocked path"); await sessionUI.composerTextarea.press("Enter"); await expect(sessionUI.chatView).toContainText("terminal/create denied", { @@ -318,8 +325,9 @@ test("operator sees blocked sandbox diagnostics without leaking secrets or writi await assertNoSensitiveLeak(appPage, runtime); }); -async function createSandboxProfileThroughUI(page: Page, name: string): Promise { - const ui = sandboxOperatorSelectors(page); +async function createSandboxProfileThroughUI(win: Locator, name: string): Promise { + const ui = sandboxOperatorSelectors(win); + const page = win.page(); await ui.createButton.click(); await expect(ui.editor).toBeVisible(); await ui.editorNameInput.fill(name); @@ -338,14 +346,14 @@ async function createSandboxProfileThroughUI(page: Page, name: string): Promise< await expect(ui.editor).toBeHidden(); } -async function assertDuplicateNameValidation(page: Page, name: string): Promise { - const ui = sandboxOperatorSelectors(page); +async function assertDuplicateNameValidation(win: Locator, name: string): Promise { + const ui = sandboxOperatorSelectors(win); await ui.createButton.click(); await expect(ui.editor).toBeVisible(); await ui.editorNameInput.fill(name); await expect(ui.editorError).toContainText(`A sandbox named "${name}" already exists.`); await expect(ui.editorSave).toBeDisabled(); - await page.getByTestId("settings-sandbox-editor-cancel").click(); + await win.getByTestId("settings-sandbox-editor-cancel").click(); } async function createSession( @@ -477,16 +485,17 @@ async function pollRestartStatus(runtime: BrowserRuntime, statusURL: string): Pr } async function assertSandboxViewports( - page: Page, + win: Locator, browserArtifacts: { captureScreenshot(name?: string, page?: Page): Promise } ): Promise { + const page = win.page(); for (const viewport of [ { width: 1280, height: 900, name: "desktop" }, { width: 768, height: 900, name: "tablet" }, { width: 375, height: 812, name: "mobile" }, ]) { await page.setViewportSize({ width: viewport.width, height: viewport.height }); - await expect(page.getByTestId("sandbox-shell")).toBeVisible(); + await expect(win.getByTestId("sandbox-shell")).toBeVisible(); await expect(page.locator("body")).not.toContainText(sensitivePattern); await browserArtifacts.captureScreenshot(`sandbox-${viewport.name}`, page); } diff --git a/web/e2e/__tests__/session-clarify.spec.ts b/web/e2e/__tests__/session-clarify.spec.ts index 421547201..f89622803 100644 --- a/web/e2e/__tests__/session-clarify.spec.ts +++ b/web/e2e/__tests__/session-clarify.spec.ts @@ -10,7 +10,8 @@ import { type HostedMcpConnection, } from "../fixtures/hosted-mcp"; import type { BrowserRuntime, RuntimePaths } from "../fixtures/runtime"; -import { sessionClarifySelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; +import { sessionWindow } from "../fixtures/os-navigation"; +import { sessionClarifySelectors, sessionWindowSelectors } from "../fixtures/selectors"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -99,7 +100,6 @@ test("operator answers a running clarification and unblocks the hosted-MCP call" runtime, }) => { assertLaunchRuntime(runtime); - const sessionUI = sessionLifecycleSelectors(appPage); const clarifyUI = sessionClarifySelectors(appPage); await useGlobalWorkspaceIfPrompted(appPage); @@ -129,8 +129,10 @@ test("operator answers a running clarification and unblocks the hosted-MCP call" await appPage.goto(runtime.url(`/agents/${MOCK_AGENT}/sessions/${sessionId}`), { waitUntil: "domcontentloaded", }); + const sessionWin = sessionWindow(appPage, sessionId); + const sessionUI = sessionWindowSelectors(sessionWin, appPage); // Keep the session live: the holding turn reports readiness then blocks until cancelled. - await expect(sessionUI.chatHeader).toBeVisible({ timeout: 20_000 }); + await expect(sessionWin).toBeVisible({ timeout: 20_000 }); await sessionUI.composerTextarea.fill("hold native approval"); await sessionUI.composerTextarea.press("Enter"); await expect(appPage.getByText("native approval ready")).toBeVisible({ timeout: 20_000 }); diff --git a/web/e2e/__tests__/session-hardening.spec.ts b/web/e2e/__tests__/session-hardening.spec.ts index 2903ecc43..052e8b2c0 100644 --- a/web/e2e/__tests__/session-hardening.spec.ts +++ b/web/e2e/__tests__/session-hardening.spec.ts @@ -7,7 +7,8 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { sessionLifecycleSelectors } from "../fixtures/selectors"; +import { sessionWindow, switchWorkspace } from "../fixtures/os-navigation"; +import { sessionLifecycleSelectors, sessionWindowSelectors } from "../fixtures/selectors"; import { cleanupBrowserSettingsFixtures, seedBrowserSettingsFixtures, @@ -148,7 +149,8 @@ test("first document navigation to a canonical session route loads the app shell waitUntil: "domcontentloaded", }); - const ui = sessionLifecycleSelectors(page); + const sessionWin = sessionWindow(page, session.id); + const ui = sessionWindowSelectors(sessionWin, page); await expect .poll(async () => ({ osDesktopVisible: await page.getByTestId("os-desktop").isVisible(), @@ -180,25 +182,26 @@ test("E2E-009: operator pages an oversized tool result to its retained tail", as await appPage.goto(runtime.url(sessionPath(toolArtifactAgent, session.id)), { waitUntil: "domcontentloaded", }); - const ui = sessionLifecycleSelectors(appPage); - await expect(ui.chatHeader).toBeVisible(); + const sessionWin = sessionWindow(appPage, session.id); + const ui = sessionWindowSelectors(sessionWin, appPage); + await expect(sessionWin).toBeVisible(); await ui.composerTextarea.fill("exercise tool artifact recovery"); await ui.composerTextarea.press("Enter"); await expect(ui.chatView).toContainText("Retained result is ready for page-back."); - await appPage.getByTestId("turn-fold-row").click(); - await appPage.getByRole("button", { name: "Toggle tool call (success)" }).click(); + await sessionWin.getByTestId("turn-fold-row").click(); + await sessionWin.getByRole("button", { name: "Toggle tool call (success)" }).click(); await expect(ui.chatView).toContainText("E2E-009 bounded retained-result preview"); - await appPage.getByRole("button", { name: "Open full result" }).click(); - const loadMore = appPage.getByRole("button", { name: "Load more" }); + await sessionWin.getByRole("button", { name: "Open full result" }).click(); + const loadMore = sessionWin.getByRole("button", { name: "Load more" }); await expect(loadMore).toBeVisible(); await loadMore.click(); await expect(loadMore).toBeEnabled(); await loadMore.click(); await expect(loadMore).toBeHidden(); - await expect(appPage.getByTestId("full-tool-result")).toContainText(toolArtifactTail); - await expect(appPage.getByText("140,084 of 140,084 bytes")).toBeVisible(); + await expect(sessionWin.getByTestId("full-tool-result")).toContainText(toolArtifactTail); + await expect(sessionWin.getByText("140,084 of 140,084 bytes")).toBeVisible(); expect(artifactOffsets).toEqual(["0", "65536", "131072"]); }); @@ -239,8 +242,9 @@ test("operator rejects a permission request, records tool output, and keeps sess waitUntil: "domcontentloaded", }); - const ui = sessionLifecycleSelectors(appPage); - await expect(ui.chatHeader).toBeVisible(); + const sessionWin = sessionWindow(appPage, session.id); + const ui = sessionWindowSelectors(sessionWin, appPage); + await expect(sessionWin).toBeVisible(); sampleDeepLinkLoading = false; await deepLinkLoadingSampler; await expect @@ -254,14 +258,14 @@ test("operator rejects a permission request, records tool output, and keeps sess await expect(ui.chatView).toContainText("Permission hardening started."); await expect(ui.permissionPrompt).toBeVisible(); - await expect(appPage.getByTestId("permission-tool-input")).toContainText("hardening.txt"); + await expect(sessionWin.getByTestId("permission-tool-input")).toContainText("hardening.txt"); const approvalResponsePromise = appPage.waitForResponse( response => response.request().method() === "POST" && response.url().endsWith(sessionAPIPath(workspace.id, session.id, "/approve")) ); - await appPage.getByTestId("permission-reject-always").click(); + await sessionWin.getByTestId("permission-reject-always").click(); expect((await approvalResponsePromise).ok()).toBe(true); await expect(ui.permissionPrompt).toBeHidden(); @@ -308,8 +312,9 @@ test("operator cancels a running prompt, clears the transcript, and deletes the waitUntil: "domcontentloaded", }); - const ui = sessionLifecycleSelectors(appPage); - await expect(ui.chatHeader).toBeVisible(); + const sessionWin = sessionWindow(appPage, session.id); + const ui = sessionWindowSelectors(sessionWin, appPage); + await expect(sessionWin).toBeVisible(); await ui.composerTextarea.fill("block until canceled"); await ui.composerTextarea.press("Enter"); await expect(ui.chatView).toContainText("block until canceled"); @@ -343,7 +348,7 @@ test("operator cancels a running prompt, clears the transcript, and deletes the expect((await clearResponsePromise).ok()).toBe(true); await expect(ui.chatView).not.toContainText("block until canceled"); await appPage.reload({ waitUntil: "domcontentloaded" }); - await expect(ui.chatHeader).toBeVisible(); + await expect(sessionWin).toBeVisible(); await expect(ui.chatView).not.toContainText("block until canceled"); const afterClear = await captureSessionSnapshot(runtime, workspace.id, session.id); @@ -353,9 +358,11 @@ test("operator cancels a running prompt, clears the transcript, and deletes the await appPage.goto(runtime.url(sessionPath(faultAgent, deletableSession.id)), { waitUntil: "domcontentloaded", }); - await expect(ui.chatHeader).toBeVisible(); - await ui.topbarOverflow.click(); - await ui.deleteButton.click(); + const deletableWin = sessionWindow(appPage, deletableSession.id); + const deletableUi = sessionWindowSelectors(deletableWin, appPage); + await expect(deletableWin).toBeVisible(); + await deletableUi.topbarOverflow.click(); + await deletableUi.deleteButton.click(); await expect(appPage.getByTestId("delete-dialog")).toBeVisible(); const deleteResponsePromise = appPage.waitForResponse( response => @@ -406,8 +413,9 @@ test("operator repairs an interrupted session through HTTP, UDS, and CLI without waitUntil: "domcontentloaded", }); - const ui = sessionLifecycleSelectors(appPage); - await expect(ui.chatHeader).toBeVisible(); + const sessionWin = sessionWindow(appPage, session.id); + const ui = sessionWindowSelectors(sessionWin, appPage); + await expect(sessionWin).toBeVisible(); await ui.composerTextarea.fill("trigger crash mid-stream"); await ui.composerTextarea.press("Enter"); await expect(ui.chatView).toContainText("partial before crash", { timeout: 15_000 }); @@ -469,20 +477,21 @@ test("operator sees the daemon-generated session title and the file-mutation ver waitUntil: "domcontentloaded", }); - const ui = sessionLifecycleSelectors(appPage); - await expect(ui.chatHeader).toBeVisible(); + const sessionWin = sessionWindow(appPage, session.id); + const ui = sessionWindowSelectors(sessionWin, appPage); + await expect(sessionWin).toBeVisible(); await ui.composerTextarea.fill("Implement checkout retry fencing"); await ui.composerTextarea.press("Enter"); await expect(ui.chatView).toContainText("Implemented checkout retry fencing."); - const markerNotice = appPage.getByTestId("transcript-marker-notice"); + const markerNotice = sessionWin.getByTestId("transcript-marker-notice"); await expect(markerNotice).toBeVisible(); await expect(markerNotice).toHaveAttribute("data-tone", "warning"); - await expect(appPage.getByTestId("transcript-marker-kind")).toContainText( + await expect(sessionWin.getByTestId("transcript-marker-kind")).toContainText( "transcript_marker.file_mutation_unverified" ); - await expect(appPage.getByTestId("transcript-marker-summary")).toContainText( + await expect(sessionWin.getByTestId("transcript-marker-summary")).toContainText( "file mutation failed and was not recovered" ); await browserArtifacts.captureScreenshot("session-auto-title-verifier-marker", appPage); @@ -584,11 +593,7 @@ test.describe("E2E-010 truthful session cost provenance by auth mode", () => { await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); await useGlobalWorkspaceIfPrompted(ui); await expect(ui.osDesktop).toBeVisible(); - await appPage.getByTestId(`workspace-avatar-${workspace.id}`).click(); - await expect(appPage.getByTestId(`workspace-avatar-${workspace.id}`)).toHaveAttribute( - "data-active", - "true" - ); + await switchWorkspace(appPage, workspace.id, workspace.name); await appPage.setViewportSize({ width: 1440, height: 900 }); const estimated = await openUsageCostForProvider(appPage, runtime, workspace.id, { @@ -621,17 +626,18 @@ async function openUsageCostForProvider( workspaceID: string, opts: { provider: string; model: string; status: string } ): Promise { - const ui = sessionLifecycleSelectors(page); const session = await createProviderSession(runtime, workspaceID, opts.provider, opts.model); await page.goto(runtime.url(sessionPath(costProvenanceAgent, session.id)), { waitUntil: "domcontentloaded", }); - await expect(ui.chatHeader).toBeVisible(); - await expect(ui.composerTextarea).toBeEnabled(); - await ui.composerTextarea.fill(costProvenancePrompt); - await ui.composerTextarea.press("Enter"); - await expect(ui.chatView).toContainText("Cost provenance run recorded."); + const sessionWin = sessionWindow(page, session.id); + const sessionUi = sessionWindowSelectors(sessionWin, page); + await expect(sessionWin).toBeVisible(); + await expect(sessionUi.composerTextarea).toBeEnabled(); + await sessionUi.composerTextarea.fill(costProvenancePrompt); + await sessionUi.composerTextarea.press("Enter"); + await expect(sessionUi.chatView).toContainText("Cost provenance run recorded."); await expect .poll( @@ -648,9 +654,10 @@ async function openUsageCostForProvider( // The page-level usage query stops refetching once the session goes idle, so // reload to mount the inspector against the now-populated usage summary. await page.reload({ waitUntil: "domcontentloaded" }); - await expect(ui.chatHeader).toBeVisible(); - await page.getByTestId("session-inspector-tab-usage").click(); - return page.getByTestId("session-inspector-usage-cost"); + await expect(sessionWin).toBeVisible(); + await sessionWin.getByTestId("session-inspector-toggle").click(); + await sessionWin.getByTestId("session-inspector-tab-usage").click(); + return sessionWin.getByTestId("session-inspector-usage-cost"); } async function createProviderSession( diff --git a/web/e2e/__tests__/session-onboarding.spec.ts b/web/e2e/__tests__/session-onboarding.spec.ts index b2805d2bc..192574fe0 100644 --- a/web/e2e/__tests__/session-onboarding.spec.ts +++ b/web/e2e/__tests__/session-onboarding.spec.ts @@ -1,7 +1,9 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; -import { sessionLifecycleSelectors } from "../fixtures/selectors"; +import { openAppWindow, sessionWindow } from "../fixtures/os-navigation"; +import { waitForSeedSessionActive } from "../fixtures/runtime"; +import { sessionLifecycleSelectors, sessionWindowSelectors } from "../fixtures/selectors"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -46,19 +48,21 @@ test.use({ test("operator can onboard, create a session, submit work, approve a permission request, reload transcript continuity, and resume controls", async ({ appPage, browserArtifacts, + runtime, }) => { const ui = sessionLifecycleSelectors(appPage); await useGlobalWorkspaceIfPrompted(ui); await expect(ui.osDesktop).toBeVisible(); - await appPage.getByTestId("nav-agents").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(ui.agentRow(browserLifecycleAgent)).toBeVisible(); + await expect(fleet.agentRow(browserLifecycleAgent)).toBeVisible(); - await ui.agentRow(browserLifecycleAgent).click(); + await fleet.agentRow(browserLifecycleAgent).click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe(`/agents/${browserLifecycleAgent}`); - await expect(ui.agentPageNewSession).toBeVisible(); - await ui.agentPageNewSession.click(); + await expect(fleet.agentPageNewSession).toBeVisible(); + await fleet.agentPageNewSession.click(); await expect(appPage.getByTestId("session-create-dialog")).toBeVisible(); await expect(appPage.getByTestId("session-create-agent-select")).toContainText( @@ -79,7 +83,7 @@ test("operator can onboard, create a session, submit work, approve a permission // The mock adapter fails loud on an unadvertised model (task_01 §7.4), so use a // model it actually accepts — the selector emits the exact canonical id. const runtimeTrigger = appPage.getByTestId("session-create-runtime-select"); - await runtimeTrigger.locator('button[data-focus="model"]').first().click(); + await runtimeTrigger.click(); await expect(appPage.getByTestId("runtime-selector-search")).toBeVisible(); await appPage.getByTestId("runtime-selector-search").fill("qa-browser-model"); await appPage.getByTestId("runtime-selector-custom").click(); @@ -101,20 +105,22 @@ test("operator can onboard, create a session, submit work, approve a permission const workspaceId = createPayload.session?.workspace_id ?? ""; expect(sessionId).not.toBe(""); expect(workspaceId).not.toBe(""); + await waitForSeedSessionActive(runtime, sessionId); await expect .poll(() => new URL(appPage.url()).pathname) .toBe(browserLifecycleSessionPath(browserLifecycleAgent, sessionId)); - await expect(ui.chatHeader).toBeVisible(); - await expect(ui.composerTextarea).toBeVisible(); - await expect(ui.stopButton).toBeVisible(); + const sessionWin = sessionWindow(appPage, sessionId); + const sessionUi = sessionWindowSelectors(sessionWin, appPage); + await expect(sessionWin).toBeVisible(); + await expect(sessionUi.composerTextarea).toBeVisible(); - await ui.composerTextarea.fill(browserLifecyclePrompt); - await ui.composerTextarea.press("Enter"); + await sessionUi.composerTextarea.fill(browserLifecyclePrompt); + await sessionUi.composerTextarea.press("Enter"); - await expect(ui.permissionPrompt).toBeVisible(); - await expect(appPage.getByTestId("permission-reject-once")).toBeVisible(); - await expect(ui.chatView).toContainText("Streaming response started."); + await expect(sessionUi.permissionPrompt).toBeVisible(); + await expect(sessionWin.getByTestId("permission-reject-once")).toBeVisible(); + await expect(sessionUi.chatView).toContainText("Streaming response started."); const approvalResponsePromise = appPage.waitForResponse(response => { return ( @@ -122,38 +128,40 @@ test("operator can onboard, create a session, submit work, approve a permission response.url().endsWith(sessionAPIPath(workspaceId, sessionId, "/approve")) ); }); - await ui.permissionAllowOnce.click(); + await sessionUi.permissionAllowOnce.click(); const approvalResponse = await approvalResponsePromise; expect(approvalResponse.ok()).toBeTruthy(); - await expect(ui.chatView).toContainText("Streaming response started."); + await expect(sessionUi.chatView).toContainText("Streaming response started."); const sessionPath = new URL(appPage.url()).pathname; await appPage.reload({ waitUntil: "domcontentloaded" }); await expect.poll(() => new URL(appPage.url()).pathname).toBe(sessionPath); - await expect(ui.chatHeader).toBeVisible(); - await expect(ui.chatView).toContainText(browserLifecyclePrompt); + await expect(sessionWin).toBeVisible(); + await expect(sessionUi.chatView).toContainText(browserLifecyclePrompt); // Rehydration is correct: the settled turn's TERMINAL assistant output stays visible… - await expect(ui.chatView).toContainText("Approval granted.Session continued after approval."); + await expect(sessionUi.chatView).toContainText( + "Approval granted.Session continued after approval." + ); // …while its earlier "Streaming response started." preamble is intentionally folded // behind the collapsed "Worked" disclosure. Reach it by expanding that disclosure — // do not assert the folded text directly (persistence/fold behavior is unchanged). - const workedFold = appPage.getByRole("button", { name: /^Worked/ }); + const workedFold = sessionWin.getByRole("button", { name: /^Worked/ }); await expect(workedFold).toHaveAttribute("data-testid", "turn-fold-row"); await expect(workedFold).toHaveAttribute("aria-expanded", "false"); await workedFold.click(); await expect(workedFold).toHaveAttribute("aria-expanded", "true"); - await expect(ui.chatView).toContainText("Streaming response started."); - await expect(ui.stopButton).toBeVisible(); - await expect(ui.resumeButton).toBeVisible(); + await expect(sessionUi.chatView).toContainText("Streaming response started."); + await expect(sessionUi.stopButton).toBeVisible(); + await expect(sessionUi.resumeButton).toBeVisible(); - await ui.resumeButton.click(); - await expect(ui.stopButton).toBeVisible(); + await sessionUi.resumeButton.click(); + await expect(sessionUi.stopButton).toBeVisible(); - await ui.stopButton.click(); - await expect(ui.resumeButton).not.toBeVisible(); + await sessionUi.stopButton.click(); + await expect(sessionUi.resumeButton).not.toBeVisible(); await browserArtifacts.captureScreenshot("session-onboarding-hydrated", appPage); }); diff --git a/web/e2e/__tests__/session-provider-override.spec.ts b/web/e2e/__tests__/session-provider-override.spec.ts index 85bad66f4..a4e6bed31 100644 --- a/web/e2e/__tests__/session-provider-override.spec.ts +++ b/web/e2e/__tests__/session-provider-override.spec.ts @@ -6,7 +6,9 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { sessionLifecycleSelectors } from "../fixtures/selectors"; +import { openAppWindow, sessionWindow, switchWorkspace } from "../fixtures/os-navigation"; +import { waitForSeedSessionActive } from "../fixtures/runtime"; +import { sessionLifecycleSelectors, sessionWindowSelectors } from "../fixtures/selectors"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -101,19 +103,16 @@ test("operator can create a provider/model override session and attach without l await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); await useGlobalWorkspaceIfPrompted(ui); await expect(ui.osDesktop).toBeVisible(); - await appPage.getByTestId(`workspace-avatar-${workspace.id}`).click(); - await expect(appPage.getByTestId(`workspace-avatar-${workspace.id}`)).toHaveAttribute( - "data-active", - "true" - ); - await appPage.getByTestId("nav-agents").click(); + await switchWorkspace(appPage, workspace.id, workspace.name); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(ui.agentRow(browserLifecycleAgent)).toBeVisible(); + await expect(fleet.agentRow(browserLifecycleAgent)).toBeVisible(); - await ui.agentRow(browserLifecycleAgent).click(); + await fleet.agentRow(browserLifecycleAgent).click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe(`/agents/${browserLifecycleAgent}`); - await expect(ui.agentPageNewSession).toBeVisible(); - await ui.agentPageNewSession.click(); + await expect(fleet.agentPageNewSession).toBeVisible(); + await fleet.agentPageNewSession.click(); await expect(appPage.getByTestId("session-create-dialog")).toBeVisible(); await expect(appPage.getByTestId("session-create-agent-select")).toContainText( @@ -124,7 +123,7 @@ test("operator can create a provider/model override session and attach without l await expect(runtimeTrigger).toContainText("ACP Mock"); // Open the popup; the provider rail is a LOCAL filter listing every workspace provider. - await runtimeTrigger.locator('button[data-focus="provider"]').first().click(); + await runtimeTrigger.click(); await expect(appPage.getByTestId("runtime-selector-popup")).toBeVisible(); const railProviders = await appPage .locator('[role="radiogroup"] [data-rail]') @@ -164,7 +163,7 @@ test("operator can create a provider/model override session and attach without l await expect(appPage.getByTestId("runtime-selector-popup")).toHaveCount(0); await expect(runtimeTrigger).toContainText("qa-browser-model"); // The custom id is unknown to the catalog, so no reasoning segment is offered. - await expect(runtimeTrigger.locator('button[data-focus="reasoning"]')).toHaveCount(0); + await expect(runtimeTrigger).not.toContainText(/Low|Medium|High/); const createRequestPromise = appPage.waitForRequest( request => request.method() === "POST" && request.url().endsWith("/api/sessions") @@ -195,12 +194,15 @@ test("operator can create a provider/model override session and attach without l const createdSession = (await createResponse.json()) as SessionEnvelope; expect(createdSession.session.provider).toBe(overrideProvider); + await waitForSeedSessionActive(runtime, createdSession.session.id); await expect .poll(() => new URL(appPage.url()).pathname) .toBe(browserLifecycleSessionPath(createdSession.session.id)); - await expect(ui.chatHeader).toBeVisible(); - await expect(appPage.getByTestId("session-status-provider")).toHaveText(overrideProvider); + const sessionWin = sessionWindow(appPage, createdSession.session.id); + const sessionUi = sessionWindowSelectors(sessionWin, appPage); + await expect(sessionWin).toBeVisible(); + await expect(sessionWin.getByTestId("session-status-provider")).toHaveText(overrideProvider); await browserArtifacts.captureScreenshot("session-provider-created", appPage); await assertSessionParity( @@ -227,11 +229,11 @@ test("operator can create a provider/model override session and attach without l ) ); - await expect(ui.resumeButton).toBeVisible(); - await ui.resumeButton.click(); + await expect(sessionUi.resumeButton).toBeVisible(); + await sessionUi.resumeButton.click(); expect((await attachResponsePromise).ok()).toBe(true); - await expect(ui.stopButton).toBeVisible(); - await expect(appPage.getByTestId("session-status-provider")).toHaveText(overrideProvider); + await expect(sessionUi.stopButton).toBeVisible(); + await expect(sessionWin.getByTestId("session-status-provider")).toHaveText(overrideProvider); await assertSessionParity( runtime, createdSession.session.workspace_id, @@ -239,8 +241,8 @@ test("operator can create a provider/model override session and attach without l overrideProvider ); - await ui.stopButton.click(); - await expect(ui.resumeButton).not.toBeVisible(); + await sessionUi.stopButton.click(); + await expect(sessionUi.resumeButton).not.toBeVisible(); }); test("operator persists an advertised model and non-empty reasoning effort on the created session", async ({ @@ -251,17 +253,18 @@ test("operator persists an advertised model and non-empty reasoning effort on th await useGlobalWorkspaceIfPrompted(ui); await expect(ui.osDesktop).toBeVisible(); - await appPage.getByTestId("nav-agents").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const fleet = sessionLifecycleSelectors(agentsWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(ui.agentRow(browserLifecycleAgent)).toBeVisible(); + await expect(fleet.agentRow(browserLifecycleAgent)).toBeVisible(); - await ui.agentRow(browserLifecycleAgent).click(); + await fleet.agentRow(browserLifecycleAgent).click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe(`/agents/${browserLifecycleAgent}`); - await ui.agentPageNewSession.click(); + await fleet.agentPageNewSession.click(); await expect(appPage.getByTestId("session-create-dialog")).toBeVisible(); const runtimeTrigger = appPage.getByTestId("session-create-runtime-select"); - await runtimeTrigger.locator('button[data-focus="model"]').first().click(); + await runtimeTrigger.click(); await expect(appPage.getByTestId("runtime-selector-popup")).toBeVisible(); const catalogRow = appPage.locator( @@ -278,7 +281,7 @@ test("operator persists an advertised model and non-empty reasoning effort on th await appPage.keyboard.press("Escape"); await expect(appPage.getByTestId("runtime-selector-popup")).toHaveCount(0); await expect(runtimeTrigger).toContainText(catalogModelLabel); - await expect(runtimeTrigger.locator('button[data-focus="reasoning"]')).toContainText("High"); + await expect(runtimeTrigger).toContainText("High"); const createRequestPromise = appPage.waitForRequest( request => request.method() === "POST" && request.url().endsWith("/api/sessions") @@ -307,7 +310,8 @@ test("operator persists an advertised model and non-empty reasoning effort on th await expect .poll(() => new URL(appPage.url()).pathname) .toBe(browserLifecycleSessionPath(created.session.id)); - await expect(ui.chatHeader).toBeVisible(); + const sessionWin = sessionWindow(appPage, created.session.id); + await expect(sessionWin).toBeVisible(); const rehydrated = await runtime.requestJSON( sessionAPIPath(created.session.workspace_id, created.session.id) @@ -406,6 +410,8 @@ async function writeWorkspaceConfig(input: { `none_security = "local_transport"`, `[providers.${overrideProvider}.models]`, `default = "qa-browser-model"`, + `[providers.${overrideProvider}.models.reasoning]`, + `apply = "acp_option"`, `[[providers.${overrideProvider}.models.curated]]`, `id = "qa-browser-model"`, `display_name = "QA Browser Model"`, diff --git a/web/e2e/__tests__/settings-hardening.spec.ts b/web/e2e/__tests__/settings-hardening.spec.ts index ac9e301c4..8e3cc0a75 100644 --- a/web/e2e/__tests__/settings-hardening.spec.ts +++ b/web/e2e/__tests__/settings-hardening.spec.ts @@ -53,7 +53,7 @@ test("operator applies Memory, Network, Automation, and Observability settings w }>("/api/settings/network"); const nextMaxReplayAge = networkBefore.config.max_replay_age + 1; await appPage.goto(runtime.url("/settings/network"), { waitUntil: "domcontentloaded" }); - await expect(appPage.getByTestId("settings-page-network-enrollment-note")).toBeVisible(); + await expect(appPage.getByTestId("settings-page-network-hero")).toBeVisible(); await expect(appPage.getByTestId("settings-page-network-greet-interval")).toHaveCount(0); await expect(appPage.getByTestId("settings-page-network-max-queue-depth")).toHaveCount(0); await appPage.getByTestId("settings-page-network-max-replay-age").fill(String(nextMaxReplayAge)); @@ -69,6 +69,10 @@ test("operator applies Memory, Network, Automation, and Observability settings w }>("/api/settings/automation"); const nextMaxConcurrent = automationBefore.config.max_concurrent_jobs + 1; await appPage.goto(runtime.url("/settings/automation"), { waitUntil: "domcontentloaded" }); + await appPage + .getByTestId("settings-page-automation-advanced") + .getByTestId("settings-advanced-toggle") + .click(); await expect(appPage.getByTestId("settings-page-automation-max-concurrent-input")).toBeVisible(); await appPage .getByTestId("settings-page-automation-max-concurrent-input") @@ -100,8 +104,13 @@ test("operator applies Memory, Network, Automation, and Observability settings w ); await appPage.goto(runtime.url("/settings/providers"), { waitUntil: "domcontentloaded" }); - await expect(appPage.getByTestId("settings-page-providers-card-codex")).toBeVisible(); - await expect(appPage.getByTestId("settings-page-providers-card-codex-catalog")).toBeVisible(); + const codexCard = appPage.getByTestId("settings-page-providers-card-codex"); + await expect(codexCard).toBeVisible(); + await codexCard.click(); + const providerDetail = appPage.getByTestId("provider-detail-dialog"); + await expect(providerDetail).toBeVisible(); + await expect(providerDetail.getByRole("heading", { name: "codex" })).toBeVisible(); + await expect(providerDetail.locator('[data-section="catalog"]')).toBeVisible(); const parity = { http: { @@ -368,8 +377,9 @@ async function captureSettingsViewportMatrix( for (const width of [375, 768, 1280]) { await appPage.setViewportSize({ width, height: 820 }); await appPage.goto(runtime.url(pathname), { waitUntil: "domcontentloaded" }); - await expect(appPage.getByTestId("settings-shell")).toBeVisible(); - await expect(appPage.getByTestId("settings-section-nav")).toBeVisible(); + const settingsWin = appPage.getByTestId("os-window-app:settings"); + await expect(settingsWin.getByTestId("settings-shell")).toBeVisible(); + await expect(settingsWin.getByTestId("settings-section-nav")).toBeVisible(); await browserArtifacts.captureScreenshot(`settings-viewport-${width}`, appPage); } } diff --git a/web/e2e/__tests__/settings.spec.ts b/web/e2e/__tests__/settings.spec.ts index ef77ee6f7..d18952b30 100644 --- a/web/e2e/__tests__/settings.spec.ts +++ b/web/e2e/__tests__/settings.spec.ts @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { reloadDaemonServedPage } from "../fixtures/navigation"; +import { openAppWindow, switchWorkspace } from "../fixtures/os-navigation"; import { settingsOperatorSelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; import { browserSettingsOperatorFlowScenario, @@ -29,11 +30,13 @@ test("operator can navigate the settings shell and complete a restart-aware gene runtime, }) => { const sessionUI = sessionLifecycleSelectors(appPage); - const settingsUI = settingsOperatorSelectors(appPage); await ensureGlobalWorkspace(runtime); await useGlobalWorkspaceIfPrompted(sessionUI); await appPage.goto(runtime.url("/settings/general"), { waitUntil: "domcontentloaded" }); + const settingsWin = appPage.getByTestId("os-window-app:settings"); + await expect(settingsWin).toBeVisible({ timeout: 20_000 }); + const settingsUI = settingsOperatorSelectors(settingsWin); await expect(settingsUI.shell.shell).toBeVisible({ timeout: 20_000 }); await expect(settingsUI.shell.sectionNav).toBeVisible({ timeout: 20_000 }); @@ -42,8 +45,10 @@ test("operator can navigate the settings shell and complete a restart-aware gene .toEqual([ "General", "Appearance", + "Layouts", "Providers", "Memory", + "Roles", "Skills", "Automation", "Network", @@ -136,7 +141,6 @@ test("operator can distinguish skills actions that apply now from policy changes runtime, }) => { const sessionUI = sessionLifecycleSelectors(appPage); - const settingsUI = settingsOperatorSelectors(appPage); const seeded = await seedBrowserSettingsFixtures(runtime, { disabledSkills: [browserSettingsOperatorFlowScenario.skills.disabledSkill], }); @@ -145,6 +149,9 @@ test("operator can distinguish skills actions that apply now from policy changes await ensureGlobalWorkspace(runtime); await useGlobalWorkspaceIfPrompted(sessionUI); await appPage.goto(runtime.url("/settings/skills"), { waitUntil: "domcontentloaded" }); + const settingsWin = appPage.getByTestId("os-window-app:settings"); + await expect(settingsWin).toBeVisible({ timeout: 20_000 }); + const settingsUI = settingsOperatorSelectors(settingsWin); await expect(settingsUI.skills.page).toBeVisible(); await expect(settingsUI.skills.disabledList).toBeVisible(); @@ -164,15 +171,18 @@ test("operator can distinguish skills actions that apply now from policy changes await settingsUI.skills.operationalLink.click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/marketplace/skills"); await expect.poll(() => new URL(appPage.url()).search).toBe("?tab=installed"); - await appPage.goBack({ waitUntil: "domcontentloaded" }); + await openAppWindow(appPage, "Settings", "settings"); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/settings/skills"); + await settingsWin + .getByTestId("settings-page-skills-advanced") + .getByTestId("settings-advanced-toggle") + .click(); await settingsUI.skills.policyRegistryInput.fill("clawhub"); await settingsUI.skills.policyBaseURLInput.fill("https://skills.example/browser-updated"); - await expect(settingsUI.skills.policySave).toBeEnabled(); - await settingsUI.skills.policySave.click(); + await expect(settingsUI.skills.save).toBeEnabled(); + await settingsUI.skills.save.click(); - await expect(settingsUI.skills.policyMessage).toContainText("restart required"); await expect(settingsUI.skills.restartNotice).toBeVisible(); await expect(settingsUI.skills.restartNotice).toContainText("Restart needed"); await browserArtifacts.captureScreenshot("tc-func-005-skills-applied-now-vs-restart", appPage); @@ -187,19 +197,21 @@ test("operator can replace a builtin provider with a config overlay and delete i runtime, }) => { const sessionUI = sessionLifecycleSelectors(appPage); - const settingsUI = settingsOperatorSelectors(appPage); const builtinProviderName = await pickBuiltinProviderName(runtime); await ensureGlobalWorkspace(runtime); await useGlobalWorkspaceIfPrompted(sessionUI); await appPage.goto(runtime.url("/settings/providers"), { waitUntil: "domcontentloaded" }); + const settingsWin = appPage.getByTestId("os-window-app:settings"); + await expect(settingsWin).toBeVisible({ timeout: 20_000 }); + const settingsUI = settingsOperatorSelectors(settingsWin); await expect(settingsUI.providers.page).toBeVisible(); await expect(settingsUI.providers.list).toBeVisible(); await expect(settingsUI.providers.card(builtinProviderName)).toBeVisible(); await settingsUI.providers.card(builtinProviderName).click(); - await appPage.getByTestId("provider-inspector-edit").click(); + await settingsUI.providers.editorEdit.click(); await expect(settingsUI.providers.editor).toBeVisible(); await settingsUI.providers.editorCommandInput.fill( browserSettingsOperatorFlowScenario.providers.overlayCommand @@ -219,7 +231,7 @@ test("operator can replace a builtin provider with a config overlay and delete i ); await settingsUI.providers.card(builtinProviderName).click(); await expect(settingsUI.providers.inspectorSource).toContainText(/config/i); - await appPage.getByTestId("provider-inspector-delete").click(); + await settingsUI.providers.editorDelete.click(); await expect(settingsUI.providers.deleteDialog).toBeVisible(); await settingsUI.providers.deleteConfirm.click(); @@ -256,9 +268,11 @@ test("operator can manage MCP servers across global and workspace scopes with vi await expect(settingsUI.mcpServers.scopeWorkspace).toBeVisible(); await expect(settingsUI.mcpServers.scopeGlobal).toBeVisible(); - await appPage.getByTestId("workspace-switcher").click(); - await appPage.getByTestId(`workspace-command-item-${workspace.id}`).click(); - await expect(appPage.getByTestId("workspace-switcher")).toHaveAttribute("aria-expanded", "false"); + await switchWorkspace(appPage, workspace.id, workspace.name); + await appPage.goto(runtime.url("/marketplace/mcps?tab=installed"), { + waitUntil: "domcontentloaded", + }); + await expect(settingsUI.mcpServers.page).toBeVisible(); await settingsUI.mcpServers.scopeWorkspace.click(); await expect(settingsUI.mcpServers.scopeWorkspace).toHaveAttribute("aria-pressed", "true"); @@ -329,7 +343,6 @@ test("operator can manage restart-aware hooks and extension policy on split sett runtime, }) => { const sessionUI = sessionLifecycleSelectors(appPage); - const settingsUI = settingsOperatorSelectors(appPage); const seeded = await seedBrowserSettingsFixtures(runtime, { hooks: [ { @@ -354,6 +367,9 @@ test("operator can manage restart-aware hooks and extension policy on split sett await appPage.goto(runtime.url("/settings/hooks"), { waitUntil: "domcontentloaded", }); + const settingsWin = appPage.getByTestId("os-window-app:settings"); + await expect(settingsWin).toBeVisible({ timeout: 20_000 }); + const settingsUI = settingsOperatorSelectors(settingsWin); await expect(settingsUI.hooks.page).toBeVisible(); await expect( @@ -388,8 +404,8 @@ test("operator can manage restart-aware hooks and extension policy on split sett await settingsUI.extensions.policyBaseURLInput.fill( "https://extensions.example/browser-updated" ); - await expect(settingsUI.extensions.policySave).toBeEnabled(); - await settingsUI.extensions.policySave.click(); + await expect(settingsUI.extensions.save).toBeEnabled(); + await settingsUI.extensions.save.click(); await expect(settingsUI.extensions.restartNotice).toBeVisible(); await browserArtifacts.captureScreenshot("tc-func-012-extensions-policy", appPage); @@ -398,6 +414,57 @@ test("operator can manage restart-aware hooks and extension policy on split sett } }); +test("operator routes a background role, persists it across reload, and keeps builtins out of the Agents fleet", async ({ + appPage, + browserArtifacts, + runtime, +}) => { + const sessionUI = sessionLifecycleSelectors(appPage); + const settingsUI = settingsOperatorSelectors(appPage); + const nextModel = "claude-haiku-4-5"; + + await ensureGlobalWorkspace(runtime); + await useGlobalWorkspaceIfPrompted(sessionUI); + await appPage.goto(runtime.url("/settings/roles"), { waitUntil: "domcontentloaded" }); + + await expect(settingsUI.roles.page).toBeVisible({ timeout: 20_000 }); + // Coordinator ships disabled — its OFF state is projected truthfully. + await expect(settingsUI.roles.badgeOff("coordinator")).toBeVisible(); + + const modelInput = settingsUI.roles.fieldInput("auto_title", "model"); + await expect(modelInput).toHaveValue(""); + await modelInput.fill(nextModel); + await expect(settingsUI.roles.saveButton).toBeEnabled(); + + const applyResponse = appPage.waitForResponse( + response => + new URL(response.url()).pathname === "/api/settings/roles" && + response.request().method() === "PATCH" + ); + await settingsUI.roles.saveButton.click(); + await applyResponse; + await expect(settingsUI.roles.saveMessage).toContainText("applied immediately"); + await browserArtifacts.captureScreenshot("e2e-006-roles-auto-title-saved", appPage); + + // The routed model survives a reload — panel and daemon config agree. + await reloadDaemonServedPage(appPage, runtime, "/settings/roles", { + readyTestId: "settings-page-roles", + }); + await expect(settingsUI.roles.fieldInput("auto_title", "model")).toHaveValue(nextModel); + + const section = await runtime.requestJSON<{ config: { auto_title: { model: string } } }>( + "/api/settings/roles" + ); + expect(section.config.auto_title.model).toBe(nextModel); + + // Virtual builtins never enter the Agents fleet. + await appPage.goto(runtime.url("/agents"), { waitUntil: "domcontentloaded" }); + await expect(sessionUI.agentRow("general")).toBeVisible(); + await expect(sessionUI.agentRow("coordinator")).toHaveCount(0); + await expect(sessionUI.agentRow("dreaming-curator")).toHaveCount(0); + await browserArtifacts.captureScreenshot("e2e-006-agents-fleet-no-builtins", appPage); +}); + function normalizeTexts(values: string[]): string[] { return values.map(value => value.trim()).filter(value => value !== ""); } diff --git a/web/e2e/__tests__/tasks-coordinator-handoff.spec.ts b/web/e2e/__tests__/tasks-coordinator-handoff.spec.ts index 8e73922e6..1fc9c4239 100644 --- a/web/e2e/__tests__/tasks-coordinator-handoff.spec.ts +++ b/web/e2e/__tests__/tasks-coordinator-handoff.spec.ts @@ -3,7 +3,17 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { sessionLifecycleSelectors, tasksOperatorSelectors } from "../fixtures/selectors"; +import { + sessionLifecycleSelectors, + sessionWindowSelectors, + tasksOperatorSelectors, +} from "../fixtures/selectors"; +import { + ensureAppWindow, + openAppWindow, + sessionWindow, + switchWorkspace, +} from "../fixtures/os-navigation"; import { seedBrowserTasksOperatorFlow } from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; import { ensureGlobalWorkspace, useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -12,11 +22,10 @@ import { ensureGlobalWorkspace, useGlobalWorkspaceIfPrompted } from "../fixtures * (manual operator control) and (task-run coordination * channels) bookends for the Tasks UI. These cases verify: * - * 1. Creating a task is saved intent only, no run is queued, the lifecycle - * pill reads "Saved intent", the runs panel reads as saved intent, and the - * Publish CTA tooltip names coordinator handoff. - * 2. Publishing/starting moves the task into "Coordinator handoff" with a - * bound coordination channel chip on the active run. + * 1. Creating a task is saved intent only, no run is queued, the runs panel + * explains that boundary, and the Publish CTA names coordinator handoff. + * 2. Publishing/starting exposes the authoritative active run with its bound + * coordination channel. * 3. Approving an agent-created approval-pending task enqueues a * coordinator-handoff run, never auto-starting on creation. * 4. Manual session start UI is unaffected by task autonomy labels. @@ -69,13 +78,13 @@ test("creating a task is saved intent, no run is enqueued and labels never imply appPage, runtime, }) => { - const tasksUI = tasksOperatorSelectors(appPage); - await ensureGlobalWorkspace(runtime); await appPage.goto(runtime.url("/tasks"), { waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(tasksUI); + await useGlobalWorkspaceIfPrompted(appPage); - await expect(tasksUI.navTasks).toBeVisible(); + const tasksWin = appPage.getByTestId("os-window-app:tasks"); + const tasksUI = tasksOperatorSelectors(tasksWin, appPage); + await expect(tasksWin).toBeVisible(); await expect(appPage).toHaveURL(/\/tasks$/); await tasksUI.openCreate.click(); @@ -104,11 +113,8 @@ test("creating a task is saved intent, no run is enqueued and labels never imply throw new Error(`Expected a created draft task for "${draftTitle}".`); } - await expect(tasksUI.detailContent).toContainText(draftTitle); - - await expect(tasksUI.detailLifecycle).toHaveText(/saved intent/i); - await expect(tasksUI.detailLifecycleHint).toContainText(/saved intent/i); - await expect(tasksUI.detailLifecycleHint).toContainText(/coordinator/i); + await expect(tasksUI.detailTitle).toHaveText(draftTitle); + await expect(tasksUI.detailStatus).toHaveText(/draft/i); const publishButton = tasksUI.detailPublish; await expect(publishButton).toBeVisible(); @@ -130,18 +136,17 @@ test("publishing a draft hands off to the coordinator and binds a coordination c appPage, runtime, }) => { - const tasksUI = tasksOperatorSelectors(appPage); + const tasksWin = appPage.getByTestId("os-window-app:tasks"); + const tasksUI = tasksOperatorSelectors(tasksWin, appPage); const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "agh-tasks-handoff-workspace-")); const workspace = await runtime.resolveWorkspace(workspaceRoot); await ensureGlobalWorkspace(runtime); await appPage.goto(runtime.url("/tasks"), { waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(tasksUI); + await useGlobalWorkspaceIfPrompted(appPage); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/tasks"); - await appPage.getByTestId("workspace-switcher").click(); - await appPage.getByTestId(`workspace-command-item-${workspace.id}`).click(); - await expect(appPage.getByTestId("workspace-switcher")).toHaveAttribute("aria-expanded", "false"); - await expect(appPage.getByTestId("workspace-switcher-name")).toContainText(workspace.name); + await switchWorkspace(appPage, workspace.id, workspace.name); + await ensureAppWindow(appPage, "Tasks", "tasks"); await tasksUI.openCreate.click(); await selectRecurringTaskTemplate(tasksUI); @@ -214,7 +219,7 @@ test("publishing a draft hands off to the coordinator and binds a coordination c }) .not.toBe(""); - await expect(tasksUI.detailLifecycle).toHaveText(/coordinator handoff|running/i); + await expect(tasksUI.detailNowRun).toBeVisible(); await expect(tasksUI.detailCoordination).toBeVisible(); await expect(tasksUI.detailCoordination).toContainText(/channel/i); await expect(tasksUI.detailCoordination).toHaveAttribute( @@ -231,14 +236,14 @@ test("approving an agent-created approval task is the coordinator-handoff bounda browserArtifacts, runtime, }) => { - const tasksUI = tasksOperatorSelectors(appPage); const seeded = await seedBrowserTasksOperatorFlow(runtime, { sessionAgentName: handoffAgentName, }); - await useGlobalWorkspaceIfPrompted(tasksUI); + await useGlobalWorkspaceIfPrompted(appPage); - await tasksUI.navTasks.click(); + const tasksWin = await openAppWindow(appPage, "Tasks", "tasks"); + const tasksUI = tasksOperatorSelectors(tasksWin, appPage); await expect(appPage).toHaveURL(/\/tasks$/); await tasksUI.modeList.click(); await expect(tasksUI.modeList).toHaveAttribute("aria-current", "page"); @@ -249,9 +254,11 @@ test("approving an agent-created approval task is the coordinator-handoff bounda expect(approvalTaskRunsBefore.runs).toHaveLength(0); await tasksUI.taskCard(seeded.approvalTask.id).click(); - await expect(tasksUI.detailLifecycle).toHaveText(/awaiting approval/i); - await expect(tasksUI.detailLifecycleHint).toContainText(/approving enqueues/i); - await expect(tasksUI.detailRunsLink(seeded.approvalTask.id)).toHaveCount(0); + await expect(tasksUI.detailApprovalPill).toContainText(/approval pending/i); + await expect(tasksUI.detailNowApproval).toContainText(/waiting for your approval/i); + await expect(tasksUI.detailNowApproval).toContainText(/manual check is required/i); + await tasksUI.detailTabRuns.click(); + await expect(tasksUI.detailRunsEmpty).toBeVisible(); await tasksUI.detailBreadcrumbTasks.click(); await tasksUI.modeInbox.click(); @@ -284,22 +291,22 @@ test("starting a manual session is unaffected by task autonomy labels", async ({ appPage, runtime, }) => { - const tasksUI = tasksOperatorSelectors(appPage); const sessionUI = sessionLifecycleSelectors(appPage); await ensureGlobalWorkspace(runtime); await appPage.goto(runtime.url("/"), { waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(tasksUI); + await useGlobalWorkspaceIfPrompted(appPage); await expect(sessionUI.osDesktop).toBeVisible(); - await appPage.getByTestId("nav-agents").click(); + const agentsWin = await openAppWindow(appPage, "Agents", "agents"); + const agentsUI = sessionLifecycleSelectors(agentsWin); await expect.poll(() => new URL(appPage.url()).pathname).toBe("/agents"); - await expect(sessionUI.agentRow(handoffAgentName)).toBeVisible(); - await sessionUI.agentRow(handoffAgentName).click(); + await expect(agentsUI.agentRow(handoffAgentName)).toBeVisible(); + await agentsUI.agentRow(handoffAgentName).click(); await expect.poll(() => new URL(appPage.url()).pathname).toBe(`/agents/${handoffAgentName}`); - await expect(sessionUI.agentPageNewSession).toBeVisible(); - await sessionUI.agentPageNewSession.click(); + await expect(agentsUI.agentPageNewSession).toBeVisible(); + await agentsUI.agentPageNewSession.click(); await expect(appPage.getByTestId("session-create-dialog")).toBeVisible(); await expect(appPage.getByTestId("session-create-agent-select")).toContainText(handoffAgentName); @@ -322,7 +329,8 @@ test("starting a manual session is unaffected by task autonomy labels", async ({ .not.toBe(""); await expect.poll(() => new URL(appPage.url()).pathname).toBe(handoffAgentSessionPath(sessionId)); - await expect(sessionUI.chatHeader).toBeVisible(); + const sessionWin = sessionWindowSelectors(sessionWindow(appPage, sessionId)); + await expect(sessionWin.chatView).toBeVisible(); const sessions = await runtime.requestJSON<{ sessions: Array<{ id: string; agent_name: string; state?: string }>; diff --git a/web/e2e/__tests__/tasks-hardening.spec.ts b/web/e2e/__tests__/tasks-hardening.spec.ts index 101e9521b..74dbd0877 100644 --- a/web/e2e/__tests__/tasks-hardening.spec.ts +++ b/web/e2e/__tests__/tasks-hardening.spec.ts @@ -8,7 +8,12 @@ import { promisify } from "node:util"; import { captureRouteState } from "../fixtures/browser-artifact-session"; import { tasksOperatorSelectors } from "../fixtures/selectors"; -import { seedBrowserTasksOperatorFlow, type BrowserRuntime } from "../fixtures/runtime"; +import { ensureAppWindow, switchWorkspace } from "../fixtures/os-navigation"; +import { + seedBrowserTasksOperatorFlow, + waitForSeedSessionActive, + type BrowserRuntime, +} from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; import { ensureGlobalWorkspace, useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -179,6 +184,7 @@ test("operator retries failed work and sees an auditable run review gate", async }) => { const ui = tasksOperatorSelectors(appPage); const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "agh-tasks-retry-workspace-")); + const workspace = await runtime.resolveWorkspace(workspaceRoot); const seeded = await seedBrowserTasksOperatorFlow(runtime, { sessionAgentName: tasksSessionAgentName, workspaceRootDir: workspaceRoot, @@ -188,6 +194,7 @@ test("operator retries failed work and sees an auditable run review gate", async tasksSessionAgentName, seeded.session.workspace_id ); + await waitForSeedSessionActive(runtime, workerSession.id); await useGlobalWorkspaceIfPrompted(ui); const task = await createTask(runtime, { @@ -210,9 +217,8 @@ test("operator retries failed work and sees an auditable run review gate", async .toBe("failed"); await appPage.goto(runtime.url("/tasks"), { waitUntil: "domcontentloaded" }); - await appPage.getByTestId("workspace-switcher").click(); - await appPage.getByTestId(`workspace-command-item-${seeded.session.workspace_id}`).click(); - await expect(appPage.getByTestId("workspace-switcher")).toHaveAttribute("aria-expanded", "false"); + await switchWorkspace(appPage, seeded.session.workspace_id, workspace.name); + await ensureAppWindow(appPage, "Tasks", "tasks"); await ui.modeInbox.click(); await expect(ui.inboxLane("failed_runs")).toBeVisible(); await expect(ui.inboxItem(task.id)).toBeVisible(); @@ -367,7 +373,6 @@ test("operator inspects child and dependency graph, edits the task, and deletes }); await useGlobalWorkspaceIfPrompted(appPage); await expect(ui.detailContent).toBeVisible(); - await ui.detailTab("children").click(); await expect(ui.detailChildItem(child.id)).toBeVisible(); await expect(ui.detailChildLink(child.id)).toHaveAttribute( "href", @@ -388,7 +393,6 @@ test("operator inspects child and dependency graph, edits the task, and deletes waitUntil: "domcontentloaded", }); await expect(ui.detailContent).toBeVisible(); - await ui.detailTab("dependencies").click(); await expect(ui.detailDependencyItem(dependency.id)).toBeVisible(); await expect(ui.detailDependencyLink(dependency.id)).toHaveAttribute( "href", @@ -431,6 +435,7 @@ test("operator inspects child and dependency graph, edits the task, and deletes }), }) ).session; + await waitForSeedSessionActive(runtime, blockedSession.id); const blockedClaimResponse = await fetch(runtime.url("/api/agent/tasks/claim-next"), { method: "POST", headers: { @@ -448,6 +453,7 @@ test("operator inspects child and dependency graph, edits the task, and deletes expect(blockedClaimBody).toBe(""); const editedTitle = uniqueTitle("Graph child edited"); + await ui.detailOverflow.click(); await ui.detailEdit.click(); await expect(ui.createEditorSurface).toBeVisible(); await ui.createTitle.fill(editedTitle); @@ -459,7 +465,7 @@ test("operator inspects child and dependency graph, edits the task, and deletes await ui.createSubmit.click(); expect((await editResponsePromise).ok()).toBe(true); await expect.poll(async () => (await getTask(runtime, child.id)).task.title).toBe(editedTitle); - await expect(ui.detailContent).toContainText(editedTitle); + await expect(ui.detailTitle).toContainText(editedTitle); const disposable = await createTask(runtime, { description: "Disposable task for delete hardening.", @@ -599,7 +605,7 @@ test("tasks list, inbox, detail, and run detail stay usable across responsive br // E2E-web-1 (_tests.md §4.1): typed blocked_reasons chips project every open // source simultaneously (dependency + approval + block), truthfully from payload. -test("task detail renders blocked_reasons chips for dependency, approval, and block sources", async ({ +test("task detail renders blocked_reasons bands for dependency, approval, and block sources", async ({ appPage, browserArtifacts, runtime, @@ -635,14 +641,12 @@ test("task detail renders blocked_reasons chips for dependency, approval, and bl await useGlobalWorkspaceIfPrompted(ui); await expect(ui.detailContent).toBeVisible(); - const blockedReasons = appPage.getByTestId("tasks-detail-blocked-reasons"); - await expect(blockedReasons).toBeVisible(); - await expect(appPage.getByTestId("tasks-detail-blocked-reason")).toHaveCount(3); - for (const source of ["dependency", "approval", "block"]) { - await expect(blockedReasons.locator(`[data-source="${source}"]`)).toHaveCount(1); - } - await expect(blockedReasons).toContainText("Waiting on operator input"); - await browserArtifacts.captureScreenshot("tasks-blocked-reasons-chips", appPage); + await expect(appPage.getByTestId("tasks-detail-now-approval")).toBeVisible(); + await expect(appPage.locator('[data-testid^="tasks-detail-now-dependency-"]')).toHaveCount(1); + const explicitBlock = appPage.locator('[data-testid^="tasks-detail-now-block-"]'); + await expect(explicitBlock).toHaveCount(1); + await expect(explicitBlock).toContainText("Waiting on operator input"); + await browserArtifacts.captureScreenshot("tasks-blocked-reasons-bands", appPage); }); // E2E-web-2 (_tests.md §4.2): the needs_attention badge + Recover action clear @@ -671,8 +675,8 @@ test("task detail exposes the needs_attention badge and a Recover action that cl await useGlobalWorkspaceIfPrompted(ui); await expect(ui.detailContent).toBeVisible(); - const badge = appPage.getByTestId("tasks-detail-needs-attention"); - const recover = appPage.getByTestId("tasks-detail-recover"); + const badge = appPage.getByTestId("tasks-detail-now-stuck"); + const recover = appPage.getByTestId("tasks-detail-now-recover"); await expect(badge).toBeVisible(); await expect(recover).toBeVisible(); await browserArtifacts.captureScreenshot("tasks-needs-attention-badge", appPage); @@ -692,7 +696,7 @@ test("task detail exposes the needs_attention badge and a Recover action that cl }); await observerPage.goto(runtime.url(detailPath), { waitUntil: "domcontentloaded" }); await useGlobalWorkspaceIfPrompted(observerPage); - const observerBadge = observerPage.getByTestId("tasks-detail-needs-attention"); + const observerBadge = observerPage.getByTestId("tasks-detail-now-stuck"); await expect(observerBadge).toBeVisible(); await observerStreamReady; @@ -733,6 +737,7 @@ test("task detail reflects the wake_creator opt-out on agent-created tasks", asy } const workspace = await runtime.resolveWorkspace(homeDir); const session = await createSession(runtime, tasksSessionAgentName, workspace.id); + await waitForSeedSessionActive(runtime, session.id); const wakeOffTask = await createAgentTask(runtime, session.id, tasksSessionAgentName, { scope: "global", @@ -758,7 +763,7 @@ test("task detail reflects the wake_creator opt-out on agent-created tasks", asy }); await useGlobalWorkspaceIfPrompted(ui); await expect(ui.detailContent).toBeVisible(); - const wakeOffPill = appPage.getByTestId("tasks-detail-wake"); + const wakeOffPill = appPage.getByTestId("tasks-detail-pill-wake"); await expect(wakeOffPill).toBeVisible(); await expect(wakeOffPill).toContainText("Wake off"); await browserArtifacts.captureScreenshot("tasks-wake-opt-out", appPage); @@ -767,7 +772,7 @@ test("task detail reflects the wake_creator opt-out on agent-created tasks", asy waitUntil: "domcontentloaded", }); await expect(ui.detailContent).toBeVisible(); - const wakeOnPill = appPage.getByTestId("tasks-detail-wake"); + const wakeOnPill = appPage.getByTestId("tasks-detail-pill-wake"); await expect(wakeOnPill).toBeVisible(); await expect(wakeOnPill).toContainText("Wake on"); }); diff --git a/web/e2e/__tests__/tasks-orchestration.spec.ts b/web/e2e/__tests__/tasks-orchestration.spec.ts index d3735bfb6..edd707e0b 100644 --- a/web/e2e/__tests__/tasks-orchestration.spec.ts +++ b/web/e2e/__tests__/tasks-orchestration.spec.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { tasksOperatorSelectors } from "../fixtures/selectors"; +import { openAppWindow } from "../fixtures/os-navigation"; import { seedBrowserTasksOperatorFlow } from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -39,14 +40,14 @@ test("operator uses the three-tab task detail, setup, inspect, and run review su browserArtifacts, runtime, }) => { - const tasksUI = tasksOperatorSelectors(appPage); const seeded = await seedBrowserTasksOperatorFlow(runtime, { sessionAgentName: tasksSessionAgentName, }); - await useGlobalWorkspaceIfPrompted(tasksUI); + await useGlobalWorkspaceIfPrompted(appPage); - await tasksUI.navTasks.click(); + const tasksWin = await openAppWindow(appPage, "Tasks", "tasks"); + const tasksUI = tasksOperatorSelectors(tasksWin, appPage); await expect(appPage).toHaveURL(/\/tasks$/); await appPage.goto(runtime.url(`/tasks/${seeded.referenceTask.id}?tab=activity&inspect=stream`), { @@ -78,10 +79,10 @@ test("operator uses the three-tab task detail, setup, inspect, and run review su await expect(tasksUI.runsRow(seeded.runningRun.id)).toBeVisible(); await tasksUI .runsRow(seeded.runningRun.id) - .getByRole("link", { name: `Attempt ${seeded.runningRun.attempt}` }) + .getByRole("link", { exact: true, name: `Attempt ${seeded.runningRun.attempt}` }) .click(); await expect(tasksUI.runDetailContent).toBeVisible(); - await expect(tasksUI.runReviews).toContainText("No reviews recorded for this run."); + await expect(tasksUI.runReviews).toHaveCount(0); await browserArtifacts.captureScreenshot("tasks-three-tab-detail", appPage); }); diff --git a/web/e2e/__tests__/tasks.spec.ts b/web/e2e/__tests__/tasks.spec.ts index b23e9f7c9..ec6e64568 100644 --- a/web/e2e/__tests__/tasks.spec.ts +++ b/web/e2e/__tests__/tasks.spec.ts @@ -1,7 +1,12 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { sessionLifecycleSelectors, tasksOperatorSelectors } from "../fixtures/selectors"; +import { + sessionLifecycleSelectors, + sessionWindowSelectors, + tasksOperatorSelectors, +} from "../fixtures/selectors"; +import { openAppWindow, sessionWindow } from "../fixtures/os-navigation"; import { seedBrowserTasksOperatorFlow } from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -59,16 +64,15 @@ test("operator can execute the shipped Tasks flow through the shared daemon-serv runtime, }) => { const sessionUI = sessionLifecycleSelectors(appPage); - const tasksUI = tasksOperatorSelectors(appPage); const seeded = await seedBrowserTasksOperatorFlow(runtime, { sessionAgentName: tasksSessionAgentName, }); - await useGlobalWorkspaceIfPrompted(tasksUI); + await useGlobalWorkspaceIfPrompted(appPage); - await expect(tasksUI.osDesktop).toBeVisible(); - await expect(tasksUI.navTasks).toBeVisible(); - await tasksUI.navTasks.click(); + await expect(sessionUI.osDesktop).toBeVisible(); + const tasksWin = await openAppWindow(appPage, "Tasks", "tasks"); + const tasksUI = tasksOperatorSelectors(tasksWin, appPage); await expect(appPage).toHaveURL(/\/tasks$/); await expect(tasksUI.modeList).toHaveAttribute("aria-current", "page"); @@ -106,7 +110,7 @@ test("operator can execute the shipped Tasks flow through the shared daemon-serv throw new Error(`Expected a created draft task for "${createdDraftTitle}".`); } - await expect(tasksUI.detailContent).toContainText(createdDraftTitle); + await expect(tasksUI.detailTitle).toHaveText(createdDraftTitle); await expect(tasksUI.detailPublish).toBeVisible(); await browserArtifacts.captureScreenshot("tasks-draft-created", appPage); @@ -140,7 +144,7 @@ test("operator can execute the shipped Tasks flow through the shared daemon-serv await browserArtifacts.captureScreenshot("tasks-draft-published", appPage); await expect(tasksUI.detailContent).toBeVisible(); - await expect(tasksUI.detailContent).toContainText(createdDraftTitle); + await expect(tasksUI.detailTitle).toHaveText(createdDraftTitle); await expect(tasksUI.detailTab("overview")).toBeVisible(); await expect(tasksUI.detailTab("runs")).toBeVisible(); await expect(tasksUI.detailTab("activity")).toBeVisible(); @@ -173,7 +177,8 @@ test("operator can execute the shipped Tasks flow through the shared daemon-serv await expect .poll(() => new URL(appPage.url()).pathname) .toBe(tasksSessionPath(seeded.session.id)); - await expect(sessionUI.chatHeader).toBeVisible(); + const sessionWin = sessionWindowSelectors(sessionWindow(appPage, seeded.session.id)); + await expect(sessionWin.chatView).toBeVisible(); await browserArtifacts.captureScreenshot("tasks-linked-session", appPage); await appPage.goto(runtime.url("/tasks"), { @@ -262,7 +267,7 @@ test("operator can execute the shipped Tasks flow through the shared daemon-serv throw new Error(`Expected a deletable draft task for "${deleteDraftTitle}".`); } - await expect(tasksUI.detailContent).toContainText(deleteDraftTitle); + await expect(tasksUI.detailTitle).toHaveText(deleteDraftTitle); await tasksUI.detailOverflow.click(); await tasksUI.detailDelete.click(); await expect(tasksUI.detailDeleteDialog).toBeVisible(); diff --git a/web/e2e/__tests__/tool-approval-grants.spec.ts b/web/e2e/__tests__/tool-approval-grants.spec.ts index b62c62821..3852a03db 100644 --- a/web/e2e/__tests__/tool-approval-grants.spec.ts +++ b/web/e2e/__tests__/tool-approval-grants.spec.ts @@ -10,7 +10,8 @@ import { type HostedMcpConnection, } from "../fixtures/hosted-mcp"; import type { BrowserRuntime, RuntimePaths } from "../fixtures/runtime"; -import { sessionLifecycleSelectors, toolApprovalGrantsSelectors } from "../fixtures/selectors"; +import { sessionWindow } from "../fixtures/os-navigation"; +import { sessionWindowSelectors, toolApprovalGrantsSelectors } from "../fixtures/selectors"; import { expect, test } from "../fixtures/test"; import { useGlobalWorkspaceIfPrompted } from "../fixtures/workspace"; @@ -77,7 +78,6 @@ test("operator remembers a native-tool decision and revokes it end to end", asyn runtime, }) => { assertLaunchRuntime(runtime); - const sessionUI = sessionLifecycleSelectors(appPage); const grantsUI = toolApprovalGrantsSelectors(appPage); // The session's workspace is the global workspace, which is also the browser's active @@ -109,10 +109,12 @@ test("operator remembers a native-tool decision and revokes it end to end", asyn await appPage.goto(runtime.url(`/agents/${MOCK_AGENT}/sessions/${sessionId}`), { waitUntil: "domcontentloaded", }); + const sessionWin = sessionWindow(appPage, sessionId); + const sessionUI = sessionWindowSelectors(sessionWin, appPage); // The native-tool permission event only renders while a prompt stream is active // (emitPromptEvent needs a non-nil active prompt). Submit a prompt that reports readiness // then blocks until cancelled, so the hosted-tool permission surfaces in that live stream. - await expect(sessionUI.chatHeader).toBeVisible({ timeout: 20_000 }); + await expect(sessionWin).toBeVisible({ timeout: 20_000 }); await sessionUI.composerTextarea.fill("hold native approval"); await sessionUI.composerTextarea.press("Enter"); await expect(appPage.getByText("native approval ready")).toBeVisible({ timeout: 20_000 }); diff --git a/web/e2e/__tests__/triggers-hardening.spec.ts b/web/e2e/__tests__/triggers-hardening.spec.ts index 12544b28c..ab5b6889c 100644 --- a/web/e2e/__tests__/triggers-hardening.spec.ts +++ b/web/e2e/__tests__/triggers-hardening.spec.ts @@ -8,7 +8,8 @@ import { promisify } from "node:util"; import type { Page } from "@playwright/test"; -import { automationOperatorSelectors, sessionLifecycleSelectors } from "../fixtures/selectors"; +import { automationOperatorSelectors, sessionWindowSelectors } from "../fixtures/selectors"; +import { sessionWindow, windowTitle } from "../fixtures/os-navigation"; import type { BrowserRuntime } from "../fixtures/runtime"; import { browserAutomationOperatorFlowScenario } from "../fixtures/runtime"; import { expect, test } from "../fixtures/test"; @@ -159,11 +160,13 @@ test("operator creates updates fires disables re-enables and deletes a webhook t browserArtifacts, runtime, }) => { - const ui = automationOperatorSelectors(appPage); - const sessionUI = sessionLifecycleSelectors(appPage); + const triggersWin = appPage.getByTestId("os-window-app:triggers"); + const ui = automationOperatorSelectors(triggersWin, appPage); + const shellUI = automationOperatorSelectors(appPage); + const triggerStatus = triggersWin.locator('[data-slot="topbar-status"]'); await ensureGlobalWorkspace(runtime); await appPage.reload({ waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(ui); + await useGlobalWorkspaceIfPrompted(shellUI); await appPage.goto(runtime.url("/triggers"), { waitUntil: "domcontentloaded" }); await expect(ui.triggersShell).toBeVisible(); @@ -204,11 +207,12 @@ test("operator creates updates fires disables re-enables and deletes a webhook t expect(created.webhook_secret_present).toBe(true); // Saving from the editor navigates straight to the new trigger's detail route. await expect(appPage).toHaveURL(new RegExp(`/triggers/${created.id}$`), { timeout: 20_000 }); - await expect(ui.detailPanel).toContainText(initialName); + await expect(windowTitle(triggersWin)).toContainText(initialName); await expect(ui.detailPanel).toContainText(webhookID); await expect(ui.detailPanel).not.toContainText(webhookSecret); - await ui.editAutomationButton.click(); + await ui.detailOverflow.click(); + await appPage.getByTestId("edit-automation-btn").click(); await expect(ui.editorDialog).toBeVisible(); await ui.triggerNameInput.fill(editedName); await ui.triggerPromptInput.fill(editedPrompt); @@ -226,7 +230,7 @@ test("operator creates updates fires disables re-enables and deletes a webhook t const updated = await waitForTriggerByName(runtime, editedName); expect(updated.endpoint_slug).toBe(editedEndpointSlug); expect(updated.prompt).toBe(editedPrompt); - await expect(ui.detailPanel).toContainText(editedName); + await expect(windowTitle(triggersWin)).toContainText(editedName); await expect(ui.detailPanel).toContainText(editedPrompt); const endpoint = endpointFor(updated); @@ -277,7 +281,7 @@ test("operator creates updates fires disables re-enables and deletes a webhook t expect(await triggerRunCount(runtime, updated.id)).toBe(1); await appPage.reload({ waitUntil: "domcontentloaded" }); - await expect(ui.detailPanel).toContainText(editedName, { timeout: 20_000 }); + await expect(windowTitle(triggersWin)).toContainText(editedName, { timeout: 20_000 }); await expect(ui.run(firstRun.id)).toBeVisible(); await expect(ui.runSessionLink(firstRun.id)).toBeVisible(); @@ -286,7 +290,7 @@ test("operator creates updates fires disables re-enables and deletes a webhook t await expect .poll(async () => (await getTrigger(runtime, updated.id)).trigger.enabled) .toBe(false); - await expect(ui.detailPanel).toContainText("DISABLED"); + await expect(triggerStatus).toContainText("DISABLED"); const disabledDelivery = await deliverWebhook(runtime, { deliveryID: uniqueName("delivery-disabled"), endpoint, @@ -318,7 +322,7 @@ test("operator creates updates fires disables re-enables and deletes a webhook t expect(await triggerRunCount(runtime, updated.id)).toBe(2); await appPage.reload({ waitUntil: "domcontentloaded" }); - await expect(ui.detailPanel).toContainText(editedName, { timeout: 20_000 }); + await expect(windowTitle(triggersWin)).toContainText(editedName, { timeout: 20_000 }); await expect(ui.run(reenabledRun.id)).toBeVisible(); await expect(ui.runSessionLink(reenabledRun.id)).toBeVisible(); @@ -363,7 +367,12 @@ test("operator creates updates fires disables re-enables and deletes a webhook t expect(Number(routeState.automation_run_count)).toBeGreaterThanOrEqual(2); await ui.runSessionLink(reenabledRun.id).click(); - await expect(sessionUI.chatHeader).toBeVisible(); + const reenabledSessionId = reenabledRun.session_id; + if (!reenabledSessionId) { + throw new Error("Expected the re-enabled trigger run to expose a linked session."); + } + const sessionUI = sessionWindowSelectors(sessionWindow(appPage, reenabledSessionId)); + await expect(sessionUI.chatView).toBeVisible(); await expect(sessionUI.chatView).toContainText("Review payload deploy for main"); await expect(sessionUI.chatView).toContainText( browserAutomationOperatorFlowScenario.transcript.assistant @@ -413,7 +422,9 @@ test("failed webhook trigger run is diagnosable with retry evidence and no secre browserArtifacts, runtime, }) => { - const ui = automationOperatorSelectors(appPage); + const triggersWin = appPage.getByTestId("os-window-app:triggers"); + const ui = automationOperatorSelectors(triggersWin, appPage); + const shellUI = automationOperatorSelectors(appPage); const trigger = await createTrigger( runtime, triggerRequest({ @@ -427,7 +438,7 @@ test("failed webhook trigger run is diagnosable with retry evidence and no secre await ensureGlobalWorkspace(runtime); await appPage.reload({ waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(ui); + await useGlobalWorkspaceIfPrompted(shellUI); await appPage.goto(runtime.url("/triggers"), { waitUntil: "domcontentloaded" }); await expect(ui.triggersShell).toBeVisible(); await expect(ui.item(trigger.id)).toBeVisible({ timeout: 20_000 }); @@ -453,7 +464,7 @@ test("failed webhook trigger run is diagnosable with retry evidence and no secre expect(failureMessage).toMatch(/peer disconnected before response|internal error/i); await appPage.reload({ waitUntil: "domcontentloaded" }); - await expect(ui.detailPanel).toContainText(trigger.name, { timeout: 20_000 }); + await expect(windowTitle(triggersWin)).toContainText(trigger.name, { timeout: 20_000 }); await expect(ui.run(failedRun.id)).toBeVisible(); await expect(ui.run(failedRun.id)).toContainText("FAILED"); await expect(ui.run(failedRun.id)).toContainText( @@ -495,7 +506,9 @@ test("operator sees fire-limit rejection across browser and runtime surfaces", a browserArtifacts, runtime, }) => { - const ui = automationOperatorSelectors(appPage); + const triggersWin = appPage.getByTestId("os-window-app:triggers"); + const ui = automationOperatorSelectors(triggersWin, appPage); + const shellUI = automationOperatorSelectors(appPage); const trigger = await createTrigger( runtime, triggerRequest({ @@ -508,7 +521,7 @@ test("operator sees fire-limit rejection across browser and runtime surfaces", a await ensureGlobalWorkspace(runtime); await appPage.reload({ waitUntil: "domcontentloaded" }); - await useGlobalWorkspaceIfPrompted(ui); + await useGlobalWorkspaceIfPrompted(shellUI); await appPage.goto(runtime.url("/triggers"), { waitUntil: "domcontentloaded" }); await expect(ui.triggersShell).toBeVisible(); await expect(ui.item(trigger.id)).toBeVisible({ timeout: 20_000 }); @@ -542,7 +555,7 @@ test("operator sees fire-limit rejection across browser and runtime surfaces", a expect(await triggerRunCount(runtime, trigger.id)).toBe(2); await appPage.reload({ waitUntil: "domcontentloaded" }); - await expect(ui.detailPanel).toContainText(trigger.name, { timeout: 20_000 }); + await expect(windowTitle(triggersWin)).toContainText(trigger.name, { timeout: 20_000 }); await expect(ui.run(acceptedRun.id)).toBeVisible(); const limitedRun = (await listTriggerRuns(runtime, trigger.id)).find( run => run.id !== acceptedRun.id @@ -912,7 +925,8 @@ async function assertTriggersViewportMatrix( runtime: BrowserRuntime, triggerID: string ): Promise { - const ui = automationOperatorSelectors(appPage); + const triggersWin = appPage.getByTestId("os-window-app:triggers"); + const ui = automationOperatorSelectors(triggersWin, appPage); for (const width of [375, 768, 1280]) { await appPage.setViewportSize({ width, height: 820 }); await appPage.goto(runtime.url("/triggers"), { waitUntil: "domcontentloaded" }); @@ -924,7 +938,8 @@ async function assertTriggersViewportMatrix( `triggers-lifecycle-history-viewport-${width}`, appPage ); - await ui.editAutomationButton.click(); + await ui.detailOverflow.click(); + await appPage.getByTestId("edit-automation-btn").click(); await expect(ui.editorDialog).toBeVisible(); await expect(ui.triggerEndpointSlugInput).toBeVisible(); await expect(ui.submitTriggerForm).toBeEnabled(); @@ -945,7 +960,8 @@ async function assertTriggerRunViewportMatrix( runID: string, prefix: string ): Promise { - const ui = automationOperatorSelectors(appPage); + const triggersWin = appPage.getByTestId("os-window-app:triggers"); + const ui = automationOperatorSelectors(triggersWin, appPage); for (const width of [375, 768, 1280]) { await appPage.setViewportSize({ width, height: 820 }); await appPage.goto(runtime.url("/triggers"), { waitUntil: "domcontentloaded" }); diff --git a/web/e2e/__tests__/workspace-setup.spec.ts b/web/e2e/__tests__/workspace-setup.spec.ts index ccb304667..02b356996 100644 --- a/web/e2e/__tests__/workspace-setup.spec.ts +++ b/web/e2e/__tests__/workspace-setup.spec.ts @@ -28,7 +28,8 @@ test("operator runs onboarding, then re-opens the ruled workspace setup dialog f await useGlobalWorkspaceIfPrompted(ui); await expect(ui.osDesktop).toBeVisible(); - await appPage.getByTestId("add-workspace-btn").click(); + await appPage.locator('[data-slot="os-menubar-workspace"]').click(); + await appPage.getByTestId("os-workspace-add").click(); const dialog = appPage.getByTestId("workspace-setup-dialog"); await expect(dialog).toBeVisible(); @@ -80,7 +81,8 @@ test.describe("first-run default model", () => { const runtimeTrigger = appPage.getByTestId("onboarding-runtime-select"); await expect(runtimeTrigger).toBeVisible(); - await runtimeTrigger.locator('button[data-focus="model"]').first().click(); + // The closed selector is one button that opens the popup (no per-segment zones). + await runtimeTrigger.click(); await expect(appPage.getByTestId("runtime-selector-popup")).toBeVisible(); await appPage .locator(`[data-provider="${mockAgentProvider}"][data-model="${reasoningCatalogModel}"]`) @@ -91,7 +93,8 @@ test.describe("first-run default model", () => { await reasoningStrip.locator('button[data-rz="high"]').click(); await appPage.keyboard.press("Escape"); await expect(runtimeTrigger).toContainText(reasoningCatalogModelLabel); - await expect(runtimeTrigger.locator('button[data-focus="reasoning"]')).toContainText("High"); + // Reasoning is projected into the trigger's accessible summary, not a sub-button. + await expect(runtimeTrigger).toHaveAccessibleName(/reasoning High/); const providerRequestPromise = appPage.waitForRequest( request => diff --git a/web/e2e/fixtures/__tests__/browser-artifact-session.test.ts b/web/e2e/fixtures/__tests__/browser-artifact-session.test.ts index cbe254ec3..f2caf2fa3 100644 --- a/web/e2e/fixtures/__tests__/browser-artifact-session.test.ts +++ b/web/e2e/fixtures/__tests__/browser-artifact-session.test.ts @@ -12,7 +12,9 @@ describe("captureRouteState", () => {