Two pure seams in native-auth-decisions.ts, following that module's existing
pattern — the value is the test that pins the contract so the god-file call
sites can't drift back to the buggy shape.
resolveReadinessProbeAuth decides how the boot readiness probe
authenticates, delegating to resolveOauthRestAuth for the oauth case rather
than duplicating the bearer-vs-cookie rule.
oauthGuardMayHardFail fixes a second way a gated gateway is misread.
authModeFromStatus maps the gateway's `auth_required: true` onto 'oauth',
but that flag only means the dashboard is GATED — it says nothing about how
you authenticate. A gateway whose providers are all username/password can
satisfy neither of the pre-flight guard's checks by construction:
start_login raises NotImplementedError, /auth/native/authorize rejects
password providers, and its cookies come from a plain password-login POST
rather than the /auth/callback redirect the OAuth partition is primed for.
The guard therefore threw "uses OAuth, but you are not signed in" one line
before a mintGatewayWsTicket that would have succeeded against that very
partition.
The helper returns false only when EVERY advertised provider is
password-based; an unknown or empty list keeps the strict guard, so backends
predating /api/auth/providers are unaffected.
The boot readiness probe calls the credential-free /api/health, and only a
404 flips it to the /api/status fallback. Both halves are wrong against a
gated gateway.
/api/health landed in ccab46ca4 (2026-07-24), after the v2026.7.20 tag, so
every container pinned to a release lacks the route. On those backends the
dashboard auth gate runs ahead of the SPA catch-all, so an unknown /api/*
path is rejected as unauthenticated rather than 404 — a credential-free
probe can never observe the 404 that the fallback keys on, and boot loops
until the 45s deadline reporting a healthy backend as "did not become
ready". Upgrading the backend is not a workaround for release-pinned
deployments.
Simulating a 0.19.0 backend (both the route and its PUBLIC_API_PATHS entry
removed, since ccab46ca4 added the two together) shows the probe's 401 means
two different things depending on whether credentials were sent:
credential-free: /api/health -> 401 no_cookie, /api/status -> 200
credentialed: /api/health -> 404, /api/sessions -> 200
So the fix is not "fall back on any 401". Uncredentialed, a gate-shaped 401
identifies a missing route and must fall back. Credentialed, a 401/403 is a
rejected session and must fail fast — falling back would hit the public
/api/status, get a 200, and report a dead session as ready, deferring the
no_cookie to the first real API call.
Split the two cases and tag the credentialed rejection as a terminal reauth
error. A generic 401 without the gate shape, plus 429 and 5xx, keep polling
as before.
The seven effort levels were enumerated four times in four shapes: a
labels map, a radio-option list, a settings value array, and an enum
list for the config field. Each carried its own hardcoded medium
fallback, which is how the picker drifted from the configured default in
the first place.
Collapse them into lib/reasoning-effort, which owns the scale, the
labels, and the resolve/fallback rules. Net -80/+26 in source. Dropping
the effortLabelKey cast also means the i18n keys are now type-checked
against the canonical list instead of asserted into place.
A Cmd+T tab's session is created unlisted (openNewSessionTile passes
`listed: false`), so it has no $sessions row until its first turn
persists and a refresh surfaces it. For that whole first exchange the
tab strip and the sidebar read "New session".
Cmd+N has no such gap: its session is created per-send, and
createBackendSessionForSend seeds the optimistic row with the user's
text as the preview. A tile never reaches that path — its runtime id is
already bound, so its createBackendSessionForSend seam is a no-op that
returns the existing id.
Seed the row the same way on a tile's first send. The tab strip already
re-syncs on $sessions (watchSessionTiles lists it in `also`), so the tab
and the sidebar both name the session within the first message; the
server's auto-title supersedes the preview when the turn completes.
Guarded so it only ever fires once per session: no-op on empty text and
on an already-listed row, matching across compression by lineage root so
a re-send can't clobber a real title with a raw message preview.
The pill hardcoded 'Med' whenever the surface had no explicit effort, so
a profile configured for high advertised a level the agent would not
use. Take the profile default as a fallback and drop the now-unused
shell.modelMenu.medium key across the locales.
Selecting a model applied 'preset.effort ?? medium', so a user running
agent.reasoning_effort: high was silently downgraded to medium on every
model switch and every new chat that inherited the pick. The Thinking
toggle and the effort radio group defaulted to the same literal.
Resolve all of them from the profile default instead, falling back to
DEFAULT_REASONING_EFFORT only when config has not loaded.
The composer only seeded effort from agent.reasoning_effort when it was
about to reseed the whole selection, which a sticky manual model pick
skips. The configured value was read and discarded, leaving every other
surface with no way to resolve the profile default.
Mirror it into $defaultReasoningEffort on every config load, independent
of the composer reseed, so the manual pick stays sticky without hiding
the default from the surfaces that need it.
A run of 3+ adjacent tool calls collapses into the 6.75rem
`.tool-group-scroll` window. That is right for status rows, but a patch
or execute_code row's body is a code block the user reads, and the
window squeezed it to a ~2-row viewport behind a fade mask.
The CSS break-out (`:has([data-tool-row][data-tool-open])`) already
lifts the cap once a row is expanded, which is why most tools are safe
to bound. It can't help here: the user has to notice the clipped block
and expand it before the code is legible.
Extend the existing UNBOUNDABLE_TOOLS opt-out to the code-bearing tools
behind an `isUnboundableTool` predicate, deriving the file-edit names
from `isFileEditTool` so a newly supported edit tool can't be exempt in
one place and clipped in the other. `terminal` stays boundable: console
output is a log tail whose last lines are the ones that matter, which
is exactly what the window pins.
Verified in Chrome against the app's compiled CSS: a 3-call run
carrying a 520px diff rendered at 108px before, full height after.
The guard matches any `.current` write inside a `useEffect`, so it also
flags refs that are not mirroring a reactive value. Seven such sites
landed on main after the rule was written:
- wiring.tsx: one-shot request-seen sentinel
- billing/plans-view.tsx: previous-confirming tracker for focus return
- config-settings.tsx: autosave bookkeeping
- gateway-settings.tsx: monotonic request-sequence counters
- toolset-config-panel.tsx: mount flag + one-shot provider-choice claim
- voice-provider-fields.tsx: one-shot config seed flag
None mirrors an atom/prop/state value, so none can produce the
one-render-lag stale read the rule exists to prevent. Each gets an
eslint-disable with the specific reason rather than widening the rule,
which would reopen the hole for real mirrors.
Verified the guard still catches the real antipattern: a probe file
mirroring `useStore($activeSessionId)` into a ref via useEffect is
reported. eslint over apps/desktop/src is 0 errors, and the 24 remaining
warnings match the pre-change baseline exactly.
A ref synced from a nanostores atom via useEffect lags the atom by one
render. Callbacks that read the ref instead of the atom get stale values —
cancelRun sent session.interrupt to the wrong session (#66485), and
steerPrompt/restoreToMessage/editMessage had closure-priority stale reads.
The rule catches the three shapes of this antipattern:
- useEffect(() => { ref.current = value }, [value])
- useEffect(() => { ref.current = value; ... }, [value])
- useEffect(() => { setMutableRef(ref, value) }, [value])
All 79 existing hits get eslint-disable-next-line with a comment. The
legitimate ref writes (DOM instances, mount flags, request tokens,
prev-value tracking, prop mirrors) stay suppressed; the 11 real bug
sites will be fixed in the next commit so their suppressions can be
removed.
Rule is scoped to apps/desktop only — ui-tui and tests-js don't use
nanostores and don't have this pattern.
Four symptoms from the same /goal flow on desktop:
- Typing '/goal <text>' sealed the command into a directive chip on
Space because /goal was registered without args:true, so the goal
prose rendered awkwardly after a pill. The registry row now matches
/personality and /tools: the arg stays editable text.
- The slash status header echoed the ENTIRE invocation ('slash:/goal
<whole goal prose>') in mono, immediately above the backend notice
that repeats the goal text again, and the kickoff user bubble that
repeats it a third time. The header now carries just the command
token (slash:/goal).
- When the session was busy, handleDispatch rendered 'session busy'
and dropped the dispatch message. For /goal that message is the
kickoff prompt, and the backend has ALREADY set the goal by then —
the goal existed but the agent never heard about it, and later turns
looked goal-unaware (#63352). The busy path now queues the kickoff
on the composer queue: it sends on settle and is visible/editable in
the queue panel meanwhile. Falls back to the old message if the
queue rejects the entry.
- A slash command issued on a fresh draft created the backend session
with no preview, so the sidebar row sat as 'Untitled session' —
and when the kickoff was dropped, auto-title never fired either
(it needs a completed user->assistant exchange). ensureSessionId now
seeds the preview with the typed command.
Tests: registry row contract, busy-path queueing (kickoff neither
sends mid-turn nor vanishes), and the header-token assertion.
`/goal status` reported "No active goal" for a goal that was live: the
desktop's slash pipeline resolved its target session differently than the
submit pipeline, so the command ran against a different session than the
chat on screen.
`/goal` state is persisted per-session in SessionDB (`state_meta` key
`goal:<session_id>`). slash.ts resolved with a bare
`hint || activeRef || createBackendSessionForSend()`, so whenever the
runtime binding was momentarily absent — profile swap, reconnect,
orphan-reap, request timeout — it silently MINTED A NEW SESSION and ran
there. submit.ts already handles this case by resuming the routed stored
session on its owning profile (#55578, #67603).
Extract that ladder into one shared resolver both pipelines use, per the
"one resolver owns each policy" rule in apps/desktop/AGENTS.md. This fixes
the whole class, not just `/goal`: every exec/rpc slash command
(`/usage`, `/status`, `/tools`, …) had the same hole.
A targeted durable conversation whose runtime cannot be rebound now
returns null instead of forking the chat — reporting that a command could
not run beats running it against the wrong session.
The adapters wrapped every field in an arrow function, including the
optional ones. That makes an absent handler unconditionally truthy, and
several children gate on a handler's PRESENCE rather than just calling
it:
- onDismissError -> assistant-message.tsx renders the dismiss button
only when defined
- onRestoreToMessage -> thread/index.tsx gates the restore-confirm flow
- onTranscribeAudio -> use-voice-recorder / use-voice-conversation gate
recording on it
- onLoadMoreMessaging / onLoadMoreProfileSessions -> sidebar paging
So the adapter would paint a dead dismiss button and let voice recording
proceed into a no-op transcription path even when the controller had
deliberately left those handlers off.
Wrap an optional field only when it is currently present, and re-read the
latest value inside the wrapper so the stale-closure fix still applies.
Presence is stable for a given actions object (the controller mutates
fields in place rather than toggling a handler between defined and
undefined), while the closure is what churns — which is exactly what the
indirection re-reads.
Adds two regression tests: absent optional handlers stay undefined, and a
present optional handler still late-binds to the latest closure. The
first was verified to fail against the unconditional-wrapper form.
Redirect/steer, regenerate, restore-checkpoint, edit-message, and
change-cwd read `activeSessionId || activeSessionIdRef.current`, which
prefers the closure-captured prop whenever it is non-null and only falls
back to the ref once the prop is null.
That precedence is backwards. The actions bag is a stable ref that
wiring.tsx mutates in place (Object.assign), and the pane surfaces are
memoized on that stable ref, so a surface does not re-render when the
active session changes and keeps whichever closure was current when it
last rendered. `activeSessionIdRef` is the authority: it is mirrored
during render in use-session-state-cache, and submit.ts /
use-session-actions pin it imperatively mid-flight without touching the
source prop. The prop is stale by design. `cancelRun` in the same file
already reads the ref exclusively and documents exactly this hazard.
User-visible effect after switching chats: a typed correction was
delivered into the previously focused conversation's live turn (the
"session suddenly working on another chat's task" report), and rewinds
truncated the wrong session's transcript — real data loss, since a
truncating resubmit deletes history after the target ordinal. Nothing
crosses over in stored state, which is why a DB/transcript audit of the
affected session comes back clean.
Also fixes the same defect in `changeSessionCwd`, where a stale target
re-anchored another conversation's workspace, pointing that agent's
terminal/file tools at the wrong project. The now-unused
`activeSessionId` option is dropped from useCwdActions rather than left
as a footgun for the next caller.
Not changed, verified not affected: model-edit-submenu reads the runtime
id via `useStore` (live subscription, re-renders on change), and
use-composer-actions guards on `attachedSessionId === activeSessionId`,
so a stale value there only skips a detach instead of writing
cross-session.
Tests: 4 regression cases pin each action to the current session when
the prop and the ref disagree. Verified to fail against the pre-fix code
(all four reported the stale `rt-abc123` instead of the current
session), including a scripted revert of all four sites.
Co-authored-by: Drew Donaldson <49219012+Automata-intelligentsia@users.noreply.github.com>
An agent-written ref rendered as a chip that went nowhere on click. It now
renders as an ordinary inline link — the agent wrote it mid-sentence, so it
should read like one — with the funnel icon leading the resolved title.
Clicking either surface (that link, or the chip in the user's own message)
opens the session as a tab, the way its sidebar row does.
The tile store loads on click rather than at import: the composer's rich
editor pulls this module in, so a static import would boot the profile store
and its REST routing along with every transcript render.
A background queue drain (fromQueue: true) whose runtime binding was
reaped by the gateway fires with sessionId=null. The expression
options?.sessionId ?? activeSessionIdRef.current
falls back to whichever runtime id the foreground happens to hold,
landing the queued prompt in the session the user is currently viewing
instead of the session that owns the queue entry — a cross-session
message leak.
Guard the fallback: only inherit the foreground runtime when the drain
targets the current view (no storedSessionId, or it matches the
foreground). A background drain (storedSessionId differs) is left with
sessionId=null so the existing session.resume path rebinds the correct
runtime before prompt.submit fires.
Includes a regression test: "a fromQueue drain with null runtime id
does NOT land in the foreground session (cross-session leak guard)".
All 53 existing tests pass.
Each case mounts a stacked group with a hidden tab whose geometry
matches the visible one — the arrangement that made the original bug
invisible to selector order.
Same ambiguity in three more lookups: blurComposerInput could blur a
hidden input and leave the focused one alone, the timeline scrolled
whichever viewport it found first, and a clarify card waiting in a
background thread swallowed the foreground composer's letter keys.
The drag snapshotted every chat surface and composer in the document,
so with a stacked tab group the link/split resolved against whichever
pane came first — usually a hidden one — and the @session chip landed
in a composer nobody was looking at.
Inactive tabs stay mounted and hidden with `visibility`, which preserves
their layout box — so a background tab answers a document-wide selector
with a rect identical to the visible tab's. Tag hidden layers and route
those lookups through visible-scoped query helpers.
Client half of leg 2. The issue's failure mode was the desktop clearing the
optimistic first message and showing nothing when the backend dropped the
turn. With the gateway now preserving the message across slow builds and
emitting a real error event on genuine failure, these tests pin the desktop
contract that makes that visible:
- an agent-init error event renders an in-transcript assistant error bubble,
keeps the user's optimistic first message (never silently cleared), fires
the global error toast, and releases busy/awaitingResponse so the composer
is usable again;
- the #65567 pre-ready cancel emit renders the same way.
Covers failAssistantMessage + the gateway-event error branch, which no test
exercised at the session-state level (todo-cleanup only asserted todo
eviction).
From PR #64327 (@Kenmege), whose target-aware drift predicate (compare route
tokens by their routed chat target; ignore selection null-resets, search/hash
churn, and background active-ref retargets; never count a move onto the
submit's own target) already reached main via the #69578 salvage
(1bdd478efa, Co-authored-by Kennedy Umege) and gained the #70986 composerScope
prong. What main covered only at the unit level (session-context-drift.test.ts)
is here pinned at the pipeline level, both directions:
- a send from a second chat rides out simultaneous programmatic churn
(selection null-reset from a gateway/profile reconnect, active-ref retarget
from a background event, search/hash-only route change from an overlay)
mid-session.resume and still reaches prompt.submit;
- a genuine user switch (selection AND route moving to another real chat)
mid-submit still aborts before prompt.submit.
Part of the #63078 fix branch.
Salvaged from PR #62805 (@floatingrain), whose diagnosis of the deterministic
frontend self-abort (createBackendSessionForSend mutating the selected ref +
route, then the caller's drift guard reading its own re-home as a user switch)
was correct — and correct about the sweeper misread that closed it: the
sweeper's implemented_on_main verdict cited submit.ts:245, which was inside
the session.resume block, while the raw post-create guard at the
createBackendSessionForSend site was still aborting every new chat on the
then-current main (4281151ae8). The mechanism itself has since landed via
8c288760d0 + 1bdd478efa, so what remains distinct is this regression: after
the pipeline adopts the created chat as its pinned target, a GENUINE user
switch (selection and route both moving to another chat during the
attachment-sync await) must still abort instead of being masked by the
re-baseline.
Part of the #63078 fix branch.
Salvaged from PR #62990 (@Roger--Han), a competing leg-1 fix for #63078. Its
mechanism (a pinned-route-token set accepting both the pre-commit and the
deterministic created-session token) was superseded by main's target-aware
drift predicate (1bdd478efa), but the PR pinned a timing case nothing on main
covered: React Router exposing the STALE new-chat route to the submit
continuation after create returns, committing the created session's URL only
before the next await settles. Both snapshots are the pipeline's own
transition — the first prompt must still reach prompt.submit.
Includes the contributors/emails mapping (added directly:
scripts/add_contributor.py's login regex rejects the consecutive hyphen in
the real GitHub login Roger--Han).
Part of the #63078 fix branch.
Salvaged from PR #62562 (@giggling-ginger). The mechanism that PR proposed
(adopt the session identity a first-message submit creates as the pipeline's
new pinned target; distinguish the expected new-chat route replacement from a
genuine concurrent switch) has since landed on main via 8c288760d0 and the
target-aware drift predicate (1bdd478efa / #69578). What main still lacked was
this PR's stronger regression coverage:
- The positive leg now asserts the FULL RPC transcript — exactly one
prompt.submit addressed to the created runtime id with the user's text, no
session.resume detour — instead of only 'not resume', and makes the creator
stub faithful to the real one (re-homes selection AND route, receives the
preview text used to seed the sidebar row).
- A new abort leg pins the route-moves-first sidebar-navigation race: the
route changes to a different chat while the selected ref still points at
the just-created session (navigate() commits before resumeSession() updates
the ref), which must still abort rather than deliver into the wrong chat.
Part of the #63078 fix branch.
Mid-turn progress lives only in process memory — the agent flushes to
SQLite at turn end — so an app/backend/machine death mid-turn lost the
turn entirely: reopening the session showed the recovered partial, but
the work never finished and the prompt itself survived nowhere durable.
Turns now write a durable marker (bounded per-profile sidecar) when they
start running and clear it when they conclude; success, handled error,
and interrupt all clear it, so a surviving marker is positive proof of a
process death. session.resume reads the marker: a fresh interruption
(desktop.auto_continue.freshness_minutes, default 15) is re-submitted
automatically as a continuation turn carrying the original prompt in an
interruption note, streams live to the client that just resumed, and
renders as a "resumed interrupted turn" event row. Stale markers are
cleared and the recovered partial speaks for itself; a turn that keeps
crashing stops auto-continuing after max_attempts (default 2) — the same
freshness + crash-loop-breaker posture as the messaging gateway's
restart auto-resume.
message.complete frames with status "error" were detected only by a text
regex heuristic, which misses the gateway's "Error: <detail>" texts and
partial-text failures — a failed turn rendered as a healthy reply. The
structured error/partial fields now drive the failure state: the bubble is
marked failed from the frame's error field, and a partial failure keeps
its streamed text visible instead of stripping it. session.resume's
inflight projection likewise carries a retained failure's error onto the
projected assistant row, so a failed turn recovered after a disconnect
renders as failed rather than as a healthy partial answer.
Co-authored-by: Reza Sayar <rsayar@uvic.ca>
The renderer's session-state cache is memory-only and the backend's
inflight snapshot dies with the backend process, so nothing survived a
full app or machine death mid-turn: reopening the session showed the
transcript up to the last committed turn and silently dropped everything
the crashed turn had streamed.
While a turn runs, the visible tail (user prompt + streamed assistant
rows, tool calls included) is now journaled to localStorage — throttled
off the delta-flush hot path, bounded (24 entries / 7 days), cleared the
moment the turn settles. Session resume folds the journaled tail back
onto the restored transcript. When the backend also has a live text-only
inflight projection for the same turn, the journal overlays its richer
structure onto that row (longer text wins, base row id kept so live
deltas keep landing) instead of treating it as caught up — the ordering
defect that dropped locally recorded tool progress in the original PR.
Co-authored-by: Omar Baradei <omar@kostudios.io>
Asked to link to a session, the agent had no way to know the @session
reference syntax exists — every mention in the tool schema described
consuming a link the user dropped, never writing one — so it answered
with the title and timestamp as prose and the desktop had nothing to
render.
Every result now carries a ready-to-copy `link`, and the schema says to
write it inline instead of restating the title around it. The profile
segment is omitted when the active profile can't be named confidently;
a bare id still resolves.
Also skip linkifying a ref a model already wrapped in a markdown link,
which would otherwise rewrite into a nested link.
Assistant text goes through the markdown renderer, not DirectiveContent,
so a session reference an agent wrote came out as literal text. Rewrite
bare refs into `#session/<value>` links during markdown preprocessing and
dispatch that href to the shared chip in MarkdownLink, alongside the
existing media and preview hrefs.
Preprocessing already skips code fences and inline code, so a ref being
discussed in code stays literal. The pure parsing/href helpers move to
session-refs.ts to keep the resolver's React and API imports out of the
per-flush preprocess path.
Route session refs in the transcript through the title resolver so a
dropped session reads as its title instead of a truncated id, and use
Tabler's funnel for the session chip icon.