Skip to content

DO NOT MERGE — merge train: 2690,2685,2677,2684,2697,2648 - #2701

Closed
vybe wants to merge 25 commits into
devfrom
train/20260911-0925
Closed

vybe wants to merge 25 commits into
devfrom
train/20260911-0925

Conversation

@vybe

@vybe vybe commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2690, #2685, #2677, #2684, #2697, #2648. Never merged; members merge individually once green.

Sibling collision resolved on the train only (not a dev conflict): #2697 × #2685 on docs/memory/learnings.md (both entries kept) and tests/registry.json (rebuilt from stages: base + both sides' new entries, 239 entries, consumers pass).

dolho and others added 25 commits September 9, 2026 13:13
…ts box (#2640)

Two defects, one report.

**The layout jumped.** `Portal.vue` toggled `<main>` between `flex-1` and
`sm:flex-[2_1_0%]` on `voiceCall.active` and swapped `PortalRail` for
`PortalVoiceCanvas` in the same frame. Nothing transitioned, so both columns
landed at their new shares in one paint, and End call jumped back.

`flex-grow` is a `<number>` and therefore animatable, so the share now
transitions — 300 ms ease-out on `<main>`, and the canvas column ramps its own
grow 0 → 3 over the same curve. The ramp is expressed as Vue enter/leave
classes rather than a class toggle because a newly inserted element has no
value to transition FROM. Opacity rides the same transition so the canvas's
content is not re-wrapping in view while the column is still moving.
`motion-reduce:transition-none` on every transitioning element.

The shares stay shares: reverting to `w-[40%]` / `w-[60%]` would animate just
as well and re-open #2581, where those summed to 100% + an 18rem sidebar and
the shell clipped the canvas column off the right edge.

Two consequences, both deliberate:

* The `v-if` / `v-else-if` chain is gone — a `<Transition>` wrapper breaks the
  adjacency a chain needs. The exclusivity it guaranteed by construction is now
  a named computed both arms read, so they cannot drift into both claiming the
  column.
* The rail waits for the canvas to finish leaving. Vue keeps a leaving element
  in the DOM for its transition; without the gate the rail would mount at full
  fixed width beside a canvas that is still shrinking — three columns in a row
  sized for two, `<main>` squeezed by flex for 300 ms, a worse jump than the one
  being fixed.

**The orb rendered squashed.** `VoiceOverlay.vue::resizeCanvas` sized the
bitmap ONCE, from the `watch(canvasEl)` that fires on mount — no
ResizeObserver, no window listener, no per-frame check — while the canvas is
`absolute inset-0 w-full h-full`. Every later width change left CSS stretching
a stale bitmap into an ellipse, and the overlay mounts in the same tick the call
re-lays out the columns, so the single measurement could capture the pre-call
width on its own.

It now observes both: a ResizeObserver for the box moving under a stable window
(the column swap, a rail drag), and a window `resize` for a devicePixelRatio
change, which resizes no box and so fires no observer. The bitmap is sized at
`css × dpr` (capped at 2) and the render loop draws in CSS pixels via
`ctx.setTransform`, so the 45px core and the particles' fixed radii keep meaning
what they meant. Resizing re-scales and never re-seeds — the particle field is
seeded once and lives in a fixed space around (0,0), so the orb does not restart
when the column moves. A zero-sized box is ignored rather than throwing the last
good size away, and a same-size measurement does not touch the bitmap, because
assigning to `canvas.width` clears the canvas.

Tests: `portalVoiceLayoutMotion.spec.js` (17) — the resize contract EXECUTED
against a stub canvas whose box changes (DPR scaling, the cap, a missing DPR,
the zero-box and same-size guards), mutation-checked by pinning the bitmap to
its first measurement, which fails exactly the "FOLLOWS a changed box" case;
plus the transition classes, the reduced-motion fallback counted over every
transitioning element, and the leave gate. Two existing guards in
`portalVoiceMode.spec.js` / `portalRail.spec.js` were rewritten to read the
shared condition instead of the retired chain — the property they protect is
exclusivity, not which construct expresses it.

Frontend suite: 113 files, 2532 tests, all passing; both ratchets green; vite
build clean.

Related to #2640

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…tab title (ent#557)

An agent replied while the operator was elsewhere and nothing indicated it.
Most of the machinery already existed — the per-agent pill, the per-chat badge,
the aggregate in the wordmark. Three things were missing.

**1. A chat you have never opened could not be unread.** ent#359 defined unread
relative to a read cursor and gave a cursorless thread a count of nothing,
correctly: inventing a cursor at the beginning of time would have badged every
historical conversation in every install on the day it shipped. What it could
not foresee is ent#523, which made Main the landing place for everything an
AGENT starts. A freshly minted Main has never been read by anyone, so the one
case an unread badge exists for produced no badge anywhere.

A cursorless thread now counts agent messages newer than the viewer's account
baseline — "anything an agent has said to you since the first time you read
anything here, in a chat you have never opened".

The baseline is a STORED, write-once row, not a derived value, and that is the
whole of the design. Both obvious derivations were tried and both fail the same
test:

* MAX(last_read_at) — "since you were last here" — advances every time the
  viewer reads anything, so an unread reply in a chat they have not opened is
  silently cleared by reading a DIFFERENT chat.
* MIN(last_read_at) — "since the first time you read anything" — looks stable
  and is not: `mark_chat_read` UPDATES the row it advances, so a viewer with one
  chat has MIN == MAX and inherits the identical bug.

MIN was implemented first and `test_reading_one_chat_does_not_silently_clear_
another` failed it, which is why the row exists. It lives in the chat-state
table under a reserved kind, written on the first ever read and never moved;
every read of that table excludes it, so it reaches neither the sidebar payload
nor either row cap. A viewer with no baseline still counts nothing — ent#359's
property preserved, and it falls out of SQL's NULL comparison rather than a
second branch.

**2. It only appeared when you did something.** `refreshThreads()` is
event-driven — a send, a navigation, a turn finishing — so an agent-initiated
reply reached the sidebar on the viewer's next action and not before, which is
the same as never for someone in another tab. It now also runs on the ent#364
asks poll (20s, visibility-aware). Folded into that timer rather than given its
own: the Workspace has no WebSocket a portal client is on, and since #2198 the
thread half is ONE request for every agent, which is what makes it cheap enough
to ride there.

**3. The tab said nothing.** While anything is unread the title carries
`(3) Trinity — Workspace`, returning to the plain title when everything is read
and cleared when the Workspace unmounts. `utils/tabTitle.js` owns it because
`document.title` now has two writers on independent schedules — the router's
label and the count — and with both assigning directly the last to fire would
erase the other's half. Neither assigns; both call in. The count is a PREFIX on
whatever the router computed, which is what lets ent#556 change the label
without touching any of this, and it leads rather than trails because browsers
truncate a tab from the right.

Reading clears it with no reload: `markRead` already cleared optimistically and
re-decorated `threads`, so the total recomputes synchronously and the tab
follows. Asks and unread stay separate (#2424) — this adds nothing that sums
them.

Tests: `test_ent557_unread_never_opened_chat.py` (12, against a real sqlite so
the SQL is the code under test), `portalUnreadTabTitle.spec.js` (14, the format
executed and the two-writer ordering), `portalUnreadLiveness.spec.js` (10).
Backend portal slice 587 passed; frontend 114 files / 2539 tests; ratchets green;
build clean.

OSS-core, deliberately ungated, per the standing Workspace ruling restated in
the issue.

Related to Abilityai/trinity-enterprise#557

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…QL (ent#557)

`count_unread_by_session` was widened to LEFT JOIN and count a never-opened
thread against the account baseline — and `service.get_chat_state` then
discarded every one of those rows. It built the payload by iterating the
viewer's state ROWS and reading the unread map off them, so a thread with no
cursor (the entire ent#557 case: an agent replies into a freshly minted Main
nobody has opened) never appeared. No badge, no per-agent pill, no wordmark
total, no tab title. AC 1 and AC 2 were undelivered; the only case the change
did reach was "starred but never opened", which is not the reported bug.

The service now runs a second pass over the unread map and emits the thread ids
the row loop did not, de-duplicated against them so one chat cannot be counted
twice in the wordmark total, and `starred: false` by construction. It is bounded
by the same read — `count_unread_by_session` is scoped to the caller's own
`enterprise_portal_messages`, so it cannot append a chat that is not theirs.

The comment where the fallback used to be argued against restoring it by citing
the INNER JOIN and the `last_read_at IS NOT NULL` filter — both of which this
same PR removed. It read as protection that exists and would have stopped the
next reader from putting the pass back (the ent#523 entry in `learnings.md`).
Replaced with what is actually true now.

Tests: five cases crossing the db→service boundary, which is the boundary all
twelve existing tests skip by calling `client_portal.db` directly — which is why
this was invisible. `test_the_api_reports_a_chat_that_has_no_state_row` fails
against the pre-fix service and passes after.

Related to Abilityai/trinity-enterprise#557

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
… step (#2640)

Two review findings.

1. **Reduced motion was not instant.** Tailwind's `transition-none` emits only
   `transition-property: none` — the `duration-300` beside it still applies, so
   `transitionDuration` stays `.3s`. That is the exact property Vue's
   `getTransitionInfo` reads to size the fallback timer it resolves
   `@after-leave` on, so under `prefers-reduced-motion` nothing animated and
   every leave was still gated for 300ms: the canvas vanished, the right column
   sat empty, then the rail popped in. `motion-reduce:duration-0` on every
   transitioning element drives that timeout to 0. Verified against Tailwind's
   own output — the variant emits `transition-duration: 0s` inside the media
   query and after `duration-300`, so it wins.

   The comment on `voiceCanvasLeaving` claimed "`after-leave` fires immediately
   and this is never observably true", which was false as written; it now says
   what makes it true. Same correction in the feature flow.

2. **The rail column still steps** — accepted here, tracked at #2676. Its
   `<aside>` carries no width transition and it is a `shrink-0` flex sibling of
   `<main>`, so on call end it mounts at full width in one frame: 48px
   collapsed, 384px open, or the dragged `--ws-rail`, which on a wide rail is a
   bigger step than the 211px snap this PR removes. The honest fix is an
   explicitly animatable width for that column — CSS cannot transition to
   `auto` — and that width is owned by ent#492, not by the voice-call code.
   Doing it from here means either a wrapper element in the row or holding the
   rail mounted through a call, both of which want a browser to verify rather
   than the node-env source scan this suite is limited to. Recorded as a Known
   Limitation in the feature flow, with a test that fails if the limitation is
   deleted from the record or if the rail starts animating.

Tests: the class-counting case now requires BOTH reduced-motion classes and
asserts the ordering relation, since asserting the class string rather than the
behaviour is exactly why (1) shipped green.

113 files / 2534 tests green.

Fixes #2640

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…as (#2676)

#2640 animated two of the three columns. The rail was the third and it still
stepped: a bare `v-if` on a `shrink-0` flex sibling whose `<aside>` carries no
width transition, so on call end it appeared at full size in one frame — 48px
collapsed, 384px open, or whatever `--ws-rail` had been dragged to, which on a
wide rail is a LARGER step than the 211px snap #2640 removed.

The width now lives on a wrapper this view owns rather than on `PortalRail`'s own
`<aside>` — the same shape the sidebar column three columns to the left already
has (`shrink-0 overflow-hidden` plus an explicit `--ws-` width). `--ws-rail` is
the RENDERED width (48px collapsed, the dragged width open), so one binding
covers both states and agrees with the inner aside at rest.

**Vue enter/leave classes, not an always-on `transition-[width]`** — the
load-bearing decision. That same variable is rewritten on every `pointermove` of
a rail drag, so a permanently-transitioned width would make dragging rubber-band
by 300ms. Vue adds the active class only for the enter/leave window and removes
it afterwards, so a drag stays instant. `!w-0` is `!`-marked for the reason the
canvas's `!grow-0` is: both are single-class selectors setting the same property,
so without it Tailwind's output order would pick the winner.

**This retires `voiceCanvasLeaving`.** The flag existed because a rail mounting
at full width beside a still-shrinking canvas put three columns in a row sized
for two. A rail entering from zero width is complementary to a canvas leaving
towards zero grow — the row's total is conserved at every frame — so the hazard
is gone by construction rather than held off by a flag, and the two motions now
overlap instead of running back to back. The reduced-motion reasoning its comment
carried is not lost: it belongs to the transition classes, and the class-counting
test requires both `motion-reduce` classes on every transitioning element,
including the two added here.

`railHasColumn` is the one shared condition, and it reads `railTabs.length`
because `PortalRail`'s own root carries `v-if="tabs.length"` — with the width on
a wrapper, a tabless rail would otherwise leave a full-width empty column.

Measured in headless Chromium against the real built stylesheet rather than
argued from the classes: mid-transition the column is at 322px of its 384
(interpolating, not stepping), it settles at 384, under `prefers-reduced-motion`
it is at 384 immediately, and a drag issued after the active classes are removed
lands in the same frame. The three Tailwind utilities involved are confirmed
present in the built CSS — `.\!w-0{width:0!important}`,
`.transition-\[width\]{transition-property:width}` and
`.w-\[var\(--ws-rail\,24rem\)\]` — since an arbitrary-value miss would have made
the whole thing silently inert.

Accepted discontinuity: `thirdColumnResizable` is gated on `!voiceCall.active`,
so the 8px resize handle still appears and disappears in one frame. It is 8px
against a column that moves 384, and animating it would mean a second animated
element whose only content is a 1px line.

Tests: the transition scan now reads CLASS ATTRIBUTES rather than the whole file
— this change's own comments name `transition-[width]` while explaining it, and
a file-wide match reports the prose as an un-guarded transitioning element. Three
source guards updated where the `v-if` moved (they assert the shared computed,
which is the property; which element carries the condition is not), and four new
cases: the ramp's classes, the static class carrying no `transition-`, the
retirement of `voiceCanvasLeaving`, and the tabless-rail gate. Removing the
`<Transition>` reds the first of those.

113 files / 2536 tests green; raw-colour ratchet unchanged.

Fixes #2676
Related to #2640

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
…y clear (ent#557)

Found re-reviewing my own fix. Emitting cursorless threads was the right
correction; it interacts badly with a cap I did not check.

`service.mark_chat_read` silently no-ops when the row would be a NEW one and the
viewer is at `MAX_CHAT_STATE_ROWS` (1000) — deliberately, on the stated ground
that "a read marker is incidental to what the user asked for (opening a chat)".
A cursorless thread is by definition a new row.

So ent#557 made that no-op load-bearing. Before it, a cursorless thread produced
no badge and the no-op was invisible, which is what made the justification true.
After it, a capped viewer gets a badge on the wordmark total, the per-agent pill
AND the browser tab title, and opening the chat cannot dismiss it — a permanent
unread marker with no user action that clears it.

The second pass is now gated on there being room. Not shown beats
shown-and-stuck: a capped viewer degrades to exactly the ent#359 behaviour,
which is the state they were in before this feature, rather than to a badge that
never goes away.

Three properties, each deliberate:

* the gate applies to the CURSORLESS pass only. A thread that already has a row
  can always be marked read (`_would_create_row_past_cap` allows updating a row
  the caller already owns), so capping its badge would hide unread the viewer
  can perfectly well clear;
* it fails OPEN — an unreadable count reports room, because refusing to show
  unread on a COUNT that could not be taken would hide real unread from every
  viewer on a transient DB error, and the write path is what enforces the cap;
* the COUNT is paid only when the pass would emit something, i.e. never on the
  ordinary sidebar load where every thread already has a row. Asserted, so it
  stays true.

Tests: the capped viewer (badge withheld, and the no-op demonstrated rather than
assumed — `mark_chat_read` is called and the count does not move), the
room-left case clearing to zero, a row-bearing thread still badging AT the cap,
the fail-open path, and the no-COUNT-on-ordinary-load cost note. Removing the
gate reds the first.

527 passed across portal / chat-state.

Related to Abilityai/trinity-enterprise#557

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
`.prose-portal` styled exactly four things — paragraphs, unordered lists,
links and code — and Tailwind's preflight reset everything else, so every
other element an agent can write arrived with the browser reset and nothing
else: a GFM table with no rules and no padding, headings at body weight,
ordered lists with their numbers gone.

The element set is now covered where the render, the style and the copy
handler already live (PortalMarkdown.vue), so the bubble, the room and any
future surface inherit one look rather than a second one being copied out:

- Tables get the platform's own anatomy — mono-caps header on chrome, 6/12
  cell padding, a rule per row — as an object one step off the bubble tint,
  the same shape a code block already has. The scroll-viewport pair is lifted
  from CanvasKit (#2583): `display: block` makes the table its own horizontal
  viewport so a ten-column table scrolls instead of widening the bubble, and
  `overflow-wrap: normal` in the cells stops an inherited "break anywhere"
  squeezing an auto-layout table to one character per line. `width:
  fit-content` keeps a two-column table from drawing its border around an
  acre of nothing.
- GFM alignment survives sanitising as the `align` attribute, but a
  presentational hint loses to author CSS, so the right/center cases are
  restated and the table carries `tabular-nums`.
- Headings get a bounded ladder: `# Title` names a section of the reply, not
  the page, so it tops out at the design system's section size (18/650) and
  lands on the meta overline by h3 — three visibly distinct steps, all inside
  the six-size scale.
- Ordered lists, list items, nested lists, blockquotes (the `ck-callout` 3px
  left rule, so a quoted line reads the same in a message and on a canvas)
  and horizontal rules.

Nothing here touches the sanitiser: the treatment is CSS on platform-owned
selectors, no new admitted tags, attributes or classes. Code blocks keep the
#2515 behaviour — labelled bar, always-visible Copy, wrap-at-the-edge `pre`.

One #2515 assertion is narrowed rather than deleted: it read "the sheet has
no `overflow-x` at all", which was a claim about the `pre` (a scroller inside
a chat bubble hides the end of a line behind a gesture nobody makes) written
as a claim about the whole stylesheet. A table must scroll in its own
viewport, so the assertion now looks at the `pre` rule it was always about.

Verified in light and dark at 1100px and at 430px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GawbQRicLvNMaPknFT8BfR
Its own commit, with the growth named, per the design-system contract — a
deliberate increase is never absorbed into a feature diff.

- components/portal/PortalMarkdown.vue: raw_gray 13 -> 34 (+21). Giving the
  Workspace transcript a typographic treatment (#2616) costs a surface,
  border and ink pair per element in BOTH themes — table, header row, cells,
  headings, blockquote, rule — and those are gray by definition ("everything
  else is gray"). raw_nongray stays 0; hardcoded_colors stays 0.
- components/portal/PortalJumpToLatest.vue: newly recorded at 1 gray. It
  arrived on dev after the last freeze and had no entry.

#2605's `refrozen` record is carried forward by hand: the regenerator writes
the file wholesale and would have dropped the history of why the ceiling sits
where it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GawbQRicLvNMaPknFT8BfR
…ting accessors (#2669)

`get_or_create_installation_id` is a write API wearing a read API's name. Three
read-shaped callers have minted durable identity by looking (#1987, the ent#190
benchmark read, the ent#545 funnel GET), each closed by a point fix, and the only
static guard lived in the private repo and pinned one module. Nothing in the OSS
tree, where the accessor lives, stopped a fourth.

`tests/unit/test_2669_minting_accessor_callers.py` pins the caller set: every use
of `get_or_create_installation_id`, `get_or_mint_sharing_id` or
`get_instance_label` must sit at an allowlisted `(path, enclosing qualname, name)`
carrying the reason that site needs the identity to exist. An import is a
binding that feeds a per-file alias map, so an aliased or re-bound call is a hit
at the call. The identity keys (`installation_id`, `telemetry_sharing_id`) are
written only by their home modules. The read twin `get_installation_id` is pinned
to exist. Token-prefiltered on case-folded bytes with a fail-closed sentinel for
a token-bearing file that does not parse (the #1677 shape); OSS tree only, the
private twin is trinity-enterprise#575. Self-tests drive every spelling family,
the scope attribution and the verdict in both directions through the same scan
the live tree uses; the first run caught the case-folding gap (learnings
2026-09-10).

Fixes #2669

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL read the helper's one-pass `replace(/<!--[\s\S]*?-->/g, '')` as
js/incomplete-multi-character-sanitization (high): nested input
(`<!--<!-- -->`) leaves a live `<!--` behind. Nothing untrusted reaches
this helper — it reads a checked-in `.vue` file so the assertions are not
confused by prose in comments — but the loop is the rule's prescribed fix
and the stronger strip, and `hardeningGuide.spec.js` already carries the
same fixpoint shape for the same reason, so this matches the established
pattern rather than dismissing the alert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
…2616)

The first attempt ran the fixpoint loop over all three comment strips
chained together, and CodeQL flagged the same rule again: the query only
recognises the loop as a complete sanitiser when the flagged replace's
result is assigned straight back to the loop variable, which a chain of
three breaks. Split out `withoutHtmlComments` so the shape matches
`hardeningGuide.spec.js` exactly — the one form already proven clean on
dev — and chain the JavaScript comment strips after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
…ever imported (#2689)

`portal_auth_exchange` read the sliding-session idle window through a name
the module does not import:

    idle_s, _ = settings_service.get_portal_session_policy()

`grep -n settings_service src/backend/client_portal/router.py` returned
exactly one line — the call. So every request to the ent#163 trusted-issuer
seam raised `NameError` and answered 500. Present on `dev` AND on `main`
(line 308 there): released, not a dev-only regression.

Introduced by ent#375 (#2099), which added the idle-window read to the
response. `dependencies.py` performs the identical read correctly, and for
two reasons that are both load-bearing: the import is function-local
because `settings_service` imports `db` and a module-level import would
make a cycle, and it is wrapped in a degrade to the shipped policy because
this is an auth path and a settings hiccup must not 500 it. The router
copied the call and neither property.

The fix reuses that reader rather than re-importing `settings_service`
here. A second copy of the call would be a second chance to omit the
degrade — and the degrade is the half that matters on an auth route.
Importing an underscore-private name across modules follows existing
practice (`from ._common import _norm_ts`, `from services.git_service
import _detect_git_dir`, `from services.template_service import
_is_platform_injected`).

Blast radius, measured on a live stack in one minute: platform login,
platform JWT, the portal OTP flow, portal session tokens, MCP keys and
agent auth all answered normally; only this route was down. The failure is
reachable only WITH a valid `portal_delegate` key — every other principal
is refused 403 by the scope fence before reaching the line — so it is not
externally probeable, and it is total for exactly the callers the route
exists for.

WHY EIGHT TESTS MISSED IT. `test_ent163_portal_delegated_identity.py` and
`test_163_portal_delegate_scope.py` cover the mint, the access rule, scope
fencing, disjoint identities and revocation — all through
`service.portal_exchange(...)`. None executes the ROUTE HANDLER, and the
NameError is in the handler body three lines after the service returns.
Proven rather than asserted: with the broken line reinstated, only the
three new tests fail and all 26 pre-existing ones still pass.

`tests/unit/test_2689_portal_exchange_route_executes.py` drives the handler.
It pins that the route answers at all, that `expires_in` is the real idle
window (compared against `_portal_session_policy()`, not a literal, so the
policy stays single-sourced), and that a raising settings backend degrades
instead of 500ing. It also re-pins the two gates the route already owned —
the delegate-scope 403 and the no-share 403 — which were previously
asserted only against the service.

An AST scan for the same shape (a module using a service singleton it never
binds) across `src/backend` found no other instance.

Related to #2689

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Self-review of the fix above. Reusing `dependencies._portal_session_policy`
was right; importing it at MODULE SCOPE was not.

Both module-scope forms capture at import time — a `from`-import binds the
function object, `import dependencies as _deps` binds the module object —
and either goes stale if `dependencies` is re-imported after this module.
That is not hypothetical in this repo: `tests/unit/conftest.py` lists
`dependencies` in `_POP_PREFIXES`, and an autouse fixture evicts it from
`sys.modules` before AND after every unit test, deliberately, to stop
cross-file stub pollution.

Measured with the first version of this fix in place: a test's
`import dependencies` produced a DIFFERENT module object than the one
`client_portal/router.py` had captured, so patching the live module returned
(111, 222) while the route went on returning the shipped 604800. With the
`from`-import form the same thing happened one level tighter — the router
held the original function object and never saw the patch at all.

No production consequence: nothing pops modules at runtime, and the route
answers correctly either way. The consequence is that the route becomes
UNTESTABLE from the outside — which is exactly how the defect this PR fixes
reached `main`, so shipping the fix in a form with that property would be
tone-deaf.

The import moves inside the handler. It resolves through `sys.modules` at
call time and cannot diverge, and it is the same shape `dependencies.py`
already uses one level down (function-local, for the import cycle).

The late-binding guard test added alongside it is REMOVED rather than kept
green by weakening it: the harness's own eviction makes that assertion
unreachable here, and a test that fails for harness reasons teaches the next
reader the wrong lesson. The file now says so where the guard would have
been, so nobody adds a flaky one back.

The mutation check still holds: reinstating the original broken line fails
three of the five new tests and none of the 26 pre-existing ent#163 ones.

Related to #2689

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
#2647 (the parent this branch was stacked on) squash-merged to dev an hour
ago, so its content arrived here from two directions at once: as the branch's
own commits and as dev's squashed form. All five conflicts are that, and the
resolution is the same in each — keep the branch, which already carries
#2647's work plus #2676's changes on top.

Portal.vue, three hunks: #2676 RETIRES `voiceCanvasLeaving` (a rail entering
from zero width is complementary to a canvas leaving toward zero grow, so the
row's total is conserved and the flag has nothing left to sequence). dev still
has the flag, its two transition handlers and the old `v-if` on `PortalRail`.
The branch's side is the intended end state.

The three spec files are the same shape one level along: each conflict is
#2676's updated assertion against the pre-#2676 one dev still holds
(`v-if="railHasColumn"` on the wrapper vs `v-if="railVisible && …"` on
`PortalRail`).

Resolved HUNK-WISE, not file-wise. `git checkout --ours` was the first attempt
and was WRONG: it takes the whole file from HEAD and so would have discarded
dev's ent#556 `PortalBrand` block from the signed-out shell — #2653's work,
untouched by this branch and present only in dev. Caught by diffing the
resolution against dev before committing. The merged file now carries both.

Frontend suite green on the result: 118 files / 2613 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Resolves the append-append conflict in docs/memory/learnings.md: the #2665
entry (2026-09-09) from dev is kept, and the #2669 entry (2026-09-10) is
re-appended after it. No other file overlapped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…next typed turn knows what was said (#2694)

Two defects, one per side of the portal messages table.

The read side — the window, not the sort. The stored order was never wrong:
both writers stamp the same ISO-Z microsecond format and the read orders by
it. `get_history` returned the newest 100 ROWS, and a 30-minute voice call is
~180 rows, so one call filled the window, every typed turn before it fell off,
and the call block — anchored at the call's first row in the window — rendered
as the head of the thread. The window is now counted in TYPED turns
(`get_portal_thread_window`: the newest 100 for the endpoint, 20 for the cold
replay), the spoken rows of the calls among them ride along whole, and a row
ceiling (600, the newest survive) reports itself as `truncated` so the chat
can say "Earlier messages in this chat aren't shown" instead of silently
re-creating the symptom. Both statements order by `created_at DESC, id DESC`
(the uuid tiebreak is stable, not chronological); verified on SQLite and
PostgreSQL.

The reply poll pays for none of it. It now reads `?limit=N` (1–50, row
semantics) and finds the reply by identity — the newest typed assistant row's
id — instead of by a count that a shifting window breaks; a spoken reply is
never the typed one.

The turn side — what the live session never heard. A resumed turn dropped the
history replay (ent#358), which was right for typed turns and wrong for
spoken ones: the voice provider wrote the call straight into the thread and
the agent's own session was not there. A resumed turn is now prefixed with the
delta — the platform-written rows since the agent's newest typed reply
(`get_platform_rows_since_last_reply` → `_format_voice_delta`; the assistant
cursor survives a failed typed turn). The cold replay (`_format_history_context`,
rewritten) renders the same rows in the same form and the delta is never added
to it, so a cold retry cannot double-send. One renderer for both: spoken rows
labelled, the platform's own rows as bracketed markers (never `You:`),
whitespace collapsed so a transcript line cannot forge a labelled line, and
ONE total spoken budget (24k chars — a whole 30-minute call fits) trimmed
oldest-first across calls with every cut named. The 12-rows-per-call counter is
gone.

No reply lands mid-call, in both directions: a call is refused (409) while a
typed reply is in flight, and a typed turn is refused (409, unbilled,
retryable) while a call is on, through a live-call marker set at call start
and cleared by the bridge's close and the REST stop. Both reads fail open.

Tests: tests/unit/test_2694_voice_thread_window.py (real tables + real
writers), tests/unit/test_2694_voice_delta_context.py (through the real
portal_chat), frontend specs for the render order and the identity-based
reply picker; the ent#534 budget test and the #2320 raise-site table updated.
Docs: workspace-voice-conversation.md (new section), requirements FR-3b, the
workspace/execution area files, two learnings entries, a CSO diff report.

Fixes #2694

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2QYRXnk9XT8aFreKyk6na
Conflict was `raw-color-baseline.json` alone, and only because both sides
re-froze it: dev's own frontend work moved `base/fieldClasses.js` 11 -> 15
gray and recorded `portal/PortalJumpToLatest.vue`, while this branch
recorded `portal/PortalMarkdown.vue` 13 -> 34.

The file is generated, so it was resolved by regenerating it against the
merged tree rather than by hand-picking hunks — the ratchet
(`tests/unit/rawColorRatchet.spec.js`) requires the baseline to be EXACT,
and a hand-merged entry that is merely plausible re-permits regressions up
to a stale ceiling. Both sides' `refrozen` prose is kept: dev's block plus
this branch's `_2616_note`, minus its now-stale sentence about
PortalJumpToLatest, which dev has since recorded itself.

Result differs from dev by exactly one entry (PortalMarkdown.vue, the
change this PR makes) and from this branch by exactly one (fieldClasses.js,
the change dev makes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh6WrChj2636YEy5UZmZ1w
…2648)

merge-train: mechanical, per the merge-train note on the PR. The two feature
flows were updated but the requirements entry still stated the ent#359 rule
this PR inverts for cursorless threads (RoE #1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/memory/learnings.md
#	tests/registry.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants