mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix: wave 1 quick wins — agent names, scroll bounce-back, chart empty states, model-pin preservation, UUID spawn normalization (#546)
* fix(panel): notifications show agent names, metrics charts get empty states * fix(panel): stop expand/collapse scroll bounce-back; add floating scroll-jump buttons * fix(llm): provider mode switches preserve per-agent model pins * fix(api): normalize agent UUID to slug at the orchestrator route boundary * fix(panel,docs): align routing-card copy and map docs with preserved-pin mode switches * fix(panel): drop dead unfiltered scroll hook, re-observe on Suspense swap, name system sender * docs(map): reflect preserved-pin mode switches, UUID-slug normalization, panel wave-1 deltas --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1589,7 +1589,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `project.py` | `Project` + `BranchReason` + `ProjectCreate`/`ProjectUpdate` (CI-watch, dep-update, quality_command fields) | 178 |
|
||||
| `agent.py` | `Agent` API model + `ModelConfig`/`AgentPermissions`/`AgentMetrics` + `AgentCreate`/`AgentUpdate` | 172 |
|
||||
| `kanban.py` | `KanbanBoard`/`KanbanColumn`/`KanbanCard`/`KanbanSwimlane` + per-role column configs + `get_column_config` | 159 |
|
||||
| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_ROLE_DEFAULTS`/`OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 132 |
|
||||
| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 103 |
|
||||
| `message.py` | `ExtractedMessage` + `RawStream` | 137 |
|
||||
| `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 |
|
||||
| `transcription.py` | `StreamBuffer` (flush heuristic) + `TranscriptionConfig` | 119 |
|
||||
@@ -1654,7 +1654,6 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `ROLE_MODEL_MAP` | dict | runtime.py:114 | per-role default tier: developer/cell_pm/main_pm→sonnet, qa/documenter→haiku, pr_reviewer/auditor/board/ceo→opus (qa→haiku, main_pm→sonnet, pr_reviewer→opus are the cost-tuned defaults) |
|
||||
| `ROLE_EFFORT_MAP` | dict | runtime.py | per-role `CLAUDE_CODE_EFFORT_LEVEL` override injected at spawn; **empty/inert by default** (opt-in per role after verifying the level moves usage) |
|
||||
| `MODEL_CATALOG` | tuple | llm_catalog.py:67 | Settings-dropdown source of truth; Anthropic entries derived from `MODEL_MAP` |
|
||||
| `OLLAMA_ROLE_DEFAULTS` | dict | llm_catalog.py:107 | Per-role model for "pure Ollama" mode |
|
||||
| `PermissionLevel` | IntEnum | permissions.py:15 | CEO=0/BOARD=1/MAIN_PM=2/CELL_PM=3/CELL_MEMBER=4/AUDITOR=99 |
|
||||
| `COMMUNICATION_MATRIX` | dict | permissions.py:83 | Who can directly communicate with whom (role→role set) |
|
||||
| `IndexType` | StrEnum | optimal.py:13 | code/documentation/conversations/journals/errors/standards/decisions/reviews/learnings/playbooks |
|
||||
@@ -1738,7 +1737,7 @@ models/
|
||||
│ └── dashboard.py FlagData, ReportData, TeamHealthData, AuditQueueItem, DashboardStorage
|
||||
├── llm
|
||||
│ ├── llm.py LLMUsage, ToonConfig, EncodedBlock, ToonMetrics
|
||||
│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_ROLE_DEFAULTS, OLLAMA_DEFAULT_MODEL
|
||||
│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_DEFAULT_MODEL
|
||||
│ └── runtime.py OrchestratorAgentState, SpawnGitContext, OrchestratorAgentConfig, AgentInstance, WaitingRecord, MODEL_MAP, ROLE_MODEL_MAP
|
||||
├── metrics
|
||||
│ └── metrics.py VelocityMetrics, BlockerMetrics, TeamMetrics, AgentMetrics, StageTiming, StageBottleneck, BottleneckReport, AgentReworkRate, TeamReworkRate, ReworkReport, Scorecard
|
||||
@@ -1831,7 +1830,7 @@ Logic-touching commits:
|
||||
| `TaskCreate._exactly_one_target` 3-way validator | task.py:405 | Tests/clients asserting the old 2-way error message ("a task needs either a project_id… or a product_id…") will fail against the new message ("a task needs exactly one target: … or cell_projects …"). Any caller that constructed a `TaskCreate` with both `project_id` and `product_id` was already rejected; callers passing `cell_projects` + another target are newly rejected. | Medium |
|
||||
| `Task.cell_projects` requires migration 052 | task.py:179 | The `cell_projects` field exists on the model regardless of DB state, but persistence (`TaskTable.cell_projects` relationship + `task_cell_projects` table) needs migration 052. On a DB where 052 hasn't run, `TaskService.create` with a non-empty `cell_projects` will fail at insert. | Medium |
|
||||
| `SpawnGitContext.task_short_id` consumer parity | runtime.py:38 | The field is additive with a `None` default, but every spawn-path consumer that should route the agent into the per-task worktree must read it; a missed consumer silently falls back to the clone root (the old behavior). | Low |
|
||||
| Ollama catalog defaults vs persisted assignments | llm_catalog.py:107 | `OLLAMA_ROLE_DEFAULTS` / `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the defaults only apply when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code / glm-5.2 instead of minimax-m3 / kimi-k2.6. Verify the new tags actually work on the Ollama Cloud plan before relying on this. | Low |
|
||||
| Ollama catalog defaults vs persisted assignments | llm_catalog.py:103 | `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the default only applies when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code instead of minimax-m3. Verify the tag actually works on the Ollama Cloud plan before relying on this. (`OLLAMA_ROLE_DEFAULTS` was removed 2026-07-17 as dead code — it was never consulted by routing.) | Low |
|
||||
| `ProductCellMapping` import cycle risk | task.py:24 | `task.py` now imports from `product.py` at module load. `product.py` imports only from `foundation.identity` and `base.py` — no cycle today, but a future `product.py` → `task.py` import would create one. | Low |
|
||||
| `AgentRole`/`Team` alias removal pending | base.py:21–24 | The aliases to `foundation.identity` are a migration shim ("Removed in Phase 4 housekeeping"). Consumers still importing `AgentRole`/`Team` from `roboco.models.base` will break when the shim is removed. | Low |
|
||||
|
||||
@@ -2522,6 +2521,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
|
||||
| `require_panel_token` | dep | api/deps.py:251 | CEO-signed HMAC gate for live-chat bridges (HTTP analog of WS gate). |
|
||||
| `CurrentAgentContext` | dep | api/deps.py:376 | Resolves agent from headers + HMAC, injects `AgentContext`. |
|
||||
| `_require_ceo` | dep | routes/orchestrator.py:37 | Router-level CEO-HMAC guard on orchestrator control routes. |
|
||||
| `_validated_agent_id` | fn | routes/orchestrator.py:99 | Path-injection guard (rejects empty/`.`/`..`/`/`/`\`/NUL) then normalizes via `_resolve_to_slug` — spawn/stop/status/resolve-wait/mark-waiting accept either a DB UUID or a slug and address the runtime container by the resolved slug; an unknown UUID passes through unchanged. |
|
||||
| `setup_middleware` | fn | api/middleware.py | Register exception handlers (422 scrub, HTTP, RobocoError, generic). |
|
||||
| `request_validation_handler` | fn | api/middleware.py:407 | Log 422 body (secrets scrubbed) + uuid remediate hint. |
|
||||
| `_scrub_secrets` | fn | api/middleware.py:389 | Deep-redact known secret fields from logged 422 bodies. |
|
||||
@@ -2665,6 +2665,7 @@ roboco/api/
|
||||
> - `d1cf6ecb` Wave 1 (#295) — adds `GET /api/tasks/summary?q=` search (`TaskService.search_tasks`), `GET /api/prompter/live/{id}/search-tasks` (intake memory), `GET /api/secretary/tasks?q=` (Secretary task-by-name lookup), and the Secretary `edit` directive action.
|
||||
> - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO.
|
||||
> - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass.
|
||||
> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
@@ -4339,7 +4340,7 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro
|
||||
| `resolve_for_agent` | method | `services/llm.py:124` | Precedence ladder agent>role>global; never raises — downgrades to legacy Anthropic path |
|
||||
| `probe_ollama_tags` | func | `services/llm.py:63` | `{base_url}/api/tags` probe; never raises, returns `([], error)` |
|
||||
| `upsert_assignment` | method | `services/llm.py:241` | Insert-or-update by `(scope, scope_value)`; routes non-catalog names to LOCAL; auto-enables LOCAL provider |
|
||||
| `apply_mode` | method | `services/llm.py:418` | Wipe + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix |
|
||||
| `apply_mode` | method | `services/llm.py:418` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix |
|
||||
| `derive_mode` | method | `services/llm.py:314` | Settings UI label from current assignments |
|
||||
| `set_ollama_api_key` / `set_grok_api_key` | methods | `services/llm.py:340,360` | Encrypt+enable / clear+disable on the seeded provider row |
|
||||
| `ProactiveKnowledgeService` | class | `services/proactive.py:90` | Builds `ContextPackage` from multiple RAG indexes on claim/session |
|
||||
@@ -4582,11 +4583,12 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441`. Range `fd10cc86..HEAD` (3a
|
||||
|
||||
No logic-touching commits to list — IMPACT: none.
|
||||
|
||||
> Post-snapshot updates (since 2026-06-29): four commits touched this slice after the baseline was cut.
|
||||
> Post-snapshot updates (since 2026-06-29): five commits touched this slice after the baseline was cut.
|
||||
> - `e4ed970f` [chore] stream-bus: poison-pill ACK + dead-letter (`DEAD_LETTER_STREAM`, `_dead_letter`), periodic `_reclaim_loop` spawned alongside `_listen_loop`, `_run_handler_guarded` catches `BaseException` for cancelled-handler marker cleanup (3 gaps).
|
||||
> - `6b441e42` [chore] converters: `InvalidIdentifierError(ValueError)` introduced; `require_uuid` now raises it for both None and unparseable input; `repo_key` git-URL normalizer added; orchestrator reaper now logs the typed error instead of silently swallowing it.
|
||||
> - `321e68d7` [sweep] proactive: `_find_code_patterns` method, its call, summary line, and count removed; `ContextPackage.code_patterns` field retained (always-empty, back-compat).
|
||||
> - `536bbb64` Chore/all/logical-gaps-sweep (#286) — merge commit pulling the above into the branch.
|
||||
> - `d83104e9` (2026-07-17, PR #546, "wave-1 quick wins") fix(llm): provider mode switches preserve per-agent model pins — `_apply_anthropic`/`_apply_grok`/`_apply_ollama`/`_apply_self_hosted` now delete only ROLE/GLOBAL `model_assignments` rows (`scope != AGENT_SLUG`) instead of wiping the whole table, so an AGENT_SLUG pin survives a mode switch; `OLLAMA_ROLE_DEFAULTS` removed from `llm_catalog.py` as dead code (it was never consulted by routing — see `models.md`).
|
||||
|
||||
## Regression Risks
|
||||
|
||||
@@ -8217,6 +8219,10 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
> - `da563487` Wave 2: A2A live view (#297) — new `app/(dashboard)/a2a/page.tsx` (classic list view + transcript + `A2AReplyComposer`), `hooks/use-a2a-live.ts`, `lib/api/a2a.ts` admin client, `useA2ALiveStream` added to `use-websocket.ts`. Backend pairs with `EventType.A2A_MESSAGE_SENT` + `websocket_bridge._handle_a2a_message_event`.
|
||||
> - `876e19b3` A2A switchboard (#298) — `page.tsx` gains the switchboard/list view toggle (default switchboard) + `peekedPair` state; new `components/a2a/{a2a-switchboard,a2a-switchboard-utils,a2a-pair-card}.tsx`; `useA2AAdminPairs` added to `use-a2a-live.ts`.
|
||||
> - `a7147702` feat(panel): full mobile responsiveness pass — touches the A2A page's single-visible-pane layout (`h-dvh`, back affordance) among other routes.
|
||||
> - (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: new `tab-findings.tsx` (7th task-detail tab, `useTaskFindings` → `GET /tasks/{id}/findings`), a `bounced xN` chip on `task-header.tsx` (`revision_count`), and "PM rejects"/"CEO rejects" columns on `delivery-tab.tsx`'s rework table. See `docs/map/review-findings.md`.
|
||||
> - `abf4b35f` (2026-07-17, PR #546, "wave-1 quick wins") — notifications page resolves `from_agent` via `getAgentDisplayName` (was `notification.from_agent.slice(0, 8)`, a raw UUID prefix); metrics charts (usage time-series, agent/team usage, model donut) gained a "no data" empty state alongside the existing loading skeleton.
|
||||
> - `ca07c83f` + `40b1a586` (2026-07-17, PR #546) — scroll-bounce fix: `scroll-restoration.tsx`'s route key now strips UI-only params before comparing (`UI_ONLY_PARAMS=["expanded"]`, exported `buildRouteKey`) so a tasks-page row expand/collapse no longer forks/resets the saved scroll position; new floating `ScrollJumpButtons` (`components/scroll-jump-buttons.tsx`, mounted as a `<main>` sibling in `(dashboard)/layout.tsx`) re-observes `<main>`'s children via `MutationObserver` across a Suspense fallback→content swap so the `ResizeObserver` never watches a detached fallback node; the dead, unfiltered duplicate `hooks/use-scroll-restoration.ts` was deleted; `agent-utils.ts` `AGENT_NAMES` gains `system: "System"` for backend-authored notifications/events.
|
||||
> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
|
||||
| `require_panel_token` | dep | api/deps.py:251 | CEO-signed HMAC gate for live-chat bridges (HTTP analog of WS gate). |
|
||||
| `CurrentAgentContext` | dep | api/deps.py:376 | Resolves agent from headers + HMAC, injects `AgentContext`. |
|
||||
| `_require_ceo` | dep | routes/orchestrator.py:37 | Router-level CEO-HMAC guard on orchestrator control routes. |
|
||||
| `_validated_agent_id` | fn | routes/orchestrator.py:99 | Path-injection guard (rejects empty/`.`/`..`/`/`/`\`/NUL) then normalizes via `_resolve_to_slug` — spawn/stop/status/resolve-wait/mark-waiting accept either a DB UUID or a slug and address the runtime container by the resolved slug; an unknown UUID passes through unchanged. |
|
||||
| `setup_middleware` | fn | api/middleware.py | Register exception handlers (422 scrub, HTTP, RobocoError, generic). |
|
||||
| `request_validation_handler` | fn | api/middleware.py:407 | Log 422 body (secrets scrubbed) + uuid remediate hint. |
|
||||
| `_scrub_secrets` | fn | api/middleware.py:389 | Deep-redact known secret fields from logged 422 bodies. |
|
||||
@@ -237,6 +238,7 @@ roboco/api/
|
||||
> - `d1cf6ecb` Wave 1 (#295) — adds `GET /api/tasks/summary?q=` search (`TaskService.search_tasks`), `GET /api/prompter/live/{id}/search-tasks` (intake memory), `GET /api/secretary/tasks?q=` (Secretary task-by-name lookup), and the Secretary `edit` directive action.
|
||||
> - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO.
|
||||
> - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass.
|
||||
> - `637c75dc` (2026-07-17, PR #546, "wave-1 quick wins") fix(api): normalize agent UUID to slug at the orchestrator route boundary — `_validated_agent_id` now also calls `_resolve_to_slug` after its path-injection checks, so a caller-supplied DB UUID (e.g. from the panel) resolves to the canonical slug before spawn/stop/status/resolve-wait/mark-waiting address the runtime, fixing UUID-named containers and registry misses.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
|
||||
+3
-4
@@ -22,7 +22,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `project.py` | `Project` + `BranchReason` + `ProjectCreate`/`ProjectUpdate` (CI-watch, dep-update, quality_command fields) | 178 |
|
||||
| `agent.py` | `Agent` API model + `ModelConfig`/`AgentPermissions`/`AgentMetrics` + `AgentCreate`/`AgentUpdate` | 172 |
|
||||
| `kanban.py` | `KanbanBoard`/`KanbanColumn`/`KanbanCard`/`KanbanSwimlane` + per-role column configs + `get_column_config` | 159 |
|
||||
| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_ROLE_DEFAULTS`/`OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 132 |
|
||||
| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 103 |
|
||||
| `message.py` | `ExtractedMessage` + `RawStream` | 137 |
|
||||
| `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 |
|
||||
| `transcription.py` | `StreamBuffer` (flush heuristic) + `TranscriptionConfig` | 119 |
|
||||
@@ -87,7 +87,6 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `ROLE_MODEL_MAP` | dict | runtime.py:114 | per-role default tier: developer/cell_pm/main_pm→sonnet, qa/documenter→haiku, pr_reviewer/auditor/board/ceo→opus (qa→haiku, main_pm→sonnet, pr_reviewer→opus are the cost-tuned defaults) |
|
||||
| `ROLE_EFFORT_MAP` | dict | runtime.py | per-role `CLAUDE_CODE_EFFORT_LEVEL` override injected at spawn; **empty/inert by default** (opt-in per role after verifying the level moves usage) |
|
||||
| `MODEL_CATALOG` | tuple | llm_catalog.py:67 | Settings-dropdown source of truth; Anthropic entries derived from `MODEL_MAP` |
|
||||
| `OLLAMA_ROLE_DEFAULTS` | dict | llm_catalog.py:107 | Per-role model for "pure Ollama" mode |
|
||||
| `PermissionLevel` | IntEnum | permissions.py:15 | CEO=0/BOARD=1/MAIN_PM=2/CELL_PM=3/CELL_MEMBER=4/AUDITOR=99 |
|
||||
| `COMMUNICATION_MATRIX` | dict | permissions.py:83 | Who can directly communicate with whom (role→role set) |
|
||||
| `IndexType` | StrEnum | optimal.py:13 | code/documentation/conversations/journals/errors/standards/decisions/reviews/learnings/playbooks |
|
||||
@@ -171,7 +170,7 @@ models/
|
||||
│ └── dashboard.py FlagData, ReportData, TeamHealthData, AuditQueueItem, DashboardStorage
|
||||
├── llm
|
||||
│ ├── llm.py LLMUsage, ToonConfig, EncodedBlock, ToonMetrics
|
||||
│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_ROLE_DEFAULTS, OLLAMA_DEFAULT_MODEL
|
||||
│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_DEFAULT_MODEL
|
||||
│ └── runtime.py OrchestratorAgentState, SpawnGitContext, OrchestratorAgentConfig, AgentInstance, WaitingRecord, MODEL_MAP, ROLE_MODEL_MAP
|
||||
├── metrics
|
||||
│ └── metrics.py VelocityMetrics, BlockerMetrics, TeamMetrics, AgentMetrics, StageTiming, StageBottleneck, BottleneckReport, AgentReworkRate, TeamReworkRate, ReworkReport, Scorecard
|
||||
@@ -264,7 +263,7 @@ Logic-touching commits:
|
||||
| `TaskCreate._exactly_one_target` 3-way validator | task.py:405 | Tests/clients asserting the old 2-way error message ("a task needs either a project_id… or a product_id…") will fail against the new message ("a task needs exactly one target: … or cell_projects …"). Any caller that constructed a `TaskCreate` with both `project_id` and `product_id` was already rejected; callers passing `cell_projects` + another target are newly rejected. | Medium |
|
||||
| `Task.cell_projects` requires migration 052 | task.py:179 | The `cell_projects` field exists on the model regardless of DB state, but persistence (`TaskTable.cell_projects` relationship + `task_cell_projects` table) needs migration 052. On a DB where 052 hasn't run, `TaskService.create` with a non-empty `cell_projects` will fail at insert. | Medium |
|
||||
| `SpawnGitContext.task_short_id` consumer parity | runtime.py:38 | The field is additive with a `None` default, but every spawn-path consumer that should route the agent into the per-task worktree must read it; a missed consumer silently falls back to the clone root (the old behavior). | Low |
|
||||
| Ollama catalog defaults vs persisted assignments | llm_catalog.py:107 | `OLLAMA_ROLE_DEFAULTS` / `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the defaults only apply when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code / glm-5.2 instead of minimax-m3 / kimi-k2.6. Verify the new tags actually work on the Ollama Cloud plan before relying on this. | Low |
|
||||
| Ollama catalog defaults vs persisted assignments | llm_catalog.py:103 | `OLLAMA_DEFAULT_MODEL` changed, but spawn reads persisted `model_assignments` rows — so the default only applies when no row exists. An operator who deletes the `model_assignments` rows (the documented "kill stale fleet model" procedure) will now get kimi-k2.7-code instead of minimax-m3. Verify the tag actually works on the Ollama Cloud plan before relying on this. (`OLLAMA_ROLE_DEFAULTS` was removed 2026-07-17 as dead code — it was never consulted by routing.) | Low |
|
||||
| `ProductCellMapping` import cycle risk | task.py:24 | `task.py` now imports from `product.py` at module load. `product.py` imports only from `foundation.identity` and `base.py` — no cycle today, but a future `product.py` → `task.py` import would create one. | Low |
|
||||
| `AgentRole`/`Team` alias removal pending | base.py:21–24 | The aliases to `foundation.identity` are a migration shim ("Removed in Phase 4 housekeeping"). Consumers still importing `AgentRole`/`Team` from `roboco.models.base` will break when the shim is removed. | Low |
|
||||
|
||||
|
||||
@@ -249,6 +249,9 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
> - `876e19b3` A2A switchboard (#298) — `page.tsx` gains the switchboard/list view toggle (default switchboard) + `peekedPair` state; new `components/a2a/{a2a-switchboard,a2a-switchboard-utils,a2a-pair-card}.tsx`; `useA2AAdminPairs` added to `use-a2a-live.ts`.
|
||||
> - `a7147702` feat(panel): full mobile responsiveness pass — touches the A2A page's single-visible-pane layout (`h-dvh`, back affordance) among other routes.
|
||||
> - (uncommitted, branch `feature/findings-ledger`, 2026-07-11) Revision-findings ledger: new `tab-findings.tsx` (7th task-detail tab, `useTaskFindings` → `GET /tasks/{id}/findings`), a `bounced xN` chip on `task-header.tsx` (`revision_count`), and "PM rejects"/"CEO rejects" columns on `delivery-tab.tsx`'s rework table. See `docs/map/review-findings.md`.
|
||||
> - `abf4b35f` (2026-07-17, PR #546, "wave-1 quick wins") — notifications page resolves `from_agent` via `getAgentDisplayName` (was `notification.from_agent.slice(0, 8)`, a raw UUID prefix); metrics charts (usage time-series, agent/team usage, model donut) gained a "no data" empty state alongside the existing loading skeleton.
|
||||
> - `ca07c83f` + `40b1a586` (2026-07-17, PR #546) — scroll-bounce fix: `scroll-restoration.tsx`'s route key now strips UI-only params before comparing (`UI_ONLY_PARAMS=["expanded"]`, exported `buildRouteKey`) so a tasks-page row expand/collapse no longer forks/resets the saved scroll position; new floating `ScrollJumpButtons` (`components/scroll-jump-buttons.tsx`, mounted as a `<main>` sibling in `(dashboard)/layout.tsx`) re-observes `<main>`'s children via `MutationObserver` across a Suspense fallback→content swap so the `ResizeObserver` never watches a detached fallback node; the dead, unfiltered duplicate `hooks/use-scroll-restoration.ts` was deleted; `agent-utils.ts` `AGENT_NAMES` gains `system: "System"` for backend-authored notifications/events.
|
||||
> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro
|
||||
| `resolve_for_agent` | method | `services/llm.py:124` | Precedence ladder agent>role>global; never raises — downgrades to legacy Anthropic path |
|
||||
| `probe_ollama_tags` | func | `services/llm.py:63` | `{base_url}/api/tags` probe; never raises, returns `([], error)` |
|
||||
| `upsert_assignment` | method | `services/llm.py:241` | Insert-or-update by `(scope, scope_value)`; routes non-catalog names to LOCAL; auto-enables LOCAL provider |
|
||||
| `apply_mode` | method | `services/llm.py:418` | Wipe + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix |
|
||||
| `apply_mode` | method | `services/llm.py:418` | Wipe role/global rows (AGENT_SLUG pins preserved) + set GLOBAL for anthropic/grok/ollama/self_hosted; per-agent map for mix |
|
||||
| `derive_mode` | method | `services/llm.py:314` | Settings UI label from current assignments |
|
||||
| `set_ollama_api_key` / `set_grok_api_key` | methods | `services/llm.py:340,360` | Encrypt+enable / clear+disable on the seeded provider row |
|
||||
| `ProactiveKnowledgeService` | class | `services/proactive.py:90` | Builds `ContextPackage` from multiple RAG indexes on claim/session |
|
||||
@@ -308,11 +308,12 @@ Baseline: `fd10cc862c2020b3f639cdb686d427b0198a2441`. Range `fd10cc86..HEAD` (3a
|
||||
|
||||
No logic-touching commits to list — IMPACT: none.
|
||||
|
||||
> Post-snapshot updates (since 2026-06-29): four commits touched this slice after the baseline was cut.
|
||||
> Post-snapshot updates (since 2026-06-29): five commits touched this slice after the baseline was cut.
|
||||
> - `e4ed970f` [chore] stream-bus: poison-pill ACK + dead-letter (`DEAD_LETTER_STREAM`, `_dead_letter`), periodic `_reclaim_loop` spawned alongside `_listen_loop`, `_run_handler_guarded` catches `BaseException` for cancelled-handler marker cleanup (3 gaps).
|
||||
> - `6b441e42` [chore] converters: `InvalidIdentifierError(ValueError)` introduced; `require_uuid` now raises it for both None and unparseable input; `repo_key` git-URL normalizer added; orchestrator reaper now logs the typed error instead of silently swallowing it.
|
||||
> - `321e68d7` [sweep] proactive: `_find_code_patterns` method, its call, summary line, and count removed; `ContextPackage.code_patterns` field retained (always-empty, back-compat).
|
||||
> - `536bbb64` Chore/all/logical-gaps-sweep (#286) — merge commit pulling the above into the branch.
|
||||
> - `d83104e9` (2026-07-17, PR #546, "wave-1 quick wins") fix(llm): provider mode switches preserve per-agent model pins — `_apply_anthropic`/`_apply_grok`/`_apply_ollama`/`_apply_self_hosted` now delete only ROLE/GLOBAL `model_assignments` rows (`scope != AGENT_SLUG`) instead of wiping the whole table, so an AGENT_SLUG pin survives a mode switch; `OLLAMA_ROLE_DEFAULTS` removed from `llm_catalog.py` as dead code (it was never consulted by routing — see `models.md`).
|
||||
|
||||
## Regression Risks
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
|
||||
import { ScrollRestoration } from "@/components/scroll-restoration";
|
||||
import { ScrollJumpButtons } from "@/components/scroll-jump-buttons";
|
||||
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
|
||||
import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver";
|
||||
|
||||
@@ -27,6 +28,10 @@ export default function DashboardLayout({
|
||||
</Suspense>
|
||||
{children}
|
||||
</main>
|
||||
{/* Sibling of <main>, not a child — fixed positioning overlays it
|
||||
regardless, and staying out keeps ScrollJumpButtons' own DOM node
|
||||
from being mistaken for the page content root it measures. */}
|
||||
<ScrollJumpButtons />
|
||||
</div>
|
||||
<BottomTabBar />
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
@@ -163,7 +164,7 @@ function NotificationCard({
|
||||
<div className="text-xs text-muted-foreground">
|
||||
From:{" "}
|
||||
<HelpTip label={notification.from_agent}>
|
||||
<span>{notification.from_agent.slice(0, 8)}</span>
|
||||
<span>{getAgentDisplayName(notification.from_agent)}</span>
|
||||
</HelpTip>{" "}
|
||||
• {formatDistanceToNow(new Date(notification.timestamp))} ago
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,10 @@ function TasksPageContent() {
|
||||
}
|
||||
});
|
||||
const query = params.toString();
|
||||
router.push(query ? `/tasks?${query}` : "/tasks");
|
||||
// scroll: false — a UI-only param write (e.g. row expand/collapse)
|
||||
// must not reset scroll on its own; ScrollRestoration's route key
|
||||
// already excludes `expanded`, this is defense in depth.
|
||||
router.push(query ? `/tasks?${query}` : "/tasks", { scroll: false });
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildRouteKey } from "../scroll-restoration";
|
||||
|
||||
describe("buildRouteKey", () => {
|
||||
it("drops UI-only params so they don't fork the saved scroll position", () => {
|
||||
const withExpanded = new URLSearchParams("status=open&expanded=abc,def");
|
||||
const withoutExpanded = new URLSearchParams("status=open");
|
||||
|
||||
expect(buildRouteKey("/tasks", withExpanded)).toBe(
|
||||
buildRouteKey("/tasks", withoutExpanded),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps real navigation params", () => {
|
||||
expect(buildRouteKey("/tasks", new URLSearchParams("status=open"))).toBe(
|
||||
"/tasks?status=open",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { UsageTimeSeriesChart } from "../usage-time-series-chart";
|
||||
|
||||
describe("UsageTimeSeriesChart", () => {
|
||||
it("renders the card title", () => {
|
||||
render(<UsageTimeSeriesChart data={undefined} isLoading={false} />);
|
||||
expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when there is no data", () => {
|
||||
render(<UsageTimeSeriesChart data={[]} isLoading={false} />);
|
||||
expect(
|
||||
screen.getByText("No usage recorded in this window yet."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the empty state while loading", () => {
|
||||
render(<UsageTimeSeriesChart data={[]} isLoading />);
|
||||
expect(
|
||||
screen.queryByText("No usage recorded in this window yet."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,10 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : tableRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-16 text-center">
|
||||
No usage recorded in this window yet.
|
||||
</p>
|
||||
) : view === "table" ? (
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -47,6 +47,10 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-16 text-center">
|
||||
No usage recorded in this window yet.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={208}>
|
||||
<PieChart>
|
||||
|
||||
@@ -61,6 +61,10 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : tableRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-16 text-center">
|
||||
No usage recorded in this window yet.
|
||||
</p>
|
||||
) : view === "table" ? (
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -59,6 +59,10 @@ export function UsageTimeSeriesChart({
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-16 text-center">
|
||||
No usage recorded in this window yet.
|
||||
</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={208}>
|
||||
<AreaChart
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
|
||||
// Fraction of main's visible height a user must have scrolled (or have left
|
||||
// to scroll) before the corresponding button engages.
|
||||
const ENGAGE_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Floating back-to-top / jump-to-bottom control for the shared <main>
|
||||
* scroll container (see layout.tsx) — same querySelector("main") target
|
||||
* scroll-restoration.tsx uses. Self-contained: no context/store.
|
||||
*/
|
||||
export function ScrollJumpButtons() {
|
||||
const pathname = usePathname();
|
||||
const [canScrollUp, setCanScrollUp] = useState(false);
|
||||
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mainElement = document.querySelector("main");
|
||||
if (!mainElement) return;
|
||||
|
||||
const update = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = mainElement;
|
||||
const overflows = scrollHeight > clientHeight + 1;
|
||||
const threshold = clientHeight * ENGAGE_RATIO;
|
||||
setCanScrollUp(overflows && scrollTop > threshold);
|
||||
setCanScrollDown(
|
||||
overflows && scrollHeight - scrollTop - clientHeight > threshold,
|
||||
);
|
||||
};
|
||||
|
||||
update();
|
||||
mainElement.addEventListener("scroll", update, { passive: true });
|
||||
|
||||
// main's own box is pinned by the flex layout, so overflowing content
|
||||
// never resizes main itself — watch its children (a page may render a
|
||||
// multi-root fragment, so all of them, not just the first). main is
|
||||
// observed too for viewport resizes.
|
||||
const observer = new ResizeObserver(update);
|
||||
const observeContent = () => {
|
||||
observer.disconnect();
|
||||
observer.observe(mainElement);
|
||||
Array.from(mainElement.children).forEach((child) =>
|
||||
observer.observe(child),
|
||||
);
|
||||
update();
|
||||
};
|
||||
observeContent();
|
||||
|
||||
// A Suspense fallback→content swap replaces main's top-level children
|
||||
// after mount — re-observe on childList changes so the ResizeObserver
|
||||
// never ends up watching a detached fallback node.
|
||||
const mutations = new MutationObserver(observeContent);
|
||||
mutations.observe(mainElement, { childList: true });
|
||||
|
||||
return () => {
|
||||
mainElement.removeEventListener("scroll", update);
|
||||
mutations.disconnect();
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
if (!canScrollUp && !canScrollDown) return null;
|
||||
|
||||
const scrollTo = (top: number) =>
|
||||
document.querySelector("main")?.scrollTo({ top, behavior: "smooth" });
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-20 z-30 flex flex-col gap-2 md:right-6 md:bottom-6">
|
||||
{canScrollUp && (
|
||||
<HelpTip label="Back to top" side="left">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full shadow-lg"
|
||||
onClick={() => scrollTo(0)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<span className="sr-only">Back to top</span>
|
||||
</Button>
|
||||
</HelpTip>
|
||||
)}
|
||||
{canScrollDown && (
|
||||
<HelpTip label="Jump to bottom" side="left">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full shadow-lg"
|
||||
onClick={() =>
|
||||
scrollTo(document.querySelector("main")?.scrollHeight ?? 0)
|
||||
}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
<span className="sr-only">Jump to bottom</span>
|
||||
</Button>
|
||||
</HelpTip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,19 @@ import { useEffect, useRef } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store";
|
||||
|
||||
// Params that reflect UI-only state (e.g. tasks/page.tsx row expand/collapse)
|
||||
// rather than a distinct "page" a user navigated to — excluded from the
|
||||
// route key so toggling them doesn't fork/reset the saved scroll position.
|
||||
const UI_ONLY_PARAMS = ["expanded"];
|
||||
|
||||
// Exported for a cheap direct unit test — no need to render the component
|
||||
// or mock next/navigation/zustand just to check param filtering.
|
||||
export function buildRouteKey(pathname: string, searchParams: URLSearchParams) {
|
||||
const filtered = new URLSearchParams(searchParams);
|
||||
UI_ONLY_PARAMS.forEach((param) => filtered.delete(param));
|
||||
return `${pathname}?${filtered.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global scroll restoration component.
|
||||
* Add this to the layout to automatically save/restore scroll positions.
|
||||
@@ -17,7 +30,7 @@ export function ScrollRestoration() {
|
||||
const hasRestored = useRef(false);
|
||||
const prevRouteKey = useRef<string>("");
|
||||
|
||||
const routeKey = `${pathname}?${searchParams.toString()}`;
|
||||
const routeKey = buildRouteKey(pathname, searchParams);
|
||||
|
||||
// Track last visited route per section
|
||||
useEffect(() => {
|
||||
|
||||
@@ -223,11 +223,15 @@ export function AIRoutingCard() {
|
||||
|
||||
// --- Mode toggle handlers ---
|
||||
const flipToAnthropic = async () => {
|
||||
if (!confirm("Switch every agent to Anthropic? Clears any overrides."))
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to Anthropic? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "anthropic" });
|
||||
toast.success("All agents now on Anthropic");
|
||||
toast.success("Role/global routing now on Anthropic — per-agent pins kept");
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
}
|
||||
@@ -238,10 +242,15 @@ export function AIRoutingCard() {
|
||||
toast.error("Save the Grok (xAI) API key first");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Switch every agent to Grok? Clears any overrides.")) return;
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to Grok? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "grok" });
|
||||
toast.success("All agents now on Grok");
|
||||
toast.success("Role/global routing now on Grok — per-agent pins kept");
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
}
|
||||
@@ -252,10 +261,15 @@ export function AIRoutingCard() {
|
||||
toast.error("Save an Ollama API key first");
|
||||
return;
|
||||
}
|
||||
if (!confirm("Switch every agent to Ollama? Clears any overrides.")) return;
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to Ollama? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "ollama" });
|
||||
toast.success("All agents now on Ollama");
|
||||
toast.success("Role/global routing now on Ollama — per-agent pins kept");
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
}
|
||||
@@ -268,7 +282,7 @@ export function AIRoutingCard() {
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to the self-hosted LLM? Clears any overrides.",
|
||||
"Switch every agent to the self-hosted LLM? Per-agent pins are kept; role/global assignments are replaced.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -277,7 +291,7 @@ export function AIRoutingCard() {
|
||||
mode: "self_hosted",
|
||||
...(selfHostedModel ? { default_model: selfHostedModel } : {}),
|
||||
});
|
||||
toast.success("All agents now on Self-Hosted LLM");
|
||||
toast.success("Role/global routing now on Self-Hosted LLM — per-agent pins kept");
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
}
|
||||
@@ -480,7 +494,7 @@ export function AIRoutingCard() {
|
||||
|
||||
{/* -------- Mode toggle -------- */}
|
||||
<section className="space-y-3">
|
||||
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted route every agent to one provider and clear all per-agent overrides below. Mix keeps whatever's picked in the table.">
|
||||
<HelpTip label="Anthropic / Grok / Ollama / Self-Hosted replace role/global routing with that provider; per-agent pins in the table below survive the switch. Mix keeps whatever's picked in the table.">
|
||||
<Label className="text-sm font-medium">Routing mode</Label>
|
||||
</HelpTip>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Scroll Restoration Hook
|
||||
*
|
||||
* Saves and restores scroll position when navigating between pages.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useScrollRestorationStore } from "@/lib/stores/scroll-restoration-store";
|
||||
|
||||
export function useScrollRestoration(
|
||||
scrollContainerRef?: React.RefObject<HTMLElement>,
|
||||
) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { setScrollPosition, getScrollPosition } = useScrollRestorationStore();
|
||||
|
||||
// Create a unique key for current route including search params
|
||||
const routeKey = `${pathname}?${searchParams.toString()}`;
|
||||
const hasRestored = useRef(false);
|
||||
|
||||
// Save scroll position on scroll
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef?.current ?? window;
|
||||
const isWindow = container === window;
|
||||
|
||||
const handleScroll = () => {
|
||||
const position = isWindow
|
||||
? { x: window.scrollX, y: window.scrollY }
|
||||
: {
|
||||
x: (container as HTMLElement).scrollLeft,
|
||||
y: (container as HTMLElement).scrollTop,
|
||||
};
|
||||
|
||||
setScrollPosition(routeKey, position);
|
||||
};
|
||||
|
||||
// Debounce scroll handler
|
||||
let timeout: NodeJS.Timeout;
|
||||
const debouncedScroll = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(handleScroll, 100);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", debouncedScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
container.removeEventListener("scroll", debouncedScroll);
|
||||
};
|
||||
}, [routeKey, scrollContainerRef, setScrollPosition]);
|
||||
|
||||
// Restore scroll position on mount
|
||||
useEffect(() => {
|
||||
if (hasRestored.current) return;
|
||||
|
||||
const savedPosition = getScrollPosition(routeKey);
|
||||
if (savedPosition) {
|
||||
const container = scrollContainerRef?.current ?? window;
|
||||
const isWindow = container === window;
|
||||
|
||||
// Delay restoration to ensure content is rendered
|
||||
requestAnimationFrame(() => {
|
||||
if (isWindow) {
|
||||
window.scrollTo(savedPosition.x, savedPosition.y);
|
||||
} else {
|
||||
(container as HTMLElement).scrollLeft = savedPosition.x;
|
||||
(container as HTMLElement).scrollTop = savedPosition.y;
|
||||
}
|
||||
hasRestored.current = true;
|
||||
});
|
||||
}
|
||||
}, [routeKey, scrollContainerRef, getScrollPosition]);
|
||||
|
||||
// Reset restoration flag when route changes
|
||||
useEffect(() => {
|
||||
hasRestored.current = false;
|
||||
}, [routeKey]);
|
||||
}
|
||||
@@ -111,6 +111,8 @@ const AGENT_NAMES: Record<string, string> = {
|
||||
"intake-1": "Intake",
|
||||
"secretary-1": "Secretary",
|
||||
"pr-reviewer-1": "PR Reviewer",
|
||||
// Backend-authored notifications/events (not an agent)
|
||||
system: "System",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Cookie, Depends, Header, HTTPException, status
|
||||
from guard_core.handlers.behavior_handler import BehaviorRule
|
||||
|
||||
from roboco.agents_config import CEO_AGENT_ID, verify_agent_token
|
||||
from roboco.agents_config import CEO_AGENT_ID, _resolve_to_slug, verify_agent_token
|
||||
from roboco.api.auth.backend import SESSION_COOKIE_NAME
|
||||
from roboco.api.auth.session import resolve_session_user
|
||||
from roboco.api.deps import (
|
||||
@@ -97,7 +97,8 @@ __all__ = ["router", "set_orchestrator"]
|
||||
|
||||
|
||||
def _validated_agent_id(agent_id: str) -> str:
|
||||
"""Reject an ``agent_id`` that could traverse a filesystem path downstream.
|
||||
"""Reject an ``agent_id`` that could traverse a filesystem path downstream,
|
||||
then normalize it to the canonical slug the runtime addresses containers by.
|
||||
|
||||
``agent_id`` is an opaque slug / uuid the orchestrator assigns, but it is a
|
||||
request path parameter and flows into per-agent paths (e.g. the grok usage
|
||||
@@ -106,6 +107,12 @@ def _validated_agent_id(agent_id: str) -> str:
|
||||
it reaches any path. Explicit guards (not a regex) so CodeQL models this as a
|
||||
path-injection barrier; the runtime ``_grok_usage_dir`` repeats the check as
|
||||
defense in depth for non-HTTP callers.
|
||||
|
||||
A caller (e.g. the panel) may pass an agent's DB UUID instead of its slug —
|
||||
``_resolve_to_slug`` maps it to the canonical slug so the runtime container
|
||||
(named ``roboco-agent-{slug}``) and instance registry are addressed
|
||||
consistently regardless of which identifier form was sent. An unknown UUID
|
||||
(not in the seed map) passes through unchanged, same as today.
|
||||
"""
|
||||
if (
|
||||
not agent_id
|
||||
@@ -118,7 +125,7 @@ def _validated_agent_id(agent_id: str) -> str:
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid agent_id",
|
||||
)
|
||||
return agent_id
|
||||
return _resolve_to_slug(agent_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -96,35 +96,6 @@ def provider_type_for_model(model_name: str) -> ModelProvider | None:
|
||||
return entry.provider_type if entry else None
|
||||
|
||||
|
||||
# Defaults per role when the user flips to "pure Ollama" mode.
|
||||
# Assignments reflect the 2026-04 public benchmarks for each cloud tag:
|
||||
# Kimi K2.6 — HLE 44.9%, AIME 95.6%, Agent Swarm (100 parallel sub-agents),
|
||||
# 200-300 sequential tool calls. Best at reasoning, orchestration, tool use.
|
||||
# MiniMax M3 — SWE-Bench 73.8%, SWE-Pro 56.2%, 10B active params (fastest,
|
||||
# cheapest). Explicitly "built for Max coding & agentic workflows".
|
||||
# GLM 5.2 — SWE-Bench 77.8% (highest of the three, 94.6% of Claude Opus 4.6),
|
||||
# self-correcting across hundreds of iterations, strong creative writing.
|
||||
OLLAMA_ROLE_DEFAULTS: dict[str, str] = {
|
||||
# High-volume agentic coding — M3 is purpose-built for this.
|
||||
"developer": "kimi-k2.7-code:cloud",
|
||||
# Deep code review — GLM 5.2 has the highest SWE-Bench and iterates thoroughly.
|
||||
"qa": "glm-5.2:cloud",
|
||||
# Orchestration + tool coordination — Kimi K2.6's Agent Swarm is the exact fit.
|
||||
"cell_pm": "kimi-k2.7-code:cloud",
|
||||
"main_pm": "kimi-k2.7-code:cloud",
|
||||
# Quality reasoning — Kimi K2.6 leads HLE by a wide margin.
|
||||
"auditor": "kimi-k2.7-code:cloud",
|
||||
# Product reasoning — same profile as PM work.
|
||||
"product_owner": "glm-5.2:cloud",
|
||||
# Writing with code-context — GLM 5.2's creative writing + SWE-Bench combo.
|
||||
"documenter": "kimi-k2.7-code:cloud",
|
||||
# Stylistic writing — GLM 5.2's creative-writing strength.
|
||||
"head_marketing": "glm-5.2:cloud",
|
||||
# CEO is human-in-the-loop; keep an entry in case someone forces
|
||||
# a route to it, but the Settings UI intentionally excludes it.
|
||||
"ceo": "glm-5.2:cloud",
|
||||
}
|
||||
|
||||
# The Ollama model picked for "pure Ollama" mode's GLOBAL row when the
|
||||
# caller doesn't override. Minimax M3 wins as the generalist because it has
|
||||
# the strongest reasoning/tool-use profile and can fall back to coding/writing
|
||||
|
||||
+43
-19
@@ -444,17 +444,20 @@ class ModelRoutingService(BaseService):
|
||||
) -> None:
|
||||
"""Apply a routing "mode" in a single transactional call.
|
||||
|
||||
All modes below preserve AGENT_SLUG pins — only ROLE/GLOBAL rows are
|
||||
replaced, so a per-agent override survives a mode switch (mixed-provider
|
||||
routing is already a supported state; see "mix").
|
||||
|
||||
Modes:
|
||||
- "anthropic": wipe all assignments so every spawn falls through
|
||||
to the legacy ROLE_MODEL_MAP + mounted ~/.claude path.
|
||||
- "ollama": wipe role/agent overrides, set GLOBAL to the given
|
||||
Ollama model (default: OLLAMA_DEFAULT_MODEL). CEO-type pins can be
|
||||
layered back manually if the user wants them.
|
||||
- "self_hosted": wipe all assignments, enable the LOCAL provider,
|
||||
and set the GLOBAL default to `default_model` (a self-hosted
|
||||
model name — not validated against the static catalog).
|
||||
- "grok": wipe all assignments, set the GLOBAL default to a
|
||||
Grok (xAI) model (default grok-build-0.1). Requires the xAI key.
|
||||
- "anthropic": wipe role/global assignments so every spawn falls
|
||||
through to the legacy ROLE_MODEL_MAP + mounted ~/.claude path.
|
||||
- "ollama": wipe role/global assignments, set GLOBAL to the given
|
||||
Ollama model (default: OLLAMA_DEFAULT_MODEL).
|
||||
- "self_hosted": wipe role/global assignments, enable the LOCAL
|
||||
provider, and set the GLOBAL default to `default_model` (a
|
||||
self-hosted model name — not validated against the static catalog).
|
||||
- "grok": wipe role/global assignments, set the GLOBAL default
|
||||
to a Grok (xAI) model (default grok-build-0.1). Requires the xAI key.
|
||||
- "mix": apply per-agent map verbatim. Any agent not in the
|
||||
map falls through to the GLOBAL default — which is whatever it
|
||||
was (preserves prior state). Self-hosted model names (not in the
|
||||
@@ -477,10 +480,16 @@ class ModelRoutingService(BaseService):
|
||||
)
|
||||
|
||||
async def _apply_anthropic(self) -> None:
|
||||
"""Wipe all assignments so every spawn uses the legacy Anthropic path."""
|
||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||
"""Wipe role/global assignments so every spawn uses the legacy Anthropic
|
||||
path. AGENT_SLUG pins are preserved — mixed-provider routing is a
|
||||
supported state (see `_apply_mix`)."""
|
||||
await self.session.execute(
|
||||
sa_delete(ModelAssignmentTable).where(
|
||||
ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
self.log.info("Mode applied: anthropic (all assignments cleared)")
|
||||
self.log.info("Mode applied: anthropic (role/global assignments cleared)")
|
||||
|
||||
async def _apply_grok(self, default_model: str | None) -> None:
|
||||
"""Wipe assignments, set the GLOBAL default to a Grok (xAI) model.
|
||||
@@ -490,9 +499,13 @@ class ModelRoutingService(BaseService):
|
||||
must be enabled here for resolve_for_agent() to route to it, mirroring
|
||||
self_hosted enabling LOCAL. Without it the seeded GROK row stays
|
||||
disabled (no key set) and agents fall back to Anthropic at spawn even
|
||||
in grok mode.
|
||||
in grok mode. AGENT_SLUG pins are preserved (see `_apply_mix`).
|
||||
"""
|
||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||
await self.session.execute(
|
||||
sa_delete(ModelAssignmentTable).where(
|
||||
ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
grok = await self._get_seeded_provider(ModelProvider.GROK)
|
||||
provider_svc = ProviderService(self.session)
|
||||
@@ -509,8 +522,14 @@ class ModelRoutingService(BaseService):
|
||||
self.log.info("Mode applied: grok", default_model=model_name)
|
||||
|
||||
async def _apply_ollama(self, default_model: str | None) -> None:
|
||||
"""Wipe assignments, set the GLOBAL default to an Ollama Cloud model."""
|
||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||
"""Wipe role/global assignments, set GLOBAL to an Ollama Cloud model.
|
||||
|
||||
AGENT_SLUG pins are preserved (see `_apply_mix`)."""
|
||||
await self.session.execute(
|
||||
sa_delete(ModelAssignmentTable).where(
|
||||
ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
model_name = default_model or OLLAMA_DEFAULT_MODEL
|
||||
await self.upsert_assignment(
|
||||
@@ -521,12 +540,17 @@ class ModelRoutingService(BaseService):
|
||||
self.log.info("Mode applied: ollama", default_model=model_name)
|
||||
|
||||
async def _apply_self_hosted(self, default_model: str | None) -> None:
|
||||
"""Wipe assignments, enable the LOCAL provider, point GLOBAL at it."""
|
||||
"""Wipe role/global assignments, enable the LOCAL provider, point GLOBAL
|
||||
at it. AGENT_SLUG pins are preserved (see `_apply_mix`)."""
|
||||
if not default_model:
|
||||
raise ValueError(
|
||||
"self_hosted mode requires a default_model (self-hosted model name)"
|
||||
)
|
||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||
await self.session.execute(
|
||||
sa_delete(ModelAssignmentTable).where(
|
||||
ModelAssignmentTable.scope != AssignmentScope.AGENT_SLUG
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
# Enable the LOCAL provider row so resolve_for_agent() will use it.
|
||||
local = await self._find_local_provider()
|
||||
|
||||
@@ -223,6 +223,28 @@ async def test_apply_mode_anthropic_clears_all(llm_setup: dict) -> None:
|
||||
assert await svc.list_assignments() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_anthropic_preserves_agent_pin(llm_setup: dict) -> None:
|
||||
"""A mode switch must not wipe per-agent pins — only role/global rows."""
|
||||
svc = llm_setup["svc"]
|
||||
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
|
||||
anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC)
|
||||
await svc.upsert_assignment(
|
||||
scope=AssignmentScope.AGENT_SLUG,
|
||||
scope_value="be-dev-1",
|
||||
model_name=ollama_model,
|
||||
)
|
||||
await svc.upsert_assignment(
|
||||
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=anthropic_model
|
||||
)
|
||||
await svc.apply_mode(mode="anthropic")
|
||||
assignments = await svc.list_assignments()
|
||||
assert len(assignments) == 1 # GLOBAL row cleared, AGENT_SLUG pin survives.
|
||||
assert assignments[0].scope == AssignmentScope.AGENT_SLUG
|
||||
assert assignments[0].scope_value == "be-dev-1"
|
||||
assert assignments[0].model_name == ollama_model
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None:
|
||||
svc = llm_setup["svc"]
|
||||
@@ -233,6 +255,26 @@ async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None:
|
||||
assert assignments[0].scope == AssignmentScope.GLOBAL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_ollama_preserves_agent_pin(llm_setup: dict) -> None:
|
||||
"""The Ollama mode-switch button must not wipe per-agent model pins."""
|
||||
svc = llm_setup["svc"]
|
||||
anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC)
|
||||
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
|
||||
await svc.upsert_assignment(
|
||||
scope=AssignmentScope.AGENT_SLUG,
|
||||
scope_value="be-dev-1",
|
||||
model_name=anthropic_model,
|
||||
)
|
||||
await svc.apply_mode(mode="ollama", default_model=ollama_model)
|
||||
assignments = await svc.list_assignments()
|
||||
assert len(assignments) == 2 # noqa: PLR2004 AGENT_SLUG pin kept + new GLOBAL row.
|
||||
by_scope = {a.scope: a for a in assignments}
|
||||
assert by_scope[AssignmentScope.AGENT_SLUG].scope_value == "be-dev-1"
|
||||
assert by_scope[AssignmentScope.AGENT_SLUG].model_name == anthropic_model
|
||||
assert by_scope[AssignmentScope.GLOBAL].model_name == ollama_model
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_mode_grok_sets_global(llm_setup: dict) -> None:
|
||||
svc = llm_setup["svc"]
|
||||
@@ -479,7 +521,8 @@ async def test_apply_mode_self_hosted_requires_default_model(
|
||||
async def test_apply_mode_self_hosted_clears_prior_assignments(
|
||||
llm_setup_with_local: dict,
|
||||
) -> None:
|
||||
"""apply_mode('self_hosted') clears ALL prior assignments."""
|
||||
"""apply_mode('self_hosted') clears role/global assignments but preserves
|
||||
AGENT_SLUG pins (mixed-provider routing is a supported state)."""
|
||||
svc = llm_setup_with_local["svc"]
|
||||
anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC)
|
||||
await svc.upsert_assignment(
|
||||
@@ -495,8 +538,11 @@ async def test_apply_mode_self_hosted_clears_prior_assignments(
|
||||
assert len(await svc.list_assignments()) == 2 # noqa: PLR2004
|
||||
await svc.apply_mode(mode="self_hosted", default_model="gemma2:9b")
|
||||
assignments = await svc.list_assignments()
|
||||
assert len(assignments) == 1 # Only the new GLOBAL row.
|
||||
assert assignments[0].provider.type == ModelProvider.LOCAL
|
||||
assert len(assignments) == 2 # noqa: PLR2004 AGENT_SLUG pin kept + new GLOBAL row.
|
||||
by_scope = {a.scope: a for a in assignments}
|
||||
assert by_scope[AssignmentScope.AGENT_SLUG].scope_value == "be-dev-1"
|
||||
assert by_scope[AssignmentScope.AGENT_SLUG].model_name == anthropic_model
|
||||
assert by_scope[AssignmentScope.GLOBAL].provider.type == ModelProvider.LOCAL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -20,12 +20,14 @@ from uuid import uuid4
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import roboco.api.routes.orchestrator as orch_route
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.agents_config import AGENT_UUIDS
|
||||
from roboco.api.deps import _ServiceHolder, set_orchestrator
|
||||
from roboco.api.routes.orchestrator import (
|
||||
_build_manual_spawn_prompt,
|
||||
_resolve_manual_spawn_prompt,
|
||||
_validated_agent_id,
|
||||
)
|
||||
from roboco.api.routes.orchestrator import (
|
||||
router as orch_router,
|
||||
@@ -275,3 +277,76 @@ async def test_spawn_offline_agent_not_flagged_already_running(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
assert response.json()["already_running"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validated_agent_id — UUID -> slug normalization (root fix: a caller that
|
||||
# addresses a runtime container/instance by an agent's DB UUID instead of its
|
||||
# slug, e.g. the panel spawn button, must resolve to the same canonical slug
|
||||
# the orchestrator's instance registry and container names use).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validated_agent_id_resolves_known_uuid_to_slug() -> None:
|
||||
uuid_str = AGENT_UUIDS["head-marketing"]
|
||||
assert _validated_agent_id(uuid_str) == "head-marketing"
|
||||
|
||||
|
||||
def test_validated_agent_id_passes_through_slug_unchanged() -> None:
|
||||
assert _validated_agent_id("head-marketing") == "head-marketing"
|
||||
|
||||
|
||||
def test_validated_agent_id_passes_through_unknown_uuid_unchanged() -> None:
|
||||
# A uuid4 is never a seeded agent UUID (the seeds are deterministic,
|
||||
# low-cardinality values) — genuinely absent from the UUID -> slug map.
|
||||
unknown_uuid = str(uuid4())
|
||||
assert unknown_uuid not in AGENT_UUIDS.values()
|
||||
assert _validated_agent_id(unknown_uuid) == unknown_uuid
|
||||
|
||||
|
||||
def test_validated_agent_id_still_rejects_traversal() -> None:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validated_agent_id("../etc/passwd")
|
||||
assert exc_info.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_by_uuid_reaches_orchestrator_by_slug(
|
||||
orch_client: tuple[AsyncClient, MagicMock],
|
||||
) -> None:
|
||||
"""The panel (or any caller) posting the agent's DB UUID as the path
|
||||
param must not produce a container/instance keyed by that UUID — the
|
||||
orchestrator only ever sees the canonical slug."""
|
||||
client, orch = orch_client
|
||||
orch.get_instance = MagicMock(return_value=None)
|
||||
instance = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
agent_id="head-marketing",
|
||||
state=AgentState.STARTING,
|
||||
current_task_id=None,
|
||||
error_count=0,
|
||||
started_at=datetime.now(UTC),
|
||||
)
|
||||
orch.spawn_agent = AsyncMock(return_value=instance)
|
||||
uuid_str = AGENT_UUIDS["head-marketing"]
|
||||
response = await client.post(
|
||||
f"/api/orchestrator/agents/{uuid_str}/spawn", headers=_HDR
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
orch.spawn_agent.assert_awaited_once()
|
||||
assert orch.spawn_agent.await_args.kwargs["agent_id"] == "head-marketing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_by_uuid_reaches_orchestrator_by_slug(
|
||||
orch_client: tuple[AsyncClient, MagicMock],
|
||||
) -> None:
|
||||
client, orch = orch_client
|
||||
orch.stop_agent = AsyncMock(return_value=None)
|
||||
uuid_str = AGENT_UUIDS["be-dev-1"]
|
||||
response = await client.post(
|
||||
f"/api/orchestrator/agents/{uuid_str}/stop", headers=_HDR
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
orch.stop_agent.assert_awaited_once()
|
||||
assert orch.stop_agent.await_args.args[0] == "be-dev-1"
|
||||
|
||||
Reference in New Issue
Block a user