feat(team): an agent can be opened, read, and edited (#264) - #489
Conversation
An agent on the Team tab was a dead end: a name, a role, and a
destructive Remove. None of what an agent *is* was reachable once it
existed — its instructions, its tier, the tools it may use, the desks it
sits on — and the "Define an agent" dialog was create-only, so the
roster was write-once per member. Iterating on an agent's instructions
meant deleting it and starting over.
The tool grants were worse than unreachable from the console: they had
no read surface anywhere. `GET …/team` sent no tools at all, so what a
company actually grants an agent could not be checked from outside the
process.
Add `GET`/`PATCH {scope}/team/{agentId}` and the console screen that
uses them.
The detail read carries identity, tier, desks, and the tool grants at
all three levels: what the agent asked for, the company allow-list it is
intersected with, and what it actually holds. The third is computed by
the same `agent_effective_grants` the harness calls when it builds the
agent, so the readout cannot drift from what is enforced; that function
and the orchestrator resolution both move out of `#[cfg(openhuman)]` so
the default-build route and the harness share one rule rather than two
copies of it.
What may be edited: the console edits what the console owns. An overlay
teammate's name, role and description are editable here. A manifest
teammate is 409 with a message naming `company.toml` — it is declared in
the version-controlled blueprint, which the overlay model exists so the
runtime never rewrites, and the one field that *is* changeable on it
(the daily budget) is changeable because tinyhumansai#343 modelled it as an override
rather than a rewrite. Tier and tools are read-only for both: there is
no override layer for either, and adding one is a policy decision.
The host states the rule rather than leaving the console to re-derive
it: every detail response carries an `editable` list, and the console
renders a field read-only exactly when the host omits it.
|
@coderabbitai review |
There was a problem hiding this comment.
M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
|
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe runtime API now supports agent detail retrieval and overlay-agent edits. The console adds agent detail navigation, resolved tool and desk displays, editable fields, validation, persistence, and backend, unit, and end-to-end coverage. ChangesAgent detail and editing flow
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant TeamView
participant OpenCompanyClient
participant team_agent
participant CompanyState
TeamView->>OpenCompanyClient: Request agent details
OpenCompanyClient->>team_agent: GET /team/{agent_id}
team_agent->>CompanyState: Resolve agent, grants, desks, and budget
CompanyState-->>team_agent: Agent detail data
team_agent-->>OpenCompanyClient: AgentDetailDto
OpenCompanyClient-->>TeamView: Display detail view
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (6)
frontend/src/lib/agent.ts (1)
27-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueFreeze the shared field definition.
AGENT_FIELDSis exported as a mutable array. Both the create dialog and the edit form render from it. Aconstassertion prevents an accidental mutation from changing both forms at once.♻️ Proposed change
-export const AGENT_FIELDS: AgentFieldSpec[] = [ +export const AGENT_FIELDS: readonly AgentFieldSpec[] = [ { key: "name", label: "Name", placeholder: "e.g. Nova", kind: "line" }, { key: "role", label: "Role", placeholder: "e.g. Growth Marketer", kind: "line" }, { key: "description", label: "What they do", placeholder: "e.g. Runs paid acquisition and reports on ROAS.", kind: "prose", }, -]; +] as const;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/lib/agent.ts` around lines 27 - 36, Update the exported AGENT_FIELDS definition to use a const assertion, freezing the shared field array and its entries against accidental mutation while preserving its current values and usage by the create dialog and edit form.frontend/src/views/team/AgentDetailView.tsx (2)
63-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReuse
emptyDraft()for the initial draft.This file already imports from
@/lib/agent. The inline literal is a second definition of the blank draft. IfAgentDraftgains a field, this literal stops compiling or silently diverges.♻️ Proposed change
- const [draft, setDraft] = useState<AgentDraft>({ name: "", role: "", description: "" }); + const [draft, setDraft] = useState<AgentDraft>(emptyDraft);Add the import:
import { agentEdits, draftFrom, draftIsValid, + emptyDraft, isEditable, summarizeGrants, tierLabel, type AgentDraft, type AgentFieldKey, } from "`@/lib/agent`";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/views/team/AgentDetailView.tsx` at line 63, Update the initial state in the AgentDetailView component to use the existing emptyDraft() helper from `@/lib/agent` instead of an inline AgentDraft literal, adding the named import alongside the file’s current agent imports.
87-112: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueReset
editingwhen the loaded agent changes.
bootre-runs whenagentId,client, orcompanychanges, and it replacesdraftwith the new agent's values. It does not clearediting. An operator who navigates from one agent to another while the edit form is open lands on the next agent already in edit mode.The state self-corrects because
draftis refreshed from the new response, so no wrong value can be saved. AddsetEditing(false)inbootto keep the screen predictable.♻️ Proposed change
const boot = useCallback(async () => { setLoad("loading"); + setEditing(false); try {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/views/team/AgentDetailView.tsx` around lines 87 - 112, Update the agent-loading function boot to call setEditing(false) whenever a newly loaded agent replaces the current agent and refreshes draft, ensuring navigation between agents exits edit mode while preserving the existing save behavior.frontend/src/views/team/AgentFields.tsx (1)
36-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueNamespace
data-testidwithidPrefixas well.
idPrefixexists so twoAgentFieldscan be mounted at once, and it namespaces the DOM ids for exactly that reason.data-testidis not namespaced, so two mounted instances produce duplicate test ids and a Playwright locator can match the wrong field. No call site mounts two today, so this is preventive.♻️ Proposed change
- data-testid={`agent-field-${field.key}`} + data-testid={`${idPrefix}-field-${field.key}`}Apply to both the
Textareaand theInputbranch, and update the matching locators infrontend/test/e2e/agent-detail.spec.ts.Also applies to: 49-49, 58-58
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/views/team/AgentFields.tsx` at line 36, Namespace each field’s data-testid with idPrefix in both the Textarea and Input branches of AgentFields, matching the existing DOM id pattern built from idPrefix and field.key. Update the corresponding Playwright locators in agent-detail.spec.ts to use the namespaced test IDs.frontend/test/unit/agent-detail.test.ts (1)
56-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover the third arm of the description contract and a partially editable agent.
EditAgentInputdocuments three distinct description values: omitted,null, and a string. The suite covers omitted andnull. It does not assert that new text is sent as a string.The read-only test uses a manifest agent with
editable: [], so every field is skipped by the same guard. An agent with one editable field would catch a regression in whichisEditableis checked for one branch of theif/else if/elsechain inagentEditsbut not another.💚 Proposed additional cases
it("trims, so whitespace alone is not a change", () => { const detail = agent(); expect(agentEdits(detail, { ...draftFrom(detail), name: " Jamie " })).toBeNull(); }); + it("sends new instructions as a string, not as null", () => { + const detail = agent(); + expect(agentEdits(detail, { ...draftFrom(detail), description: " Owns SEO. " })).toEqual({ + description: "Owns SEO.", + }); + }); + + it("sends only the editable half when the host allows one field", () => { + // The all-locked case below cannot catch a guard that is missing from one + // branch of the field loop; this one can. + const detail = agent({ editable: ["role"] }); + expect( + agentEdits(detail, { name: "Nope", role: "Head of Growth", description: "Nope" }), + ).toEqual({ role: "Head of Growth" }); + }); + it("never sends a field the host says is read-only", () => {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/test/unit/agent-detail.test.ts` around lines 56 - 83, Extend the unit coverage around agentEdits to assert that non-empty trimmed description text is emitted as a string. Replace or supplement the all-read-only manifest case with a partially editable agent so one editable field is sent while read-only fields remain excluded, exercising isEditable across each relevant branch of agentEdits.src/server/ops/team_agent.rs (1)
583-870: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a test for the budget and inbox fields on the detail response.
The tests cover identity, tools, desks, orchestrator resolution, editability, and every patch arm. They do not assert
inboxEnabled,budgetUsdDaily,spentTodayUsd,budgetSetBy, orbudgetSetAtMillis. Those five fields are new contract surface that the console renders. Add one test that sets a cap throughPUT …/team/{id}/budget, toggles the inbox throughPUT …/team/{id}/inbox, and then asserts the detail read reports both. This also pins the absent-means-uncapped rule, which is easy to break by removing askip_serializing_if.As per coding guidelines: "Add focused tests with every behavior change".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server/ops/team_agent.rs` around lines 583 - 870, Add a focused test near the existing detail-read tests that creates an agent, sets its daily budget via the team budget PUT endpoint, enables inbox via the team inbox PUT endpoint, then fetches the agent detail and asserts inboxEnabled, budgetUsdDaily, spentTodayUsd, budgetSetBy, and budgetSetAtMillis. Also verify an agent without a configured budget reports the uncapped representation, preserving absent/null budget fields rather than serializing a zero value.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/views/team/AgentDetailView.tsx`:
- Around line 281-284: Update the empty-tools rendering in AgentDetailView to
use summary.standardGrant when choosing the message: show the existing “nothing
it asked for is covered” text only when the agent requested tools but none were
granted, and use appropriate no-tools wording when it requested nothing. Keep
the effective-tools empty-state structure unchanged.
- Around line 73-80: Update the AgentDetailView load-state handling so 404
remains "missing", while transport and server failures map to a distinct "error"
state rather than "unsupported"; reserve "unsupported" for hosts that are
confirmed not to support the route, and add the corresponding empty-state
rendering alongside the existing states.
In `@frontend/src/views/team/AgentFields.tsx`:
- Around line 42-59: In AgentFields, replace disabled={locked} with
readOnly={locked} on both the Textarea and Input controls so locked values
remain keyboard-accessible while preventing edits.
In `@frontend/test/e2e/agent-detail.spec.ts`:
- Around line 110-122: Move the try block in the “an agent defined in the
console can be read back and edited” test to begin before the Add member dialog
interaction and creation POST. Keep the existing finally cleanup intact so
failures in creation or the roster assertion still remove the “Detail Spec”
teammate.
---
Nitpick comments:
In `@frontend/src/lib/agent.ts`:
- Around line 27-36: Update the exported AGENT_FIELDS definition to use a const
assertion, freezing the shared field array and its entries against accidental
mutation while preserving its current values and usage by the create dialog and
edit form.
In `@frontend/src/views/team/AgentDetailView.tsx`:
- Line 63: Update the initial state in the AgentDetailView component to use the
existing emptyDraft() helper from `@/lib/agent` instead of an inline AgentDraft
literal, adding the named import alongside the file’s current agent imports.
- Around line 87-112: Update the agent-loading function boot to call
setEditing(false) whenever a newly loaded agent replaces the current agent and
refreshes draft, ensuring navigation between agents exits edit mode while
preserving the existing save behavior.
In `@frontend/src/views/team/AgentFields.tsx`:
- Line 36: Namespace each field’s data-testid with idPrefix in both the Textarea
and Input branches of AgentFields, matching the existing DOM id pattern built
from idPrefix and field.key. Update the corresponding Playwright locators in
agent-detail.spec.ts to use the namespaced test IDs.
In `@frontend/test/unit/agent-detail.test.ts`:
- Around line 56-83: Extend the unit coverage around agentEdits to assert that
non-empty trimmed description text is emitted as a string. Replace or supplement
the all-read-only manifest case with a partially editable agent so one editable
field is sent while read-only fields remain excluded, exercising isEditable
across each relevant branch of agentEdits.
In `@src/server/ops/team_agent.rs`:
- Around line 583-870: Add a focused test near the existing detail-read tests
that creates an agent, sets its daily budget via the team budget PUT endpoint,
enables inbox via the team inbox PUT endpoint, then fetches the agent detail and
asserts inboxEnabled, budgetUsdDaily, spentTodayUsd, budgetSetBy, and
budgetSetAtMillis. Also verify an agent without a configured budget reports the
uncapped representation, preserving absent/null budget fields rather than
serializing a zero value.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d54af95b-cc06-40fd-958c-3660e22c4f43
📒 Files selected for processing (17)
docs/spec/runtime/api.mdfrontend/src/api/client.tsfrontend/src/api/types.tsfrontend/src/components/app-shell.tsxfrontend/src/lib/agent.tsfrontend/src/views/TeamView.tsxfrontend/src/views/team/AgentDetailView.tsxfrontend/src/views/team/AgentFields.tsxfrontend/test/e2e/agent-detail.spec.tsfrontend/test/unit/agent-detail.test.tssrc/company/types.rssrc/harness/orchestrator.rssrc/runtime/builder.rssrc/server/ops/language.rssrc/server/ops/mod.rssrc/server/ops/team.rssrc/server/ops/team_agent.rs
Both Rust lanes failed to compile: `src/company/mod.rs` declares `mod
types` privately and re-exports its items, so the three new references
to `crate::company::types::{ORCHESTRATOR_TIER, orchestrator_id}` were
`E0603`. Add both to the existing `pub use types::{…}` list and reach
them through `crate::company`, which is how every other consumer of that
module already names its items.
Also from review:
* Classify a failed detail read by what actually failed. A `404` is two
different facts — a host that predates the route, or a teammate that is
gone — and mapping every one of them to "no longer on the roster" told
an operator on an old host to look for a deletion that never happened,
which is the outcome the comment beside it claimed to avoid. The
roster settles it, but only if asked whether it still *contains* this
agent: `GET …/team` is the older route, so an out-of-date host answers
it perfectly and its success proves nothing on its own. Everything that
is not a `404` is now its own "couldn't load" state rather than a
version complaint about a dropped connection.
* Say the right thing when an agent holds no tools because the company
allows none, rather than blaming a request it never made.
* Lock read-only fields with `readOnly`, not `disabled`, so the values
this screen exists to show stay reachable by keyboard.
* Open the e2e `try` before the teammate is created, so a failure in the
assertion that follows the POST cannot leak it onto a host the spec
promises to leave as it found it.
There was a problem hiding this comment.
M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
|
@coderabbitai review |
|
`clippy::result_large_err`, under `-D warnings`: `trimmed_field` returned `Result<Option<String>, Response>`, and an axum `Response` is at least 128 bytes against a 24-byte `Ok`, so every successful call carried the footprint of the refusal it did not make. Return `ApiError` and convert at the call site. The handler above it keeps `Response` because its own `Ok` variant is larger still, which is why the lint exempts it; `daily_spend_samples` next door already pairs a 24-byte `Ok` with `ApiError` and passes this lint today.
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
@coderabbitai review |
|
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
…is not there
The spec hit the 60s test timeout in `Console E2E`, and no assertion
failed: the page snapshot at the deadline shows the roster with the card
it created, untouched. The budget went on waiting.
`dismissOnboarding` blocked on `waitFor({ state: "visible", timeout:
15_000 })` and swallowed the timeout, so the ABSENT case — the common one
— cost the full fifteen seconds. `desk membership is on the agent` in the
same file measures it exactly: 15.6s wall clock for one navigation and
three assertions. This test navigates three times, so it paid the toll in
the `beforeEach` and again in the cleanup, spending half the budget
before any work.
Suppress the tour before the app boots instead, by seeding its own
localStorage markers through an init script, the way
`board-columns.spec.ts` already does. Registered in the `beforeEach` so
it re-applies after the storage-clearing reload mid-test, which wipes
those markers along with everything else. The click path stays as a
fallback for a host whose marker key the list does not name, but it polls
briefly rather than blocking.
Nothing about the assertions changes.
|
@coderabbitai review |
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
✅ Action performedReview finished.
|
The e2e failure was a real defect, not a slow assertion. `TeamView` renders the agent detail as an early return inside itself, so opening an agent never unmounts the roster and never re-runs its load. A rename saved in the panel reached the host and the cards behind it went on showing what they held before the edit: press Back and the old role is still there, until a hard reload. The panel and the roster disagreed about the same company, and the roster was the one that was wrong. The roster is re-read when the sub-page is left, keyed on the addressed agent rather than on the Back button, so the browser's own Back refreshes too. No skeleton on that path: the cards on screen are the right cards, only possibly stale. The spec's reload was also not one. `goto(page.url())` on the URL the page is already on is a same-document navigation, so the app kept every piece of in-memory state and the assertions that claimed to prove host-backing were reading the panel's own. That is how a green step sat directly above a stale roster. It reloads properly now, which is also what lets it catch a persistence failure at all.
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
Summary
Fixes #264.
An agent on the Team tab was a dead end. The card rendered a name and a role, the only action behind its menu was a destructive Remove, and none of what an agent is was reachable once it existed: not the description and instructions it was defined with, not which tools it may use, not which desks it belongs to, not its tier, and no edit at all. The "Define an agent" dialog is create-only, so the roster was write-once per member and iterating on an agent's instructions meant deleting it and starting over.
The tool grants were worse than unreachable from the console: they had no read surface anywhere.
GET …/teamcarried no tools for any member, so what a company actually grants an agent could not be checked from outside the process. That is the verification gap the issue names, and it is closed here.This adds
GET/PATCH {scope}/team/{agentId}and the console screen that uses them.The detail read
Identity (id, name, role),
tier,isOrchestrator, the description as authored, desk membership with the lead flag, inbox state, the budget fields, and the tool grants.isOrchestratoris nottier === "orchestrator", and the difference is load-bearing. The roster rule is "the first agent taggedtier = "orchestrator", else the first agent declared". A company that tags nobody still has an orchestrator. A console that read the tier string alone would label every teammate on such a roster a Worker and be wrong about all of them, including the one actually running the company. The host resolves it and sends the answer; the UI renders what it is told.To send it from the default build, two rules moved out of
#[cfg(feature = "openhuman")]:runtime::builder::agent_effective_grantswas gated becausebuild_rosterwas its only caller. It is now also the detail route's grant resolver, and that route ships in the default build.company::types::orchestrator_id, withharness::orchestrator::orchestrator_iddelegating to it.Both are single-source-of-truth moves rather than conveniences: a second copy would eventually disagree, and a tool-grant readout that disagrees with the harness is worse than none — it would show an operator a tool list the agent does not hold, or hide one it does.
The three tools lists, and which one the UI shows
toolscarriesrequested,companyAllowandeffective:requested[[agent]].toolsglobs. Empty means the company's standard grant, not "no tools"companyAllow[tools].allowceiling the request is intersected witheffectiveThe UI shows
effectiveby default, as the primary list of badges. That is the only one of the three that answers the question an operator is asking, which is "what can this agent do".requestedon its own reports the opposite of the truth for exactly the agents where it matters most: an agent that lists no tools inherits the whole allow-list, so rendering its empty request would tell the operator their agent is powerless when it holds everything the company allows. And a request the allow-list does not cover is not a grant, however plainly the manifest lists it.The other two are shown as context around that list, not as peers of it:
requestedminuseffectiveis rendered as a struck-through "asked for but not granted" line, which is the line an operator checking a tool-grant change is actually looking for and which no surface showed before, andcompanyAllowis a single footnote line naming the ceiling. Whenrequestedis empty the section subtitle says so in words ("this agent lists no tools of its own, so it holds everything the company allows") rather than leaving the operator to infer it from an absence.What may be edited, and why that is the line
The issue leaves this open, so here is the rule, stated once and enforced in one place: the console edits what the console owns.
add_agent— lives on theCompanyRecordthis process writes. Itsname,roleanddescriptionare editable. That is the whole of the issue's "write-once per member".company.tomland is409here, with a message naming where the edit belongs. This is the same line the existing remove refusal already draws (MANIFEST_TEAMMATE_DELETE), drawn in the same place for the same reason: the overlay model exists so the runtime never rewrites the blueprint, and a consolePATCHthat edited it would make the deployed company silently diverge from the file in git.BudgetOverridelayered on top rather than as a rewrite. It keeps its own route and is untouched here.tierandtoolsare read-only for both kinds. There is no override layer for either, and inventing one is a policy decision (an operator raising an agent's own tool grants from a browser is a privilege question, not a form field) rather than something to smuggle into a detail view. Named as the follow-up below rather than half-shipped.The host states the rule; the console does not re-derive it. Every detail response carries an
editablelist naming the fieldsPATCHwill accept, and the console renders a field read-only exactly when that list omits it. A client-side copy of the rule would drift from what the host accepts and the operator would meet the disagreement as a failed save instead of as a disabled input.PATCHis a real patch. An absent key leaves that field alone;"description": nullclears it. The two must stay apart or every partial save would erase an agent's instructions, which is the single worst thing this route could do. A blankname/roleis400, an unknown teammate404.One axum detail worth naming
GETandPATCHare attached toteam::router's existing/team/{agent_id}entry rather than merged in as their own router. axum panics when two routers claim the same path, even for disjoint methods — aRouter::mergeofget(...).patch(...)against the existingdelete(remove_member)would take the process down at startup, not fail a request. So the handlers live in a sibling module (ops::team_agent) and are composed as aMethodRouter:team_agent::method_router().delete(remove_member).Console
#/team/<agentId>is a sub-page, not a modal, through the sub-segmentuseHashViewalready supports: an agent is linkable, survives a refresh, and Back returns to the roster. The card's own name is the way in (a whole-card click would swallow the inbox switch and the actions menu, both of which live inside it), plus a "View agent" menu item. Only host-backed cards open — a starter-team card is a local placeholder with no record, so its id would 404 and the detail view would report a teammate that was never removed.The create dialog and the edit form now render from one definition.
AGENT_FIELDSinlib/agent.tsis the single description of the three authored fields, andviews/team/AgentFields.tsxrenders it for both surfaces, so "Define an agent" and "Edit agent" cannot drift into two sets of labels, placeholders and orderings for one set of values.API Or Behavior Changes
New routes (both under the two existing scope forms,
…/company/…and…/companies/{id}/…):GET {scope}/team/{agentId}— one agent in full. Any signed-in member.PATCH {scope}/team/{agentId}— edit an overlay teammate'sname/role/description. Any signed-in member, matchingPOST …/team: defining a teammate was never admin-only, so correcting one is not either. Setting a budget still is, on its own route.No existing route changed shape, and no behaviour changed for anything that does not call the new ones. Two functions were un-feature-gated (
agent_effective_grants, and the orchestrator resolution moving tocompany::types); the harness now delegates to the shared copy and computes the same answers it did before.The console gains a sub-route (
#/team/<agentId>) and a menu item. A host that predates the route 404s, and the console renders "this host can't open an agent yet" rather than claiming the teammate was removed.Tests
cargo fmt --all -- --check— N/A - full local build matrix is prohibited on this machine; verified in CI.cargo clippy --all-targets -- -D warnings— N/A - full local build matrix is prohibited on this machine; verified in CI.cargo build --all-targets— N/A - full local build matrix is prohibited on this machine; verified in CI.cargo test— N/A - full local build matrix is prohibited on this machine; verified in CI.cargo fmtitself cannot resolve a manifest in this checkout (no vendor submodules), so formatting was verified withrustfmt --edition 2024 --checkrun directly over every touched Rust file: clean.Rust — 13 route tests in
src/server/ops/team_agent.rs, on the wire through the real router:effective_tools_are_the_intersection_not_the_request— an agent requesting a tool the company never allowed holds it not; an agent requesting nothing holds the whole allow-list;an_untagged_roster_still_names_an_orchestrator;an_operator_added_desk_membership_shows_on_the_agent— resolved througheffective_desk_members, not the manifest list;409with nothing written, unknown id404on both verbs, and theeditablecontract itself.Console — 12 vitest cases in
frontend/test/unit/agent-detail.test.tson the derivations that fail silently: the patch body (only-changed, never a read-only field,nullvs absent for a cleared description), draft validity, the three tool-list readings, and the tier label.Browser — a new Playwright spec,
frontend/test/e2e/agent-detail.spec.ts, walking the exact path the issue describes against the livecompanies/e2e_harnesshost and pinned to that manifest: open the CEO card from the roster, read its instructions, assert Orchestrator, assert the effective grants (workspace.read,composio,mcp:*), assert it sits on no desk, assert there is no Edit and the reason is on screen; deep-link to#/team/engineerand assert the Engineering desk; then define an agent through the dialog, open it, edit it, and prove it stuck through a storage-cleared reload before removing it again.The issue's testing requirements, answered item by item
Console E2ECI job, which builds a real host and drives the suite against it. The spec above performs that exact walk, step for step. If a hand-driven pass is wanted before merge, it needs someone who can build the host.playwright.config.tshastestDir: "./test/e2e"and theConsole E2Ejob runsnpm run e2eover that directory, so a new spec file is picked up without registration. Nothing here is behindcapabilities.ts— the routes ship in the default build, which is the feature set that lane's host is built with.npm run typecheck,npm run typecheck:unitandnpm run typecheck:e2eall clean; the 12 new unit tests pass (vitest run test/unit/agent-detail.test.ts→ 12 passed);rustfmt --checkclean. Not run locally: the Rust suite and the browser suite, per the prohibition above. The numbers for both come from this PR's CI run.One pre-existing failure, unrelated and untouched:
frontend/test/unit/tour-resume.test.tsfails 10/10 locally withwindow.localStorage.clear is not a function. I confirmed it fails identically on a clean stash ofmain, and my local Node is v25.3.0 while CI pins Node 22, so it reads as a local-environment artifact rather than a repo defect. Flagged rather than fixed, since it is neither caused by nor related to this change.Follow-ups this deliberately does not do
tierandtools. Read-only here for both kinds of teammate, for the reason given above: there is no override layer, and adding one is a privilege decision that deserves its own issue rather than a form field on a detail view.company.toml— and the same shape would extend to instructions if the product wants it. That is a design decision about how far the overlay model should reach, not a gap in this screen.Documentation
docs/spec/runtime/api.md— both routes added to the route table, with the three-listtoolscontract, theeditablecontract, and the manifest-vs-overlay edit rule written out.While there: that file claimed overlay teammates are "roster-only… no harness
Agentis built for them yet", which #71 falsified —build_rosterpromotes each one into a realCompanyAgent. Corrected, because this PR reports overlay teammates' effective grants and the stale sentence would have contradicted it. (Same species of expired claim as #475, one file over.)Summary by CodeRabbit
Update: the compile break, and what stops it recurring
The first push failed both Rust lanes. One root cause, presenting at three sites:
src/company/mod.rsdeclaresmod types;privately and re-exports its items throughpub use types::{…}. The new code reachedcrate::company::types::{ORCHESTRATOR_TIER, orchestrator_id}directly, which iserror[E0603]: module 'types' is private, atharness/orchestrator.rs:75,harness/orchestrator.rs:122andserver/ops/team_agent.rs:387.That is why the two lanes died differently rather than identically: the default
Rustlane compiles only the feature-free site (team_agent.rs), whileRust (openhuman, tinycortex)additionally compilessrc/harness/, so it hit all three.The fix is to add both names to the existing
pub use types::{…}list and reach them throughcrate::company, which is how every other consumer of that module already names its items — not to makemod typespublic. The module is private on purpose: its public surface is the curated re-export list, and widening the module to fix one call site would trade a compile error for an unbounded API.Then the same mistake was audited for across the whole tree, since one instance of it usually means several:
No matches: every reference to that module from outside
src/company/now goes through the re-export. (Insidesrc/company/the shorttypes::path is correct and untouched.)Worth naming plainly: this is the class of error a local
cargo checkwould have caught in seconds, and it was not run because a full local build matrix fills the shared disk on this machine. The trade is deliberate and the cost is a CI round-trip.Update: the four review threads
All four CodeRabbit findings were accepted; none were declined.
The 404 branch showed the opposite message from the one its own comment promised. The sharpest of the four and a real defect: a host that predates this route 404s the path it does not serve, so mapping every 404 to
missingrendered "This agent is no longer on the roster" — precisely the outcome the comment beside it said to avoid. And every non-404 (a dropped connection, a500) fell intounsupported, telling an operator their host was too old when their network had simply gone away.Fixed with a documented
classifyFailurehelper and a distincterrorstate. The suggested fix was refined rather than taken as written. It treats "listTeamsucceeded" as proof the teammate is gone, butGET …/teamis the older route — an out-of-date host answers it perfectly, so its success proves nothing on its own. The question that actually separates the two facts is whether the roster still contains this agent:GET …/teamunsupportedmissingerrorThe empty-tools message was wrong for an agent that asked for nothing. Correct: under a company whose allow-list is empty, an agent that lists no tools also lands in that branch, and it was told "nothing it asked for is covered" when it had asked for nothing.
summary.standardGrantnow picks the wording.disabled→readOnlyon locked fields. Correct on accessibility: a disabled input leaves the tab order, so the very values this screen exists to show would be unreachable by keyboard and awkward to select or copy.readOnlyrefuses the edit and keeps the value reachable. Two stale doc comments that still said "disabled" were corrected with it. Safe becauseagentEditsalready refuses to send a field the host marks read-only, so the form cannot smuggle one through.Widen the e2e
tryabove the creation step. Correct: the POST lands the moment the dialog is submitted, so a failure in the assertion right after it skippedfinallyand leaked the teammate onto a host this spec's own header promises to leave as it found it — where the leftover card could also change whatcard(page, role)matches.Re-verified locally after these:
npm run typecheck,npm run typecheck:e2eandnpm run typecheck:unitclean, the 12 unit cases inagent-detail.test.tsstill pass, andrustfmt --edition 2024 --checkclean on every touched Rust file. Rust compilation and tests remain CI-only.Update: a second lint behind the first, and why it was fixed by convention rather than by boxing
With the
E0603gone,Build (openhuman, tinycortex)passed — the tree compiles under both feature arms and the new test targets compile — and a single new error surfaced inClippy, on both lanes:It was hidden behind the resolution error:
E0603aborts before lints run, so the first round could only ever surface one layer. It was also introduced by a review fix in the previous round — collapsing twomatcharms into?is what made this helper return aResultat all.Clippy's own suggestion is to box the
Response. That was not taken. The crate already has a convention, and it is not a close call:src/server/ops/Result<_, ApiError>Result<_, Response>in a non-handler helperteam.rs::load_record, whoseOkis a wholeCompanyRecord, so the lint exempts it-> Option<Response>refusal helpersteam.rs::validate_cap,team.rs::require_roster_teammateBox<Response>anywhere insrc/Boxing would have silenced the lint while leaving this one helper shaped unlike all 34 of its neighbours, and would have introduced the crate's first
Box<Response>. The twoOption<Response>refusal helpers are inteam.rs— the very file this module extends — and exist for exactly this reason: to keep a large error out of aResult.So
trimmed_fieldreturnsResult<Option<String>, ApiError>and the caller converts at the boundary. The handler above it keepsResponse, legitimately: itsOkisJson<AgentDetailDto>, which is larger still, which is why clippy exempted it and why the log named one function rather than three.That
ApiErrorclears the threshold is precedent, not hope:daily_spend_samplesthree functions up inteam.rspairs a 24-byteOk(Option<Vec<UsageSample>>, the same size asOption<String>) withApiErrorand passes this lint onmaintoday. The reasoning is recorded in a doc comment on the function so the shape is not collapsed back later.Review threads
All four CodeRabbit threads are resolved, each addressed by a code change plus a reply on the thread explaining what was done and why. None was declined. Where the suggested diff was not taken verbatim — the 404 classification, where roster membership rather than roster success is the discriminator — the divergence is written on the thread rather than left for a reader to spot in the diff.