Skip to content

fix(workspace): a failed agent-page fetch says so instead of rendering an empty agent (#2597) - #2602

Merged
vybe merged 4 commits into
devfrom
fix/2597-info-body-load-failure
Sep 9, 2026
Merged

vybe merged 4 commits into
devfrom
fix/2597-info-body-load-failure

Conversation

@dolho

@dolho dolho commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

usePortalAgentPage has always returned error, loaded and reload. Both consumers ignored a different half of them, and the two failures are mirror images:

failed FIRST load failed REFRESH
PortalAgentDetails (before) rendered an empty agent —
PortalAgentBand (before) skeleton, forever InlineError ✓
both (now) LoadFailed + retry InlineError beside the data ✓

The reported half

PortalAgentDetails destructured { header, capabilities } only, so a failed /page fell through to the computed fallbacks: identity degraded to the slug, the description vanished, and "This agent hasn't published anything it can do yet" appeared — a positive claim about the agent on no evidence, indistinguishable from a real answer. "Your chats" kept working (a slice of the shell's thread list, not /page), which made the rest read as loaded rather than broken.

The half the issue attributes to the band

The issue cites the band as the surface that already follows the ent#253 rule. It follows half of it: InlineError is gated on loaded, so a failed first load leaves three placeholders pulsing forever with no error and no retry. There are exactly two consumers of this fetch, and fixing one of two is how this class keeps coming back — so both arms are wired here.

Scope of the failure arm

It replaces only the regions /page feeds and deliberately leaves the chat list alone: one wrapper around the whole body would hide a list that is still perfectly good. Every arm gates on the verdict (loaded), never on loading (#2540/#1927).

Tests

Two ent#523 guards pinned the band's literal v-if="!loaded". The properties they protect — skeleton shaped like the row it precedes, gated on the verdict and never on loading — are unchanged, so they are re-pointed at the narrowed gate rather than dropped.

New portalAgentPageFailure.spec.js pins both consumers, both directions, and that the chat list stays out of it. Frontend suite: 2257 passed (102 files); loading-gate ratchet green.

Why now

ent#547 widened it: the body is mounted once per participant in a room, so N independent chances to fail silently — including for a participant off the viewer's roster, where _require_roster answers a uniform 404 by design.

Fixes #2597

🤖 Generated with Claude Code

https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ

dolho and others added 2 commits September 8, 2026 11:17
…g an empty agent (#2597)

`usePortalAgentPage` has always returned `error`, `loaded` and `reload`. Both
consumers ignored a different half of them, and the two failures are mirror
images of each other.

`PortalAgentDetails` destructured `{ header, capabilities }` only, so a failed
`/page` fell through to the computed fallbacks: identity degraded to the slug,
the description vanished, and "This agent hasn't published anything it can do
yet" appeared — a positive claim about the agent made on no evidence, and
indistinguishable from a real answer. "Your chats" kept working, because it is
a slice of the shell's thread list rather than of `/page`, which made the rest
read as loaded instead of broken.

`PortalAgentBand` reads `error` but gates its banner on `loaded`, so it covers
a failed REFRESH and left a failed FIRST load as three placeholders pulsing
forever, with no error and no retry. The issue cites the band as the surface
that already follows the rule; it follows half of it. There are exactly two
consumers of this fetch, and fixing one of two is how this class keeps coming
back — so both arms are wired here.

Both now: `LoadFailed` + retry on a failed first load, in place of the region
the payload feeds; `InlineError` beside the data on a failed refresh (ent#253).
The failure arm deliberately replaces only the `/page`-fed regions and leaves
the chat list alone — one wrapper around the whole body would hide a list that
is still perfectly good. Every arm gates on the VERDICT, never on `loading`
(#2540/#1927).

Two ent#523 guards pinned the band's literal `v-if="!loaded"`. The properties
they protect — skeleton shaped like the row it precedes, gated on the verdict
and never on `loading` — are unchanged, so they are re-pointed at the narrowed
gate rather than dropped.

ent#547 is why this was worth fixing rather than recording: the body is now
mounted once per participant in a room, so N independent chances to fail
silently — including for a participant off the viewer's roster, where
`_require_roster` answers a uniform 404 by design.

Frontend suite: 2257 passed (102 files); loading-gate ratchet green.

Related to #2597

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
…not a branch inside it

Self-review finding on my own first cut, in two parts.

LAYOUT. The `LoadFailed` arm landed INSIDE `flex flex-wrap items-center`, where
its centred icon column becomes a flex item in a row of stat figures — and
`dense` is `py-6` against a band that is `py-2.5`, so a failure would roughly
triple the band's height and shift the whole conversation column (contract
principle 4). Its sibling, the failed-refresh `InlineError`, sits outside that
row; the two faces of one failure were rendering in two different languages, in
two different places.

Both faces are now `InlineError`, and the first-load arm is a sibling of the
whole stat row. That placement is load-bearing rather than cosmetic: the row's
`v-else` renders `stats`, which falls back to `{ total_executions: 0 }` — zeros
are a claim about performance where "no data" is a claim about nothing, so a
banner ABOVE a still-rendering row would have reproduced the exact "empty
agent" defect this issue is about, one component over.

TESTS. My guard named `LoadFailed`, so replacing the primitive turned it red
for no behavioural reason — the same mistake I had just called out in the
ent#534 guard. It now pins the property: an arm gated on `error && !loaded`
that offers a retry, and the failure arm ordered before the stat row. A "one
failure language" guard replaces the primitive name.

Consequently the two ent#523 guards go back untouched: `v-if="!loaded"` is
unchanged again, so editing them was collateral of a design since replaced.

Frontend suite: 2258 passed (102 files).

Related to #2597

@dolho dolho left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/review — PR #2602 (self-review)

Branch: fix/2597-info-body-load-failure → dev · Files: 5
Scope: CLEAN, with one deliberate widening — the issue is written about PortalAgentDetails, and the fix also covers PortalAgentBand. Justification under [I1].

Critical

None.

Findings from this pass — fixed here

[F1] My own failure arm reproduced the bug it was fixing, one component over (Confidence: 9/10)
src/frontend/src/components/portal/PortalAgentBand.vue

The first cut put LoadFailed v-else-if="!loaded" inside <div class="flex flex-wrap items-center gap-x-6 gap-y-2">. Two things wrong, and the second is the real one:

  1. Layout. LoadFailed renders a centred icon column at py-6; the band is py-2.5. As a flex item in a row of stat figures it reads wrong, and it roughly triples the band's height on failure, shifting the whole conversation column down — contract principle 4. Its own sibling, the failed-refresh InlineError, sits outside that row. One failure, two languages, two places.

  2. The arm had to be a sibling of the row, not a branch inside it. The row's v-else renders stats, which the composable falls back to as { total_executions: 0, timeline: [] }. Zeros are a claim about performance where "no data" is a claim about nothing — so a failure banner sitting above a still-rendering stat row would have been the exact "empty agent" defect this issue is about, reproduced in the component I was adding the fix to.

Both faces are now InlineError, and the failure arm is a sibling of the whole row. Pinned by a guard that asserts the failure arm is ordered before the row rather than nested in it.

[F2] My guard named a primitive instead of a property (Confidence: 8/10)
portalAgentPageFailure.spec.js

expect(band).toMatch(/<LoadFailed[\s\S]{0,240}@retry="reload"/) — so replacing LoadFailed with InlineError turned it red for no behavioural reason. That is precisely the mistake I flagged in the ent#534 guard in #2601 and in the ent#523 guards below, made in a test I wrote in the same session. It now pins the property: an arm gated on error && !loaded that offers a retry, plus a "one failure language" guard.

Consequently the two ent#523 guards in portalAgentPageUx.spec.js go back untouched — v-if="!loaded" is unchanged again, so editing them was collateral of a design I have since replaced. Worth noting as a general lesson: I edited two other people's guards to accommodate a design that did not survive my own review pass.

Informational

[I1] The band fix is outside the issue's stated scope, deliberately (Confidence: 8/10)

#2597 is written about the Info body and cites the band as the surface that already follows the ent#253 rule. It follows half of it: InlineError v-if="error && loaded" covers a failed refresh, and a failed first load left three placeholders pulsing forever with no error and no retry.

There are exactly two consumers of usePortalAgentPage (grepped). Fixing one of two is the incomplete-fix class the repo keeps re-learning (/review 4.14), so both arms are wired. A reviewer who wants the band split out has a fair case; the counter-argument is that the two failures are the same rule and share a composable, and a follow-up issue for the other half of a two-line rule is overhead.

[I2] The panel header still degrades on a failed load — and I am not fixing it (Confidence: 8/10)

The issue lists "identity falls back to the slug, description blank" among the symptoms. I fixed the description (it is gated on header?.description, so it simply does not render) and the capabilities claim, and left the header. Checked rather than assumed:

  • healthLabel falls back to 'Status unknown' (PortalAgentDetails.vue:243) — honest;
  • availability is computed from props.agent, the roster payload — a different fetch — with header.value?.owner only as an owner fallback, so it stays valid;
  • the label degrades to the slug via agentDisplayName.

None of those is a false claim; the slug is a degraded label, not an assertion. The only thing that asserted something untrue was "hasn't published anything it can do yet", which is fixed. Stating this explicitly so the remaining degradation is a recorded decision rather than an oversight.

Clean (verified)

  • error/loaded/reload were already exposed by the composable — the whole fix is consumer-side, pinned by a guard so a future change there cannot silently make these guards test nothing.
  • The chat list is deliberately outside the failure arm, pinned by a test: it is a slice of the shell's thread list, and one wrapper around the body would hide a list that is still perfectly good.
  • Every arm gates on the verdict, never on loading (#2540/#1927); loadingGateRatchet green; raw-colour scan clean on the touched files; semantic status tokens only.
  • Frontend suite: 2258 passed (102 files).

Summary

Critical: 0 · Findings fixed in-pass: 2 · Informational: 2 · Scope: one stated widening.

Cannot self-approve; posting as a review comment. [F1] is the substantive one — it was a real defect in my own fix, caught by asking where the element actually renders rather than whether the test passed.

…the window selector

Re-review of my own previous fix, which over-corrected.

That commit moved the failure OUT of the stat row entirely, replacing the whole
row. The objection it was answering was real but specific to the PRIMITIVE:
`LoadFailed` renders a centred `py-6` column, which in a `py-2.5` band is both
shaped wrong for a row of stat figures and a layout shift of the conversation
column. It was not an objection to the PLACEMENT.

Replacing the row cost something I did not notice until re-reading where the
elements actually render: the window `<select>` is a sibling of the v-if chain,
not part of it, so it went with the numbers. Changing the window is a second
way out of a failure — `watch(timeWindow)` re-fetches — alongside the retry
button, and a failed 30d load could no longer be answered by trying 7d.

So the failure is now the FIRST ARM of the existing chain, inside the row.
`InlineError` is row-shaped, so it costs no layout; the selector renders in
every state; and it is still an arm rather than a banner, because the `v-else`
branch renders `stats`, which falls back to `{ total_executions: 0 }` — zeros
are a claim about performance where "no data" is a claim about nothing.

Tests: the ordering guard now pins the three-arm chain, plus a new guard that
the selector stays reachable while the failure shows — the regression this pass
caught. The two ent#523 guards are matched position-independently
(`v-(?:else-)?if="!loaded"`) so they keep asserting their own properties, the
skeleton's shape and never-gate-on-`loading`, instead of failing every time an
arm is added above them. I had already edited those two guards twice for this
one change; that was the signal they were pinned too tightly.

Frontend suite: 2259 passed (102 files).

Related to #2597

@dolho dolho left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/review — re-review of the fix commits (PR #2602)

Scope: the two commits added since the first review, not the original diff. Verified: full frontend suite 2259 passed (102 files) at the new head.

Re-reviewing my own fix found a defect the fix itself introduced — the useful outcome of this pass.

Critical

None.

Finding — fixed in this pass

[F3] The previous fix over-corrected and removed a recovery affordance (Confidence: 9/10)
PortalAgentBand.vue

The last commit moved the failure out of the stat row and replaced the whole row with it. Re-reading where the elements actually render rather than whether the tests passed:

<div class="flex flex-wrap items-center …">     ← the row I was replacing
  <template v-if="!loaded"> skeleton </template>
  <template v-else> …stats… </template>
  <div class="flex-1"></div>
  <select v-model="timeWindow" …>              ← a SIBLING of the chain, not part of it
</div>

So replacing the row took the window <select> with the numbers. Changing the window is a second way out of a failure — watch(timeWindow, () => load()) re-fetches — alongside the retry button, so a failed 30d load could no longer be answered by trying 7d.

The objection that drove the previous commit was real but specific to the primitive: LoadFailed's centred py-6 column in a py-2.5 band is shaped wrong for a row of stat figures and a layout shift of the conversation column. It was never an objection to the placement. I generalised it one step too far.

The failure is now the first arm of the existing chain, inside the row: InlineError is row-shaped so it costs no layout, the selector renders in every state, and it is still an arm rather than a banner — the v-else branch renders stats, which falls back to { total_executions: 0 }, and zeros are a claim about performance where "no data" is a claim about nothing.

[F4] I had now edited two other people's guards twice for one change (Confidence: 8/10)
portalAgentPageUx.spec.js

The ent#523 guards pin v-if="!loaded". My first design made it !loaded && !error; my second made it v-else-if="!loaded"; each time I edited those two tests to match. Editing someone else's guard twice for one change is the signal that it is pinned too tightly, not that it keeps being wrong.

They now match v-(?:else-)?if="!loaded" — position-independent — so they keep asserting the two properties they exist for (the skeleton's shape; never gating on loading) and stop failing whenever an arm is added above them. Both negative assertions are untouched.

Informational

[I3] Three structural iterations on a two-line rule (Confidence: n/a)

Inside-the-row (LoadFailed) → sibling-of-the-row (InlineError) → arm-inside-the-row (InlineError). The middle step was wrong and the tests passed at every step, because at each step I had pinned the shape I had just written. The guard that finally caught it is the new one — "the selector stays reachable while the failure shows" — which asserts a user-visible consequence rather than a structure. Recorded because it is the transferable lesson from this PR, not because the end state is in doubt.

Clean (re-verified at the new head)

  • Every state of the three-arm chain: error && !loaded → InlineError; !loaded → skeleton; otherwise → stats, with the failed-refresh InlineError still below and gated on loaded. No state renders zeros, and none renders both a skeleton and a failure.
  • The window selector renders in all three, pinned by a guard that also checks no v-if/v-else opens between the last arm and the <select>.
  • LoadFailed is fully gone from the band — import removed, and the "one failure language" guard reads a comment-stripped view so the reference in the explanatory comment cannot make it vacuous.
  • PortalAgentDetails is unchanged by these two commits and keeps LoadFailed, which is right there: it sits in a vertical space-y-6 column and replaces a section body, exactly as the Reports section below it already does.
  • Suite 2259 passed; loadingGateRatchet green.

Summary

Critical: 0 · Fixed in this pass: 2 · Informational: 1.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

@vybe

vybe commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

merge-train: ejected — structural conflict with #2604. The compact-header change removed the flex-1 … p-4 space-y-6 wrapper in PortalAgentDetails.vue that this PR inserts its <InlineError> into (two hunks, ~L44–62 and ~L64–173). Intent is compatible but the re-apply is yours. Also: portalAgentPageFailure.spec.js:43 asserts the literal 'reload: load', which #2604 rewrote to const reload = () => load({ force: true }) — that assertion goes red the moment you rebase. Please also change "Related to #2597" to "Fixes #2597". Rides the next train once green.

@vybe

vybe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-09-09): not on this train — src/frontend/src/components/portal/PortalAgentDetails.vue conflicts with dev, which restructured the panel (the wrapping div.flex-1 was removed and the sections dedented, #2608) after this branch forked. Where the new InlineError sits in the restructured layout is a placement call for you rather than a mechanical re-apply. Merge origin/dev, re-place the block, and it rides the next train.

Structural conflict with #2604, per the merge-train note. Resolved by taking
dev's structure and re-applying this PR's two additions into it:

* ent#547 removed the `flex-1 … p-4 space-y-6` wrapper in
  `PortalAgentDetails.vue` that the `<InlineError>` refresh-failure banner was
  inserted into. It is now a direct child of the panel body at dev's
  indentation; the ent#253 rule it implements is unchanged.
* The `LoadFailed` arm still comes FIRST in "What it can do", ahead of the
  empty arm — the empty arm is a positive claim a failed fetch supplies no
  evidence for, which is the whole defect.

Two guards in `portalAgentPageFailure.spec.js` were pinning shapes dev has
since changed:

* `'reload: load'` — #2604 rewrote the alias to
  `const reload = () => load({ force: true })`. The consumers need the
  composable to RETURN something called `reload`, not for its definition to
  have a particular spelling, so the assertion now says that.
* "keeps the time-window selector reachable while the failure shows" — ent#547
  DELETED that selector (the window is fixed at 7 days). The test asserted a
  control that no longer exists. Its structural half survives — the failure is
  an ARM INSIDE the row, so anything else the row holds is a sibling of the
  chain — and it now also pins the selector's absence rather than silently
  passing on a missing element.

Frontend suite: 107 files, 2397 tests, all passing.
@dolho
dolho force-pushed the fix/2597-info-body-load-failure branch from cfccb6c to 533eb67 Compare September 9, 2026 08:32
@dolho

dolho commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

dev merged in 533eb678; body switched to Fixes #2597.

The #2604 conflict, resolved as you described. Took dev's structure and re-applied this PR's two additions into it:

  • ent#547 removed the flex-1 … p-4 space-y-6 wrapper the <InlineError> refresh banner was inserted into, so it is now a direct child of the panel body at dev's indentation. The ent#253 rule it implements is unchanged — a failed refresh keeps everything on screen and says so beside it.
  • The LoadFailed arm still comes first in "What it can do", ahead of the empty arm. That ordering is the whole defect: "hasn't published anything" is a positive claim a failed fetch supplies no evidence for.

Two guards were pinning shapes dev has since changed — you called the first, and re-running the suite found its twin:

  • portalAgentPageFailure.spec.js:43 asserted the literal 'reload: load'; feat(workspace): the compact header — Info as a rail tab, one paperclip, voice at the composer (abilityai/trinity-enterprise#547, #2580) #2604 rewrote it to const reload = () => load({ force: true }). What the consumers need is that the composable returns something called reload, not that its definition has a particular spelling, so the assertion now says that.
  • "keeps the time-window selector reachable while the failure shows" would have gone red next: ent#547 deleted that selector (window fixed at 7 days), so the test asserted a control that no longer exists. Its structural half survives — the failure is an ARM INSIDE the row, so anything else the row holds is a sibling of the chain and cannot be swallowed — and it now pins the selector's absence explicitly rather than passing silently on a missing element.

Frontend suite on the merged branch: 107 files, 2397 tests, all passing.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train (2026-09-09): validated at lane B — /validate-pr + /review clean, touched unit tests and ratchets pass locally against the current dev tip. Below the train floor, so merged individually.

@vybe
vybe merged commit 60e2648 into dev Sep 9, 2026
27 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants