Skip to content

[release] v0.115.3 - #6616

Open
github-actions[bot] wants to merge 81 commits into
mainfrom
release/v0.115.3
Open

[release] v0.115.3#6616
github-actions[bot] wants to merge 81 commits into
mainfrom
release/v0.115.3

Conversation

@github-actions

@github-actions github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

New version v0.115.3 in

  • web
    • web/oss
    • web/ee
    • web/mobile
  • services
  • api
  • sdks
    • sdks/python
  • clients
    • clients/python
    • clients/typescript
  • kubernetes
    • kubernetes/helm

@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agenta-documentation Ready Ready Preview Sep 8, 2026 11:58pm UTC

Request Review

…overy

fix(sessions): preserve active turns when watchdog checks fail
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Railway Preview Environment

Preview URL https://gateway-pr-6616.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-6616-76a9834
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-09-09T00:08:40.762Z

- Default the SDK binding type to env, so a binding with only a name parses.
- Sync the entity ref in a layout effect instead of during render.
- Follow the revision prop in the secret request dock.
- Gate the queue card and jump pill on the secret dock on both hosts.
- Parse the commit response with safeParseWithLogging.
- Write the rotated digest to a new store path in the release gate cell.
- Add the handler and desired-state paths to the custom-secret gate trigger.
- Fix the checklist link, the plan status, and the storybook scenario guard.

Claude-Session: https://claude.ai/code/session_01XVrWftaiLxJT5DnL7D1xjY
[fix] Update attachment acceptance test for saved chat records
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

📘 Docs preview

Status ✅ Ready
Preview https://pr-6616-agenta-docs-preview.mahmoud-637.workers.dev/docs
Inspect Actions run
Commit c181101e3fbfc5a79cfe0c679299c61820ae54ba

Pages changed in this pull request

This comment updates in place on every push.

… gh.ssl stacks

The api calls the runner directly to cancel a turn (Stop) and to tear down a sandbox
(kill). The gh.yml and dev.yml files pass AGENTA_RUNNER_INTERNAL_URL to the api service,
but the gh.local.yml (oss and ee) and gh.ssl.yml files pass it only to services. On those
stacks every Stop logs "cancel: no runner internal_url/token configured" and never reaches
the runner.

Closes #6627

Claude-Session: https://claude.ai/code/session_015akbm8MYys7pVjkhdP9R9a
…he matching troubleshooting entry

The platform API calls the runner directly to cancel a turn (Stop) and to tear down a sandbox
(kill). The docs described the runner hop as Services-only, claimed every Compose file set the
locator on both callers, and had no troubleshooting entry for a Stop that never lands.

Closes #6629

Claude-Session: https://claude.ai/code/session_015akbm8MYys7pVjkhdP9R9a
[6629] docs(self-host): the api needs the runner URL for Stop, plus the matching troubleshooting entry
…6673)

* test(gate): rename a session between two turns and assert the agent was told

The gate could not see a per-turn fact that never reaches the agent. Issue #6661
shipped through a green matrix because no cell renamed a session mid-conversation
and no cell asserted on the session facts at all.

Add matrix_n1_session_context.py. It drives one session on the path the playground
posts, renames the session between turns through the route the UI uses, and asks
for the name, the agent's own display name, and the name again with a forged
meta.session_context in the body. Every expected value carries a random token
minted for the run and spoken nowhere, so a transcript-derived answer cannot match.
Two controls keep a FAIL honest: an echo probe and a read-back of the stored header
after every rename.

Wire it into path_triggers.py for the SDK session-context module, the platform
prompt renderer, the agent handler, and the API session-context resolver, into
SKILL.md as a mandatory cell, and into coverage.md.

Document in the shared invoke helper that it posts to the /services path exactly
like the browser, so nobody writes a cell that assumes an API-side stamp. Add an
optional meta argument for the forged control, and AGENTA_SERVICE_BASE so a fix can
be gate-verified before it is deployed.

* test(gate): classify a failed workflow create, and document every function

CodeRabbit review follow-up.

create_workflow ran before the try, so a non-200 or a transport error escaped
n1_for uncaught. Under --harness-all that aborted the whole run with a traceback
and printed no results, which is the outcome the handler comment exists to
prevent. It now runs inside the try, so the same classifier turns it into a SKIP
or a FAIL. wf is bound to None first and the archive in finally is guarded, since
there is nothing to archive when the create itself failed.

