diff --git a/docs/memory/architecture/workspace.md b/docs/memory/architecture/workspace.md index 599830c08..b0c5c154c 100644 --- a/docs/memory/architecture/workspace.md +++ b/docs/memory/architecture/workspace.md @@ -741,6 +741,32 @@ The seat's active decisions ride the shared memory block into every turn reusable and `cites` — the health metric — non-zero. `PortalAgentDecisions.vue` in Agent details. Requirement §5.37 of `core-agent.md`; flow in `workspace-seat-decisions.md`. +## Suggestions — what you can do with this agent, and what is waiting (ent#465) + +`client_portal/suggestions/` (router → service → db, the `work/` shape) answers +`GET /api/enterprise/client-portal/agents/{name}/suggestions` for ONE viewer and +ONE agent. `service.build` is pure over an injected `now`: signals in (my pending +asks, my seat's decisions past `review_by`, the agent's schedules + one windowed +query of recent runs, overdue reminders, my last Workspace message, the playbooks +I have started, the exposed playbooks from the ONE briefing path), ranked +`(Suggestion, fingerprint)` pairs out. **Door, per class:** the router 404s a +portal token before any read (ent#78 auth-path invariant); `configure` classes +reach only owner or admin — `PortalPrincipal.is_admin` exists for this ONE +decision (role `admin` AND scope in `ADMIN_GATE_SCOPES`); everywhere else a +non-owner admin stays a viewer (ent#358). **Fingerprint = the identity of the +state, never a count** (the first failure of a streak, the set of held schedules), +so a dismissal outlives more of the same and ends when the state changes. The +feedback write (`POST …/suggestions/feedback`, key in the body) recomputes and 404s +a key not currently emitted — writes are bounded to real items and the fingerprint +is the server's. `workspace_suggestion_feedback` is named generically with a +`surface` column so the post-action next-step tier (design-system p27) shares one +dismissal model; CASCADE on the agent. The briefing is fetched only when the agent +can answer (#2196 availability first) and cached 60 s. Frontend: +`PortalSuggestions.vue` in the Info tab (full) and on the empty chat (compact, top +3, no chrome when empty); the shell loads the slice for the active 1:1 agent so +Info's rail dot (`updated` + a `note`, "2 suggestions") lights mid-conversation. +Requirement §5.39 of `core-agent.md`; flow in `workspace-suggestions.md`. + ## Agents at the centre — Main, Reset, and the one page (ent#523, ent#524) Clicking an agent opens the **conversation** you were last in. `/workspace/a/:agentName` @@ -958,11 +984,13 @@ exactly ONE participant, never "at least one". Two properties are load-bearing: leave N−1 in a **permanent loading skeleton**. Grouping is unblocked by keying that store per agent — a store change, not a rail change. -Info is the registry's first **static** tab: `signal: RAIL_SIGNAL_NONE` and `empty: null`, -declared rather than filled, because an agent always has a name, a health state and a chat -list (no empty state to teach) and nothing writes an `info` signal (no dot that can light). -`signalFor` already answers `emptySignal()` for an unmentioned tab, so the static form needs -no read-side special case. It is also absent from `feedsFor` by design — its body owns its +Info declares `empty: null` — an agent always has a name, a health state and a chat list, so +there is no empty state to teach. It shipped as the registry's first **static** tab +(`signal: RAIL_SIGNAL_NONE`, ent#547); **ent#465 amended that**: Info now carries +`RAIL_SIGNAL_UPDATED`, and the shell writes an `info` entry — with a `note`, "N suggestions", +that the tooltip and mobile strip read instead of "updated" — while suggestions are waiting +in it. A signalling tab without an empty state is therefore a legal registry shape; +`RAIL_SIGNAL_NONE` stays for a tab with neither. It is also absent from `feedsFor` by design — its body owns its own two reads, the one docked tab not fed by the shell. Both `` mounts (the column and the mobile sheet) receive `#tab-info`; one alone leaves the phone on the generic empty state. Mobile is a **gain**: the old panel was `hidden sm:flex`, so the header button diff --git a/docs/memory/design-system.md b/docs/memory/design-system.md index af5466960..435248f80 100644 --- a/docs/memory/design-system.md +++ b/docs/memory/design-system.md @@ -478,7 +478,7 @@ The behavioral half of the standard (the visual half is §1–6). Confirmed in t 24. Identity is encoded in form as well as color — classes and states are distinguishable by shape or icon, never by hue alone. 25. Meaningful errors everywhere: every failure surface says what happened, what it means, and what to do — in user vocabulary; stack traces and HTTP codes go behind a details disclosure, never the headline. 26. Expectation setting: non-instant actions state what will happen and roughly how long; multi-step operations show staged progress; say where the result will appear. -27. Post-action next steps: after a completed action, offer the natural next move via static per-flow sequencing. (An adaptive, system-wide suggestion tier is deliberately deferred.) +27. Post-action next steps: after a completed action, offer the natural next move via static per-flow sequencing. (An adaptive, system-wide suggestion tier is deliberately deferred.) The per-agent, per-viewer suggestions list (ent#465, `PortalSuggestions.vue`) is not that tier — it is computed from state, not triggered by the action just taken — but it owns the dismissal model that tier should reuse (`workspace_suggestion_feedback.surface`). 28. Unbounded data is contained: any surface that can render a large or unbounded set (tables, logs, activity streams, execution lists) gets a bounded viewport — max-height with internal scroll, pagination, or virtualization — and never grows the page itself without limit. Sticky headers stay visible while scrolling; the surface states the total ("412 executions · latest 50 shown") so what's beyond the fold is known, not hidden. Companion of 7 (horizontal) and 4 (stable footprint). ## 8. Do / don't — the shape of a violation diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index 6551b3665..d2627ecbf 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -96,6 +96,7 @@ | Schedule → Workspace Delivery | [schedule-workspace-delivery.md](feature-flows/schedule-workspace-delivery.md) | A schedule names one Workspace user; its output lands as a brief in their Main chat (ent#498) | | Workspace Role Card | [workspace-role-card.md](feature-flows/workspace-role-card.md) | The Info rail's role card — a projection of the agent's canon files, plus the owner's readiness stamp (ent#527, #663) | | Workspace Seat Decisions | [workspace-seat-decisions.md](feature-flows/workspace-seat-decisions.md) | The seat-level decision record — why a thing was approved, deferred or killed; recorded by the companion over MCP or the person in Agent details; the evidence base for the autonomy dial (ent#638, R25) | +| Workspace Suggestions | [workspace-suggestions.md](feature-flows/workspace-suggestions.md) | Per-viewer, per-agent suggestions — waiting asks and decisions, schedule health for owners, dormancy, playbooks you have not run; Accept prefills / opens / deep-links, Dismiss holds until the state changes (ent#465) | ### Dashboard & Monitoring diff --git a/docs/memory/feature-flows/workspace-rail.md b/docs/memory/feature-flows/workspace-rail.md index a41d36276..200246244 100644 --- a/docs/memory/feature-flows/workspace-rail.md +++ b/docs/memory/feature-flows/workspace-rail.md @@ -61,8 +61,11 @@ mounted panels therefore leave N−1 stuck in a **permanent loading skeleton** absence, because it claims to be fetching something it has already abandoned. Keying that store per agent unblocks the grouping; it is a store change, not a rail change. -**The third registry shape: a STATIC tab.** Info declares `signal: RAIL_SIGNAL_NONE` and -`empty: null`. Both absences are declared rather than filled, because an agent always has +**The third registry shape: a STATIC tab** (ent#547 — **amended by ent#465**: Info now +declares `signal: RAIL_SIGNAL_UPDATED`, lit by the shell with a `note` — "N suggestions" — +while suggestions wait in it; see `workspace-suggestions.md`. `empty: null` stands, and +`RAIL_SIGNAL_NONE` remains the shape for a tab with neither.) As first shipped, Info declared +`signal: RAIL_SIGNAL_NONE` and `empty: null`. Both absences are declared rather than filled, because an agent always has a name, a health state and a chat list — the body always renders, so the rail's generic empty branch is unreachable — and nothing ever writes an `info` signal, so a borrowed `updated` would light no dot while documenting a rule that does not exist, diluting the diff --git a/docs/memory/feature-flows/workspace-suggestions.md b/docs/memory/feature-flows/workspace-suggestions.md new file mode 100644 index 000000000..7ff746e47 --- /dev/null +++ b/docs/memory/feature-flows/workspace-suggestions.md @@ -0,0 +1,92 @@ +# Workspace — suggestions: what you can do with this agent, and what is waiting (trinity-enterprise#465) + +## Overview + +A platform user who opens an agent in the Workspace gets a short list computed for +**them and that agent**: questions the agent is waiting on them for, decisions on their +seat past review, the health of the agent's schedules (owner/admin only), how long since +they last talked to it, and playbooks it exposes that they have never run. Each item +shows its evidence (`signal`) and has two verbs: **Accept** — prefill the composer, open +a section, focus the chat, or deep-link to the operator schedules tab — and **Dismiss**. +Nothing is sent on the person's behalf, and an agent with nothing to say says so. + +Requirement: `docs/memory/requirements/core-agent.md` §5.39. Journey: J13 (skeleton). + +## Flow + +``` +Portal.vue (shell) + watch(active 1:1 agent, platform session) → store.loadAgentSuggestions(name) [60 s reuse] + #empty slot → PortalSuggestions compact limit=3 ─┐ + Info tab → PortalAgentDetails → PortalSuggestions (full) ─┤ both read one store slice + railSignals.info = {updated, note: "N suggestions"} ─────────┘ → Info's rail dot + +GET /api/enterprise/client-portal/agents/{name}/suggestions + suggestions/router._gate: not is_platform → 404 · agent_on_roster(…, True) → 404 · rate limit 60/min/viewer + suggestions/service.get_suggestions + can_configure = principal.is_admin OR portal_owns_agent(email, name, True) + asyncio.gather( + to_thread(_gather) → list_asks · list_seat_decisions+effective_status · list_schedules + · last_user_message_at · slash_texts_by_viewer · dismissed_fingerprints + · [owner/admin] autonomy · recent_runs_by_schedule (ROW_NUMBER ≤10) · overdue reminders + _playbooks(name) → _availability_map → _bounded_briefing (only if it can answer) → playbooks | None + ) + build(signals, now) → ranked (Suggestion, fingerprint) pairs [PURE] + shape(pairs, dismissed) → hide where fingerprint == dismissed one; cap 5; total; capabilities; basis + +POST …/suggestions/feedback {key, action: accept|dismiss} + same gate → key class allowlisted (else 422) → recompute → key not emitted → 404 (nothing written) + → db.record_feedback: dismissed_at + dismissed_fingerprint | accepted_at + accept_count+1 +``` + +## Why it is shaped this way + +- **Two kinds, per the engine-bay ruling (decision 15, option 1).** `invoke` items act + inside the Workspace; `configure` items only deep-link to the operator page. The + Workspace still never configures (§5.11). +- **Prefill, never send.** The ent#138 rule every hint follows. `open_chat` focuses the + composer; `link` opens a new tab and only for an `/agents/…` path. +- **One `autonomy_held` item, not one per schedule.** Autonomy is off by default, so a + per-schedule item would fill every slot, and nothing records when autonomy was turned + off — "held N days" would be invented. It cites the last real run instead, and + suppresses "never fired" (a held schedule cannot fire). +- **"Haven't run /name", not "never used".** Usage is explicit slash invocations in the + viewer's own Workspace messages and executions attributed to them; plain-language + requests are undetectable. A playbook an enabled schedule already runs is not offered. +- **Fingerprints are state identities, never counts.** Otherwise a failing schedule + returns with every new failure — nagging, not "the signal materially changed". +- **Bounded, unforgeable writes.** The key travels in the body (it can carry an + agent-authored playbook name); the server recomputes and writes only for an item it + would show this viewer now, with its own fingerprint. +- **Info's dot, not a number.** The rail has two signal shapes in one hue (principle + 24); Info borrows `updated` and its words ("Info · 2 suggestions") come from `note`. + The shell loads the slice itself so the dot works with the rail collapsed. +- **No LLM.** A handful of indexed queries, one windowed runs query, and at most one + bounded briefing call (cached 60 s, skipped for a stopped agent). + +## Files + +| Layer | Files | +|---|---| +| Router | `src/backend/client_portal/suggestions/router.py` (registered in `main.py`) | +| Service | `src/backend/client_portal/suggestions/service.py` (`build`, `shape`, thresholds) | +| DB | `src/backend/client_portal/suggestions/db.py`; table `workspace_suggestion_feedback` in `db/schema.py`, `db/tables.py`, `db/migrations.py`, Alembic `0078_workspace_suggestion_feedback`; CASCADE in `db/agent_cleanup.py` | +| Auth | `client_portal/portal_auth.py` — `PortalPrincipal.is_admin`, `_is_admin_principal` | +| Frontend | `components/portal/PortalSuggestions.vue`, `PortalAgentDetails.vue`, `views/Portal.vue`, `stores/clientPortal.js` (suggestions slice), `components/portal/portalRail.js` (Info signal + `note`) | +| Tests | `tests/unit/test_ent465_suggestions.py`, `src/frontend/tests/unit/portalSuggestions.spec.js`, `portalRail.spec.js`, `tests/journeys/test_j13_suggestions_journey.py` | + +## Known limits (review, 2026-09-24) + +- "Show decisions" accepted from the **empty chat** opens the Info tab at its top; only the + Info-tab placement scrolls to the Decisions section. +- Two concurrent feedback writes from one viewer on SQLite can meet `SQLITE_BUSY` (a deferred + transaction reads, then writes); the client restores the item with its inline error and + nothing is lost. Concurrent new keys can leave a viewer one row over the 1,000-row cap. + +## Deferred + +- A suggestion that consults another agent (2026-09-21 amendment → ent#698) and + suggestions derived from the current conversation (2026-09-22 → ent#699). +- Role, project and objective-gap inputs (#500, #661, #477–#479); the ent#178 curated + skill set replaces the briefing ladder as the capability source when it lands. +- The Inbox placement (#610). diff --git a/docs/memory/learnings/2026-09-24-a-second-stack-reads-other-stacks-volumes-as-orphans.md b/docs/memory/learnings/2026-09-24-a-second-stack-reads-other-stacks-volumes-as-orphans.md new file mode 100644 index 000000000..9ab6a3bb9 --- /dev/null +++ b/docs/memory/learnings/2026-09-24-a-second-stack-reads-other-stacks-volumes-as-orphans.md @@ -0,0 +1,3 @@ +## 2026-09-24 — pitfall — A second Trinity stack on the same Docker daemon reads every other stack's agent volumes as orphans +**Context**: trinity-enterprise#465's local demo. `cleanup_service._sweep_orphan_agent_volumes` (#1581) reclaims an `agent-*` volume that (1) no ownership row claims, (2) no container mounts, (3) for three consecutive cycles, past a 1h creation grace — up to 100 per cycle, force-removed. All three conditions are evaluated against the stack's OWN database and the daemon's WHOLE volume list. A stack booted with a fresh database (a demo, a `--keep` verify run, a second worktree's dev stack) owns no rows, so every labelled agent volume on the host that nothing currently mounts is an orphan to it; on the developer machine where this surfaced that was 207 of 238. There is no setting that turns the sweep off, and `/verify-local`'s overlay does not neutralise it — a run that stays up past ~15 minutes is inside the window. +**Lesson**: Docker volumes are host-global state; a sweep that decides "unowned" from a per-stack database is only safe when that database is the only one on the daemon. Before booting any second stack on a shared daemon, count `docker volume ls --filter label=trinity.agent-name` minus the attached ones — if it is non-zero, disable that sweep for the extra stack (a demo-only `sitecustomize` import hook that no-ops the method works without touching the branch) or scope the sweep to volumes this stack created. The structural fix is a stack-identity label on every agent volume, checked by the sweep before it reclaims anything. diff --git a/docs/memory/learnings/2026-09-24-content-above-a-chat-empty-state-scrolls-out-of-view.md b/docs/memory/learnings/2026-09-24-content-above-a-chat-empty-state-scrolls-out-of-view.md new file mode 100644 index 000000000..fa7f6ed35 --- /dev/null +++ b/docs/memory/learnings/2026-09-24-content-above-a-chat-empty-state-scrolls-out-of-view.md @@ -0,0 +1,3 @@ +## 2026-09-24 — pitfall — Content placed ABOVE a chat pane's empty state is scrolled out of view by stick-to-bottom +**Context**: trinity-enterprise#465. The empty-chat suggestions were mounted in `PortalConversation`'s `#empty` slot above `PortalBriefing`; the transcript scroller sticks to the bottom, so on a normal viewport the new list sat above the fold, cut off at the top edge, with only its third item partly visible. Every unit and mounted test passed — jsdom has no layout — and the defect was found only by the live screenshot pass. +**Lesson**: anything added to the empty-chat state goes BETWEEN the agent's identity and its hints (`PortalBriefing`'s `before-hints` slot), sized to fit one viewport with the hints — never prepended to the slot. For any new Workspace surface inside the transcript scroller, the screenshot at a real viewport is the test; plan it, don't treat it as polish. diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md index 38298efd9..3d8b1c036 100644 --- a/docs/memory/requirements/core-agent.md +++ b/docs/memory/requirements/core-agent.md @@ -3429,3 +3429,72 @@ to localStorage in the clear. the real chat and headless paths), `test_2958_chat_compact_metadata.py`, `test_2958_session_cleanup_chat_marker.py`, `test_2958_marker_path_parity.py`, `test_2610_resume_surface_parity.py`, `src/mcp-server/src/tools/executions.compact.test.ts`. + +### 5.39 Workspace — suggestions: what you can do with this agent, and what is waiting (trinity-enterprise#465) +- **Status**: 🚧 In progress +- **Requirement ID**: WORKSPACE_SUGGESTIONS +- **GitHub Issue**: abilityai/trinity-enterprise#465 (1.0 story step 3, #649) +- **Description**: A platform user who opens an agent in the Workspace sees a short list of + suggestions computed for **that user and that agent** — never a list identical for every + user. Each is an actionable object, not advice: **Accept** or **Dismiss**, both recorded. + Every item displays the signal it came from so the claim can be checked. When nothing + crosses a threshold the surface says so and is never padded to a fixed count. +- **Two kinds (decision 15, option 1 — the engine-bay ruling)**: `invoke` items act inside + the Workspace — Accept **prefills** the composer (`/ `) or opens the section the + item names; nothing is ever sent on the person's behalf (the ent#138 prefill rule). + `configure` items (a schedule's health, autonomy) are read-only awareness whose Accept is a + **deep link** to the operator agent page (`/agents/?tab=schedules`). Building agents + stays operator-side (§5.11 "it reports; it does not configure"). +- **Door, declared per class**: every v1 class is behind the **platform-authenticated door** + (ent#357). A verified-email portal token gets a uniform 404 on the read and the feedback + write — usage- and schedule-derived data must be unreachable from the external projection + (ent#78 auth-path invariant). `configure` classes additionally require **owner or admin**, + the same audience that may enable/disable a schedule (`OwnedAgent`). The Workspace + principal carries `is_admin` for this one decision only; everywhere else a non-owner admin + stays a viewer (ent#358). +- **v1 classes** (order = display priority; at most 5 shown, `total` returned; UTC): + | Class | Kind | Emitted when | Signal shown | + |---|---|---|---| + | `asks` | invoke | ≥1 pending ask addressed to me on this agent | "N questions waiting on you" | + | `decisions_due` | invoke | ≥1 decision on my seat past `review_by` | "N decisions past their review date" | + | `schedule_failing:` | configure | last ≥3 terminal runs failed (skipped/cancelled neither count nor break the streak) | "Failed K runs in a row, last " | + | `autonomy_held` | configure | autonomy off and ≥1 enabled schedule or a pending reminder past `fire_at` | "N schedules won't run — autonomy is off" | + | `schedule_never_fired:` | configure | enabled, autonomy on, never run, and the cron's first expected fire after creation is >1h past | "Enabled since , has never run" | + | `schedule_disabled:` | configure | disabled ≥7 days and it had run before | "Disabled since — it used to run" | + | `dormant` | invoke | I have chatted here and my last message is ≥14 days old | "Your last conversation was " | + | `unused_playbook:` | invoke | an exposed playbook I have not started, at most 3 | "You haven't run /name yet" | + One agent-level `autonomy_held` item replaces per-schedule "held" items: autonomy is off by + default, so per-schedule items would fill every slot, and no timestamp records when it was + turned off — a "held N days" figure would be invented. Autonomy off suppresses + `schedule_never_fired`. +- **Usage dimension (recorded decision)**: per-user — yes, keyed by the viewer's email. + Per-skill — **explicit slash invocations only**: `/` at the start of the person's own + Workspace messages or of executions attributed to them (`source_user_email`), and a + playbook an enabled schedule already runs is never offered. A plain-language request for + the same work is not detected, which is why the copy says "haven't run /name" rather than + "never used". Reads existing rows only — no parallel tracking store. +- **Capabilities**: the same briefing ladder as the hints and "What it can do" (ent#380) — + the ent#178 curated set slots into that seam when it lands. **Playbooks only**: template + use-case text is not offered, because whether someone has "used" a free-text example is + unverifiable. When the agent is not running or does not answer, capability items are + omitted and the response says capabilities are unavailable — never "nothing unused". + With no usage history the list is capability-only and says so. +- **Dismissal**: per user + agent + suggestion, persisted. A dismissed item stays hidden + while its **state fingerprint** is unchanged — the identity of the state, never a count + (the first failure of a streak, the set of held schedules, the set of waiting asks), so a + failing schedule does not return with every additional failure but does return when a new + streak starts. Accept is recorded (usefulness) and does not hide an item: the signal clears + itself when the thing is done. Writes are bounded to items currently emitted for the + caller; the server computes the fingerprint. +- **Placement**: the rail's **Info** tab carries the full list and the tab shows its count; + an empty chat shows the top 3 above "Things you can ask". Suggestions never arrive as + messages. +- **Cost**: no LLM call. Per read: at most one bounded briefing fetch (cached 60s, skipped + for a stopped agent), a handful of indexed queries and one windowed executions query. + Rate-limited per viewer. +- **Not in scope (follow-ups)**: a suggestion that consults another agent (2026-09-21 + amendment → ent#698); suggestions derived from the current conversation (2026-09-22 → + ent#699); role, project + and objective-gap inputs (#500, #661, #477–#479) — each attaches as a new class without + changing the object; the Inbox placement (#610). +- **Flow**: `docs/memory/feature-flows/workspace-suggestions.md` diff --git a/docs/testing/JOURNEYS.md b/docs/testing/JOURNEYS.md index 7a85c38e6..dc8aa3162 100644 --- a/docs/testing/JOURNEYS.md +++ b/docs/testing/JOURNEYS.md @@ -23,8 +23,9 @@ coverage debt is queryable rather than rediscovered. | **J10** | My agents can call each other, and I can see what they said. | user | local | journey-smoke | **no** | `tests/journeys/test_j10_agent_calls_agent_journey.py` | P-01, P-02, AC-01, L-03, IA-01, IA-02, IA-03, IA-04 | #2349 | | **J11** | A companion's brief reaches me where I already work, without me asking. | user | local | live-stack | **no** | `tests/journeys/test_j11_brief_delivery_journey.py` | E-01, E-05, L-03 | #2565 | | **J12** | My agent's own declaration of what it measures is what Trinity actually knows about it. | user | local | live-stack | **no** | `tests/journeys/test_j12_declared_metrics_journey.py` | L-02, L-03 | #477, #478, #479, #666 | +| **J13** | My agent tells me what I can do with it and what is waiting on me — and only things that are true. | user | local | live-stack | **no** | `tests/journeys/test_j13_suggestions_journey.py` | — | #465 | -**0 of 12 journeys built.** Green/red is NOT recorded here — regenerate with `--junit ` against CI artifacts for a coverage figure. +**0 of 13 journeys built.** Green/red is NOT recorded here — regenerate with `--junit ` against CI artifacts for a coverage figure. ## Variants diff --git a/src/backend/client_portal/portal_auth.py b/src/backend/client_portal/portal_auth.py index a21c14d90..5e08d15f7 100644 --- a/src/backend/client_portal/portal_auth.py +++ b/src/backend/client_portal/portal_auth.py @@ -56,6 +56,25 @@ def _reject_if_blocked(email: str) -> None: raise HTTPException(status_code=403, detail="Access has been revoked.") +def _is_admin_principal(user) -> bool: + """Admin by role AND by credential scope (trinity-enterprise#465). + + The same allowlist the admin gates use (`ADMIN_GATE_SCOPES`, #2323): a key + whose scope is not on it does not inherit its owner's admin role here + either. Agent keys never reach this line (`reject_agent_principal` above). + Fails closed on a principal missing either attribute. + """ + from dependencies import ADMIN_GATE_SCOPES + + if getattr(user, "role", None) != "admin": + return False + try: + scope = user.mcp_scope + except AttributeError: + return False + return scope in ADMIN_GATE_SCOPES + + class PortalPrincipal(NamedTuple): """Who is asking, and by which credential (ent#357). @@ -77,6 +96,13 @@ class PortalPrincipal(NamedTuple): # `is_person_principal` holds: a system-scoped key keeps its read breadth # here (#2198) but is not a person, and an answer is recorded as a person's. is_person: bool = True + #: trinity-enterprise#465 — whether the platform principal is an instance + #: admin, for ONE decision: who sees a suggestion that deep-links to agent + #: configuration. Everywhere else a non-owner admin stays a viewer in the + #: Workspace (ent#358, `service.portal_owns_agent`). Last field with a + #: default so every positional construction keeps its meaning; always False + #: for a portal token, which carries no platform identity. + is_admin: bool = False #: Response header carrying a rotated Workspace session token (ent#375). The @@ -156,7 +182,7 @@ async def get_portal_principal( # show what that client sees, and an operator whose OWN email is blocked has # been blocked as a client — the block is on the identity, not on the route. _reject_if_blocked(email) - return PortalPrincipal(email, True, is_person_principal(user)) + return PortalPrincipal(email, True, is_person_principal(user), _is_admin_principal(user)) async def get_portal_identity( diff --git a/src/backend/client_portal/suggestions/__init__.py b/src/backend/client_portal/suggestions/__init__.py new file mode 100644 index 000000000..7a5a7d2dc --- /dev/null +++ b/src/backend/client_portal/suggestions/__init__.py @@ -0,0 +1,23 @@ +"""Workspace suggestions — what you can do with this agent, and what is waiting (trinity-enterprise#465). + +A short list computed for ONE viewer and ONE agent from signals the platform +already holds: questions and decisions waiting on the viewer, the health of the +agent's schedules, how long since the viewer last talked to it, and the +playbooks it exposes that the viewer has never started. Each item is an +actionable object — Accept (prefill / open / deep link) or Dismiss — and shows +the signal it came from. Requirement: `docs/memory/requirements/core-agent.md` +§5.39; flow: `docs/memory/feature-flows/workspace-suggestions.md`. + +It lives under `client_portal/` beside `work/` and `asks/` for the reasons +those packages give: a client-portal surface on the client-portal prefix that +inherits `client_portal`'s Dockerfile COPY line (the #1033 trap), and not in +`service.py`, the codebase's top churn × complexity hotspot. + +**Platform-authenticated door only.** ent#78's auth-path invariant: usage- and +schedule-derived data is internal, so a verified-email portal token gets a +uniform 404 from the router before any read. `configure` items additionally +require owner or admin — the audience that may act on a schedule. + +**OSS core by decision** — no `requires_entitlement`. The Workspace is OSS core +(ent#356), and ent#465 is a Workspace capability. +""" diff --git a/src/backend/client_portal/suggestions/db.py b/src/backend/client_portal/suggestions/db.py new file mode 100644 index 000000000..0f492e059 --- /dev/null +++ b/src/backend/client_portal/suggestions/db.py @@ -0,0 +1,200 @@ +"""Data access for Workspace suggestions (trinity-enterprise#465). + +SQL only — no thresholds, no wording. Every read is scoped to ONE agent, and +the viewer-scoped ones to the viewer's own (lower-cased) email. +""" +from __future__ import annotations + +from typing import Dict, List, Optional + +from sqlalchemy import and_, delete, func, select + +from db.engine import get_engine, make_insert +from db.tables import ( + agent_reminders, + agent_schedules, + enterprise_portal_messages, + schedule_executions, + workspace_suggestion_feedback as feedback, +) + +#: Rows of message/execution text scanned for `/name` invocations, newest +#: first. A bound, not a correctness knob: the question is "ever started", and +#: a playbook someone runs is started recently. +MAX_USAGE_ROWS = 2000 + +#: One viewer's feedback rows. Existing keys are always updatable; a new key +#: past the cap evicts the viewer's oldest row instead of being refused — a +#: hard refusal would leave a dismiss button that silently stops working. +MAX_FEEDBACK_ROWS = 1000 + + +def _email(email: str) -> str: + return (email or "").lower() + + +def list_schedules(agent_name: str) -> List[dict]: + """The agent's live schedules. `message` is read for the playbook check only + and never leaves the service (§5.11: prompts do not cross this surface).""" + stmt = select( + agent_schedules.c.id, agent_schedules.c.name, agent_schedules.c.enabled, + agent_schedules.c.cron_expression, agent_schedules.c.timezone, + agent_schedules.c.message, agent_schedules.c.created_at, + agent_schedules.c.updated_at, agent_schedules.c.last_run_at, + ).where( + agent_schedules.c.agent_name == agent_name, + agent_schedules.c.deleted_at.is_(None), + ) + with get_engine().connect() as conn: + return [dict(r) for r in conn.execute(stmt).mappings()] + + +def recent_runs_by_schedule(agent_name: str, since: str, per_schedule: int, + schedule_ids: List[str]) -> Dict[str, List[dict]]: + """`{schedule_id: [run, ...]}` newest first, at most `per_schedule` each. + + ONE windowed query for the whole agent (served by + `idx_executions_agent_started`) rather than one per schedule — restricted + to the agent's live schedules, so chat/API runs (`schedule_id='__manual__'`) + are never ranked just to be thrown away. + """ + if not schedule_ids: + return {} + rn = func.row_number().over( + partition_by=schedule_executions.c.schedule_id, + order_by=(schedule_executions.c.started_at.desc(), schedule_executions.c.id.desc()), + ).label("rn") + inner = select( + schedule_executions.c.id, schedule_executions.c.schedule_id, + schedule_executions.c.status, schedule_executions.c.started_at, rn, + ).where( + schedule_executions.c.agent_name == agent_name, + schedule_executions.c.started_at >= since, + schedule_executions.c.schedule_id.in_(schedule_ids), + ).subquery() + stmt = select(inner.c.id, inner.c.schedule_id, inner.c.status, inner.c.started_at).where( + inner.c.rn <= per_schedule + ).order_by(inner.c.schedule_id, inner.c.rn) + out: Dict[str, List[dict]] = {} + with get_engine().connect() as conn: + for r in conn.execute(stmt).mappings(): + out.setdefault(r["schedule_id"], []).append(dict(r)) + return out + + +def count_overdue_reminders(agent_name: str, now_iso: str) -> int: + """Pending reminders already past `fire_at` — held when autonomy is off (#1806).""" + stmt = select(func.count()).select_from(agent_reminders).where( + agent_reminders.c.agent_name == agent_name, + agent_reminders.c.status.in_(("pending", "firing")), + agent_reminders.c.fire_at < now_iso, + ) + with get_engine().connect() as conn: + return int(conn.execute(stmt).scalar() or 0) + + +def last_user_message_at(agent_name: str, email: str) -> Optional[str]: + """When this viewer last wrote to this agent in the Workspace. + + The viewer's OWN messages, not the session's `last_message_at`: a scheduled + brief delivered into the Main chat (ent#498) moves that without the person + saying anything, and would hide exactly the dormancy this measures. + """ + stmt = select(func.max(enterprise_portal_messages.c.created_at)).where( + enterprise_portal_messages.c.agent_name == agent_name, + enterprise_portal_messages.c.client_email == _email(email), + enterprise_portal_messages.c.role == "user", + ) + with get_engine().connect() as conn: + return conn.execute(stmt).scalar() + + +def viewer_has_runs(agent_name: str, email: str, since: str) -> bool: + """Whether this viewer started anything on this agent outside the Workspace + (operator chat, MCP) — attributed executions count as history too.""" + stmt = select(schedule_executions.c.id).where( + schedule_executions.c.agent_name == agent_name, + schedule_executions.c.started_at >= since, + func.lower(schedule_executions.c.source_user_email) == _email(email), + ).limit(1) + with get_engine().connect() as conn: + return conn.execute(stmt).first() is not None + + +def slash_texts_by_viewer(agent_name: str, email: str, since: str) -> List[str]: + """Texts this viewer started with `/` on this agent — Workspace messages and + executions attributed to them (operator chat, MCP) — newest first, bounded.""" + em = _email(email) + portal = select(enterprise_portal_messages.c.content).where( + enterprise_portal_messages.c.agent_name == agent_name, + enterprise_portal_messages.c.client_email == em, + enterprise_portal_messages.c.role == "user", + enterprise_portal_messages.c.content.like("/%"), + ).order_by(enterprise_portal_messages.c.created_at.desc()).limit(MAX_USAGE_ROWS) + runs = select(schedule_executions.c.message).where( + schedule_executions.c.agent_name == agent_name, + schedule_executions.c.started_at >= since, + func.lower(schedule_executions.c.source_user_email) == em, + schedule_executions.c.message.like("/%"), + ).order_by(schedule_executions.c.started_at.desc()).limit(MAX_USAGE_ROWS) + with get_engine().connect() as conn: + texts = [r[0] for r in conn.execute(portal) if r[0]] + texts += [r[0] for r in conn.execute(runs) if r[0]] + return texts + + +def dismissed_fingerprints(agent_name: str, email: str) -> Dict[str, str]: + """`{suggestion_key: fingerprint}` for this viewer's dismissals on this agent.""" + stmt = select(feedback.c.suggestion_key, feedback.c.dismissed_fingerprint).where( + feedback.c.client_email == _email(email), + feedback.c.agent_name == agent_name, + feedback.c.dismissed_at.is_not(None), + ) + with get_engine().connect() as conn: + return {r[0]: r[1] or "" for r in conn.execute(stmt)} + + +def record_feedback(*, email: str, agent_name: str, key: str, source: str, + action: str, fingerprint: str, now: str) -> None: + """Upsert one accept or dismiss. The two live in separate columns so an + accept never erases a dismissal (or the reverse).""" + em = _email(email) + pk = and_( + feedback.c.client_email == em, + feedback.c.agent_name == agent_name, + feedback.c.suggestion_key == key, + ) + with get_engine().begin() as conn: + exists = conn.execute(select(func.count()).select_from(feedback).where(pk)).scalar() + if not exists: + total = conn.execute( + select(func.count()).select_from(feedback).where(feedback.c.client_email == em) + ).scalar() or 0 + if total >= MAX_FEEDBACK_ROWS: + oldest = conn.execute( + select(feedback.c.agent_name, feedback.c.suggestion_key) + .where(feedback.c.client_email == em) + .order_by(feedback.c.updated_at.asc()) + .limit(total - MAX_FEEDBACK_ROWS + 1) + ).all() + for agent, k in oldest: + conn.execute(delete(feedback).where( + feedback.c.client_email == em, + feedback.c.agent_name == agent, + feedback.c.suggestion_key == k, + )) + if action == "dismiss": + values = {"dismissed_at": now, "dismissed_fingerprint": fingerprint} + update = dict(values) + else: + values = {"accepted_at": now, "accept_count": 1} + update = {"accepted_at": now, "accept_count": feedback.c.accept_count + 1} + stmt = make_insert(feedback).values( + client_email=em, agent_name=agent_name, suggestion_key=key, + surface="agent", source=source, updated_at=now, + accept_count=values.pop("accept_count", 0), **values, + ).on_conflict_do_update( + index_elements=[feedback.c.client_email, feedback.c.agent_name, feedback.c.suggestion_key], + set_={**update, "source": source, "updated_at": now}, + ) + conn.execute(stmt) diff --git a/src/backend/client_portal/suggestions/models.py b/src/backend/client_portal/suggestions/models.py new file mode 100644 index 000000000..e6c684382 --- /dev/null +++ b/src/backend/client_portal/suggestions/models.py @@ -0,0 +1,68 @@ +"""Pydantic models for Workspace suggestions (trinity-enterprise#465).""" +from __future__ import annotations + +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + +#: `invoke` acts inside the Workspace (prefill, open a section, open the chat); +#: `configure` is read-only awareness whose Accept deep-links to the operator +#: agent page (decision 15, option 1 — the engine-bay ruling). +SuggestionKind = Literal["invoke", "configure"] + +#: Which signal produced the item. Stored with the feedback row so a source +#: whose suggestions are always dismissed can be found — and dropped — by data. +SuggestionSource = Literal["asks", "decisions", "schedules", "usage", "capability"] + +#: The door each class is declared behind (ent#78: no surface ships +#: undeclared). `platform` = any platform-authenticated viewer of the agent; +#: `owner_or_admin` = additionally the agent's owner or an instance admin. +SuggestionDoor = Literal["platform", "owner_or_admin"] + +#: What Accept does on the client. `prefill` puts `value` in the composer and +#: never sends (ent#138); `open_section` opens the Info-tab section `value` +#: names; `open_chat` focuses the composer; `link` navigates to the operator +#: path in `value`. +ActionType = Literal["prefill", "open_section", "open_chat", "link"] + + +class SuggestionAction(BaseModel): + type: ActionType + value: Optional[str] = None + + +class Suggestion(BaseModel): + """One actionable suggestion. The shape is the contract later sources + (role, project, objective gap, cross-agent) attach to without changing it.""" + key: str + kind: SuggestionKind + source: SuggestionSource + door: SuggestionDoor + title: str + #: The evidence, in words the viewer can check ("Failed 4 runs in a row"). + signal: str + description: Optional[str] = None + action: SuggestionAction + + +class PortalSuggestions(BaseModel): + agent_name: str + suggestions: List[Suggestion] = Field(default_factory=list) + #: How many were eligible before the display cap — a truncated list says so. + total: int = 0 + #: `available` — the agent answered and exposes playbooks; `none` — it + #: answered and exposes none; `unavailable` — it did not answer (stopped, + #: unreachable, over budget), so "nothing unused" would be a guess. + capabilities: Literal["available", "none", "unavailable"] = "unavailable" + #: `capabilities_only` — the viewer has never talked to this agent, so the + #: list can only say what the agent can do (said, not implied). + basis: Literal["history", "capabilities_only"] = "history" + + +class SuggestionFeedback(BaseModel): + key: str = Field(..., min_length=1, max_length=200) + action: Literal["accept", "dismiss"] + + +class SuggestionFeedbackResult(BaseModel): + ok: bool = True diff --git a/src/backend/client_portal/suggestions/router.py b/src/backend/client_portal/suggestions/router.py new file mode 100644 index 000000000..34178ba76 --- /dev/null +++ b/src/backend/client_portal/suggestions/router.py @@ -0,0 +1,62 @@ +# mcp: none — a Workspace UI read computed for one human viewer; agents read the same signals through schedules/asks/decisions tools +"""FastAPI router for Workspace suggestions (trinity-enterprise#465). + +Platform-authenticated only (ent#78's auth-path invariant): a verified-email +portal token gets the same uniform 404 as an agent outside the roster, before +any read. Order is access-first (OSS invariant #8) — door, then roster, then the +per-viewer rate limiter — so the limiter never keys on an unvalidated name. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from client_portal.portal_auth import PortalPrincipal, get_portal_principal + +from . import service +from .models import PortalSuggestions, SuggestionFeedback, SuggestionFeedbackResult + +router = APIRouter( + prefix="/api/enterprise/client-portal/agents", + tags=["client-portal"], +) + + +def _gate(agent_name: str, principal: PortalPrincipal, limiter_key: str, limit: int) -> None: + if not principal.is_platform: + raise HTTPException(status_code=404, detail="Not found") + from client_portal import service as portal + + if not portal.agent_on_roster(agent_name, principal.email, True): + raise HTTPException(status_code=404, detail="Agent not found") + from services import rate_limiter + + rate_limiter.enforce(f"{limiter_key}:{principal.email}", limit, 60) + + +@router.get("/{agent_name}/suggestions", response_model=PortalSuggestions) +async def get_suggestions( + agent_name: str, + principal: PortalPrincipal = Depends(get_portal_principal), +): + """Suggestions computed for this viewer and this agent (requirement §5.39).""" + _gate(agent_name, principal, "portal_suggestions", 60) + return await service.get_suggestions(agent_name, principal.email, is_admin=principal.is_admin) + + +@router.post("/{agent_name}/suggestions/feedback", response_model=SuggestionFeedbackResult) +async def post_feedback( + agent_name: str, + body: SuggestionFeedback, + principal: PortalPrincipal = Depends(get_portal_principal), +): + """Accept or dismiss one suggestion that is currently shown to this viewer. + The key travels in the body (it can carry an agent-authored playbook name).""" + _gate(agent_name, principal, "portal_suggestions_feedback", 60) + try: + await service.record_feedback( + agent_name, principal.email, is_admin=principal.is_admin, + key=body.key, action=body.action, + ) + except service.SuggestionError as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + return SuggestionFeedbackResult() diff --git a/src/backend/client_portal/suggestions/service.py b/src/backend/client_portal/suggestions/service.py new file mode 100644 index 000000000..cdfd0fdbe --- /dev/null +++ b/src/backend/client_portal/suggestions/service.py @@ -0,0 +1,463 @@ +"""Workspace suggestions — business rules (trinity-enterprise#465). + +Two halves, kept apart so the rules are testable without a database: + +* `build(signals, now=..., can_configure=...)` — PURE. Signals in, ranked + `(Suggestion, fingerprint)` pairs out. Every threshold and every word of copy + lives here, against an injected `now` (UTC). +* `get_suggestions` / `record_feedback` — gather the signals (DB reads run in a + thread, concurrently with the one bounded briefing fetch), apply the viewer's + dismissals, and bound the write. + +The fingerprint is the IDENTITY of the state an item reports — the first +failure of a streak, the set of held schedules — never a count, so a dismissed +item stays dismissed while the same state persists and comes back when a new +one starts (requirement §5.39). +""" +from __future__ import annotations + +import asyncio +import hashlib +import logging +import re +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional, Sequence, Tuple +from urllib.parse import quote + +from . import db as sdb +from .models import PortalSuggestions, Suggestion, SuggestionAction + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Thresholds — one place, named in requirement §5.39. +# --------------------------------------------------------------------------- +MAX_SHOWN = 5 +MAX_UNUSED_PLAYBOOKS = 3 +FAILING_STREAK = 3 +DORMANT_DAYS = 14 +DISABLED_DAYS = 7 +NEVER_FIRED_GRACE = timedelta(hours=1) +#: How far back runs and attributed invocations are read. Execution rows are +#: pruned by retention anyway; this bounds the scan on a busy agent. +LOOKBACK_DAYS = 90 +#: Runs read per schedule for the failure streak. +RUNS_PER_SCHEDULE = 10 +#: Agent-authored schedule names are capped before they cross (§5.11). +MAX_NAME_CHARS = 80 +#: The briefing (a live call into the agent) is cached per agent this long. +BRIEFING_TTL_SECONDS = 60.0 +#: Agents held in that cache per process; the fleet size bounds it in practice. +BRIEFING_CACHE_MAX = 500 + +_FAILED = frozenset({"failed", "error"}) +#: Neither count toward a streak nor break it: a skipped tick (lock held, git +#: freeze) or a cancel says nothing about whether the schedule works, and an +#: in-flight run has no verdict yet. +_NEUTRAL = frozenset({"skipped", "cancelled", "running", "queued", "pending_retry"}) + +#: A playbook starter is exactly `/ ` (`service._playbook_starter`); +#: anything else in the briefing is template use-case text, which is not offered +#: (whether someone has "used" a free-text example is unverifiable). +_STARTER = re.compile(r"^/([A-Za-z0-9][A-Za-z0-9._:-]{0,99}) $") +_SLASH = re.compile(r"^\s*/([A-Za-z0-9][A-Za-z0-9._:-]{0,99})(?:\s|$)") + +#: Display order — what is waiting on the viewer first, then what is broken, +#: then what could be better. +_ORDER = ( + "asks", "decisions_due", "schedule_failing", "autonomy_held", + "schedule_never_fired", "schedule_disabled", "dormant", "unused_playbook", +) +#: The feedback write accepts only keys of these classes (bounded, and never a +#: path segment). +KEY_CLASSES = frozenset(_ORDER) + + +class SuggestionError(Exception): + def __init__(self, status_code: int, detail: str): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +@dataclass +class Playbook: + name: str + title: str + description: Optional[str] = None + + +@dataclass +class Signals: + """Everything `build` reads. Defaults describe an agent with nothing to say.""" + ask_ids: Sequence[str] = () + decisions_due_ids: Sequence[str] = () + autonomy_enabled: bool = True + schedules: Sequence[dict] = () + runs: Dict[str, List[dict]] = field(default_factory=dict) + overdue_reminders: int = 0 + last_user_message_at: Optional[str] = None + #: None = the agent did not answer (capabilities unknown), [] = none exposed. + playbooks: Optional[Sequence[Playbook]] = None + used_playbooks: frozenset = frozenset() + + +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + +def _parse(ts) -> Optional[datetime]: + if not ts: + return None + if isinstance(ts, datetime): + return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc) + s = str(ts).strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(s.replace(" ", "T", 1)) + except ValueError: + return None + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + +def _day(dt: datetime) -> str: + """`Sep 3` — a date the viewer can check against the schedule list.""" + return f"{dt:%b} {dt.day}" + + +def _plural(n: int, one: str, many: Optional[str] = None) -> str: + return f"{n} {one if n == 1 else (many or one + 's')}" + + +def _hash(values) -> str: + joined = "\n".join(sorted(str(v) for v in values)) + return hashlib.sha256(joined.encode()).hexdigest()[:16] + + +def _cap(text: str) -> str: + text = (text or "").strip() + return text if len(text) <= MAX_NAME_CHARS else text[: MAX_NAME_CHARS - 1] + "…" + + +def slash_name(text: Optional[str]) -> Optional[str]: + """The playbook a message starts with (`/weekly-report now` → `weekly-report`).""" + m = _SLASH.match(text or "") + return m.group(1).lower() if m else None + + +def playbooks_from_briefing(items) -> List[Playbook]: + """Briefing hint items → playbooks. Use-case hints are dropped (see `_STARTER`).""" + out: List[Playbook] = [] + for p in items or (): + starter = getattr(p, "starter_prompt", None) if not isinstance(p, dict) else p.get("starter_prompt") + m = _STARTER.match(starter or "") + if not m: + continue + title = (getattr(p, "title", None) if not isinstance(p, dict) else p.get("title")) or m.group(1) + desc = getattr(p, "description", None) if not isinstance(p, dict) else p.get("description") + out.append(Playbook(name=m.group(1), title=title, description=desc)) + return out + + +def failure_streak(runs: Sequence[dict]) -> Tuple[int, Optional[dict], Optional[dict]]: + """`(count, newest_failure, first_failure)` over runs newest-first. + + Failures count, `_NEUTRAL` rows are stepped over, anything else (a success) + ends the streak. + """ + count, newest, first = 0, None, None + for r in runs: + status = (r.get("status") or "").lower() + if status in _NEUTRAL: + continue + if status in _FAILED: + count += 1 + newest = newest or r + first = r + continue + break + return count, newest, first + + +def first_expected_fire(cron: str, tz_name: Optional[str], created: datetime) -> Optional[datetime]: + """The cron's first fire after `created`, in UTC; None when it can't be computed.""" + try: + from croniter import croniter + from zoneinfo import ZoneInfo + + tz = ZoneInfo(tz_name or "UTC") + nxt = croniter(cron, created.astimezone(tz)).get_next(datetime) + except Exception: # noqa: BLE001 — an unparseable cron is not a suggestion + return None + if nxt.tzinfo is None: + nxt = nxt.replace(tzinfo=tz) + return nxt.astimezone(timezone.utc) + + +def _link(agent_name: str) -> SuggestionAction: + return SuggestionAction(type="link", value=f"/agents/{quote(agent_name, safe='')}?tab=schedules") + + +# --------------------------------------------------------------------------- +# The rules +# --------------------------------------------------------------------------- + +def build(signals: Signals, *, agent_name: str, now: datetime, + can_configure: bool) -> List[Tuple[Suggestion, str]]: + """Every eligible suggestion for this viewer, ranked, with its fingerprint. + + Not truncated and not filtered by dismissals — the caller does both, so the + feedback path can check a key against the full eligible set. + """ + items: Dict[str, List[Tuple[Suggestion, str]]] = {k: [] for k in _ORDER} + + def add(cls: str, s: Suggestion, fp: str) -> None: + items[cls].append((s, fp)) + + # -- waiting on the viewer ------------------------------------------------ + if signals.ask_ids: + n = len(signals.ask_ids) + add("asks", Suggestion( + key="asks", kind="invoke", source="asks", door="platform", + title="Answer what this agent asked you", + signal=f"{_plural(n, 'question')} waiting on you", + action=SuggestionAction(type="open_section", value="asks"), + ), _hash(signals.ask_ids)) + + if signals.decisions_due_ids: + n = len(signals.decisions_due_ids) + add("decisions_due", Suggestion( + key="decisions_due", kind="invoke", source="decisions", door="platform", + title="Review decisions past their date", + signal=f"{_plural(n, 'decision')} past the review date", + action=SuggestionAction(type="open_section", value="decisions"), + ), _hash(signals.decisions_due_ids)) + + # -- schedule health (owner / admin only) --------------------------------- + if can_configure: + live = list(signals.schedules) + enabled = [s for s in live if s.get("enabled")] + failing_ids = set() + for s in enabled: + count, newest, first = failure_streak(signals.runs.get(s["id"], ())) + if count >= FAILING_STREAK: + failing_ids.add(s["id"]) + when = _parse(newest.get("started_at")) + add("schedule_failing", Suggestion( + key=f"schedule_failing:{s['id']}", kind="configure", source="schedules", + door="owner_or_admin", + title=f"Check “{_cap(s.get('name') or 'schedule')}”", + signal=f"Failed {count} runs in a row" + (f" · last {_day(when)}" if when else ""), + action=_link(agent_name), + ), str(first.get("id"))) + + if not signals.autonomy_enabled and (enabled or signals.overdue_reminders): + parts = [] + if enabled: + parts.append(f"{_plural(len(enabled), 'schedule')} won't run") + if signals.overdue_reminders: + parts.append(f"{_plural(signals.overdue_reminders, 'reminder')} held") + last = max((d for d in (_parse(s.get("last_run_at")) for s in live) if d), default=None) + add("autonomy_held", Suggestion( + key="autonomy_held", kind="configure", source="schedules", door="owner_or_admin", + title="Turn on autonomy, or pause these schedules", + signal=" and ".join(parts) + " — autonomy is off" + + (f" · last run {_day(last)}" if last else ""), + action=_link(agent_name), + ), _hash([s["id"] for s in enabled] + [f"reminders:{bool(signals.overdue_reminders)}"])) + + if signals.autonomy_enabled: + for s in enabled: + if s.get("last_run_at") or s["id"] in failing_ids: + continue + created = _parse(s.get("created_at")) + if not created: + continue + due = first_expected_fire(s.get("cron_expression") or "", s.get("timezone"), created) + if due is None or now - due < NEVER_FIRED_GRACE: + continue + add("schedule_never_fired", Suggestion( + key=f"schedule_never_fired:{s['id']}", kind="configure", source="schedules", + door="owner_or_admin", + title=f"Check “{_cap(s.get('name') or 'schedule')}”", + signal=f"Enabled since {_day(created)} · has never run", + action=_link(agent_name), + ), s["id"]) + + for s in live: + if s.get("enabled") or not s.get("last_run_at"): + continue + since = _parse(s.get("updated_at")) + if not since or now - since < timedelta(days=DISABLED_DAYS): + continue + add("schedule_disabled", Suggestion( + key=f"schedule_disabled:{s['id']}", kind="configure", source="schedules", + door="owner_or_admin", + title=f"Re-enable or delete “{_cap(s.get('name') or 'schedule')}”", + signal=f"Disabled since {_day(since)} · it used to run", + action=_link(agent_name), + ), str(s.get("updated_at"))) + + # -- the viewer's own use ------------------------------------------------- + last = _parse(signals.last_user_message_at) + if last and now - last >= timedelta(days=DORMANT_DAYS): + add("dormant", Suggestion( + key="dormant", kind="invoke", source="usage", door="platform", + title="Pick up where you left off", + signal=f"Your last conversation was {_day(last)}", + action=SuggestionAction(type="open_chat"), + ), str(signals.last_user_message_at)) + + scheduled = {slash_name(s.get("message")) for s in signals.schedules if s.get("enabled")} + used = set(signals.used_playbooks) | {n for n in scheduled if n} + for p in (signals.playbooks or ())[:]: + if len(items["unused_playbook"]) >= MAX_UNUSED_PLAYBOOKS: + break + if p.name.lower() in used: + continue + add("unused_playbook", Suggestion( + key=f"unused_playbook:{p.name}", kind="invoke", source="capability", door="platform", + title=p.title, signal=f"You haven't run /{p.name} yet", description=p.description, + action=SuggestionAction(type="prefill", value=f"/{p.name} "), + ), p.name) + + return [pair for cls in _ORDER for pair in items[cls]] + + +def shape(pairs: List[Tuple[Suggestion, str]], dismissed: Dict[str, str], *, + agent_name: str, playbooks, has_history: bool) -> PortalSuggestions: + """Apply dismissals and the display cap; state what is and isn't known.""" + visible = [s for s, fp in pairs if dismissed.get(s.key) != fp] + if playbooks is None: + capabilities = "unavailable" + else: + capabilities = "available" if playbooks else "none" + return PortalSuggestions( + agent_name=agent_name, + suggestions=visible[:MAX_SHOWN], + total=len(visible), + capabilities=capabilities, + basis="history" if has_history else "capabilities_only", + ) + + +# --------------------------------------------------------------------------- +# Signal gathering +# --------------------------------------------------------------------------- + +_briefing_cache: Dict[str, Tuple[float, Optional[List[Playbook]]]] = {} + + +async def _playbooks(agent_name: str) -> Optional[List[Playbook]]: + """The agent's exposed playbooks via the ONE briefing path (ent#380), or None + when the agent did not answer. Availability is resolved first so a stopped + agent never pays a connect failure or the 3 s budget (#2196).""" + hit = _briefing_cache.get(agent_name) + if hit and time.monotonic() - hit[0] < BRIEFING_TTL_SECONDS: + return hit[1] + from client_portal import service as portal + + availability = (await portal._availability_map([agent_name])).get(agent_name, "unknown") + briefing, ok = await portal._bounded_briefing(agent_name, availability) + result = playbooks_from_briefing(briefing.playbooks) if ok else None + if len(_briefing_cache) >= BRIEFING_CACHE_MAX: + # Bounded: drop entries past their TTL first, then the oldest. + cutoff = time.monotonic() - BRIEFING_TTL_SECONDS + for k in [k for k, (t, _) in _briefing_cache.items() if t < cutoff] or [min(_briefing_cache, key=lambda k: _briefing_cache[k][0])]: + _briefing_cache.pop(k, None) + _briefing_cache[agent_name] = (time.monotonic(), result) + return result + + +def _gather(agent_name: str, email: str, now: datetime, can_configure: bool) -> dict: + """The DB half of the signals. Each read degrades on its own — a failing + source costs its items, never the list.""" + from database import db + from services.seat_decision_service import effective_status + + from client_portal.asks.service import list_asks + + since = (now - timedelta(days=LOOKBACK_DAYS)).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + now_iso = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + out: dict = {} + + def attempt(name, fn, default): + try: + out[name] = fn() + except Exception: # noqa: BLE001 + logger.warning("[ent#465] suggestions: %s read failed for %s", name, agent_name, exc_info=True) + out[name] = default + + attempt("ask_ids", lambda: [a.id for a in list_asks(email, True, agent_name) if a.status == "pending"], []) + attempt("decisions_due_ids", lambda: [ + r["id"] for r in db.list_seat_decisions(agent_name, email, limit=500) + if effective_status(r, today=now.date()) == "expired" + ], []) + attempt("schedules", lambda: sdb.list_schedules(agent_name), []) + attempt("last_user_message_at", lambda: sdb.last_user_message_at(agent_name, email), None) + attempt("used_playbooks", lambda: frozenset( + n for n in (slash_name(t) for t in sdb.slash_texts_by_viewer(agent_name, email, since)) if n + ), frozenset()) + attempt("dismissed", lambda: sdb.dismissed_fingerprints(agent_name, email), {}) + attempt("has_runs", lambda: sdb.viewer_has_runs(agent_name, email, since), False) + if can_configure: + attempt("autonomy_enabled", lambda: bool(db.get_autonomy_enabled(agent_name)), True) + attempt("runs", lambda: sdb.recent_runs_by_schedule( + agent_name, since, RUNS_PER_SCHEDULE, + [x["id"] for x in out.get("schedules", []) if x.get("enabled")], + ), {}) + attempt("overdue_reminders", lambda: sdb.count_overdue_reminders(agent_name, now_iso), 0) + return out + + +def can_configure(email: str, agent_name: str, is_admin: bool) -> bool: + from client_portal import service as portal + + return bool(is_admin) or portal.portal_owns_agent(email, agent_name, True) + + +async def _compute(agent_name: str, email: str, is_admin: bool, now: Optional[datetime]): + now = now or datetime.now(timezone.utc) + configure = can_configure(email, agent_name, is_admin) + gathered, playbooks = await asyncio.gather( + asyncio.to_thread(_gather, agent_name, email, now, configure), + _playbooks(agent_name), + ) + dismissed = gathered.pop("dismissed", {}) + has_runs = gathered.pop("has_runs", False) + signals = Signals(playbooks=playbooks, **gathered) + pairs = build(signals, agent_name=agent_name, now=now, can_configure=configure) + # History = the viewer's own Workspace messages OR runs attributed to them + # (operator chat, MCP) — an owner who only uses the console has history. + has_history = bool(signals.last_user_message_at) or bool(has_runs) + return pairs, dismissed, signals, has_history + + +async def get_suggestions(agent_name: str, email: str, *, is_admin: bool, + now: Optional[datetime] = None) -> PortalSuggestions: + pairs, dismissed, signals, has_history = await _compute(agent_name, email, is_admin, now) + return shape(pairs, dismissed, agent_name=agent_name, playbooks=signals.playbooks, + has_history=has_history) + + +async def record_feedback(agent_name: str, email: str, *, is_admin: bool, key: str, + action: str, now: Optional[datetime] = None) -> None: + """Record an accept or dismiss for a suggestion CURRENTLY emitted for this + viewer. Anything else is a 404 with nothing written — the write is bounded + to real items and the fingerprint is the server's, never the client's.""" + if key.split(":", 1)[0] not in KEY_CLASSES: + raise SuggestionError(422, "unknown suggestion key") + pairs, _, _, _ = await _compute(agent_name, email, is_admin, now) + match = next(((s, fp) for s, fp in pairs if s.key == key), None) + if match is None: + raise SuggestionError(404, "Suggestion not found") + s, fp = match + from utils.helpers import utc_now_iso + + await asyncio.to_thread( + sdb.record_feedback, email=email, agent_name=agent_name, key=key, + source=s.source, action=action, fingerprint=fp, now=utc_now_iso(), + ) diff --git a/src/backend/db/agent_cleanup.py b/src/backend/db/agent_cleanup.py index cd0f77d4b..10916be10 100644 --- a/src/backend/db/agent_cleanup.py +++ b/src/backend/db/agent_cleanup.py @@ -187,6 +187,8 @@ class AgentRef: # keyed on a purged share id would be orphaned forever with nothing left # to join it back to. AgentRef("portal_file_dismissals", "agent_name", Policy.CASCADE), + # ent#465 — Workspace suggestion accept/dismiss rows name the agent. + AgentRef("workspace_suggestion_feedback", "agent_name", Policy.CASCADE), # --- Public links and chained tables ----------------------------------- # Order: chained tables before agent_public_links so the link rows diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py index 113d5eecd..e6d412724 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -4693,6 +4693,41 @@ def _migrate_agent_capability_grants(cursor, conn): conn.commit() +def _migrate_workspace_suggestion_feedback_table(cursor, conn): + """Accept/dismiss of a Workspace suggestion (trinity-enterprise#465). + + Per viewer + agent + suggestion key. A dismissal holds while the suggestion's + state fingerprint is unchanged (`dismissed_fingerprint`); an accept is + counted for usefulness and never hides anything. Named generically, with a + `surface` column, so the post-action next-step tier shares this one + dismissal model rather than growing a second table. Additive only. + + Mirrored by the Alembic revision 0078_workspace_suggestion_feedback. + """ + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS workspace_suggestion_feedback ( + client_email TEXT NOT NULL, + agent_name TEXT NOT NULL, + suggestion_key TEXT NOT NULL, + surface TEXT NOT NULL DEFAULT 'agent', + source TEXT, + dismissed_at TEXT, + dismissed_fingerprint TEXT, + accepted_at TEXT, + accept_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (client_email, agent_name, suggestion_key) + ) + """ + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_workspace_suggestion_feedback_agent " + "ON workspace_suggestion_feedback(agent_name)" + ) + conn.commit() + + def _migrate_role_readiness_rollout_seed(cursor, conn): """The readiness gate's rollout (trinity-enterprise#689), data only. @@ -4903,4 +4938,5 @@ def _migrate_auto_sync_enabled_backfill(cursor, conn): ("auto_sync_enabled_backfill", _migrate_auto_sync_enabled_backfill), ("operator_queue_ask_object", _migrate_operator_queue_ask_object), ("execution_chain_depth", _migrate_execution_chain_depth), + ("workspace_suggestion_feedback_table", _migrate_workspace_suggestion_feedback_table), ] diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py index bb3995ced..9a9235f44 100644 --- a/src/backend/db/schema.py +++ b/src/backend/db/schema.py @@ -914,6 +914,28 @@ ) """, + # trinity-enterprise#465 — accept/dismiss of a Workspace suggestion, per + # viewer + agent + suggestion. Generic name + `surface` so the post-action + # next-step tier (OP-5) shares one dismissal model instead of growing a + # second table. `dismissed_fingerprint` is the STATE the viewer dismissed; + # the item returns when the state changes. `agent_name` makes the row follow + # the agent's lifecycle (`AGENT_REFS`, CASCADE). + "workspace_suggestion_feedback": """ + CREATE TABLE IF NOT EXISTS workspace_suggestion_feedback ( + client_email TEXT NOT NULL, + agent_name TEXT NOT NULL, + suggestion_key TEXT NOT NULL, + surface TEXT NOT NULL DEFAULT 'agent', + source TEXT, + dismissed_at TEXT, + dismissed_fingerprint TEXT, + accepted_at TEXT, + accept_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (client_email, agent_name, suggestion_key) + ) + """, + # ------------------------------------------------------------------------- # Settings Tables # ------------------------------------------------------------------------- @@ -2083,6 +2105,10 @@ # this one is for the sweeper, which purges by the share id. "CREATE INDEX IF NOT EXISTS idx_portal_file_dismissals_file " "ON portal_file_dismissals(file_id)", + # ent#465 — the PK leads with `client_email` (the read); this one serves the + # per-agent cascade delete. + "CREATE INDEX IF NOT EXISTS idx_workspace_suggestion_feedback_agent " + "ON workspace_suggestion_feedback(agent_name)", # Public links indexes "CREATE INDEX IF NOT EXISTS idx_public_links_token ON agent_public_links(token)", diff --git a/src/backend/db/tables.py b/src/backend/db/tables.py index c2157b5df..351e0147b 100644 --- a/src/backend/db/tables.py +++ b/src/backend/db/tables.py @@ -811,6 +811,21 @@ def process_bind_param(self, value, dialect): Column("dismissed_at", Text), ) +workspace_suggestion_feedback = Table( + "workspace_suggestion_feedback", + metadata, + Column("client_email", Text, primary_key=True), + Column("agent_name", Text, primary_key=True), + Column("suggestion_key", Text, primary_key=True), + Column("surface", Text), + Column("source", Text), + Column("dismissed_at", Text), + Column("dismissed_fingerprint", Text), + Column("accepted_at", Text), + Column("accept_count", Integer), + Column("updated_at", Text), +) + system_settings = Table( "system_settings", metadata, diff --git a/src/backend/main.py b/src/backend/main.py index 25df47335..0e22a28a2 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -133,6 +133,7 @@ # keeps the move reviewable as a move. from client_portal.asks.router import router as portal_asks_router from client_portal.work.router import router as portal_work_router +from client_portal.suggestions.router import router as portal_suggestions_router from client_portal.router import router as client_portal_router from shared_sessions.router import budget_router as room_budget_router from shared_sessions.router import router as rooms_router @@ -1388,6 +1389,8 @@ async def add_security_headers(request: Request, call_next): # (trinity-enterprise#525, the visual half of ent#457). Same prefix, same # transition rule as the asks router above; platform-door only inside. app.include_router(portal_work_router) +# trinity-enterprise#465 — Workspace suggestions: per-viewer, per-agent, platform door only. +app.include_router(portal_suggestions_router) # #847 Phase 0 — Enterprise modules (closed-source companion submodule diff --git a/src/backend/migrations/versions/0078_workspace_suggestion_feedback.py b/src/backend/migrations/versions/0078_workspace_suggestion_feedback.py new file mode 100644 index 000000000..65b444959 --- /dev/null +++ b/src/backend/migrations/versions/0078_workspace_suggestion_feedback.py @@ -0,0 +1,50 @@ +"""workspace_suggestion_feedback — accept/dismiss of a Workspace suggestion. + +PostgreSQL half of the dual-track pair (trinity-enterprise#465); the SQLite half +is ``db/migrations.py::workspace_suggestion_feedback_table``. + +One row per viewer + agent + suggestion key. A dismissal holds while the +suggestion's state fingerprint is unchanged; an accept is counted for +usefulness. ``agent_name`` is a CASCADE entry in ``db/agent_cleanup.py``. + +Revision ID: 0078_workspace_suggestion_feedback +Revises: 0077_execution_chain_depth +""" +from alembic import op +import sqlalchemy as sa + +revision = "0078_workspace_suggestion_feedback" +down_revision = "0077_execution_chain_depth" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # A fresh PostgreSQL database is built from `db/schema.py`'s DDL — which + # declares this table — and only THEN runs the revisions, so guard on + # existence rather than create blindly (0046-0058's rule). + bind = op.get_bind() + if not sa.inspect(bind).has_table("workspace_suggestion_feedback"): + op.create_table( + "workspace_suggestion_feedback", + sa.Column("client_email", sa.Text(), nullable=False), + sa.Column("agent_name", sa.Text(), nullable=False), + sa.Column("suggestion_key", sa.Text(), nullable=False), + sa.Column("surface", sa.Text(), nullable=False, server_default=sa.text("'agent'")), + sa.Column("source", sa.Text(), nullable=True), + sa.Column("dismissed_at", sa.Text(), nullable=True), + sa.Column("dismissed_fingerprint", sa.Text(), nullable=True), + sa.Column("accepted_at", sa.Text(), nullable=True), + sa.Column("accept_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("updated_at", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("client_email", "agent_name", "suggestion_key"), + ) + op.execute( + "CREATE INDEX IF NOT EXISTS idx_workspace_suggestion_feedback_agent " + "ON workspace_suggestion_feedback(agent_name)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_workspace_suggestion_feedback_agent") + op.execute("DROP TABLE IF EXISTS workspace_suggestion_feedback") diff --git a/src/frontend/src/components/portal/PortalAgentDetails.vue b/src/frontend/src/components/portal/PortalAgentDetails.vue index 72fcd2bca..7c917be0e 100644 --- a/src/frontend/src/components/portal/PortalAgentDetails.vue +++ b/src/frontend/src/components/portal/PortalAgentDetails.vue @@ -55,6 +55,16 @@

{{ header.description }}

+ + + @@ -184,7 +194,9 @@ - +
+ +
@@ -206,6 +218,7 @@ import PortalAvatar from './PortalAvatar.vue' import PortalAgentMemory from './PortalAgentMemory.vue' import PortalAgentRole from './PortalAgentRole.vue' import PortalAgentDecisions from './PortalAgentDecisions.vue' +import PortalSuggestions from './PortalSuggestions.vue' import { agentDisplayName } from '@/utils/agentName' import { availabilityChip, threadTitle, MAIN_TAB_LABEL } from './portalUtils' import { usePortalAgentPage } from '@/composables/usePortalAgentPage' @@ -219,10 +232,20 @@ const props = defineProps({ // control, not a button this body owns — a second X inside the column would // be a second way to do one thing, and the two would disagree about whether // the rail is shut or merely on another tab. -defineEmits(['open-thread', 'use-playbook']) +// ent#465: `open-rail-tab` / `focus-composer` carry a suggestion's Accept to +// the shell, which owns the rail and the composer. +const emit = defineEmits(['open-thread', 'use-playbook', 'open-rail-tab', 'focus-composer']) const store = useClientPortalStore() const openReport = ref(null) +const decisionsEl = ref(null) + +// ent#465: a suggestion's "Show …". Decisions live in this panel, so scroll to +// them; asks are answered in the Work tab's "Waiting on you". +function openSection(name) { + if (name === 'decisions') decisionsEl.value?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }) + else if (name === 'asks') emit('open-rail-tab', 'work') +} // The window is fixed here: this panel shows no windowed figure, and a second // selector disagreeing with the band's would be two controls for one fact. diff --git a/src/frontend/src/components/portal/PortalBriefing.vue b/src/frontend/src/components/portal/PortalBriefing.vue index 5aea7178d..d9a2d7fa8 100644 --- a/src/frontend/src/components/portal/PortalBriefing.vue +++ b/src/frontend/src/components/portal/PortalBriefing.vue @@ -34,6 +34,11 @@ rather than adding a second keeps the raw-gray count flat. -->

{{ fallbackLine }}

+ + + +
+

Suggested for you

+ +
+
+ Loading suggestions… +
+ + + + +
+ + + diff --git a/src/frontend/src/components/portal/portalRail.js b/src/frontend/src/components/portal/portalRail.js index a2604ece3..a456ebd05 100644 --- a/src/frontend/src/components/portal/portalRail.js +++ b/src/frontend/src/components/portal/portalRail.js @@ -199,17 +199,23 @@ export const RAIL_TABS = Object.freeze([ // rail's column; false once it IS the column's content. Collapsing the // rail dismisses it, and the rail already remembers the tab you left. // - // A STATIC tab — the third registry shape. No signal (nothing about an agent's - // context is an event) and no empty state (an agent always has a name, a - // health state and a chat list, so the body always renders and the rail's - // generic empty branch is unreachable). Both are declared as absent rather - // than filled with a plausible value; see RAIL_SIGNAL_NONE. + // No empty state (an agent always has a name, a health state and a chat + // list, so the body always renders and the rail's generic empty branch is + // unreachable) — declared absent rather than filled with a plausible value. + // + // ent#465 amends ent#547's "static tab": Info now carries the `updated` dot + // while suggestions are waiting for you in it (the shell writes an `info` + // entry with a `note`, "2 suggestions", which the tooltip and the mobile + // strip read instead of "updated"). The dot is the rail's own vocabulary — + // one shape, one hue (principle 24) — rather than a numeric badge the design + // pass never defined. A signalling tab without an empty state is therefore a + // legal registry shape; `RAIL_SIGNAL_NONE` stays for a tab with neither. Object.freeze({ id: 'info', label: 'Info', door: RAIL_DOORS.SOLO_AGENT, scope: RAIL_SCOPE_PARTICIPANTS, - signal: RAIL_SIGNAL_NONE, + signal: RAIL_SIGNAL_UPDATED, icon: 'info', empty: null, }), @@ -348,7 +354,12 @@ export function signalFor(signals, tab) { if (!raw || typeof raw !== 'object') return emptySignal() const live = Number.isInteger(raw.live) && raw.live > 0 ? raw.live : 0 const agents = participantList(raw.agents) - return { live, updated: raw.updated === true, agents } + const out = { live, updated: raw.updated === true, agents } + // ent#465: an optional short phrase that replaces "updated" in words + // ("2 suggestions"). Carried only when present, so every existing signal + // keeps its exact shape. + if (typeof raw.note === 'string' && raw.note) out.note = raw.note + return out } /** Which shape the tab wears: live outranks updated; null = no dot. */ @@ -364,7 +375,7 @@ export function railTitle(tab, sig) { const label = tab && tab.label ? tab.label : '' const shape = signalShape(sig) if (shape === RAIL_SIGNAL_LIVE) return `${label} · ${sig.live} running` - if (shape === RAIL_SIGNAL_UPDATED) return `${label} · updated` + if (shape === RAIL_SIGNAL_UPDATED) return `${label} · ${sig.note || 'updated'}` return label } @@ -383,6 +394,7 @@ export function collapsedSignals(signals, visible) { live: sig.live, updated: sig.updated, agents: sig.agents, + note: sig.note, shape: signalShape(sig), title: railTitle(tab, sig), } @@ -405,7 +417,9 @@ export function stripSegments(signals, visible) { return signalled.map((s) => ({ id: s.id, shape: s.shape, - text: s.shape === RAIL_SIGNAL_LIVE ? `${s.label} · ${s.live} running` : `${s.label} updated`, + text: s.shape === RAIL_SIGNAL_LIVE + ? `${s.label} · ${s.live} running` + : (s.note ? `${s.label} · ${s.note}` : `${s.label} updated`), })) } return (Array.isArray(visible) ? visible : []).map((t) => ({ id: t.id, shape: null, text: t.label })) diff --git a/src/frontend/src/stores/clientPortal.js b/src/frontend/src/stores/clientPortal.js index 80c080853..5ee495ed6 100644 --- a/src/frontend/src/stores/clientPortal.js +++ b/src/frontend/src/stores/clientPortal.js @@ -100,6 +100,10 @@ function writeSuppressed(on) { // `axios.defaults` and a suppressed session must carry no Authorization). export const portalHttp = axios.create() +// ent#465 — the suggestions read is reused this long across the two +// placements that mount it together (the Info tab and the empty chat). +export const SUGGESTIONS_FRESH_MS = 60_000 + // #2261 — what to do when a workspace request 401s while the workspace session // IS the platform session (ent#357's operator case). // @@ -396,6 +400,19 @@ export const useClientPortalStore = defineStore('clientPortal', { decisionsError: null, decisionError: null, // {code, message, fields:{}} of the failed verb decisionBusy: null, // 'record' | decision id in flight + // ent#465 — suggestions for one viewer + one agent (platform door only). + // Same generation guard. Both placements (the Info tab and the empty chat) + // read this one slice, so a dismiss in one is gone from the other. + suggestionsAgent: null, + _suggestionsGeneration: 0, + suggestions: null, // PortalSuggestions + suggestionsLoaded: false, + suggestionsError: null, + suggestionsFetchedAt: 0, + suggestionError: null, // {key, message} of the failed dismiss + _suggestionsInFlight: null, // the running load, shared by both placements + _suggestionsLoadSeq: 0, // only the LATEST load may write + _suggestionsDismissed: [], // keys dismissed since the last load STARTED reportPayloads: {}, // id -> {total, loaded}; present only for a payload the server actually // windowed, so a bounded document never renders a paging footer. @@ -1086,6 +1103,119 @@ export const useClientPortalStore = defineStore('clientPortal', { } }, + // ---- ent#465: suggestions ------------------------------------------ + + resetAgentSuggestions(agentName = null) { + this._suggestionsGeneration += 1 + this.suggestionsAgent = agentName + this.suggestions = null + this.suggestionsLoaded = false + this.suggestionsError = null + this.suggestionsFetchedAt = 0 + this.suggestionError = null + this._suggestionsInFlight = null + this._suggestionsDismissed = [] + }, + + /** + * Load (or reuse, when fresh) this agent's suggestions. Two placements + * mount at once, so a fetch younger than `SUGGESTIONS_FRESH_MS` is reused + * rather than repeated; `force` bypasses it. + */ + async loadAgentSuggestions(agentName, { force = false } = {}) { + if (this.suggestionsAgent !== agentName) this.resetAgentSuggestions(agentName) + if (!force && this.suggestionsLoaded && Date.now() - this.suggestionsFetchedAt < SUGGESTIONS_FRESH_MS) return + // Both placements mount together: the second caller joins the first + // request instead of sending (and making the agent answer) its own. + if (!force && this._suggestionsInFlight) return this._suggestionsInFlight + const gen = this._suggestionsGeneration + const seq = ++this._suggestionsLoadSeq + this.suggestionsError = null + this._suggestionsDismissed = [] + // Superseded: an older load answering after a newer (forced) one must not + // overwrite it — same generation, so the agent guard alone cannot tell. + const stale = () => gen !== this._suggestionsGeneration || seq !== this._suggestionsLoadSeq + const run = (async () => { + try { + const { data } = await portalHttp.get( + `/api/enterprise/client-portal/agents/${encodeURIComponent(agentName)}/suggestions`, + { headers: this.authHeader }, + ) + if (stale()) return + // A dismiss that landed while this load was in flight must not come + // back with it (the server may have answered before the write). + const gone = new Set(this._suggestionsDismissed) + const list = (data.suggestions || []).filter((x) => !gone.has(x.key)) + this.suggestions = { ...data, suggestions: list, total: Math.max(0, (data.total || 0) - ((data.suggestions || []).length - list.length)) } + this.suggestionsLoaded = true + this.suggestionsFetchedAt = Date.now() + } catch { + if (stale()) return + this.suggestionsError = "Couldn't load suggestions. Check your connection and try again." + } finally { + if (gen === this._suggestionsGeneration && this._suggestionsInFlight === run) this._suggestionsInFlight = null + } + })() + this._suggestionsInFlight = run + return run + }, + + /** + * Dismiss: hidden at once, restored with the error beside it if the write + * fails (design system p18 — a verb's failure sits next to its control). + */ + async dismissSuggestion(agentName, key) { + const gen = this._suggestionsGeneration + const list = this.suggestions?.suggestions || [] + const index = list.findIndex((s) => s.key === key) + const removed = index >= 0 ? list[index] : null + this.suggestionError = null + if (removed) { + this.suggestions = { + ...this.suggestions, + suggestions: list.filter((s) => s.key !== key), + total: Math.max(0, (this.suggestions.total || 0) - 1), + } + } + this._suggestionsDismissed = [...this._suggestionsDismissed, key] + try { + await portalHttp.post( + `/api/enterprise/client-portal/agents/${encodeURIComponent(agentName)}/suggestions/feedback`, + { key, action: 'dismiss' }, { headers: this.authHeader }, + ) + return true + } catch { + if (gen !== this._suggestionsGeneration) return false + this._suggestionsDismissed = this._suggestionsDismissed.filter((k) => k !== key) + // Put back only the item this dismiss removed, into whatever list is + // current now — never a snapshot that a newer load has replaced. + const now = this.suggestions?.suggestions || [] + if (removed && !now.some((s) => s.key === key)) { + const next = now.slice() + next.splice(Math.min(index, next.length), 0, removed) + this.suggestions = { ...this.suggestions, suggestions: next, total: (this.suggestions.total || 0) + 1 } + } + this.suggestionError = { key, message: "Couldn't dismiss that. Try again." } + return false + } + }, + + /** + * Accept is recorded for usefulness only. The action itself (prefill, open, + * link) has already happened on the client, so a failed record is not the + * person's problem and is deliberately not surfaced. + */ + async acceptSuggestion(agentName, key) { + try { + await portalHttp.post( + `/api/enterprise/client-portal/agents/${encodeURIComponent(agentName)}/suggestions/feedback`, + { key, action: 'accept' }, { headers: this.authHeader }, + ) + } catch { /* usefulness data only — see the docblock */ } + }, + + clearSuggestionError() { this.suggestionError = null }, + // ---- ent#638: the seat decision record ------------------------------ resetAgentDecisions(agentName = null) { diff --git a/src/frontend/src/views/Portal.vue b/src/frontend/src/views/Portal.vue index 5b9f38f03..b7b24e8d2 100644 --- a/src/frontend/src/views/Portal.vue +++ b/src/frontend/src/views/Portal.vue @@ -401,7 +401,22 @@ @@ -676,6 +693,8 @@ :threads="threads" @open-thread="(t) => { railSheetOpen = false; openThread(t) }" @use-playbook="(text) => { railSheetOpen = false; usePlaybook(text) }" + @open-rail-tab="openRailOn" + @focus-composer="() => { railSheetOpen = false; focusConversationComposer() }" /> @@ -729,6 +748,7 @@ import { } from '@/components/portal/portalAttachments' import PortalAgentBand from '@/components/portal/PortalAgentBand.vue' import PortalAgentDetails from '@/components/portal/PortalAgentDetails.vue' +import PortalSuggestions from '@/components/portal/PortalSuggestions.vue' import ColumnResizeHandle from '@/components/ColumnResizeHandle.vue' import { useColumnResize } from '@/composables/useColumnResize' import PortalSkeleton from '@/components/portal/PortalSkeleton.vue' @@ -1108,7 +1128,26 @@ const rail = usePortalRailFeeds({ }) // ent#525: the Work signal is store-derived now — the owner merges the // conversation's emit into the feed's running rows BY EXECUTION ID. -const railSignals = computed(() => ({ ...rail.signals.value })) +// ent#465: Info's dot — suggestions waiting for you on the agent on screen. +// Read only when the store slice belongs to that agent (the #2162 rule). +const infoSignal = computed(() => { + const name = activeAgent.value?.name + if (!name || !store.isPlatformSession || store.suggestionsAgent !== name) return null + const n = store.suggestions?.total || 0 + return n > 0 ? { updated: true, note: n === 1 ? '1 suggestion' : `${n} suggestions` } : null +}) +// Loaded by the shell, not only by the bodies that render it: the dot exists +// for the person mid-conversation with the rail collapsed, where neither the +// empty chat nor the Info tab is mounted. Reused for 60 s across placements. +watch( + () => (store.isPlatformSession && !activeRoomIdFromRoute.value ? activeAgent.value?.name : null), + (name) => { if (name) store.loadAgentSuggestions(name) }, + { immediate: true }, +) +const railSignals = computed(() => ({ + ...rail.signals.value, + ...(infoSignal.value ? { info: infoSignal.value } : {}), +})) function setRailOpen(open) { railState.value = { ...railState.value, open } } function setRailTab(tab) { railState.value = { ...railState.value, tab } } @@ -1144,6 +1183,15 @@ function askAboutIt(text) { railSheetOpen.value = false usePlaybook(text) } +// ent#465: a suggestion's Accept. Asks are answered in Work's "Waiting on +// you"; decisions live in the Info tab. `open_chat` puts the caret in the +// composer — never a send. +function openSuggestionSection(name) { + openRailOn(name === 'asks' ? 'work' : 'info') +} +function focusConversationComposer() { + conversationRef.value?.focusComposer?.() +} function onRoomParticipants(list) { roomParticipants.value = Array.isArray(list) ? list : [] } // A signal belongs to the chat that reported it. The conversation is keyed by diff --git a/src/frontend/tests/unit/portalRail.spec.js b/src/frontend/tests/unit/portalRail.spec.js index 5da75dc9d..d0ca35a95 100644 --- a/src/frontend/tests/unit/portalRail.spec.js +++ b/src/frontend/tests/unit/portalRail.spec.js @@ -113,6 +113,11 @@ describe('ent#474 — the tab contract', () => { const isStatic = tab.signal === RAIL_SIGNAL_NONE if (isStatic) { expect(tab.empty, `${tab.id}: a static tab declares no empty state`).toBeNull() + } else if (tab.empty === null) { + // ent#465: a tab whose body always renders may still signal — Info's + // dot says suggestions are waiting in it. It can only borrow the + // "updated" shape; "live" means something is running. + expect(tab.signal, `${tab.id}: a body-always tab signals only "updated"`).toBe(RAIL_SIGNAL_UPDATED) } else { expect([RAIL_SIGNAL_LIVE, RAIL_SIGNAL_UPDATED]).toContain(tab.signal) expect(typeof tab.empty.title).toBe('string') @@ -129,7 +134,7 @@ describe('ent#474 — the tab contract', () => { // ent#547 — Info, the static tab. Its icon must have its OWN entry in // `PortalRail.vue`'s ICONS map; `iconPath` falls back to `bolt` for an // unknown id, so a missing entry is silent and shows Work's glyph. - expect(tab('info')).toMatchObject({ door: RAIL_DOORS.SOLO_AGENT, signal: RAIL_SIGNAL_NONE, icon: 'info' }) + expect(tab('info')).toMatchObject({ door: RAIL_DOORS.SOLO_AGENT, signal: RAIL_SIGNAL_UPDATED, icon: 'info', empty: null }) expect(src('components/portal/PortalRail.vue')).toMatch(/\n\s*info:\s*'M/) // Each teaches its next action (design pass, "Tab contract"). expect(railEmptyCopy(tab('loops'), ['scout'])).toMatchObject({ title: 'No loops running', action: 'Start a loop', event: 'start-loop' }) @@ -294,6 +299,21 @@ describe('ent#474 — the collapsed signal', () => { expect(hasLiveSignal({ work: { live: 1 } }, visibleTabs(RAIL_TABS, PLATFORM))).toBe(true) }) + it('Info lights the updated dot for waiting suggestions and says how many (ent#465)', () => { + const visible = visibleTabs(ALL_TABS, PLATFORM) + const signals = { info: { updated: true, note: '2 suggestions' } } + const info = collapsedSignals(signals, visible).find((s) => s.id === 'info') + expect(info.shape).toBe(RAIL_SIGNAL_UPDATED) + expect(info.title).toBe('Info · 2 suggestions') + expect(stripSegments(signals, visible)).toEqual([ + { id: 'info', shape: RAIL_SIGNAL_UPDATED, text: 'Info · 2 suggestions' }, + ]) + // Without a note the words are the rail's usual ones. + expect(railTitle(tab('canvas'), { live: 0, updated: true, agents: [] })).toBe('Canvas · updated') + // A signal carries `note` only when one was written. + expect(signalFor({ info: { updated: true } }, 'info')).toEqual({ live: 0, updated: true, agents: [] }) + }) + it('the mobile strip says the same thing in words, and names the tabs when there is nothing to say', () => { const visible = visibleTabs(ALL_TABS, PLATFORM) const signals = { work: { live: 1 }, loops: { live: 1 }, canvas: { updated: true }, files: { updated: true } } @@ -844,7 +864,14 @@ describe('ent#475 — the shell owns the feeds (source guards)', () => { // ent#525: the Work signal is store-derived through the owner now (the // emit is merged in by execution id), so the shell no longer spreads it. expect(call).toContain('workEmit: workSignal') - expect(portal).toContain('const railSignals = computed(() => ({ ...rail.signals.value }))') + // ent#465 adds ONE shell-owned entry, Info's suggestions dot; Work stays + // the owner's (no `work:` key is re-spread here). + const sigAt = portal.indexOf('const railSignals = computed(() => ({') + expect(sigAt).toBeGreaterThan(-1) + const sigBody = portal.slice(sigAt, portal.indexOf('}))', sigAt)) + expect(sigBody).toContain('...rail.signals.value,') + expect(sigBody).toContain('{ info: infoSignal.value }') + expect(sigBody).not.toMatch(/\bwork\s*:/) }) it('feeds nothing behind a door, nothing before the stage verdict, and does not blank a room\'s first beat', () => { diff --git a/src/frontend/tests/unit/portalSuggestions.spec.js b/src/frontend/tests/unit/portalSuggestions.spec.js new file mode 100644 index 000000000..0886c444a --- /dev/null +++ b/src/frontend/tests/unit/portalSuggestions.spec.js @@ -0,0 +1,281 @@ +// @vitest-environment jsdom +/** + * trinity-enterprise#465 — Workspace suggestions, mounted. + * + * What only a mount proves: each Accept does its OWN thing and never sends + * (prefill emits text, a section emits its name, a configure item opens the + * operator path in a new tab and nothing else), Dismiss hides at once and + * restores with the error beside it when the write fails, the four states look + * different (loading / failed / nothing-to-suggest / list), and the compact + * placement renders no chrome at all unless it has something to show. + * Mounted against the real store with HTTP mocked (#2918). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.hoisted(() => { + const store = new Map() + globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + } +}) +vi.mock('@/stores/auth', async () => { + const { ref } = await import('vue') + const authed = ref(true) + return { useAuthStore: () => ({ get isAuthenticated() { return authed.value }, get authHeader() { return { Authorization: 'Bearer jwt' } } }) } +}) +vi.mock('axios', () => { + const inst = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), + interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } }, + defaults: { headers: { common: {} } }, + } + return { default: Object.assign(inst, { create: () => inst }) } +}) +vi.mock('@/api', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } })) + +import axios from 'axios' +import { useClientPortalStore } from '@/stores/clientPortal' +import PortalSuggestions from '@/components/portal/PortalSuggestions.vue' + +const AGENT = 'sales-companion' +const URL = `/api/enterprise/client-portal/agents/${AGENT}/suggestions` +const FEEDBACK = `${URL}/feedback` + +function item(over = {}) { + return { + key: 'unused_playbook:weekly-report', kind: 'invoke', source: 'capability', door: 'platform', + title: 'Weekly report', signal: "You haven't run /weekly-report yet", description: 'Summarise the week', + action: { type: 'prefill', value: '/weekly-report ' }, + ...over, + } +} + +function payload(suggestions, over = {}) { + return { agent_name: AGENT, suggestions, total: suggestions.length, capabilities: 'available', basis: 'history', ...over } +} + +function mountIt(props = {}) { + return mount(PortalSuggestions, { props: { agentName: AGENT, ...props } }) +} + +beforeEach(() => { + setActivePinia(createPinia()) + axios.get.mockReset() + axios.post.mockReset() +}) + +describe('PortalSuggestions', () => { + it('shows the evidence and Accept on a playbook prefills, never sends', async () => { + axios.get.mockResolvedValue({ data: payload([item()]) }) + axios.post.mockResolvedValue({ data: { ok: true } }) + const w = mountIt() + await flushPromises() + expect(axios.get).toHaveBeenCalledWith(URL, expect.anything()) + expect(w.get('[data-testid="portal-suggestion-signal"]').text()).toBe("You haven't run /weekly-report yet") + await w.get('[data-testid="portal-suggestion-accept"]').trigger('click') + expect(w.emitted('use-playbook')).toEqual([['/weekly-report ']]) + expect(axios.post).toHaveBeenCalledWith(FEEDBACK, { key: 'unused_playbook:weekly-report', action: 'accept' }, expect.anything()) + // Still listed: accept records usefulness, it does not hide the item. + expect(w.findAll('[data-testid="portal-suggestion"]')).toHaveLength(1) + }) + + it('a section item emits its name; open_chat emits open-chat', async () => { + axios.get.mockResolvedValue({ data: payload([ + item({ key: 'asks', source: 'asks', title: 'Answer what this agent asked you', signal: '2 questions waiting on you', action: { type: 'open_section', value: 'asks' } }), + item({ key: 'dormant', source: 'usage', title: 'Pick up where you left off', signal: 'Your last conversation was Sep 3', action: { type: 'open_chat', value: null } }), + ]) }) + axios.post.mockResolvedValue({ data: { ok: true } }) + const w = mountIt() + await flushPromises() + const accepts = w.findAll('[data-testid="portal-suggestion-accept"]') + expect(accepts[0].text()).toBe('Show questions') + await accepts[0].trigger('click') + await accepts[1].trigger('click') + expect(w.emitted('open-section')).toEqual([['asks']]) + expect(w.emitted('open-chat')).toHaveLength(1) + expect(w.emitted('use-playbook')).toBeUndefined() + }) + + it('a configure item opens the operator path in a new tab, and only an operator path', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + axios.get.mockResolvedValue({ data: payload([ + item({ key: 'autonomy_held', kind: 'configure', source: 'schedules', door: 'owner_or_admin', + title: 'Turn on autonomy, or pause these schedules', signal: "2 schedules won't run — autonomy is off", + action: { type: 'link', value: `/agents/${AGENT}?tab=schedules` } }), + item({ key: 'schedule_failing:x', kind: 'configure', source: 'schedules', door: 'owner_or_admin', + title: 'Check it', signal: 'Failed 3 runs in a row', action: { type: 'link', value: 'https://evil.example.com' } }), + ]) }) + axios.post.mockResolvedValue({ data: { ok: true } }) + const w = mountIt() + await flushPromises() + const accepts = w.findAll('[data-testid="portal-suggestion-accept"]') + expect(accepts[0].text()).toBe('Open schedules') + await accepts[0].trigger('click') + await accepts[1].trigger('click') + expect(open).toHaveBeenCalledTimes(1) + expect(open).toHaveBeenCalledWith(`/agents/${AGENT}?tab=schedules`, '_blank', 'noopener') + expect(w.emitted('use-playbook')).toBeUndefined() + open.mockRestore() + }) + + it('dismiss hides at once; a failed write restores it with the error beside it', async () => { + axios.get.mockResolvedValue({ data: payload([item(), item({ key: 'unused_playbook:triage', title: 'Triage', signal: "You haven't run /triage yet" })]) }) + let reject + axios.post.mockImplementation(() => new Promise((_, r) => { reject = r })) + const w = mountIt() + await flushPromises() + await w.findAll('[data-testid="portal-suggestion-dismiss"]')[0].trigger('click') + expect(w.findAll('[data-testid="portal-suggestion"]').map((n) => n.attributes('data-key'))).toEqual(['unused_playbook:triage']) + reject(new Error('boom')) + await flushPromises() + const rows = w.findAll('[data-testid="portal-suggestion"]') + expect(rows.map((n) => n.attributes('data-key'))).toEqual(['unused_playbook:weekly-report', 'unused_playbook:triage']) + expect(rows[0].text()).toContain("Couldn't dismiss that") + }) + + it('a successful dismiss stays gone and records the dismiss', async () => { + axios.get.mockResolvedValue({ data: payload([item()]) }) + axios.post.mockResolvedValue({ data: { ok: true } }) + const w = mountIt() + await flushPromises() + await w.get('[data-testid="portal-suggestion-dismiss"]').trigger('click') + await flushPromises() + expect(w.findAll('[data-testid="portal-suggestion"]')).toHaveLength(0) + expect(w.find('[data-testid="portal-suggestions-empty"]').exists()).toBe(true) + expect(axios.post).toHaveBeenCalledWith(FEEDBACK, { key: 'unused_playbook:weekly-report', action: 'dismiss' }, expect.anything()) + }) + + it('loading, failed and nothing-to-suggest are three different renders', async () => { + let resolve + axios.get.mockImplementationOnce(() => new Promise((r) => { resolve = r })) + const w = mountIt() + expect(w.find('[aria-busy="true"]').exists()).toBe(true) + resolve({ data: payload([]) }) + await flushPromises() + expect(w.find('[aria-busy="true"]').exists()).toBe(false) + expect(w.get('[data-testid="portal-suggestions-empty"]').text()).toBe('Nothing to suggest right now.') + + setActivePinia(createPinia()) + axios.get.mockRejectedValueOnce(new Error('down')) + const f = mountIt() + await flushPromises() + expect(f.text()).toContain("Couldn't load suggestions") + expect(f.find('[data-testid="portal-suggestions-empty"]').exists()).toBe(false) + }) + + it('says when the list can only be capabilities, and when capabilities are unknown', async () => { + axios.get.mockResolvedValue({ data: payload([item()], { basis: 'capabilities_only', capabilities: 'unavailable' }) }) + const w = mountIt() + await flushPromises() + expect(w.find('[data-testid="portal-suggestions-basis"]').exists()).toBe(true) + expect(w.find('[data-testid="portal-suggestions-capabilities"]').exists()).toBe(true) + }) + + it('never claims "capabilities only" over a list with no capability item (found live on ops-watcher)', async () => { + axios.get.mockResolvedValue({ data: payload([ + item({ key: 'autonomy_held', kind: 'configure', source: 'schedules', door: 'owner_or_admin', + title: 'Turn on autonomy, or pause these schedules', signal: "2 schedules won't run — autonomy is off", + action: { type: 'link', value: `/agents/${AGENT}?tab=schedules` } }), + ], { basis: 'capabilities_only', capabilities: 'none' }) }) + const w = mountIt() + await flushPromises() + expect(w.find('[data-testid="portal-suggestions-basis"]').exists()).toBe(false) + }) + + it('compact renders nothing unless there is something to show, and respects the limit', async () => { + axios.get.mockResolvedValueOnce({ data: payload([]) }) + const empty = mountIt({ compact: true, limit: 3 }) + await flushPromises() + expect(empty.find('[data-testid="portal-suggestions"]').exists()).toBe(false) + + setActivePinia(createPinia()) + axios.get.mockRejectedValueOnce(new Error('down')) + const failed = mountIt({ compact: true, limit: 3 }) + await flushPromises() + expect(failed.find('[data-testid="portal-suggestions"]').exists()).toBe(false) + + setActivePinia(createPinia()) + const many = ['a', 'b', 'c', 'd'].map((n) => item({ key: `unused_playbook:${n}`, title: n.toUpperCase() })) + axios.get.mockResolvedValueOnce({ data: payload(many) }) + const full = mountIt({ compact: true, limit: 3 }) + await flushPromises() + expect(full.findAll('[data-testid="portal-suggestion"]')).toHaveLength(3) + }) + + it('two placements mounting together send ONE request (the in-flight load is shared)', async () => { + let resolve + axios.get.mockImplementationOnce(() => new Promise((r) => { resolve = r })) + const a = mountIt() + const b = mountIt({ compact: true, limit: 3 }) + resolve({ data: payload([item()]) }) + await flushPromises() + expect(axios.get).toHaveBeenCalledTimes(1) + expect(a.findAll('[data-testid="portal-suggestion"]')).toHaveLength(1) + expect(b.findAll('[data-testid="portal-suggestion"]')).toHaveLength(1) + }) + + it('an item dismissed while a reload is in flight does not come back with it', async () => { + axios.get.mockResolvedValueOnce({ data: payload([item()]) }) + axios.post.mockResolvedValue({ data: { ok: true } }) + const w = mountIt() + await flushPromises() + let resolve + axios.get.mockImplementationOnce(() => new Promise((r) => { resolve = r })) + const store = useClientPortalStore() + const reload = store.loadAgentSuggestions(AGENT, { force: true }) + await w.get('[data-testid="portal-suggestion-dismiss"]').trigger('click') + resolve({ data: payload([item()]) }) // the server answered before the write landed + await reload + await flushPromises() + expect(w.findAll('[data-testid="portal-suggestion"]')).toHaveLength(0) + expect(store.suggestions.total).toBe(0) + }) + + it('an older load that answers after a newer one does not overwrite it', async () => { + let first, second + axios.get + .mockImplementationOnce(() => new Promise((r) => { first = r })) + .mockImplementationOnce(() => new Promise((r) => { second = r })) + const store = useClientPortalStore() + const a = store.loadAgentSuggestions(AGENT) + const b = store.loadAgentSuggestions(AGENT, { force: true }) + second({ data: payload([item({ key: 'dormant', source: 'usage', title: 'New', signal: 's', action: { type: 'open_chat' } })]) }) + await b + first({ data: payload([item()]) }) // the stale answer arrives last + await a + expect(store.suggestions.suggestions.map((x) => x.key)).toEqual(['dormant']) + }) + + it('a failed dismiss restores only its item into the CURRENT list', async () => { + axios.get.mockResolvedValueOnce({ data: payload([item()]) }) + let reject + axios.post.mockImplementation(() => new Promise((_, r) => { reject = r })) + const w = mountIt() + await flushPromises() + await w.get('[data-testid="portal-suggestion-dismiss"]').trigger('click') + // A newer load lands in the meantime with a different item. + axios.get.mockResolvedValueOnce({ data: payload([item({ key: 'dormant', source: 'usage', title: 'Pick up', signal: 's', action: { type: 'open_chat' } })]) }) + await useClientPortalStore().loadAgentSuggestions(AGENT, { force: true }) + reject(new Error('boom')) + await flushPromises() + expect(w.findAll('[data-testid="portal-suggestion"]').map((n) => n.attributes('data-key')).sort()) + .toEqual(['dormant', 'unused_playbook:weekly-report']) + }) + + it('two placements share one fetch while it is fresh', async () => { + axios.get.mockResolvedValue({ data: payload([item()]) }) + mountIt() + await flushPromises() + mountIt({ compact: true, limit: 3 }) + await flushPromises() + expect(axios.get).toHaveBeenCalledTimes(1) + const store = useClientPortalStore() + await store.loadAgentSuggestions(AGENT, { force: true }) + expect(axios.get).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/journeys/catalog.yaml b/tests/journeys/catalog.yaml index ff3b09fc0..ad036d213 100644 --- a/tests/journeys/catalog.yaml +++ b/tests/journeys/catalog.yaml @@ -225,3 +225,22 @@ journeys: # J03's first half. Recording it here rather than minting a J11 keeps the # catalog a list of PROMISES; "an agent can be restarted" is a step inside # "I get a useful answer", not something a user asks for on its own. + + - id: J13 + # ent#465 — the issue asked for "J11-recommended-actions"; J11 was taken by + # #2565's brief delivery and J12 by the declared metrics, so this is J13. + # `built: false`: the unit island proves the rules, the SQL and the doors + # (test_ent465_suggestions.py) and the mount proves the verbs + # (portalSuggestions.spec.js); neither is a person opening a real agent + # whose schedule really failed and seeing it named. + issue: [465] + promise: "My agent tells me what I can do with it and what is waiting on me — and only things that are true." + actor: user + lanes: [local] + tier: live-stack + harness: tests/journeys/test_j13_suggestions_journey.py + built: false + owner: trinity + # No catalog invariant covers "a surface computed per viewer stays behind + # the platform door" yet — an empty list, not an invented id. + invariants: [] diff --git a/tests/journeys/test_j13_suggestions_journey.py b/tests/journeys/test_j13_suggestions_journey.py new file mode 100644 index 000000000..732b0b995 --- /dev/null +++ b/tests/journeys/test_j13_suggestions_journey.py @@ -0,0 +1,42 @@ +"""Journey J13: an agent tells me what I can do with it and what is waiting on me (ent#465). + +**Promise:** I open an agent in the Workspace and see a short list made for me — +a question it is waiting on me for, a schedule of mine that keeps failing, a +playbook I have never run — each saying why, each one click away, and nothing +padded in when there is nothing to say. + +The issue asked for "J11-recommended-actions"; J11 and J12 were already taken, +so this ships as **J13**. + +**A skeleton, deliberately.** The rules, the SQL and the doors are proven in +the unit island (`tests/unit/test_ent465_suggestions.py`) and the verbs by a +mount (`src/frontend/tests/unit/portalSuggestions.spec.js`); none of that is a +real schedule failing on a real agent and the owner being told. `strict=True` +is the load-bearing half: if this ever passes by accident the suite goes red. +""" +import pytest + +pytestmark = pytest.mark.journey + + +@pytest.mark.xfail( + strict=True, + reason="J13 harness not built — abilityai/trinity-enterprise#465. The " + "walk needs a running agent whose schedule really fails and a " + "platform user and a magic-link client on the same instance.", +) +def test_j13_suggestions_are_mine_and_true(): + """What this has to assert when it is built, in the user's own terms: + + 1. as the OWNER, create a schedule whose command fails, let it run three + times — `GET .../agents/{name}/suggestions` names it ("Failed 3 runs in + a row"), and its Accept opens `/agents/{name}?tab=schedules`; + 2. dismiss it, let it fail a fourth time — it stays dismissed; let it + succeed and then fail three more times — it is back; + 3. as a SHARED platform user of the same agent — the failing schedule is + not in their list, and their list differs from the owner's; + 4. as a MAGIC-LINK client of the same agent — the endpoint is a 404; + 5. stop the agent — capability items disappear and the response says + capabilities are unavailable, while the schedule item stays. + """ + raise NotImplementedError("J13 harness not built") diff --git a/tests/registry.json b/tests/registry.json index 37d8db304..901aa3140 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -3527,6 +3527,26 @@ "git" ], "description": "Git-sync write access is validated where GitHub enforces it (#2107): probe_push_access runs REAL `git push --dry-run` against a local fake smart-HTTP server — 403 + `Write access to repository not granted.` → denied with GitHub's line verbatim; a valid receive-pack advertisement → ok with nothing POSTed; 5xx / unreachable → transient; the PAT reaches the server as an Authorization header and is never on git's argv; is_push_denied over refusal vs non-refusal texts. Create-path cases (400 before any container, transient non-blocking, source mode not probed) in test_1484_create_agent_characterization.py; the named sync_failing item in test_sync_health_service.py. 20 of 23 red with the fix reverted." + }, + { + "file": "unit/test_ent465_suggestions.py", + "feature": "trinity-enterprise#465", + "added": "2026-09-24", + "categories": [ + "backend", + "workspace" + ], + "description": "Workspace suggestions (ent#465): every rule at its boundary over an injected now (failure streak with skipped/cancelled stepped over, cron-relative never-fired, disabled-that-used-to-run, dormant, capped unused playbooks), autonomy off as ONE item that suppresses never-fired, state-identity fingerprints (no re-nag on another failure, back on a new streak), honest capabilities/basis states, the SQL over a real SQLite file (windowed runs, viewer-scoped usage, accept and dismiss side by side, evict-oldest cap), bounded feedback writes (404 for a key not emitted, 422 for an unknown class, server fingerprint), a stopped agent never calling the briefing, the platform door (portal token 404 on read and write), and is_admin by role AND allowlisted scope." + }, + { + "file": "journeys/test_j13_suggestions_journey.py", + "feature": "abilityai/trinity-enterprise#465", + "added": "2026-09-24", + "categories": [ + "journeys", + "integration" + ], + "description": "Journey J13 (suggestions): my agent tells me what I can do with it and what is waiting on me, and only things that are true. A strict=True xfail skeleton — the rules, SQL and doors are unit-proven and the verbs mount-proven; the walk needs a real failing schedule, a shared platform user and a magic-link client on one instance." } ] } diff --git a/tests/unit/test_2198_portal_batch_sessions.py b/tests/unit/test_2198_portal_batch_sessions.py index 76434cbbf..35b76015b 100644 --- a/tests/unit/test_2198_portal_batch_sessions.py +++ b/tests/unit/test_2198_portal_batch_sessions.py @@ -329,4 +329,5 @@ async def test_a_portal_session_token_is_untouched_by_the_agent_fence(monkeypatc principal = await pa.get_portal_principal( SimpleNamespace(), SimpleNamespace(headers={}), token="portal-token") - assert (principal.email, principal.is_platform) == ("client@example.com", False) + # By field, not tuple equality: ent#465 appended `is_admin` (default False). + assert (principal.email, principal.is_platform, principal.is_admin) == ("client@example.com", False, False) diff --git a/tests/unit/test_2338_journey_catalog.py b/tests/unit/test_2338_journey_catalog.py index f278ede10..0890f2322 100644 --- a/tests/unit/test_2338_journey_catalog.py +++ b/tests/unit/test_2338_journey_catalog.py @@ -129,7 +129,7 @@ def test_no_status_field_is_committed(catalog): # happen is a gap or a reordering, so the assertion is "a dense J01..JNN run in # order", and the count moves in the same commit that adds the record. # ent#498 added J11 (a companion's brief reaches you where you work). -DECLARED_JOURNEY_COUNT = 12 +DECLARED_JOURNEY_COUNT = 13 def test_every_journey_is_declared_densely_and_in_order(catalog): diff --git a/tests/unit/test_ent465_suggestions.py b/tests/unit/test_ent465_suggestions.py new file mode 100644 index 000000000..ae246f59c --- /dev/null +++ b/tests/unit/test_ent465_suggestions.py @@ -0,0 +1,585 @@ +"""trinity-enterprise#465 — Workspace suggestions. + +Requirement: docs/memory/requirements/core-agent.md §5.39. +Flow: docs/memory/feature-flows/workspace-suggestions.md. + +What is pinned here, and why: + +* **The rules** (`service.build`) are pure over an injected `now`, so every + threshold is asserted at its boundary: one tick before is silent, the + threshold fires. +* **Honesty**: a skipped/cancelled run neither counts toward nor breaks a + failure streak; autonomy off collapses to ONE agent-level item and suppresses + "never fired"; a playbook a schedule already runs is never offered; an agent + that did not answer reports capabilities `unavailable`, never "nothing unused". +* **Dismissal** keys on the state's identity, never a count: another failure + in the same streak keeps it dismissed, a new streak brings it back. +* **The doors**: a portal token gets 404 on read and write; `configure` items + reach only owner or admin; the write is bounded to currently-emitted keys. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +pytestmark = pytest.mark.unit + +NOW = datetime(2026, 9, 24, 12, 0, tzinfo=timezone.utc) +AGENT = "atlas" + + +def _iso(dt: datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def _svc(): + from client_portal.suggestions import service + return service + + +def _sched(sid="s1", *, enabled=True, last_run=None, created=None, updated=None, + cron="0 9 * * *", message="do the thing", name="Morning check"): + return { + "id": sid, "name": name, "enabled": 1 if enabled else 0, "cron_expression": cron, + "timezone": "UTC", "message": message, + "created_at": _iso(created or NOW - timedelta(days=30)), + "updated_at": _iso(updated or NOW - timedelta(days=30)), + "last_run_at": _iso(last_run) if last_run else None, + } + + +def _runs(*statuses): + """Runs newest-first, ids r0 (newest) .. rN.""" + return [{"id": f"r{i}", "status": st, "started_at": _iso(NOW - timedelta(hours=i + 1))} + for i, st in enumerate(statuses)] + + +def _build(signals, *, can_configure=True, now=NOW): + return _svc().build(signals, agent_name=AGENT, now=now, can_configure=can_configure) + + +def _keys(pairs): + return [s.key for s, _ in pairs] + + +# --------------------------------------------------------------------------- +# The rules +# --------------------------------------------------------------------------- + +class TestRules: + def test_nothing_to_say_is_an_empty_list(self): + assert _build(_svc().Signals()) == [] + + def test_waiting_items_lead_and_cite_their_count(self): + S = _svc().Signals + pairs = _build(S(ask_ids=["a1", "a2"], decisions_due_ids=["d1"], + schedules=[_sched(last_run=NOW)], runs={"s1": _runs("failed", "failed", "failed")})) + assert _keys(pairs)[:3] == ["asks", "decisions_due", "schedule_failing:s1"] + assert pairs[0][0].signal == "2 questions waiting on you" + assert pairs[0][0].action.type == "open_section" and pairs[0][0].action.value == "asks" + assert pairs[1][0].signal == "1 decision past the review date" + + @pytest.mark.parametrize("statuses,expected", [ + (("failed", "failed"), None), # below the threshold + (("failed", "failed", "failed"), 3), # at it + (("failed", "skipped", "failed", "cancelled", "failed"), 3), # neutral rows stepped over + (("failed", "failed", "success", "failed"), None), # a success ends the streak + (("running", "failed", "error", "failed"), 3), # in-flight has no verdict + ]) + def test_failure_streak(self, statuses, expected): + pairs = _build(_svc().Signals(schedules=[_sched(last_run=NOW)], runs={"s1": _runs(*statuses)})) + failing = [s for s, _ in pairs if s.key == "schedule_failing:s1"] + if expected is None: + assert failing == [] + else: + assert failing[0].signal.startswith(f"Failed {expected} runs in a row") + assert failing[0].kind == "configure" and failing[0].door == "owner_or_admin" + assert failing[0].action.type == "link" + assert failing[0].action.value == "/agents/atlas?tab=schedules" + + def test_streak_fingerprint_is_the_first_failure_not_the_count(self): + """Another failure in the SAME streak keeps the fingerprint (no re-nag); + a new streak after a success changes it.""" + S = _svc().Signals + def fps(runs): + return [fp for _, fp in _build(S(schedules=[_sched(last_run=NOW)], runs={"s1": runs}))] + + three = _runs("failed", "failed", "failed", "success") + # A fourth failure lands on top: the oldest failure of the streak is the same row. + four = [{"id": "new", "status": "failed", "started_at": _iso(NOW)}] + three + assert fps(three) == fps(four) == ["r2"] + new_streak = [{"id": f"x{i}", "status": "failed", "started_at": _iso(NOW)} for i in (1, 2, 3)] + new_streak.append({"id": "ok", "status": "success", "started_at": _iso(NOW)}) + assert fps(new_streak) == ["x3"] + + def test_autonomy_off_is_one_item_and_suppresses_never_fired(self): + S = _svc().Signals + pairs = _build(S(autonomy_enabled=False, overdue_reminders=2, + schedules=[_sched("s1"), _sched("s2", last_run=NOW - timedelta(days=3)), + _sched("s3", enabled=False)])) + assert _keys(pairs) == ["autonomy_held"] + s = pairs[0][0] + assert s.signal == "2 schedules won't run and 2 reminders held — autonomy is off · last run Sep 21" + + def test_autonomy_held_fingerprint_is_the_schedule_set_not_counts(self): + S = _svc().Signals + a = _build(S(autonomy_enabled=False, overdue_reminders=1, schedules=[_sched("s1")]))[0][1] + b = _build(S(autonomy_enabled=False, overdue_reminders=5, schedules=[_sched("s1")]))[0][1] + c = _build(S(autonomy_enabled=False, overdue_reminders=5, schedules=[_sched("s1"), _sched("s2")]))[0][1] + assert a == b != c + + def test_never_fired_waits_for_the_crons_first_expected_fire(self): + """Measured against the cron, not a fixed delay: a weekly schedule created + two days ago has not missed anything yet.""" + S = _svc().Signals + created = datetime(2026, 9, 23, 8, 0, tzinfo=timezone.utc) # first 09:00 fire: Sep 23 09:00 + daily = _sched(created=created, cron="0 9 * * *") + # 1h grace after the first expected fire — silent one tick before, fires at it. + assert _build(S(schedules=[daily]), now=datetime(2026, 9, 23, 9, 59, 59, tzinfo=timezone.utc)) == [] + fired = _build(S(schedules=[daily]), now=datetime(2026, 9, 23, 10, 0, tzinfo=timezone.utc)) + assert _keys(fired) == ["schedule_never_fired:s1"] + assert fired[0][0].signal == "Enabled since Sep 23 · has never run" + weekly = _sched(created=datetime(2026, 9, 22, 8, 0, tzinfo=timezone.utc), cron="0 9 * * 1") # Mondays + assert _build(S(schedules=[weekly])) == [] # next Monday is Sep 28 + + def test_unparseable_cron_is_not_a_suggestion(self): + assert _build(_svc().Signals(schedules=[_sched(cron="not a cron")])) == [] + + def test_disabled_schedule_that_used_to_run(self): + S = _svc().Signals + seven = _sched(enabled=False, last_run=NOW - timedelta(days=20), updated=NOW - timedelta(days=7)) + six = _sched(enabled=False, last_run=NOW - timedelta(days=20), + updated=NOW - timedelta(days=7) + timedelta(seconds=1)) + never_ran = _sched(enabled=False, updated=NOW - timedelta(days=30)) + assert _keys(_build(S(schedules=[seven]))) == ["schedule_disabled:s1"] + assert _build(S(schedules=[six])) == [] + assert _build(S(schedules=[never_ran])) == [] + + def test_configure_items_need_owner_or_admin(self): + S = _svc().Signals + sig = S(ask_ids=["a"], autonomy_enabled=False, schedules=[_sched()]) + assert _keys(_build(sig, can_configure=False)) == ["asks"] + assert _keys(_build(sig, can_configure=True)) == ["asks", "autonomy_held"] + + def test_dormant_boundary(self): + S = _svc().Signals + at = _build(S(last_user_message_at=_iso(NOW - timedelta(days=14)))) + before = _build(S(last_user_message_at=_iso(NOW - timedelta(days=14) + timedelta(seconds=1)))) + assert _keys(at) == ["dormant"] and at[0][0].action.type == "open_chat" + assert before == [] + + def test_unused_playbooks_honest_and_bounded(self): + svc = _svc() + P = svc.Playbook + playbooks = [P("weekly-report", "Weekly report", "Summarise the week"), P("triage", "Triage"), + P("scheduled-one", "Scheduled"), P("a", "A"), P("b", "B"), P("c", "C")] + pairs = _build(svc.Signals( + playbooks=playbooks, used_playbooks=frozenset({"triage"}), + schedules=[_sched(message="/scheduled-one please", last_run=NOW)], + )) + unused = [s for s, _ in pairs if s.key.startswith("unused_playbook:")] + assert [s.key for s in unused] == ["unused_playbook:weekly-report", "unused_playbook:a", "unused_playbook:b"] + first = unused[0] + assert first.signal == "You haven't run /weekly-report yet" + assert first.action.type == "prefill" and first.action.value == "/weekly-report " + assert first.description == "Summarise the week" + + def test_schedule_names_are_capped(self): + long = "x" * 300 + pairs = _build(_svc().Signals(schedules=[_sched(name=long, last_run=NOW)], + runs={"s1": _runs("failed", "failed", "failed")})) + assert len(pairs[0][0].title) < 100 + + def test_slash_name_parsing(self): + f = _svc().slash_name + assert f("/weekly-report now") == "weekly-report" + assert f(" /Triage") == "triage" + assert f("please /weekly-report") is None + assert f(None) is None + + def test_briefing_items_keep_playbooks_and_drop_use_cases(self): + from client_portal.models import PortalPlaybook + items = [PortalPlaybook(title="Weekly report", description="d", starter_prompt="/weekly-report "), + PortalPlaybook(title="Ask me about sales", description=None, starter_prompt="Ask me about sales")] + pbs = _svc().playbooks_from_briefing(items) + assert [(p.name, p.title) for p in pbs] == [("weekly-report", "Weekly report")] + + +class TestShape: + def _pairs(self, n): + from client_portal.suggestions.models import Suggestion, SuggestionAction + return [(Suggestion(key=f"unused_playbook:p{i}", kind="invoke", source="capability", door="platform", + title=f"P{i}", signal="s", action=SuggestionAction(type="prefill", value="/p ")), f"p{i}") + for i in range(n)] + + def test_cap_and_total(self): + out = _svc().shape(self._pairs(7), {}, agent_name=AGENT, playbooks=[1], has_history=True) + assert len(out.suggestions) == 5 and out.total == 7 + + def test_dismissed_hidden_only_while_fingerprint_matches(self): + pairs = self._pairs(2) + out = _svc().shape(pairs, {"unused_playbook:p0": "p0", "unused_playbook:p1": "stale"}, + agent_name=AGENT, playbooks=[1], has_history=True) + assert [s.key for s in out.suggestions] == ["unused_playbook:p1"] + + @pytest.mark.parametrize("playbooks,expected", [(None, "unavailable"), ([], "none"), ([1], "available")]) + def test_capabilities_state_is_stated(self, playbooks, expected): + assert _svc().shape([], {}, agent_name=AGENT, playbooks=playbooks, has_history=True).capabilities == expected + + def test_no_history_says_capabilities_only(self): + assert _svc().shape([], {}, agent_name=AGENT, playbooks=[], has_history=False).basis == "capabilities_only" + + +# --------------------------------------------------------------------------- +# The SQL +# --------------------------------------------------------------------------- + +@pytest.fixture() +def sdb(tmp_path, monkeypatch): + db_file = tmp_path / "trinity-465.db" + monkeypatch.setenv("TRINITY_DB_PATH", str(db_file)) + import db.connection as conn_mod + monkeypatch.setattr(conn_mod, "DB_PATH", str(db_file)) + + from sqlalchemy import delete + from db.engine import get_engine + from db.tables import ( + metadata as m, agent_schedules, schedule_executions, agent_reminders, + enterprise_portal_messages, workspace_suggestion_feedback, + ) + tables = [agent_schedules, schedule_executions, agent_reminders, + enterprise_portal_messages, workspace_suggestion_feedback] + m.create_all(get_engine(), tables=tables) + with get_engine().begin() as conn: # idempotent on a shared PG tier + for t in tables: + col = t.c.agent_name + conn.execute(delete(t).where(col.in_([AGENT, "borealis"]))) + from client_portal.suggestions import db as sdb_mod + return sdb_mod + + +def _insert(table, **values): + from sqlalchemy import insert + from db.engine import get_engine + with get_engine().begin() as conn: + conn.execute(insert(table).values(**values)) + + +class TestSql: + def test_runs_windowed_per_schedule_newest_first(self, sdb): + from db.tables import schedule_executions as t + for sid in ("s1", "s2"): + for i in range(4): + _insert(t, id=f"{sid}-{i}", schedule_id=sid, agent_name=AGENT, status="failed", + started_at=_iso(NOW - timedelta(hours=i))) + _insert(t, id="other", schedule_id="s1", agent_name="borealis", status="failed", started_at=_iso(NOW)) + _insert(t, id="old", schedule_id="s1", agent_name=AGENT, status="success", + started_at=_iso(NOW - timedelta(days=200))) + _insert(t, id="chat", schedule_id="__manual__", agent_name=AGENT, status="failed", started_at=_iso(NOW)) + since = _iso(NOW - timedelta(days=90)) + runs = sdb.recent_runs_by_schedule(AGENT, since, 3, ["s1", "s2"]) + assert [r["id"] for r in runs["s1"]] == ["s1-0", "s1-1", "s1-2"] + assert [r["id"] for r in runs["s2"]] == ["s2-0", "s2-1", "s2-2"] + # Only the live schedules asked for are ranked — never chat/API runs. + assert "__manual__" not in runs + assert set(sdb.recent_runs_by_schedule(AGENT, since, 3, ["s2"])) == {"s2"} + assert sdb.recent_runs_by_schedule(AGENT, since, 3, []) == {} + + def test_soft_deleted_schedules_excluded(self, sdb): + from db.tables import agent_schedules as t + base = dict(agent_name=AGENT, cron_expression="0 9 * * *", message="m", enabled=1, + created_at=_iso(NOW), updated_at=_iso(NOW)) + _insert(t, id="live", name="Live", **base) + _insert(t, id="gone", name="Gone", deleted_at=_iso(NOW), **base) + assert [s["id"] for s in sdb.list_schedules(AGENT)] == ["live"] + + def test_usage_is_the_viewers_own(self, sdb): + from db.tables import enterprise_portal_messages as pm, schedule_executions as ex + _insert(pm, id="m1", agent_name=AGENT, client_email="alice@example.com", session_id="x", + role="user", content="/weekly-report now", created_at=_iso(NOW - timedelta(days=20))) + _insert(pm, id="m2", agent_name=AGENT, client_email="bob@example.com", session_id="y", + role="user", content="/triage", created_at=_iso(NOW)) + _insert(pm, id="m3", agent_name=AGENT, client_email="alice@example.com", session_id="x", + role="assistant", content="/not-mine", created_at=_iso(NOW)) + _insert(ex, id="e1", schedule_id="__manual__", agent_name=AGENT, status="success", + started_at=_iso(NOW), message="/digest", source_user_email="Alice@Example.com") + texts = sdb.slash_texts_by_viewer(AGENT, "ALICE@example.com", _iso(NOW - timedelta(days=90))) + assert sorted(texts) == ["/digest", "/weekly-report now"] + assert sdb.last_user_message_at(AGENT, "alice@example.com") == _iso(NOW - timedelta(days=20)) + assert sdb.last_user_message_at(AGENT, "carol@example.com") is None + + def test_runs_attributed_to_the_viewer_count_as_history(self, sdb): + from db.tables import schedule_executions as ex + since = _iso(NOW - timedelta(days=90)) + assert sdb.viewer_has_runs(AGENT, "owner@example.com", since) is False + _insert(ex, id="op1", schedule_id="__manual__", agent_name=AGENT, status="success", + started_at=_iso(NOW), message="hello", source_user_email="Owner@Example.com") + assert sdb.viewer_has_runs(AGENT, "owner@example.com", since) is True + assert sdb.viewer_has_runs(AGENT, "someone@example.com", since) is False + + def test_overdue_reminders(self, sdb): + from db.tables import agent_reminders as t + _insert(t, id="due", agent_name=AGENT, message="m", fire_at=_iso(NOW - timedelta(hours=1)), + status="pending", created_at=_iso(NOW)) + _insert(t, id="later", agent_name=AGENT, message="m", fire_at=_iso(NOW + timedelta(hours=1)), + status="pending", created_at=_iso(NOW)) + _insert(t, id="done", agent_name=AGENT, message="m", fire_at=_iso(NOW - timedelta(hours=1)), + status="fired", created_at=_iso(NOW)) + assert sdb.count_overdue_reminders(AGENT, _iso(NOW)) == 1 + + def test_accept_and_dismiss_live_side_by_side(self, sdb): + kw = dict(email="Alice@example.com", agent_name=AGENT, key="asks", source="asks", now=_iso(NOW)) + sdb.record_feedback(action="dismiss", fingerprint="fp1", **kw) + sdb.record_feedback(action="accept", fingerprint="fp1", **kw) + sdb.record_feedback(action="accept", fingerprint="fp1", **kw) + assert sdb.dismissed_fingerprints(AGENT, "alice@example.com") == {"asks": "fp1"} + from sqlalchemy import select + from db.engine import get_engine + from db.tables import workspace_suggestion_feedback as f + with get_engine().connect() as conn: + row = conn.execute(select(f).where(f.c.client_email == "alice@example.com")).mappings().one() + assert row["accept_count"] == 2 and row["surface"] == "agent" + + def test_cap_evicts_oldest_and_existing_key_stays_writable(self, sdb, monkeypatch): + monkeypatch.setattr(sdb, "MAX_FEEDBACK_ROWS", 2) + for i, key in enumerate(["k1", "k2", "k3"]): + sdb.record_feedback(email="a@example.com", agent_name=AGENT, key=key, source="usage", + action="dismiss", fingerprint="x", now=_iso(NOW + timedelta(seconds=i))) + assert set(sdb.dismissed_fingerprints(AGENT, "a@example.com")) == {"k2", "k3"} + sdb.record_feedback(email="a@example.com", agent_name=AGENT, key="k2", source="usage", + action="dismiss", fingerprint="y", now=_iso(NOW + timedelta(seconds=9))) + assert sdb.dismissed_fingerprints(AGENT, "a@example.com") == {"k2": "y", "k3": "x"} + + +# --------------------------------------------------------------------------- +# The service: bounded writes and the stopped agent +# --------------------------------------------------------------------------- + +class TestService: + @pytest.fixture() + def stubbed(self, monkeypatch): + svc = _svc() + state = {"gathered": {}, "playbooks": None, "writes": []} + monkeypatch.setattr(svc, "_gather", lambda *a, **k: dict(state["gathered"])) + + async def playbooks(agent): + return state["playbooks"] + monkeypatch.setattr(svc, "_playbooks", playbooks) + monkeypatch.setattr(svc, "can_configure", lambda email, agent, is_admin: is_admin) + monkeypatch.setattr(svc.sdb, "record_feedback", lambda **kw: state["writes"].append(kw)) + return svc, state + + @pytest.mark.asyncio + async def test_feedback_for_a_key_not_emitted_is_404_and_writes_nothing(self, stubbed): + svc, state = stubbed + with pytest.raises(svc.SuggestionError) as e: + await svc.record_feedback(AGENT, "a@example.com", is_admin=False, key="asks", action="dismiss", now=NOW) + assert e.value.status_code == 404 and state["writes"] == [] + + @pytest.mark.asyncio + async def test_feedback_unknown_class_is_422(self, stubbed): + svc, state = stubbed + with pytest.raises(svc.SuggestionError) as e: + await svc.record_feedback(AGENT, "a@example.com", is_admin=False, key="bogus:x", action="accept", now=NOW) + assert e.value.status_code == 422 and state["writes"] == [] + + @pytest.mark.asyncio + async def test_feedback_stores_the_servers_fingerprint(self, stubbed): + svc, state = stubbed + state["gathered"] = {"ask_ids": ["a1"]} + await svc.record_feedback(AGENT, "a@example.com", is_admin=False, key="asks", action="dismiss", now=NOW) + (w,) = state["writes"] + assert w["fingerprint"] == svc._hash(["a1"]) and w["source"] == "asks" + + @pytest.mark.asyncio + async def test_configure_item_not_writable_by_a_viewer(self, stubbed): + svc, state = stubbed + state["gathered"] = {"autonomy_enabled": False, "schedules": [_sched()]} + with pytest.raises(svc.SuggestionError): + await svc.record_feedback(AGENT, "a@example.com", is_admin=False, key="autonomy_held", + action="dismiss", now=NOW) + await svc.record_feedback(AGENT, "a@example.com", is_admin=True, key="autonomy_held", + action="dismiss", now=NOW) + assert len(state["writes"]) == 1 + + @pytest.mark.asyncio + async def test_stopped_agent_never_calls_the_briefing(self, monkeypatch): + svc = _svc() + from client_portal import service as portal + svc._briefing_cache.clear() + called = [] + + async def availability(names): + return {n: "stopped" for n in names} + + async def briefing(*a, **k): + called.append(a) + raise AssertionError("must not be called for a stopped agent") + monkeypatch.setattr(portal, "_availability_map", availability) + monkeypatch.setattr(portal, "_agent_briefing", briefing) + assert await svc._playbooks("stopped-agent") is None + assert called == [] + svc._briefing_cache.clear() + + +# --------------------------------------------------------------------------- +# The doors +# --------------------------------------------------------------------------- + +class TestDoors: + @pytest.fixture() + def client(self, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from client_portal.suggestions import router as r + from client_portal import service as portal + from services import rate_limiter + from client_portal.suggestions.models import PortalSuggestions + + principal = {"value": None} + calls = [] + app = FastAPI() + app.include_router(r.router) + app.dependency_overrides[r.get_portal_principal] = lambda: principal["value"] + monkeypatch.setattr(portal, "agent_on_roster", lambda a, e, inc: a == AGENT) + monkeypatch.setattr(rate_limiter, "enforce", lambda *a, **k: None) + + async def get(agent, email, *, is_admin): + calls.append(("get", agent, email, is_admin)) + return PortalSuggestions(agent_name=agent) + + async def feedback(agent, email, *, is_admin, key, action): + calls.append(("post", agent, email, is_admin, key, action)) + monkeypatch.setattr(r.service, "get_suggestions", get) + monkeypatch.setattr(r.service, "record_feedback", feedback) + return TestClient(app), principal, calls + + def test_portal_token_is_404_on_read_and_write(self, client): + from client_portal.portal_auth import PortalPrincipal + c, principal, calls = client + principal["value"] = PortalPrincipal("a@example.com", False) + assert c.get(f"/api/enterprise/client-portal/agents/{AGENT}/suggestions").status_code == 404 + assert c.post(f"/api/enterprise/client-portal/agents/{AGENT}/suggestions/feedback", + json={"key": "asks", "action": "dismiss"}).status_code == 404 + assert calls == [] + + def test_off_roster_is_404(self, client): + from client_portal.portal_auth import PortalPrincipal + c, principal, calls = client + principal["value"] = PortalPrincipal("a@example.com", True) + assert c.get("/api/enterprise/client-portal/agents/other/suggestions").status_code == 404 + assert calls == [] + + def test_platform_viewer_reads_and_admin_flag_travels(self, client): + from client_portal.portal_auth import PortalPrincipal + c, principal, calls = client + principal["value"] = PortalPrincipal("a@example.com", True, is_admin=True) + assert c.get(f"/api/enterprise/client-portal/agents/{AGENT}/suggestions").status_code == 200 + assert calls == [("get", AGENT, "a@example.com", True)] + + def test_feedback_body_is_validated(self, client): + from client_portal.portal_auth import PortalPrincipal + c, principal, calls = client + principal["value"] = PortalPrincipal("a@example.com", True) + url = f"/api/enterprise/client-portal/agents/{AGENT}/suggestions/feedback" + assert c.post(url, json={"key": "asks", "action": "explode"}).status_code == 422 + assert c.post(url, json={"key": "x" * 201, "action": "accept"}).status_code == 422 + assert c.post(url, json={"key": "unused_playbook:a/b", "action": "accept"}).status_code == 200 + assert calls[-1] == ("post", AGENT, "a@example.com", False, "unused_playbook:a/b", "accept") + + +class TestPrincipal: + def test_positional_construction_keeps_its_meaning(self): + from client_portal.portal_auth import PortalPrincipal + p = PortalPrincipal("a@example.com", True) + assert p.is_admin is False and p.is_platform is True + + @pytest.mark.parametrize("role,scope,expected", [ + ("admin", None, True), ("admin", "user", True), ("admin", "system", True), + ("admin", "ops", False), ("admin", "portal_delegate", False), ("creator", None, False), + ]) + def test_admin_needs_role_and_an_allowlisted_scope(self, role, scope, expected): + from types import SimpleNamespace + from client_portal.portal_auth import _is_admin_principal + assert _is_admin_principal(SimpleNamespace(role=role, mcp_scope=scope)) is expected + + def test_admin_fails_closed_without_a_scope_attribute(self): + from types import SimpleNamespace + from client_portal.portal_auth import _is_admin_principal + assert _is_admin_principal(SimpleNamespace(role="admin")) is False + + +def test_table_is_a_cascade_agent_ref(): + from db.agent_cleanup import AGENT_REFS + refs = {(r.table, r.column): r.policy.name for r in AGENT_REFS} + assert refs[("workspace_suggestion_feedback", "agent_name")] == "CASCADE" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("role,scope,expected", [("admin", None, True), ("admin", "ops", False), ("user", None, False)]) +async def test_the_platform_door_stamps_is_admin_on_the_principal(monkeypatch, role, scope, expected): + """Executes the wiring line itself (`PortalPrincipal(email, True, is_person_principal(user), _is_admin_principal(user))`), + not only the helper: a platform principal carries `is_admin`, and a portal token never does.""" + from types import SimpleNamespace + from client_portal import portal_auth as pa + from client_portal import db as portal_db + import database + + user = SimpleNamespace(username="u1", role=role, mcp_scope=scope, agent_name=None) + + async def current_user(request, token): + return user + monkeypatch.setattr(pa, "decode_portal_session", lambda t: None) + monkeypatch.setattr(pa, "get_current_user", current_user) + monkeypatch.setattr(database.db, "get_user_by_username", lambda name: {"email": "U1@Example.com"}) + monkeypatch.setattr(portal_db, "is_client_blocked", lambda e: False) + + principal = await pa.get_portal_principal(SimpleNamespace(), SimpleNamespace(headers={}), token="jwt") + assert (principal.email, principal.is_platform, principal.is_admin) == ("u1@example.com", True, expected) + + +class TestGatherEndToEnd: + """`_gather` + `_compute` over a real SQLite file — the composition the unit + rules cannot see: live schedule ids reach the streak query, a chat run never + does, and a console-only owner has history.""" + + @pytest.fixture() + def wired(self, sdb, monkeypatch): + import database + from client_portal.asks import service as asks_service + svc = _svc() + monkeypatch.setattr(asks_service, "list_asks", lambda email, is_platform, agent: []) + monkeypatch.setattr(database.db, "list_seat_decisions", lambda agent, seat, limit=500: []) + monkeypatch.setattr(database.db, "get_autonomy_enabled", lambda agent: True) + from client_portal import service as portal + monkeypatch.setattr(portal, "portal_owns_agent", lambda email, agent, include_owned: False) + + async def no_playbooks(agent): + return [] + monkeypatch.setattr(svc, "_playbooks", no_playbooks) + return svc + + @pytest.mark.asyncio + async def test_a_failing_schedule_surfaces_and_chat_runs_do_not_count(self, wired): + from db.tables import agent_schedules as sch, schedule_executions as ex + _insert(sch, id="s1", agent_name=AGENT, name="Nightly sync", cron_expression="0 2 * * *", + message="sync", enabled=1, created_at=_iso(NOW - timedelta(days=30)), + updated_at=_iso(NOW - timedelta(days=30)), last_run_at=_iso(NOW - timedelta(days=1))) + for i in range(3): + _insert(ex, id=f"f{i}", schedule_id="s1", agent_name=AGENT, status="failed", + started_at=_iso(NOW - timedelta(days=i + 1))) + # Newer chat runs on the same agent: never part of any schedule's streak. + for i in range(5): + _insert(ex, id=f"c{i}", schedule_id="__manual__", agent_name=AGENT, status="success", + started_at=_iso(NOW - timedelta(hours=i + 1)), source_user_email="owner@example.com") + out = await wired.get_suggestions(AGENT, "owner@example.com", is_admin=True, now=NOW) + assert [s.key for s in out.suggestions] == ["schedule_failing:s1"] + assert out.suggestions[0].signal.startswith("Failed 3 runs in a row") + # The owner never used the Workspace, but ran things from the console: history. + assert out.basis == "history" + + @pytest.mark.asyncio + async def test_no_workspace_and_no_runs_is_capabilities_only(self, wired): + out = await wired.get_suggestions(AGENT, "new@example.com", is_admin=False, now=NOW) + assert out.suggestions == [] and out.basis == "capabilities_only"