Add one-line docstrings to stored_session_name, n1_for and main.
…ient meta (#6667)

* fix(sdk): resolve the session facts in the agent service, not off the wire

The playground posts a turn straight to the agent service, so the API prelude
that stamps request.meta.session_context never runs on that path and every
playground turn reached the agent with no session facts. The service now reads
the three facts itself, with the caller's own credential, and ignores the blob
on the wire, which is client input on this path.

Fixes #6661

* fix(sdk): bound the session-context reads and refuse ambiguous inputs

Codex review on #6667:

- Give the optional resolution one TOTAL deadline covering client construction
  and teardown, not the tool resolver's 30 s per-operation timeout. An expired
  budget cancels the outstanding reads. An outside cancellation still propagates.
- Tell a malformed session body apart from a legitimately unnamed session. A
  shape this code cannot trust reports UNKNOWN for the whole pair, because
  reading it as unnamed tells a named session to rename itself.
- Decline to resolve an agent name when a request names competing workflow
  artifact families. Inline-config runs bypass the family validator.

Also corrects the API comments that claimed the stamp covers playground turns.

* docs(sdk): correct the session-context docstrings to what the code does

From the Codex review: three requests not two reads, and the stream read
touches Redis as well as Postgres. The run context is not universally
populated, because inline config skips hydration. A saved artifact with
unsaved config is a draft and still has a name. first_turn means no durable
turn row at lookup time, so a retry or a cold resume of the first exchange
reads False. The session-id shortcut is rare, because the normalizer mints
an id before the handler runs. The handler is where turns pass, not every
possible SDK entry point.

Records why a client is built per call and why the facts must never be
cached.

* fix(sdk): check artifact ambiguity before the run context, and bound the unwind

Codex round 2 on #6667:

- The competing-families check never ran on the service path. The run context
  is built from the same references and prefers the workflow family, so the
  early return handed back the very preference the check exists to refuse. The
  check now runs first. A bare-handler test could not see this, because it has
  no ambient run context, so the cover is a service-level test that installs one.
- One deadline owner, shared by the module and the handler. It runs the work as
  its own task, so the unwind is bounded by a grace period rather than awaited
  forever, and a teardown that raises can no longer replace a caller's
  cancellation with an ordinary exception the boundary would swallow.
- Refuse a non-finite timeout override, which would disable the deadline.
- Raise the default budget to 2 s. A fresh client per turn pays connection
  setup, and the stream read does Redis work before Postgres.
- Canonicalize reference ids so two families spelling one artifact differently
  do not read as a disagreement.
- Correct the docstring claim that the streams GET route omits null fields.

* test(services): drive the session-facts tests through the real invoke route

The service-level cover now posts to /invoke rather than calling the handler,
so the tracing context that feeds the artifact lookup is built by the route
from the request's own references instead of handed in by the test. That is
the layer where the competing-families bug was reachable and where a bare
handler test could not see it. Reverting the check ordering makes the
competing-families cell fail with the wrong agent's name, as Codex reported.

* fix(sdk): one deadline owner, and keep a raising resolver inside the boundary

Codex round 3:

- A resolver that raises synchronously before returning its awaitable escaped
  the boundary, because the call was evaluated before entering it. The typed
  interface admits such a factory, so run_optional now takes the callable.
- The default path nested two deadlines. The outer grace period was watching an
  inner wrapper unwind rather than the client that holds the connections. The
  handler now calls the unbounded read, and the bounded entrypoint stays for a
  caller that reaches past the handler.
- A detached cleanup is held in a set that releases on completion. A done
  callback is not a reference, so the loop could collect one mid-unwind and
  report 'Task was destroyed but it is pending!'.
- Land the docstring correction that reached a test comment last round.
- Initialize the SDK singleton in the service tests rather than inheriting it,
  which made them fail under xdist on a loaded box.
- Tighten two loose assertions: all three reads must cancel, and the slow
  teardown is bounded by budget plus grace rather than a second.

* test: own the SDK singleton, and pin the close on the abandonment path

The service tests take the SDK singleton through a fixture that restores what
was there, rather than initializing a process global and leaving it installed
for every later test in the worker.

Adds the guarantee the abandonment path actually makes: cancelling the task
raises into the async with, so the client's close is entered every time, and
the caller does not wait for it. If that close itself hangs there is nothing
further to force, since httpx exposes no way past aclose.

* test: correct two comments that claimed more than the code does

Round 4 nits, both wording and no behaviour change. The one-second threshold
also rejected the ten-second teardown, so the tightening is not justified by
what the comment said. And host= does not isolate the exporter, because the
AGENTA_API_URL the fixture sets wins; the reason its failures are harmless is
that the exporter flushes off the request path.

* test(services): make the forged turn position disagree with the backend

The forged-meta cell sent first_turn false while the backend also answered
false, so a route that trusted the forged value passed. The backend turn list
is now empty, which makes this the first turn while the forgery denies it, and
the rendered marker tells the two answers apart. Injecting trust of only the
forged turn position now fails the cell.
…#6672)

* fix(frontend): stop the Permissions dropdown calling Allow reads the default

New agents are created with Allow all since #6641, but the sub-line under
Allow reads still read "Reads run, writes ask; default". The word moves to
the option a new agent actually gets. Both hosts read the same list, so the
wording changes in one place.

* fix(frontend): drop the default marker instead of moving it

Codex review: "the default" is two policies here. The standard template
creates an agent on Allow all, but an agent whose config names no policy
runs on Allow reads, and both selectors display that fallback as the
applied value. A sub-line cannot say which one it means, so it says
neither. The issue allowed either fix.

* test(frontend): check the label too, and shorten the file comment

CodeRabbit: a future Default marker in option.label would have passed the
assertion, which only read option.help.
…h hosts (#6675)

* fix(frontend): refuse a chat link whose target names a host, not a path

harden parses a target that starts with ../ and hands back its pathname.
The pathname of ..//evil.com/x is //evil.com/x, which the browser reads as
a host, so a reply could carry a link labelled like a file that opens
another site. The chat anchor on both hosts now refuses any target that
resolves to a host, in its encoded and backslash spellings too, and renders
it the way harden renders a target it refused.

* fix(frontend): drop tabs and newlines before the host check, as the URL parser does

The URL parser removes every ASCII tab, newline and carriage return from
the whole href, not just its ends, so a browser reads /<tab>/evil.com as
//evil.com. The check now runs on the string the parser would see.

* fix(frontend): narrow the file-candidate guard and test /m through the real pipeline

Two findings from the Codex review.

The file-candidate guard reused the anchor's predicate, which decodes. A
raw filename can legitimately contain %2F or a backslash, and nothing on
that path navigates, so the wide test rejected real names for no safety.
It now tests the literal two-slash prefix only.

The mobile suite called the anchor renderer directly, so it could not
catch a missing plugin or a renderer that stopped using the component
map. A second suite renders AssistantMarkdown itself and covers raw HTML,
autolinks and streaming mode.

Also corrects the predicate's comment: a browser does not decode %2F, so
the decode rounds are belt and braces for downstream consumers, not a
model of the parser. And the desktop renderer no longer claims the
composer shares it.

* test(frontend): isolate the resolver assertion from earlier renders

CodeRabbit: the resolver array accumulates across the file, so the
positive case could have passed on an entry another test left behind.
It now reads only what its own render added. Also shortens two comments.
The cell pinned sandbox local. Staging enables Daytona only and answers 403 for
a local sandbox, so the cell could not run on the stage that matters.

Add --sandbox local|daytona, defaulting to local, the way matrix_t9 does. The
value reaches the agent config and is recorded in the result. The docstring says
why the flag exists, and that the claude harness on Daytona SKIPs on the
credential because Daytona rejects subscription auth by design.
Four pages still called Allow reads the default. The standard template
has created agents on Allow all since #6641, so the tutorial step that
said the option "may already be selected" sent readers past a change
they had to make. Each page now names Allow all as where a new agent
starts.
…6682)

* fix(sdk): stop telling agents that allow_reads is the new-agent default

Two strings said allow_reads is "the default" without saying which
default. It is what the runner applies when the field is absent, which
is true, but the standard template has written allow since #6641, so an
agent reading either string could conclude a new agent starts on
allow_reads. Both now say which is which.

* fix(sdk): tighten the wording and align the config example

Codex review. Name runner.permissions.default explicitly rather than
"the field", and say "the standard creation template" rather than "a new
agent", which was broader than the code supports: a bare
AgentTemplateSchema still yields allow_reads.

Also set the example object to allow. A model copying the whole example
got allow_reads while the sentence three lines below said the template
writes allow.
…ew (#6687)

* test: correct three comments that claimed more than the tests prove

Follow-up to #6667, comment and name only, no behaviour change.

The exporter comment described the ordering backwards. The fixture sets up
before the one that sets AGENTA_API_URL, so the loopback host is used; an
ambient value would still win, which is why this is not exporter isolation.

The fixture docstring said it owns the SDK singleton. It restores the
ag.tracing alias and nothing else, and init also replaces api, async_api and
the tracer and installs a provider.

The abandonment test was named for a close that always happens. What it can
prove is that the close is entered and that the caller does not wait for it.

* test(services): make the fixture order real instead of assumed

Two function-scoped fixtures with no dependency have no guaranteed order, so a
comment describing one was asserting something pytest does not promise.
backend_facts now depends on sdk_singleton, which is the fixture whose
environment variable the other reads.

The comment no longer claims where the exporter points either. init prefers
AGENTA_API_INTERNAL_URL and then AGENTA_API_URL over the host passed to it, and
either can be ambient, so ordering alone settles nothing. The claim that does
hold is that isolation is not needed here.
…ng its tabs (#6685)

* test(frontend): wait for the Use API drawer to settle before clicking inside it

* test(frontend): shorten the added comments to one line each
…write-only value (#6691)

The playground's Model section decided whether the project holds a key for
the selected model's provider by reading the key value off the vault row.
Secrets are write-only on staging and on every dev stack, so `/secrets/`
returns no value and reports presence through `value_status.configured`,
which the transform surfaces as `hasKey`. Every connected project therefore
read as keyless, and the amber "Connect key" badge and its "Connect the
model's provider key to run this agent." tooltip stayed up across reloads
while the agent ran on that same key.

The rule moves into `shouldPromptForProviderKey` and asks `hasStoredKey`,
the one vault presence rule. Its exemptions are unchanged: a self-managed
connection signs itself in, a named connection points at a vault record this
rule never looks up, and an unresolved vault prompts for nothing.
…very session surface (#6686)

* fix(frontend): make the Rename menu entry open the inline editor

The shared session menu offers "Rename" on every surface, but the shared
router had no branch for its key: clicking it did nothing at all. It has
been dead since the rename modal was deleted in v0.114.1, on the chat tab
strip and on every card list. The nav rail and the sessions list were
unaffected because they intercept the key themselves.

Route the key back out as `onRename`, the way "open" already travels, and
wire the surfaces that were missing it. The chat tab strip asks through
the same one-shot request atom Alt+R uses, so the entry and the shortcut
open one editor. The card list gains the in-place edit the standalone
list already had, which covers the mobile chat panes, the agent overview
and the workspace home.

The row context menu also had to stop taking the caret back: Radix
restores focus to the trigger as the menu closes, which lands after the
editor's autoFocus, and the resulting blur commits and closes it. A verb
that took the caret itself now says so and that one focus restore is
suppressed.

* fix(frontend): defer the rename editor until the menu has closed

Codex review. A menu item's select runs while the menu still holds a focus
trap, so an editor opened there is blurred straight back out, and a blur
commits. Suppressing the later focus restore does not help, because the
first blur has already happened.

Replace the boolean return with a named handoff: a verb may return a
function, which runs from the menu's close, after the trap is released.
It lives in one hook, `useDeferredMenuSelect`, shared by the right-click
menu and the row kebab so they cannot drift.

Also from the review: the mobile chat session tabs had a Rename entry with
no editor behind it, and the sessions page dropped the commit callback on
its browse-rail branch. Both are wired.

Tests now drive the real card-list row rather than the stub: pick Rename,
close the menu, type, press Enter. Dropping the editor or running the
deferred work early both fail the suite.
…record read (#6658)

* fix(frontend): show a sent chat message at once instead of after the record read

A session that advertises the durable `queue` capability routes every composer
send through the server, which adds nothing to the local AI SDK chat. The user's
message therefore only appears once the invoke POST, the durable event, and the
records read that adopts the saved row have all landed. The composer clears in
about 20 ms and the transcript then stays unchanged for the rest of that window.

Measured on a local EE stack: 945 ms median on the desktop and 747 ms median on
mobile, and 3.2 s when the server queues the input behind a turn it still counts
as running. Remote deployments add their round trips on top, which is the 4-5 s
users report.

The queue hook now keeps a local echo of each durable send and renders it between
the adopted transcript and the live preview. An echo retires the moment something
else owns the message: the queued-messages dock on a 202, the saved user row on a
200, and the composer restore when the send is refused. Coverage is counted
rather than matched by id, because the server keys saved rows by the record's own
id and never echoes the client id back.

Desktop drops to 62 ms median and mobile to 217 ms, with each message appearing
exactly once and no stranded echo rows.

* fix(frontend): drop a pending-send echo a rewind stranded below its origin

Review note from the verification pass: if an adoption ever replaced the
transcript with a shorter list, the user count would fall and echoes waiting on a
higher count would sit on screen until an unrelated send raised it again.

Each echo now records the user count it was created at. A transcript that falls
below that number was rewound past the point the send belongs to, so the echo can
never be covered and is dropped instead of left behind.

* fix(frontend): retire a pending-send echo during render, not in an effect

Review finding: the passive cleanup ran in an effect, so it fired after the paint.
The frame that first carried a newly adopted durable row also carried its echo,
which is one duplicate-bubble flash per send.

Retirement now happens during render. React discards that pass and re-runs before
painting, so no frame ever holds both rows. The new test records every render and
asserts the durable count plus the echo count never exceeds one; it fails against
the effect form and passes against this one.

Also cuts the comments in the touched files to one line each, per the hard rule in
web/AGENTS.md.

* fix(frontend): retire a pending-send echo on its own turn id, not on a count

Codex review, both P1s.

Counting is wrong on a shared session, and Mahmoud runs /w and /m on the same
one. Any foreign user row raised the count and retired this client's echo while
its POST was still unresolved: another tab's message, a promoted queued input, a
Steer, or a history adoption. Worse, two echoes reserving counts four and five
left the second duplicated forever if the first failed, because its own record
then saved as row four while the echo still waited for five.

The invoke response's first frame is `data-session-accepted` and carries the
`executionId`, which is the `turn_id` the saved user record carries. Verified end
to end against a live stack. So an acknowledged echo now retires only when that
turn id appears among the adopted user rows, and ignores the count entirely. The
count remains as the fallback for the one round trip before the frame arrives,
and dropping an echo renumbers the ones behind it against the current transcript.

An HTTP 200 only proves the request was taken. The stream is now read for that
acceptance frame rather than drained blindly, and a stream that ends without one
never started a turn, so the echo is dropped instead of left on screen.

Also from the review: an echo retires when the durable queue reports its id, so
the dock owns the row; and `mergePendingSendRows` orders an earlier turn's live
preview above the echo while keeping the echo above the preview of its own turn.

`transcriptToMessages` now emits `metadata.turnId` on user rows, which is what
makes the identity match possible. Assistant rows are left alone because
`latestTurnId` reads that field as the Stop guard.

Tests reproduce both P1 cases and fail against the count-only version.

* test(frontend): pin the user-row turn id that pending-send identity depends on

* fix(frontend): own send echoes in state, and retire each on evidence about itself

Codex pass two, plus the remaining items from pass one.

The render-phase retirement I added was unsafe. It mutated a ref during render,
and React does not roll that back when it abandons a render, so an echo could be
lost permanently and the next send could then reserve a count already taken.
Codex reproduced it under React 19.2.6.

The lifecycle now lives in usePendingSendEchoes, with state as the only source of
truth. Visible rows are DERIVED by pure filtering during render, which keeps an
adopted row and its echo out of the same commit without writing anything, and the
stored list is pruned afterwards in an effect where a discarded render cannot do
harm. Coverage is allocated inside the state updater from the list React hands it,
so a burst never reuses a number and no shadow ref is needed.

Every exit is now evidence about that particular send:

- Accepted. The run stream's `data-session-accepted` frame names the turn, which
  is the `turn_id` the saved user record carries, so the echo waits for its own
  row and ignores the count entirely.
- Parked. A 202 body names the durable input, and the echo waits until the dock
  is OBSERVED to list that id. A completed snapshot request is not evidence the
  dock has the row.
- Failed. An HTTP 200 only proves the request was taken, so the stream is read
  for an error frame before acceptance, and an end with no acceptance is treated
  as a turn that never started.

Ordering against an answer already streaming is fixed too: a preview belonging to
an execution no echo owns is an earlier turn's answer, so a newly sent question
goes below it rather than above.

Tests: an abandoned-render regression test built on a suspending transition, which
fails against the ref version; a StrictMode double-render case; the foreign-row,
parked-dock, burst, and renumbering cases; and the no-duplicate-commit invariant.

* fix(frontend): stop calling a silent stream a failure, and never delete a refused send

Codex pass three. Two of its findings still applied after the state rework, and
both were mine.

The runner emits the acceptance frame only for a DETACHED request, and this
adapter also sends ordinary ones. Treating a stream that ends without acceptance
as a refusal therefore classified good turns as failures. Silence now reports
nothing and the send falls back to the count, exactly as before identity existed.
Only an error frame before acceptance is a failure, and a dropped connection says
nothing either way, which is what the comment already claimed.

Worse, a late refusal deleted the echo outright. The composer clears on submit and
its restore only runs when the submit promise rejects, so by then the user's text
existed nowhere: the row vanished and took the message with it. That is worse than
the gap this PR set out to close. A failed send now KEEPS its row, flagged, so the
text stays on screen. The `failed` field was dead until this commit.

Also from the review: the stream reader handles CRLF and CR-only framing and a
final frame with no trailing newline; `mergePendingSendEchoRows` no longer guesses
at previews while any echo is unacknowledged, which had put an answer above its
own question; and `userStop` reads `turnId` from assistant rows only, since user
rows now carry one and a question and its answer would otherwise share an identity.

Tests drive the reader through real Response bodies: split chunks, both framings,
a missing trailing newline, an error frame, an ordinary stream that never accepts,
and connection drops before and after acceptance.

* fix(frontend): bound an acknowledged echo by its own turn's terminal frame

The last of the six published limits, and the one that mattered: an acknowledged
echo waited forever if its user record never persisted. Acceptance precedes
persistence and exhausted ingest retries drop records, so that is the original
vanish class wearing a new hat, a row that sits on screen forever instead of one
that never appears.

The adapter now reports `onSettled` after the run stream ends AND its records have
been re-read. An echo still waiting at that point stops waiting silently: it is
flagged, so the text stays on screen and the state is visible.

Retirement precedence is reordered so this stays a guess and not a verdict. The
saved row is checked FIRST, ahead of the failure flag, so a record adopted after
the terminal frame still retires an echo the guess had flagged.

* test(frontend): pin the pre-acknowledgement window and say why it is the safer failure

The browser cell for this class reports INCONCLUSIVE on our stack, because the
second tab's send returns 202 and queues, so it cannot demonstrate the race. That
is a property of the stack, not evidence the race cannot happen, so the limit
belongs in a unit test over the adoption path instead.

Two tests: a history adoption retires an unacknowledged echo, and the same
adoption leaves an acknowledged one alone. The first pins current behaviour rather
than endorsing it.

The comment records the trade rather than just the symptom. Holding an
unacknowledged echo until its turn settles would close the window, but any send
whose stream carries no acceptance frame would then sit beside its own durable row
for the whole turn. A persistent duplicate is worse than a brief re-vanish that
heals when the real row lands.

* fix(frontend): render the failed state on a refused send, on both hosts

The flag was set and nothing read it, so a send refused inside a 200 kept its text
on screen looking like an ordinary turn that would never get an answer. The text
was safe; the state was invisible. Three unit assertions passed the whole time,
which is a good reminder that a green test can certify a value exists rather than
that anyone is shown it.

Both hosts now render a note under the row: "Not sent. Copy the text and try
again." It carries `data-pending-send-failed` and `role="status"`, so it is
detectable in the DOM and announced, rather than signalled by colour alone.

NOT yet seen in a browser. The stack belongs to another agent, and the QA cell
that asserts this is written and waiting. Treat the wording and placement as
provisional until that pass runs.

* fix(frontend): register an echo against the current transcript, and bound a parked one

Codex pass four, the two findings that were new rather than carried.

An echo registered against a stale user count. `add` closed over the count from
the render that created it, and capability resolution is a round trip, so a send
could enter the lifecycle long after that. If the transcript moved on meanwhile,
the echo reserved a number already taken and was hidden the moment it appeared.
The baseline is now read at call time from a ref written in an effect, so it
tracks the last COMMITTED render and an abandoned one cannot move it. The updater
still receives it as a plain argument and stays replay-safe.

The first version of that test passed against the broken code, because
`result.current` hands back the newest callback rather than the one a pending
submit is holding. It now captures the handler before the adoption, and fails
against the closed-over version.

A parked echo could wait forever. The backend drops a pending input from its query
once its execution is running, and a parked echo never receives a turn id, so dock
membership was its only possible successor. If promotion beat observation, nothing
could ever retire it. The count is now its fallback, exactly as it is before any
send has an identity.

Also aligns the failed-row wording with the composer's, word for word. Two
phrasings for one event reads as carelessness; to the user a refused send is one
thing, whether the stream failed before or after the promise resolved.

* fix(frontend): settle only an accepted turn, and recover a late refusal like any other

Three findings, two of which put a false statement in front of the user.

onSettled fired at every stream end, accepted or not. On an ordinary non-shared
send the runner emits no acceptance frame, so the echo had no turn id, the flag
was set, and retirement's failed branch outranks the count rule that would have
cleared it. The result was a permanent "wasn't sent" under a message that was sent
and answered. My own comment said "the accepted turn finished" while the code
settled unconditionally, the same shape of bug as the flag nothing read. The
reader now reports whether it named a turn, and settlement is gated on that.

The trailing-frame rescan discarded an error verdict, so the same refusal reported
nothing without a newline and a failure with one. Its return value is now used.

Recovery for the two refusal shapes is unified, per review. A refusal arriving
after the send promise resolved now goes back to the composer through the same
channel a rejected send uses, and the row is dropped. One event, one place, one
wording. The flagged row survives only where no composer can take the text, which
is also what closes the worst combination in the review: even if the
pre-acknowledgement window has already retired the echo, the composer still gets
the message back.

Desktop is wired. Mobile passes the seam through but supplies no restorer yet,
because its composer sits below the hook with no handle, so a late refusal there
keeps the flagged row. That asymmetry is deliberate and marked in the code.

* fix(frontend): do not let a stale run completion adopt over a newer transcript

Review found this by testing a belief of mine rather than accepting it, and the
belief was wrong. I had said an unmount mid-stream degrades to discarded rather
than wrong, on the grounds that both hosts key by session. It does not: the
session registry deliberately preserves the same Chat across a remount, so the old
reader's completion still calls `onExecuted` on the live session and adopts its
stale snapshot over a newer transcript, then persists it. That truncates a
transcript rather than misplacing a row, which makes it the most serious thing
found in this PR.

Every continuation past an await now checks that this mount is still alive before
refreshing the queue or triggering adoption. The reader itself is left running
rather than aborted, because aborting the invoke body could tear down a live run
on the non-shared path, which would be a worse cure than the disease.

NOT covered by a unit test. Exercising it needs the request builder's jotai setup
around a mid-stream unmount, and I could not get that harness right without
leaving a broken test behind. The fix is small and the reasoning is in the review;
it should be verified there rather than taken on trust, which is the same mistake
that produced the bug.

* fix(frontend): never overwrite a draft the user typed after a send was refused

Review note on my own recovery change, and it was right. I hand-rolled the late
refusal's restore instead of reusing the one this file already had, and lost three
properties in the process.

A late refusal arrives seconds after the composer cleared, so unlike a rejected
send the user has had time to type something else. My version called setMarkdown
unconditionally, with no guard and no undo. It also restored the text without the
staged files, so a refused send with attachments came back as text alone. And it
returned true whenever an editor existed, so the queue dropped the echo row even
when the restore had just destroyed a draft, which is precisely the case where the
row is the recovery surface worth keeping.

It now calls `restoreRefusedSend`, the helper the rejected-send path already uses,
which refuses a non-empty composer, puts the staged files back with the text, and
reports whether it took them. Returning false leaves the flagged row in place.

Two tests pin the properties the late path depends on, rather than the ones the
synchronous path happened to get for free.

* fix(frontend): say a file-only send failed, instead of showing cards that look fine

Both hosts painted no bubble at all for a message carrying only attachments, and
the failure note lived inside that bubble, so a refused file-only send rendered as
attachment cards with nothing to say they had not been sent. The note is now what
the bubble carries when there are no words, on both hosts.

This was the second half of the reason checking metadata alone was not enough. The
flag was set, the accessor was tested, and for this shape of message nobody would
ever have seen it.

* fix(frontend): stop settlement writing a delivered message back into the composer

I introduced this with the recovery unification and it is worse than anything it
replaced. Settlement called the restorer unconditionally, so on the NORMAL accepted
path — row adopted, echo retired, stream ends — a message that had been delivered
and answered was written back into the composer under "wasn't sent". The
non-empty-composer guard does not help, because the composer is usually empty
right after a send, which is exactly when this fires.

Settlement now only marks an echo that is still waiting. Marking one that has
already retired is a no-op, which is the behaviour a browser pass confirmed as
correct before I changed it. A genuine refusal still restores, because a refusal
means the message was never delivered.

The lesson is the one this PR keeps teaching: I unified two paths on the grounds
that they were one event, and they are not. A refusal says the message did not
arrive. Settlement says the turn is over, which is usually because it arrived.

* fix(frontend): never let a refused message end up with nowhere to be

Codex blocker three, both halves.

A refusal arriving after the count rule had already retired the echo left the
message with neither a row nor a restored draft, whenever the composer also
declined because the user had typed since. Both recovery surfaces were checked and
both were unavailable, and nothing noticed. A refusal now re-creates the row when
the echo has gone, so there is always somewhere the message can still be seen.

And `setMarkdown` returns void and does nothing at all once the handle's internal
ref is gone, so the restore reported success without checking. That told the queue
the message was safe in the composer when it was nowhere, and the queue used that
to drop the only row showing it. Success is now confirmed by reading the text
back.

The existing test stub never stored what it was given, so it would have passed
either way. It now models an editor that keeps its text, which is what makes the
read-back assertion mean anything.

* fix(frontend): gate every transcript adoption on the mount that started its read

A durable send's run stream outlives the mount that started it. When it completes it calls
onExecuted, which starts a records read of its own, and the mount can go away while that read is in
flight. The session registry deliberately preserves the same Chat across a remount, so the old
adopter still holds a working setMessages and a working persistence atom, and it still holds the
old mount's watermark refs. Its stale snapshot therefore passed its own guard and replaced a newer
transcript, on screen and in the cache, on both hosts.

The previous fix checked a boolean before the completion callback and before the queue refresh. It
did not cover the read onExecuted starts, and a boolean also fails open across a StrictMode effect
replay, which re-arms it after the cleanup.

useMountGeneration hands each mount a monotonic generation. Every asynchronous chain that ends in
an adoption or a persistence write captures one when it starts and re-checks it immediately before
the write. The generation is a required argument on both hosts' adopters, so a call site that
forgets one does not compile rather than failing open.

Also: a refused send reports a successful restore only when the composer really took all of it. A
send carrying attachments the tray has no staged entry for, and an attachments-only send whose
staged entries are gone, now keep the flagged row instead of being dropped with nothing restored.

* fix(frontend): stop reading a mid-run failure as a refused send

An ordinary request never gets an acceptance frame, so the reader had nothing to tell a refusal
from a turn that started and then failed. Every error frame in the stream was read as a refusal.

Found live on the EE dev stack. The workspace model key had run out of credits, so the run failed
about 13 seconds after the answer's first frame, by which time the user's row was saved, adopted
and on screen. The reader called that a refused send. The composer restore was attempted for a
message that had been delivered, and the echo was flagged, leaving a permanent second copy of the
message under "Message wasn't sent - try again" beside the real one. Measured before: peak
transcript row count 2 on the first send of a fresh browser context, and the duplicate never went
away. After: peak 1 on all five sends, on both hosts.

A start frame now marks the turn as begun, and an error after that belongs to the turn rather than
to the send. Scanning continues past it, because a detached run names its turn after the answer's
first frame and that id is what retires the echo on identity.

* fix(frontend): read admission from the turn id, not the adapter's start frames

The previous commit used the start frame as evidence that a turn had begun. Codex found that
wrong and it was: the Vercel adapter yields start and start-step before it reads a single runner
event, so a request the runner then refuses carries them exactly as a successful one does. The
rule would have swallowed a genuine competing-turn refusal.

The runner emits its turn event only after it admits the request, and the adapter forwards that id
as message metadata. That frame is the evidence, and it is the one thing in an ordinary request's
stream that proves a turn exists.

Verified live on the same stack that produced the original finding. The credits-exhausted run
emits start, start-step and the turn id together at 26.2 s, and its error at 39.6 s. The transcript
ends with two rows, the durable user row and the assistant row, and no flagged echo. A refusal
injected behind the adapter's start frames is still recovered on both hosts.

Also, per the same review: the early rejection path has no row to fall back on, because its echo is
already dropped, so it now places what it can rather than refusing a partial restore. The
StrictMode hook-ordering contract is pinned by a test with an effect declared above the hook. The
mobile unmount test flushes the useChat throttle before reading the screen, so its on-screen
assertion is no longer satisfied by a value that has not settled.
it("reads CRLF and CR-only framing", async () => {
for (const eol of ["\r\n", "\r"]) {
const w = watcher()
const frame = accepted("turn-3").replace("\n", eol)
…ed (#6676)

* fix(api): refuse an automatic session rename over a name a person typed

An approval card parks the agent's rename_session call. The person renames the
session by hand while it waits. The deferred call then runs with the name the
agent chose before the rename, so the person's name is replaced and the agent
reports the stale one.

Nothing in the agent's own view separates a stale intent from a fresh one, so the
row answers instead. A header edit now says who chose the name: author=user for a
person typing one, author=auto for a program proposing one. A person's name is
stamped on the row as a reserved ag. tag, and an auto edit that would replace it
is refused with 409 carrying the current name, so the agent adopts it.

author is a query parameter, never a body field. The rename_session catalog entry
fixes author=auto inside its own path and the model fills only the body, so an
agent cannot claim a person chose its name. The browser's auto-title sends
author=auto too, so the agent can still name a session over it.

override_user_name in the body is the way past the refusal, for the rename a
person actually asked for.

Fixes #6657

* fix(api): decide the rename guard inside the write transaction, and bind the override to a name

Three changes from review.

An automatic edit no longer moves the name-source stamp at all. It used to
clear it on every edit that carried a name, so the agent repeating the name a
person typed - which the rule allows, because it changes nothing - left the row
unprotected and the next automatic rename replaced the person's name.

The guard now runs inside the DAO's write transaction, against a row locked
with FOR UPDATE. A check a layer up was a read-then-write: a person's rename
could commit between the read and the write, and the automatic edit would
replace a name that did not exist when it was checked.

override_user_name is replaced by replacing_name, the exact name the edit
replaces. A bare permission is timeless: an agent authorized to rename A can
sit parked while the person renames to B, and approving it then overwrites B.
A precondition cannot, because it stops matching.

Also from review: author=user|auto is now name_source=manual|automatic, which
names where a name came from rather than who called; the tag encoding moved out
of the core DTOs into the postgres mapper and the rule into its own core module;
the 409 carries the agent-actionable envelope from api/AGENTS.md instead of a
bare string; and SessionStream no longer exposes the decoded source, since only
the write path reads it.

Adds a Postgres integration suite. One cell holds the row's lock and asserts the
automatic write waits for it, which fails without FOR UPDATE.

* fix(api): spend a rename authorization on one state, and refuse an automatic clear

Two more holes from the Codex re-pass, both of which let an automatic write
weaken the protection it had just been checked against.

A successful authorized rename could replay. The person names the session A,
asks the agent for B, gets B, then puts A back themselves. Replaying the same
call took B again, because the name matched a second time. The row now carries
a name revision that counts every name change, and the precondition names both
the name and the revision it was read at. A restored name brings back the
string, never the revision, so the authorization is spent.

Both halves are load-bearing. A revision counts from one and is guessable; a
name is not. A name repeats; a revision does not.

An automatic caller could clear the name. On a person-named session, an
authorized {name: ""} left the row with an empty name, and every later
automatic rename walked past the guard, because a row with no name has nothing
to protect. Only a person removes a name now.

Also: FOR NO KEY UPDATE rather than FOR UPDATE, so a rename does not contend
with the KEY SHARE lock a session_turns insert takes on this row; and the
refusal message bounds the name it repeats, so a long name cannot push the
machine-readable half of the envelope past the runner's 2000-character cut.

Web: the browser auto-title now drops its optimistic title when the server
refuses the write, instead of showing a name the server rejected until the next
list poll disagrees.

* fix(api): accept the transitional author spelling on the header route

A warm agent session holds the tool descriptors it opened with, so a sandbox
started under an earlier build of this change can still execute
?author=auto after the service has moved on. Reading it costs one branch and
keeps that call on the guarded path; the alternative is a rename that silently
claims a person made it.

An explicit name_source always wins, so the alias cannot override a caller that
speaks the current spelling, and an unrecognized value falls through to the
default rather than failing the rename. The parameter is hidden from the schema
and marked deprecated, and can be deleted once no session that old can be alive.

This never shipped in a release: git log --all -S 'author=auto' returns only
this PR's own commits.

* fix(api): keep the precondition values whole in the refusal envelope

A rename can store a name longer than the prose bound, and replacing_name has
to match the stored value exactly. A caller that copied a shortened quote out
of the message was refused forever with no way to tell why, so a long
person-chosen name became impossible to replace legitimately.

The sentences no longer repeat a name they cannot repeat whole: past the bound
they stop naming the session and point at details.current_name instead. Every
next_step now names details.current_name and details.name_revision rather than
the prose above it. The revision comes first in details, so the runner's
2000-character cut of the serialized detail reaches the small field before a
long name can crowd it out.

Two follow-ups from the Codex pass, both non-blocking there.

replacing_revision now accepts 0. A title filled from a session's first message
carries no revision, so a person who keeps that title owns a name nobody
counted; rejecting 0 made that one session impossible to rename on request.

The browser auto-title clears its optimistic title only when the server REFUSED
the write, not when the write merely failed. A refusal means the server holds a
name a person chose. A network failure means nobody knows yet, and blanking the
row then would lose a title for no reason.
…annot use it (#6694)

The model picker listed every OpenAI-compatible ("custom") vault connection
under Claude Code as well as Pi. Picking the Claude Code entry committed a
revision the server refuses, and the Model row then read "Unavailable".

`custom` is an endpoint address, not a protocol declaration, and Claude Code
publishes it among its deployments for its own Anthropic gateway. So consuming
the surface was never enough. A connection with no saved harness policy now
admits each model per route, and the two kinds of route are not the same claim.
An OpenAI-compatible route takes every model on the connection, because that
surface is a wire format and a gateway serving Mistral or DeepSeek over it is
ordinary. Any other route, Claude Code's Anthropic gateway being the only one
today, is a claim about the endpoint's protocol, so it takes only the models
that name that vendor.

That is a default, not a verdict, so a saved harness policy skips it. A model's
upstream vendor does not establish the endpoint's protocol: a LiteLLM gateway
serves OpenAI-named models over the Anthropic Messages endpoint for exactly this
harness. The tick is the user's own statement about the endpoint, and the record
carries nothing that could contradict it.

A model id is read past its storage namespace. The API stores a custom model as
`<provider-slug>/<kind>/<model-slug>`, so a connection called "openai" holding an
Anthropic model still reaches Claude Code, and one called "anthropic" holding an
OpenAI model does not. The saved model slugs find that boundary, because the
stored prefix is a free-text header name that may itself contain a slash.

An admitted route carries the provider the SDK needs to resolve it, so a Claude
Code route writes `anthropic` instead of falling back to `openai` and being
refused. An OpenAI-compatible pick still writes no provider and is byte-identical
to before. The technical limit still wins over everything.

Fixes #6692
… message's row (#6705)

* fix(frontend): drop a refused message's row only once the composer confirms it took the text

On the desktop a refusal arriving as an HTTP 200 whose stream errors left the message in two
places: a flagged row in the transcript saying it was not sent, and the same text back in the
composer. The user could send it twice. Reproduced on staging at 66ed5a6, with the non-ok
refusal and the mobile path passing as controls.

The composer is Lexical, and setMarkdown schedules the write rather than applying it, so the
read-back that confirms the placement runs against a state that has not changed yet. It answered
no for a placement that was on its way. The caller kept the flagged row on that answer and the
text arrived a moment later.

The read-back stays, because reporting success without one loses the message when the editor
handle is gone. It now gets the ticks it needs, and the seam reports asynchronously so a host that
cannot answer in the calling tick does not have to.

The queue now fails to the safe side. The row goes up first and comes down only once the composer
confirms, so the message is never in neither place, and it ends in exactly one.

This is the case my browser cell for PR #6658 could not see: with SHAPE=b and no DRAFT the cell
served a non-ok status instead of the erroring stream, so the desktop path was never exercised.
Codex raised the read-back as a P1 against that PR and I recorded it as a follow-up on the
strength of that cell.

* test(frontend): take the queue harness's restore type from the hook instead of restating it

The harness declared the restore callback as synchronous while the seam it exercises had widened
to accept a host that answers later, so the asynchronous mocks in the new tests contradicted the
declared type. It also referenced QueuedMessage without importing it.

Neither showed up locally: the package tsconfig includes src only, so nothing typechecks this
directory. Deriving the field from the hook's own parameter type means the harness cannot drift
from the seam again, whether or not anything ever typechecks it.

Raised on the PR by CodeRabbit and, separately, by Codex.
#6708 (#6709)

* test(frontend): quarantine the variant Use API test on the drawer freeze

* test(frontend): shorten the quarantine comment to one line
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