Skip to content

fix(desktop-app): stop team-id mechanics from blocking the composer - #1425

Merged
alfredolopez80 merged 32 commits into
devfrom
fix/1409-composer-gate-and-catalog-hop-isolation
Nov 12, 2026
Merged

alfredolopez80 merged 32 commits into
devfrom
fix/1409-composer-gate-and-catalog-hop-isolation

Conversation

@alfredolopez80

@alfredolopez80 alfredolopez80 commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1409. Refs #1410.

Review rounds 1 to 3 and a closing audit are done; their findings are fixed in the commits below. T2 has not run on the final head; see "Not verified".

Why

The Desktop composer could stay blocked on "Checking agent access for this team…" with no way out. The team id is being retired (#334, then #594, then #1222). This PR stops team-id mechanics from affecting normal use. With the gate gone, a send the server rejects became the path a user meets, so this PR also stops such a send from losing the user's input.

What changes, one commit per root cause

Commit Root cause Change
fix(desktop-app): build the access catalog for one committed session Main read the committed session with no owner while a team hop or switch replaced it in steps, and published the result with no check Read the session under the native commit owner, build outside it, re-check and publish under it. Nothing else waits for a build
fix(desktop-app): remove the composer's team catalog gate The composer refused to send unless the cached catalog's team id matched the session; two states had no exit Gate, notices, retry button and resolver removed. The server authorizes each send. Undoes R7-L1 (02341fd84, 10803b41a) and follow-ups 8244067e0, 25f5da7a3 from #1084
fix(desktop-app): keep the selected agent when the catalog does not list it The current team's catalog decided which agent could stay selected The catalog no longer deselects. A selection ends in one of these cases:
• the server denies the agent, on a read, a rename, a running task's status check, or a send it answers as a loss of access;
• the user selects something else;
• the session identity changes;
• a restored workspace snapshot replaces it.
Replaces the selection rule of 36e4d9ef2
fix(desktop-app): show the workspace for a selected agent when the catalog is empty ChatPage and AgentsPage showed no workspace for a selected agent with an empty catalog A selected agent gets the workspace
test(desktop-app): remount the composer by navigating in the focus test An existing focus test emptied the catalog to unmount the composer, which the previous commit stops The test goes to Files and back. Assertions unchanged
feat(desktop-app): remove unanswered local turns from the chat store The store could not take back a turn the server never accepted removeMessages in the chat store, limited to local user turns with no stored sign of a server answer, with its IPC channel
fix(desktop-app): return a rejected send to the composer A send the server rejected returned after the composer was cleared and the turn was written, and a 401 was treated as a doubt about the agent The text and attachments go back to the composer, the turn is withdrawn, and only a reported loss of access holds the agent
fix(desktop-app): show the failure sentence for a send kept behind Recover input The composer alert fell back to the raw IPC error for a failed send once the send-time error was cleared The alert shows the sentence for the failure's kind; the raw error stays under Technical details. The access hint no longer mentions a team
fix(desktop-app): keep a held send that was posted and failed without a rejection While another path held the agent, any error withdrew the turn, including after the Host may have accepted it A held send that never reached the main process, or was answered with a 401 or 403, is withdrawn; a send handed to the main process that failed in another way keeps its turn
fix(desktop-app): release a send's activity entry on every exit Some exits of a send left the agent's activity stream open One finally releases the send's activity entry unless a started task took it over

These commits change no behaviour:

  • a catalog test for a team hop from a session with no team;
  • a selection test for a held agent restored from a workspace snapshot;
  • a test comment and a code comment;
  • synthetic test tokens for the public boundary scanner;
  • a simplification of the rejected-send path, which removes a toast that could not fire;
  • tests for its attachment guards;
  • tests for the guards of the rejected-send path and for the tombstone and sender checks of chat:removeMessages;
  • a refactor that shares the durable write of a kept turn;
  • comments on what the removal rule, a withdrawal, each send failure reason and the "handed to the main process" flag cover;
  • tests that pin the activity release on a generic failure and on a synchronous reply, and a logout test whose witness now observes the logout;
  • descriptive test titles in place of review ids;
  • a store test that pins both orders of a removal and a reconcile replace on one chat: a withdrawn turn is not written back.

The branch also merges dev up to e611ebfcb (no conflicts).

Behaviour changes to review

  • A catalog request can now reject where it used to resolve with a catalog of a session that no longer existed.
    • It rejects with stale_auth_epoch when the user logged out or changed during its build, or when the session changed twice.
    • It rejects with Not authenticated when a logout lands between a discarded build and the rebuild's session read (read in the code, not run).
    • getConnectors and the plugin SDK's contexts.read capability use the same entry and inherit these rejections. The SDK's mcp.read capability uses the same entry, but its existing catch turns any catalog failure into an empty agent list, so it does not surface them.
    • A build overtaken by the same user's session change is discarded and rebuilt once.
  • An agent removed on the server stays selected until the server denies it: on a read, on a rename, on the status check of a running task, or on a send answered with host_access_revoked or with a 403 whose follow-up read is also denied. A send rejected in any other way (a 401, or a 403 whose follow-up read is not denied) keeps the agent selected.
  • A rejected send no longer loses the input.
    • When the composer can take the input back, the text and attachments return to it with no message. The rejected turn is removed from the transcript, and the user can send again.
    • Otherwise the input waits behind Recover input, and the composer shows the text for that kind of failure. Under Technical details it shows the raw error, or, for a send the hold stopped before it reached the main process, "The agent was on hold, so the message was not sent." For a rejection or a loss of access the text is: "Access issue while sending to this agent. You may not have access to this agent anymore. Sign in again or ask an admin for access." This happens when the composer changed while the send was pending, when the send was a Retry that carried attachments or met a draft, and when a send with attachments ended while the agent was held.
    • A 403 whose follow-up read is not denied (it succeeds, or fails with a network or server error) used to show an error and Recover input; it now behaves as any other generic rejection.
    • A rejection that persists returns the text on every send, with no message.
  • When the server reports that access to the agent is lost, the agent is held as before, with no notice at that moment.
  • A send that fails for another reason (network, 5xx, 404, 413, 422) keeps its error, toast, Retry and Recover input while the agent is selectable. One thing changes: the alert keeps its sentence after the user types, attaches something or returns to the chat. It used to turn into the raw error then. The raw error stays under Technical details.
  • The hint shown for an access failure no longer mentions a team. It read "Authentication/permission issue. Re-authenticate or switch team/access scope." and now reads "You may not have access to this agent anymore. Sign in again or ask an admin for access." No other send-failure text mentioned a team.
  • If another path holds the agent while a send is pending:
    • A send that never reached the main process, or that the server answers with a 401 or 403, is handled as a loss of access, and its turn is withdrawn.
      • A text-only send returns its text to the draft, or waits behind Recover input when a newer draft is there.
      • A send with attachments waits behind Recover input with its text. It shows the sentence for the kind of failure that ended it: the access sentence when the hold stopped it or the server rejected it, and the connection sentence when the network failed before the send reached the main process.
    • A send handed to the main process that fails in another way keeps its turn, as before. This includes a refusal inside the main process before the HTTP request (request validation, minting the RPC token). Its input, which used to be unreachable, is offered by Recover input and Retry when the agent is selectable again.
  • Every exit of a send now releases its entry in the agent's activity stream unless a started task took it over. This closes the stream when the chat was deleted, the agent was held, or the session scope changed while the send was pending.
  • The catalog's build does not hold the commit owner, so sends, team switches and logouts do not wait for a build. The session read before the build, and the publish after it, wait for the current holder of the commit owner. That read can call getMe (only when no user is cached) and bind the chat store, so a logout can wait for it.
  • The composer shows no access notice. While catalog data is retained, a failed catalog refresh shows no error on the Chat and Agents pages. The Members tab of an agent with no members to show still prints the refresh error, which can be a raw stale_auth_epoch message. That display predates this PR.
  • With an agent selected, a catalog failure with no retained data is not shown on the Chat or Agents page: both pages render the workspace for a selected agent.

Validation (Node 24.22.1)

Check Result
scripts/minikube/t0.sh at the head 18691a395, which runs the desktop-app tests T0_EXIT=0, T0_AFFECTED_CHECKS=PASS, 477 files, 5878 tests. A first run on a saturated host timed out in App.gfsColdStart.r15.test.tsx, a dev file this PR does not touch; that file passes 21/21 on its own, and the rerun passed
Removal and replace ordering on one chat Both orders leave the withdrawn turn removed. Without the queue around removeMessages, the new test and the upsert ordering test turn red
tsc main and UI, prettier on the touched files, style-rules, public boundary scanner, at c3fa9a974 Green; style-rules has 1 warning in a file this PR does not touch
Fail-before on d02fcb9f1, access catalog tests 12 failed, 4 passed (the 4 pin behaviour that must not change)
Fail-before on d02fcb9f1, selection and page tests 13 failed, 6 passed
18 mutations of the round-1 production changes, one at a time Each turned red every test the plan named, at f7318e80c
Mutations of the round-2 changes Each turned its named tests red at d01c1db4e. One mutation could not fail, because the check it removed had no observable effect; the check was removed instead
Mutations of the round-3 changes, at the commit that adds their tests Each turned its named tests red. Only the one that moves the "task took over the activity entry" flag earlier survives: no statement between its two positions can throw, so the move cannot be observed. Removing the finally release now turns 7 tests red
Round 2's reviewed head (d01c1db4e): its tests on the code at 331141614 306 passed, 2 failed. Both are intended changes: a held send handed to the main process that failed on the network now keeps its turn, and the alert keeps its sentence after the send-time error is cleared
Commits 5 and 6 of round 3 reverted alone in a scratch clone Each applies without conflict, and the touched renderer tests pass
Manual run of this branch's Desktop build against production Signed in, Settings → Information shows the production endpoints with health OK
CI on 18691a395 63 checks passed

Not verified

  • T2 has not run on the final head. A T2 run on cd16755b6 stopped in T1 on one control-api test: 1054 of 1055 passed. That test races its own COMMIT (control-api: passwordResetLostCommit real-Postgres test reads before the COMMIT is applied #1442) and this PR does not touch control-api. The rerun was refused because dev had moved.
  • No Playwright run has been done.
  • The catalog fix relies on the server accepting a token after a later team switch issued another one. This was read in the server code at d02fcb9f1 and 27776e8, not executed against a server. Remove Team Token PR2: Runtime actions use validated access paths #594 changes the session model, so the premise must be re-read when it merges.
  • The trigger in the original report was not identified. The race is the mechanism that best explains it.
  • How often each accepted edge case of the rejected-send change occurs is not measured on the client. On the server, control-api logged one rpc_host_access_denied in 30 days on clerum-dev (host_missing) and none on clerum, against 254 and 599 sends.

Known limits

🤖 Generated with Claude Code

alfredolopez80 and others added 15 commits November 10, 2026 16:22
A transient team hop and a deliberate team switch replace the committed
token and principal in steps while they hold the native commit owner.
refreshAccessCatalog read those two fields with no owner and published
its result with no check, so a build could borrow the hop's token, its
principal, or one of each, and a build overtaken by a switch, a logout
or a login published the old session's catalog as the current one.

The session is now read under the owner, the catalog is built outside
it, and it is published under the owner after checking that the session
is still the same one. Nothing else waits for a catalog build. The token
is not compared: a hop restore mints a new home token without advancing
the generation, and a catalog built across a hop still belongs to the
session.

Behaviour that changes for callers: a catalog request now rejects with
stale_auth_epoch when the user logged out or changed during its build,
or when the session changed twice. A build overtaken by the same user's
session change is discarded and rebuilt once. Before this change those
requests resolved with a catalog of a session that no longer existed.

Orphans checked: refreshAccessCatalog's locals moved to
buildAccessCatalogForSession; removed: none.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The composer refused to send unless the cached catalog's team id matched
the session and listed the selected agent. Two of its states had no exit:
a settled catalog stamped with another team stayed "Checking agent
access for this team…" until something else refetched it, and a signed
out state did the same. The server authorizes every send, so the check
only blocked users on stale client data.

The gate, its notices, the retry button and the resolver are removed.
The renderer keeps no opinion on whether a send is authorized.

This undoes review fix R7-L1 (02341fd, 10803b4) and its follow-ups
8244067 and 25f5da7, which came in through PR #1084.

Orphans checked: useAuthContext and useAgentsDataController in
ComposerPanel, agentCatalogAvailability and its two test files, and the
produceSerializedAccessCatalogMalformedResponse test helper;
removed: all of them. Button, useId and composer-attachment-notice stay
in use.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ist it

The selection effect dropped any agent the catalog did not list, unless
the catalog's team id had just changed. The catalog is direct grants
plus the current team's grants, so the current team decided which agent
could stay selected: an agent kept through a switch was dropped by the
next catalog change, and a tab or notification of an unlisted agent
could not be opened at all.

The catalog no longer deselects. A selection ends when the user changes
it or when the server denies it through the existing hold path: the
chat-list read on selection and team change, the send, and the re-check
of a held agent. The effect stays only for a selection restored from a
workspace snapshot without a server check.

An agent removed on the server now stays selected until a read or a send
is denied. This replaces the selection rule of 36e4d9e from PR #1084.

Orphans checked: accessCatalogScopeRef, agentsData.agentNames in the
effect; removed: accessCatalogScopeRef.
authenticatedPrincipalIdentity keeps its other callers.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…talog is empty

ChatPage and AgentsPage rendered no workspace when the catalog was
empty, even with an agent selected, so a kept selection had a tab and no
composer whenever the current team's catalog was empty.

A selected agent now gets the workspace. Both ChatPage guards change
together: with only the empty-state guard changed, a composer mount
refreshes the catalog, the loading guard swaps the workspace for the
skeleton, and the workspace mounts and refreshes again.

With no selection both pages behave as before.

Orphans checked: the ChatPage and AgentsPage empty and loading branches;
removed: none.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test that a late composer mount does not take focus from the compact
navigation trigger emptied the catalog to unmount the composer. A
selected agent now keeps its workspace when the catalog is empty, so
that no longer unmounts it. The test now leaves Chat for Files and comes
back, which is a real path to a new composer. Its assertions are
unchanged, and it still fails with the composer's compact navigation
focus check removed.

Orphans checked: appQueryClient and desktopQueryKeys in the test file;
removed: both.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The public boundary scanner rejected six literals under a token key that
did not say they are synthetic. The tokens the test's switch fixture
issues are now named synthetic-session-<team>. The assertions compare
the same tokens as before.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g a catalog build

refreshAccessCatalog refuses to publish when the session generation or
the team changed during the build. Every existing test that moves the
team also advances the generation or ends the session, so the team
comparison had no test of its own.

A session with no team does: its first hop has nothing to restore and
does not advance the generation, so after the hops the team is team-a
and the generation is unchanged. The new test holds a build of the
no-team session in its user-agents request, runs the hops through
listPendingWorkflowApprovals, and checks that the build is discarded
and the catalog is rebuilt and published for team-a. With the team
comparison removed, the test fails with a catalog that has no team.

The setup gains a homeWithoutTeam option; without it every existing
test runs as before.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The selection effect in useAppController clears a selection that names a
held agent. Its remaining job is a selection restored from a workspace
snapshot, which restoreWorkspaceNavigation writes with no server check.
No test restored a snapshot, so disabling the branch left every test
green.

The new test opens agent-x's Members route, captures the snapshot,
moves to agent-y, and in one row has the server deny agent-x's chat list
so it is held. Restoring the snapshot brings back the Agents page and
the Members route in both rows. The held row ends with no selection;
the control row ends with agent-x selected, which shows the snapshot
names it. With the effect's condition disabled, the held row fails with
agent-x selected and the control row stays green.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send writes its user turn to the local chat store before the POST, so
that a Host wake, a tab switch or a restart cannot erase the only copy.
The store had no operation that takes a message back: upsertMessages
and replaceMessages merge with what is stored, and saveMessages is not
exposed. A turn the server rejected therefore stayed stored, came back
when the chat reloaded, and was doubled by a retry.

ChatStore.removeMessages removes local user turns the server never
answered, under the chat's serializer. Each requested message that is
stored must be a user turn with no server turn number and no task id,
with no assistant message lacking a server turn number stored after it.
Numbered server turns after it are what a reconcile normally stores and
do not count; a synchronous reply or a legacy reply does. If any
requested message fails the rule, the call throws and removes nothing.
Unknown ids are ignored, a deleted or missing chat returns 0 and is not
created, and the indexed message count is not lowered.

The channel chat:removeMessages checks the sender, requires agentRef and
chatId, and accepts 1 to 16 message ids of at most 128 characters
through the new sanitizeChatMessageIds, which rejects anything else
instead of slicing it. It uses the store bound to the signed-in user.
The preload entry, the renderer type, the useChatStore wrapper and both
renderer test bridges gain removeMessages.

The only production caller arrives in the next commit:
withdrawUndeliveredTurn in useAgentChatController.ts.

Refs #1409
Refs #1410

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send clears the composer, writes its user turn and marks it waiting
before the server has accepted anything. Its authorization exits undid
none of that: the text and attachments were lost, the turn stayed in
the transcript and a resend showed it twice. A 401 was also treated as
a doubt about the agent and deselected it.

A send the server rejects now ends in one of two ways.

- Generic rejection (a 401, or a 403 whose follow-up read is not
  denied): the agent stays selected, nothing is shown, and the text and
  attachments go back to the composer.
- Loss of access (host_access_revoked on the send, or a 403 whose
  follow-up read is denied): the agent is revoked or held as before. A
  text-only send finds its text in the draft when the agent is
  selectable again; a send with attachments keeps them, with its text,
  behind Recover input.

The payload returns all or nothing: only when the draft of the send's
chat is free and the composer still holds what the send cleared, on the
visible chat. Otherwise the retained send keeps it for Recover input.
The composer state and the hold are settled synchronously, as before;
only the withdrawal of the turn awaits.

The rejected turn is withdrawn from the visible list and from the
store through removeMessages. Its id is fenced, so a reconcile or an
older-page backfill that read the transcript earlier does not write it
back; the hydrate filters both where it merges and after the chat
create it awaits before writing. After the removal the auth scope is
checked again. A refused removal keeps the turn, marks its activity as
an error, logs a warning without the message text and shows an error
toast.

The two exits taken when another path holds the agent (before the POST
and after it fails) keep the input the same way. Failures that are not
a rejection (network, 5xx, 404, 413, 422) keep today's error.

Recover input and the rejected send share one function that puts a
payload back in the composer. The crossChat 401 row now expects no
hold, and the deep-link test expects the text and the document back in
the composer after a send 403.

Depends on the previous commit (`removeMessages`). Reverting that
commit requires reverting this one first.

Orphans checked: holdHostAccess, isHttp403, classifyErrorKind's auth
branch, the "Access issue while sending" text; removed: none.

Refs #1409
Refs #1410

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment in test (b) said that an equal second catalog would keep
the same query object so that no selection effect runs again. The
selection no longer depends on the catalog. What the different catalog
still provides is an observable change: the query keeps the same data
object for an equal result, so nothing that reads the catalog would see
a change, and the assertion could not tell the refetched catalog from
the first one. The comment now says that. No behaviour changes.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oes not list

The comment on the Authorize buttons in AgentWorkspace said the only
gap in this agent's mapped rows is transient, while the catalog is
mid-hydration. A selected agent can now stay selected while the catalog
does not list it. The mapped rows come from the catalog only, so such
an agent shows the global preview rows when that preview is available,
and no rows otherwise, for as long as the catalog omits it. The comment
now names both cases. No behaviour changes.

Refs #1409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Simplifications from a KISS and YAGNI review of the rejected-send
change. No behaviour the user can see changes.

- S1: restoreComposerPayload no longer reads the draft again. Both
  callers reach it only with a free draft, and it writes the text only
  when there is one.
- S2: sendStillAuthorized is built from sendAuthScopeIsCurrent and
  sendChatWasDeleted, which move up so that "same scope" and "chat
  deleted" are defined once.
- S3: returnUndeliveredSend uses sendChatId directly instead of a
  `?? null` alias of a value that is already string | null.
- S4: ChatStore.removeMessages derives the removed count from the
  filtered list instead of keeping a counter in the loop.
- C1: the comment on restoreComposerPayload no longer describes a false
  return.
- C2: the comment before the composer write and the hold states the
  ordering instead of its history.
- O1: restoreComposerPayload returns nothing. setComposerDraft returns
  true on every path, so its false branch, the third operand of the
  put-back condition and Recover input's "This composer no longer owns
  the draft" toast could not be reached.
- O2: a withdrawn turn's activity entry is no longer removed. The only
  reader looks it up for a rendered message, the withdrawn turn is not
  rendered, and the map is pruned on task terminals and cleared on a
  hold or a scope reset.

Orphans checked: restoreComposerPayload's boolean result, the "no longer
owns the draft" toast in Recover input, the activity-entry removal in
withdrawUndeliveredTurn, getComposerDraft, setComposerDraft and
setActivityByAgentMessage in useAgentChatController.ts; removed: the
boolean result, the Recover toast and the activity-entry removal. The
other helpers keep their callers. The same toast in ChatThread.tsx is
outside this change.

Refs #1409
Refs #1410

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two guards of the rejected-send path had no test that fails without
them:

- U1: restoreComposerPayload restores documents only when the send
  carried some. handleRestoreComposerFiles replaces the composer's
  documents, so without the guard a text-only send rejected after the
  user attached a document would erase that document. The new test
  fails with the guard removed (no document left in the composer).
- U2: the put-back requires the composer's attachment revision to be
  the one the send's own clear left. The new test changes the
  attachments while the POST is pending; without the condition the
  payload goes into the changed composer and nothing is retained, and
  the test fails.

The arrange code of the file is also shared, with no test removed and
the same 34 titles before the two new tests:

- T1: sendHeldAtPost starts a send whose POST stays pending.
- T2: holdByDeniedRead holds the agent through a denied transcript read.
- T3: the two T-A5 tests that differ only in the POST's failure are one
  it.each.
- T4: the first T-A12 test ends with the shared assertion helper, plus
  its own check of the visible order.
- T5: expectInputReturnedAfterReselect checks the draft and the
  withdrawn turn after access returns.

Refs #1409
Refs #1410

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path

Five guards of the rejected-send path had no test that fails without them.
Each new test builds the interleaving or state its guard handles:

- an older page loaded while the removal of a rejected turn is pending does
  not write the turn back (the withdrawn-id filter of the backfill);
- a turn withdrawn while its chat is hidden does not come back from the
  remembered transcript when a later local read fails;
- a 401 whose removal is refused after a logout reports nothing in the new
  scope;
- a logout during the follow-up read of a 403 leaves the draft store and the
  turn alone;
- a rejected send that returns its input to the composer releases its
  retained snapshot.

Each test was run green on this code and red with its guard removed, with
every other test of the file green. No production code changes.

Two describe titles drop the ids of an earlier plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alfredolopez80 and others added 6 commits November 11, 2026 18:34
…veMessages

Two checks of the removal path had no test that fails without them:

- the store returns 0 and keeps the stored snapshot when the chat is deleted
  in scope while its files are still on disk (a deletion whose cleanup did
  not finish);
- the IPC handler rejects an untrusted sender before the store is called;
  the same payload from the trusted sender removes the turn.

Each test was run green on this code and red with its check removed, with
every other test of the file green. No production code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cover input

A retained failure whose POST failed records the thrown error as its
message, and the composer alert fell back to that message. So a send that
waits behind Recover input showed the raw IPC error (for example "Error
invoking remote method 'rpc:invokeHostMessage': Error: 403 Forbidden: ...")
as its alert, and an ordinary failed send turned its sentence into the raw
error as soon as the user typed, attached something or returned to the chat.

The sentence and hint of a failed send now come from one function,
describeSendFailure in lib/format.ts, which the generic catch and the alert
of a retained failure both use. A retained failure whose reason is
post_failed shows the sentence and hint for its kind; the raw error stays
under "Technical details". Other reasons keep the text they recorded (the
Host's own error text, the files sentences).

The hint of an access failure no longer mentions a team. It read
"Authentication/permission issue. Re-authenticate or switch team/access
scope." and now reads "You may not have access to this agent anymore. Sign
in again or ask an admin for access." No other send-failure text mentioned
a team.

Orphans checked: the inline mapping of the generic catch (the sentences, the
size hint, isRequestEntityTooLarge, friendlyMessage) and the controller's
import of errorRecoveryHint. Removed: all of them; no literal is left in the
controller. errorRecoveryHint is still used by describeSendFailure and its
test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generic catch of sendAgentMessage marks the turn of a failed send with
preserveLocal and stores it. That block becomes a local function,
keepUndeliveredTurn, with the same body; the generic catch calls it where the
block was. No behaviour change and no test change.

The next commit adds its second caller: the held exit of the catch, for a
send that was posted and failed without a rejection. Until then the function
has one caller; that is the cost of keeping the two later fixes of this
controller on lines that do not touch, so each of them reverts on its own.

Orphans checked: the inline durable write of the generic catch. Removed: the
block and its comment; nothing else referred to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a rejection

When another path held the agent while a send was pending, the catch
withdrew the send's turn and returned its input for any error. For a POST
that failed without a rejection (a network error, a timeout, a 404, a 5xx)
the Host may have received the message, so returning the text to the draft
invited a second send of the same message.

The held exit of the catch now decides by whether the POST was issued. A
flag, set immediately before invokeHostMessage is called, tells a send that
was never posted (for example its activity subscription failed first) from
one that was:

- never posted: withdrawn, and the input returned, as before, with the kind
  of the error that ended it;
- posted and answered with a 401 or 403: withdrawn, and the input returned,
  as before, with kind auth;
- posted and failed in any other way: the turn stays, marked preserveLocal
  and stored through the shared durable write, and the input is retained
  with the failure, behind Recover input and Retry. This is what the same
  failure does for an agent that is not held.

The same rule applies when the Host accepted the POST and a later local
write threw while the agent was held: the POST was issued and the error is
not a 401 or 403, so the turn is kept.

The comment on the held helpers drops the id of an earlier plan and the
claim that nothing new is shown.

Tests: a held send whose POST fails with a network error, a 504 or a 404
keeps its turn and offers the input back (the network row leaves the table
of returned inputs); a kept held send with a reference shows the network
sentence; a held send whose activity stream fails before the POST, with a
network error or a 403, is withdrawn and returns its text, or with a
reference waits behind Recover input under the network sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A send registers an activity entry for its agent and opens the agent's
activity stream before its POST. The entry was released only by a
withdrawal, a synchronous reply and the generic catch; a started task takes
it over. Every other exit returned without releasing it. That was safe while
those exits were caused only by a scope change or a hold, which release the
entries themselves. A chat deleted while the send was pending is a third
cause that releases nothing, so the stream stayed open after a 401, a 403
whose read is not denied, any other POST error, an accepted POST, or a
deletion before the POST.

One function, releaseSendActivity, now releases the entry. The finally of
the send runs it on every exit that did not hand the entry to a task; the
hand-off is marked as the last statement of the try, so a throw anywhere
before it still releases the entry, as the generic catch did. The release is
idempotent, so running it after a withdrawal or a hold changes nothing.

Tests: a send whose chat is deleted while it is pending closes the agent's
activity stream after a 401, a 403 whose read is not denied, a network
error, an accepted POST, and a deletion before the POST; a send accepted as
a task keeps the stream open.

Orphans checked: the inline releases of the synchronous reply, of the
generic catch and of withdrawUndeliveredTurn. Removed: the first two; the
third is replaced by a call to releaseSendActivity. maybeStopAgentActivityStream
keeps its other callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The removal of a rejected turn checks what is stored; it cannot ask the
server. The docstring of ChatStore.removeMessages, the comment of the
chat:removeMessages handler and its renderer declaration now say that: a
turn with no stored sign of a server answer is removed, a stored message
that fails the rule makes the call throw, and a deleted or missing chat
removes nothing and returns 0. The examples of an unnumbered assistant
message after the turn now include an error reply and the reply of an
earlier task stored before its turn number is known.

A send's turn is withdrawn, and its input returned, when the server
rejected the send, and also when the send ended before its POST while its
agent was on hold. The comments of the retained-send store, of the withdrawn
ids, of the composer put-back and of the optimistic write said only the
first.

Comments only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-and-catalog-hop-isolation

* origin/dev: (76 commits)
  fix(deps): raise the sharp override to 0.35.5 for Next 16.4.6
  docs: describe which control-ui proxy requests the timeout bounds
  fix(control-ui): default the control-api proxy timeout to 300 s
  ci: gate React bundled inside next, lockfile advisories and Next.js images
  fix(deps): upgrade Next.js 16.3.5 -> 16.4.6 in control-ui and profile-ui
  docs: warn against applying the mcp-host policies file on its own
  docs: describe what a fresh install without deploy/base gets in mcp-host
  docs: create only deny-all-mcp-host on a fresh install without deploy/base
  docs: cover fresh installs without deploy/base and every path git quotes
  test(ci): probe a jwt-key-policy test file in both modes of the version check
  test(e2e): read the WRC window from the core in the overlap bound test
  docs: say how to fix the version check's stayed-at failure
  test(ci): let the version check's pin accept job hardening
  fix(ci): keep a version inside the version check's annotation
  fix(ci): read changed paths unquoted in the version check
  fix(ci): give each service one rule in both modes of the version check
  chore: keep dev's versions for services this PR does not change
  test(deploy): state the version pin's limit and check 4's fallback negative
  docs(deploy): limit the no-records note to passes that reach the policy
  test(e2e): pin the WRC recovery check as a statement of its own
  ...
@alfredolopez80
alfredolopez80 marked this pull request as ready for review November 11, 2026 20:34
alfredolopez80 and others added 3 commits November 11, 2026 22:18
…cords

The comment on the retained-failure alert and the describeSendFailure
docstring said that reason post_failed records the thrown error of a failed
POST. It also records the error of opening the activity stream or of a later
step of the send, and the fixed sentence of a send that its agent's hold
stopped before it was handed to the main process.

postIssued marks that the send was handed to the main process, not that its
POST was made. Main can still refuse it before the POST, for example on
request validation or while minting the RPC token, and the send is then
treated as posted: kept, not withdrawn. The comments now say so, and the
comments that described a withdrawal as ending "before its POST" now say
"before it was handed to the main process".

Five sibling texts still said "rejected" where a held send that ends before
it is handed to main is also covered. They now say "withdrawn" or
"undelivered", including the warning logged when the store refuses a
withdrawal; the two tests that match that warning follow it.

The comment of the best-effort catch in keepUndeliveredTurn said the
in-memory view still shows the turn. That is false when the chat is not
visible, for example after a hold deselected the agent. The catch itself is
unchanged.

Comments and one log message only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two chat store tests shared one title prefix; each now has its own title.
A rejected-send test title regains the space it lost after its colon.

The logout test claimed that an empty draft of the sending chat witnessed
the logout clearing the draft store, but that draft was already empty
before the logout. It now writes a draft for another chat before the logout
and expects it to be gone afterwards. With the logout removed from the test,
that assertion fails.

Only a deleted chat pinned the release of a send's activity entry in the
shared finally. Two rows now pin it for a send that starts no task: a POST
that fails with a network error while the agent is not held, which keeps the
turn, and a synchronous reply. Each first witnesses that the send opened the
stream and took its path, then expects the stream to close. With the release
removed, both rows fail, together with the deleted-chat rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests this branch added to the chat store, the rejected-send journey,
the team catalog selection and the Agents and Chat pages started their
titles with scenario ids from a working plan. The titles now keep only the
behaviour they describe. Tests that predate the branch keep their titles.
Every title stays unique within its file.

Titles only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alfredolopez80 and others added 2 commits November 11, 2026 22:27
The rewritten comment of the best-effort catch in keepUndeliveredTurn said
that, when the chat is not visible, the input survives only in the retained
snapshot. The store still holds the optimistic copy written before the send,
without the mark, and the generic failure path also sets the failed send.
The comment now says that the mark is lost and the input is kept in the
retained snapshot, without claiming that nothing else holds it.

Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The access catalog session isolation tests, added on this branch, started
their titles with scenario ids from a working plan, and two comments named
other tests by those ids. The titles now keep only the behaviour they
describe, and the comments point to the tests below them. Every title stays
unique within the file.

Titles and comments only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Mircala

Mircala commented Nov 11, 2026

Copy link
Copy Markdown

Evenfire Reviewer — Round 1

Reviewed head: cd16755b6812bde37ef3be36d3109ac354a2327e

Decision: Approve with follow-ups

The composer no longer blocks on a team-stamped catalog, a catalog built across a team hop or a team switch is published only for the session it belongs to, and a send the server rejects returns its input instead of losing it. I found no merge-blocking defect. Two statements in the description do not match the head, and T2 has not been run.

Must fix

No must-fix items found.

Verified

  • The catalog is read and published under the native commit owner, built outside it, and rejected with stale_auth_epoch when the session changed; the token is correctly not compared, because a hop mints a new token without advancing the generation and the previous token stays valid.
  • chat:removeMessages checks the sender, bounds the ids, and the store removes only an unanswered local user turn; a reconcile that read the transcript before the withdrawal cannot write the turn back.
  • A held send is withdrawn only when it was never posted or the server answered 401 or 403; a posted send that failed any other way keeps its turn. One finally releases the activity entry unless a started task took it over.

Validation gaps

  • Previous-head regression proof: not verified in this environment.
  • No T2, Playwright, or live Desktop run was performed. The description already schedules T2 on this head. dev has moved since the merge (mergeable_state: behind), so rebase or merge before that run.

PR description

Update required. R1-L1 — the sandboxUiSessionEpoch comment does not claim that a hop bumps sessionGeneration; it says the opposite, and the PR does not touch it. The opening line also still says the PR is a draft.

Pre-existing

Pre-existing: none identified during the scoped review.

Next action

Run T2 once on this head, and correct the two statements in the description.

useAgentChatController.ts already has four fix: commits in this PR. Another correction there should come with a decision table for the send exits, not another local patch.

Full review archive: pr-reviews/pr-1425/r1-26-11-11.md

— Review performed by Evenfire Reviewer

…-and-catalog-hop-isolation

* origin/dev:
  test(desktop-app): pin explicit profile origin precedence
  test: remove redundant preview assertion for r4-l4
  test: assert restore ownership outcome for r4-l3
  test: cover profile origin switching for r4-l2
  fix: accept loopback profile aliases for r4-l1
  fix: pair explicit profile origin for r4-m1
  fix(desktop-app): codeql-2004 mark synthetic test token
  fix(desktop-app): codeql-2004 remove unused useEffect import
  fix: confirm external GFS links for J-I1269-N2
  fix(desktop-app): preserve configured local profile ports r1-m1
  fix(format): j-i1269-f1 omit ignore file from source targets
  test(desktop-app): assert generation-scoped preview permission refresh
  test(desktop-app): j-i1269-n2 cover cold-start delivery
  fix(desktop-app): r9-m1 preserve restored session generation
  fix(desktop-app): j-i1269-n1 use active deployment for links
…isolation

* commit 'e611ebfcb': (34 commits)
  docs(mcp-host): reflow R2-L2 grant-ceiling docstring
  test(mcp-host): make R2-L9 epoch guard match a UUID and name the cap pin
  test(mcp-host): cover R2-L4 bounded labels on both decoders and total usage
  fix(mcp-host): log R2-L3 non-Error throws by type only in the reasoning port
  fix(mcp-host): harden R2-L9 Responses test pins and drop its dead tokenField
  test(mcp-host): require R2-L3 alias payload comparison to see a request
  docs(mcp-host): state R2-L2 why the corpus refuses regeneration in CI
  test(mcp-host): pin R2-L1 string branch to the profile's own endpoint
  fix(mcp-host): raise R2-L6 openai floor to the first SDK with Responses
  test(mcp-host): pin R2-L5 whitespace-only summary fallback in compaction
  docs(mcp-host): correct R2-L2 grant-ceiling wire field for Responses
  fix(mcp-host): bound R2-L4 provider status and code of a failed response
  fix(mcp-host): log R2-L3 context-breakdown failures through the Error summary
  test(mcp-host): correct R2-L4 stale family comments and corpus test title
  test(mcp-host): harden R2-L3 alias guard against hidden members and divergent args
  test(mcp-host): fail R2-L2 chat corpus regeneration outside CI too
  fix(mcp-host): refuse R2-L1 ready-built client on azure and compatible profiles
  fix(mcp-host): use fake-marked synthetic API keys in workflow Responses tests
  fix(mcp-host): record only the SDK missing-credential sentence in the chat corpus
  docs: state R1-L2 official OpenAI runs on Responses, not Chat Completions
  ...
…og-hop-isolation' into fix/1409-composer-gate-and-catalog-hop-isolation

* origin/fix/1409-composer-gate-and-catalog-hop-isolation:
@alfredolopez80

Copy link
Copy Markdown
Contributor Author

@claude Please run a closing review and audit of this PR at head fc4cef0ca.

Scope. Review the desktop-app/ changes against dev: git diff origin/dev...fc4cef0ca -- desktop-app. The merges of dev into this branch bring no change of their own; do not review the code they bring in. Read the PR body first. It lists every intended behaviour change, and anything that differs from it is a finding.

1. Access catalog (desktop-app/src/appService.ts, refreshAccessCatalog)

  • The session is read under withNativeAuthEnvironmentCommit, the catalog is built outside it, then it is re-checked and published under it. At most 2 builds.
    • Confirm that no path publishes a catalog built for a different user, team or sessionGeneration.
    • Confirm that a logout or user change during the build rejects with stale_auth_epoch.
    • Confirm that a same-user session change discards the build and rebuilds once.
  • Look for deadlocks: can any caller already holding the commit owner reach refreshAccessCatalog, directly or through getConnectors, access:refreshCatalog/access:getCatalog, or the plugin SDK contexts.read/mcp.read?
  • Check what each caller does with the new rejections. The PR body states that mcp.read swallows them.

2. Composer gate removal and agent selection (ComposerPanel.tsx, useAppController.ts, ChatPage.tsx, AgentsPage.tsx)

  • Confirm that nothing still blocks sending on the team catalog. Search for leftovers of the removed gate, resolver and notices: agentCatalogAvailability, accessCatalogScopeRef, "Checking agent access".
  • The selection effect now only deselects an agent the server holds. Check every way a selection can end (listed in the body). Look for a stale selection after logout, a user change or a workspace snapshot restore.
  • With a selected agent and an empty or failed catalog, both pages render the workspace. Check that nothing crashes or renders against a missing agent record.

3. Rejected send (desktop-app/ui/src/hooks/domain/useAgentChatController.ts, sendAgentMessage and helpers)

Trace every return and every throw site of the send try, and for each exit check the following:

  • No input is lost. Text and attachments either go back to the composer or the draft of the send's own chat, or stay behind Recover input.
  • Correct target. The input lands in the right chat, agent and composer, including after the user switched chat, agent or tab while the POST was pending.
  • No duplicate send on the Host, except an explicit Retry.
  • No turn comes back. A withdrawn turn is never written back by hydrate, older-page loads or backfill. Check the withdrawnTurnIdsRef fence and the re-filter after the createChat await.
  • Held agent. Classify each outcome as before the handoff to main, after it, 401/403, or another error. Check postIssued: it is set right before invokeHostMessage. Is that the right point, and can a hold land between the check and the call?
  • Activity entry. The finally must release the send's entry exactly when no started task took it over (activityHandedToTask). Look for a leaked stream, a double release, or a stale release that closes a newer scope's stream.
  • Scope changes. Check logout, team switch and user change during each await. Nothing from the old session may be written into the new session's stores (draft, chat store, retained send).
  • Texts. Check describeSendFailure/errorRecoveryHint in ui/src/lib/format.ts and the composer alert:
    • the alert never shows a raw IPC error;
    • the sentence matches the recorded kind;
    • no user-facing text mentions a team.

4. chat:removeMessages (desktop-app/src/chatStore.ts, ipc.ts, preload.ts, renderer.d.ts, ui/src/hooks/useChatStore.ts)

  • The IPC handler runs assertTrustedSender first. Check that the sanitizer enforces 1–16 ids of at most 128 characters and rejects path traversal.
  • The store may remove only local user turns that carry no stored sign of a server answer: no task_id, no assistant or server turn, not answered synchronously, no legacy answer. Removal must be all-or-nothing per chat, must not create a chat, and must be serialized with upsert and replace.
  • A deleted or missing chat returns 0.
  • Could a compromised renderer use this channel to delete data that it could not already delete through chat:upsertMessages/chat:replaceMessages?

5. Regressions against dev

For each changed unit, compare the behaviour on dev with the head. Every changed behaviour must match a statement in the PR body. Report any unexplained change, any broken caller anywhere in the repository (plugin SDK, other UIs, e2e or Playwright specs, docs), and any code left without a caller by the removals.

6. Tests

  • Do the new and rewritten tests check real behaviour rather than mocks?
  • Does every negative assertion (not.toHaveBeenCalled, "nothing was written") have a positive witness in the same test proving the code path ran?
  • Is there test-only production code, a fixed sleep that could hide a race, or a test that would still pass with the feature removed?
  • Name the changed behaviour that has no test that would fail if it broke.

7. Text and repository rules

  • Code comments, docstrings, .d.ts files, test titles and the PR body must be true against the code at fc4cef0ca.
  • Check the CLAUDE.md/AGENTS.md rules: logging standard, shared capability naming (no team-id, agent or vendor names), no swallowed errors or unrequested fallbacks introduced by this PR.

Reporting

List findings by severity (High / Medium / Low). Each finding needs:

  • file:line at fc4cef0ca;
  • a concrete scenario;
  • whether this PR introduced it or it already existed on dev.

State explicitly what you verified as clean.

Known and out of scope: #1435, #1436, #1442, and the three #1410 items in its comment (silent generic rejection, retained attachments dropped by a team switch, the access sentence once access is back).

@claude

claude Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Claude finished @alfredolopez80's task in 9m 26s —— View job


Closing review & audit — head fc4cef0ca (diff vs origin/dev e611ebfcb)

Decision: Approve. I traced the desktop-app/ diff against dev for all seven audit sections. No High or Medium defect found. Every behaviour change I could reach matches the PR body. The findings below are Low and non-blocking; three are introduced-but-benign, one is a pre-existing comment (correctly disclosed in the PR body).

  • §1 Access catalog · [x] §2 Gate removal & selection · [x] §3 Rejected send · [x] §4 chat:removeMessages · [x] §5 Regressions · [x] §6 Tests · [x] §7 Text & rules

Verified clean

§1 Access catalog (appService.ts:4335 refreshAccessCatalog)

  • Session read, build, re-check, publish are three correctly ordered steps; the build runs outside the commit owner and at most twice.
  • Publish is guarded by me.id (logout/user change → stale_auth_epoch), then sessionGeneration and teamId (same-user switch → discard + one rebuild). No path publishes a catalog for a different user/team/generation.
  • I confirmed the token is correctly not compared: a transient runWithTeamContext hop calls switchSessionToTeam(..., { advanceSessionGeneration: false }) for both the switch-in and the restore (appService.ts:1437,1450), and commitSessionToken only bumps when advanceSessionGeneration !== false (appService.ts:1219). So a hop does not advance the generation, and the hop's whole switch→operation→restore runs inside one withNativeAuthEnvironmentCommit (appService.ts:1423), so the publish check can never observe a mid-hop team. The fix is sound.
  • No deadlock. All entrypoints (access:getCatalog/access:refreshCatalog at ipc.ts:1374/1378, getConnectors at ipc.ts:1717, the SDK contexts.read/mcp.read) call the service outside any commit block; bindCurrentChatStore does not re-enter the queue.
  • Rejection handling matches the body: contexts.read (pluginSdkCapabilities.ts:256) propagates; mcp.read swallows via .catch(() => null) (:284).

§2 Gate removal & selection — The team gate is gone with zero leftovers repo-wide (agentCatalogAvailability, accessCatalogScopeRef, selectedAgentSendBlocked, composer-agent-catalog-notice, "Checking agent access" all return nothing; the module and its tests are deleted). The selection effect (useAppController.ts:690) now only clears a held agent; the early !auth.isAuthenticated return matches dev, so logout clearing is unchanged, not regressed. AgentsPage/ChatPage render the workspace for a selected agent with an empty catalog, and AgentWorkspace tolerates an agent missing from the catalog (agentDisplayByName[selectedAgent] ?? selectedAgent, connectorActionsByName → undefined).

§3 Rejected send — I traced every exit of the send try:

  • No input loss: each undelivered exit routes through returnUndeliveredSend (composer when draft free + attachments returnable, else retained snapshot) or keepUndeliveredTurn.
  • Correct target / no cross-session write: returnUndeliveredSend is only reached under sendAuthScopeIsCurrent(); attachments return only when sendChatIsVisible() and the attachment revision is unchanged; draft text is written to the send's own sendChatId.
  • postIssued = true sits immediately before invokeHostMessage with no intervening await, so a hold cannot land between the flag and the call. 401/403/pre-post → withdraw; handed-to-main non-auth → keep. Matches the body exactly.
  • withdrawnTurnIdsRef fence is applied at the server backfill (:1726), hydrate (:2264) and the re-filter after the createChat await (:2292).
  • Texts: describeSendFailure never returns the raw IPC error (only fixed sentences / kind-selected copy); the auth hint no longer mentions a team.

§4 chat:removeMessages — assertTrustedSender first; sanitizeChatMessageIds enforces 1–16 trimmed non-blank ids ≤128 chars. (Ids are only matched against stored message.id via a Set, never used as filesystem paths, so traversal is a non-issue; agentRef/chatId keep the same sanitizeString protection as the sibling handlers.) The store rule is all-or-nothing, serialized via serializeChat, does not create a deleted/missing chat (returns 0), and removes strictly less than replaceMessages already can — so no new capability for a compromised renderer.

§5 / §6 / §7 — No orphaned consumers of the deleted module. Test fixtures delegate removeMessages to a real in-memory store (mockClerum.ts:141), so the withdraw path is exercised for real; the body's mutation-testing evidence is strong. The sandboxUiSessionEpoch/comment caveats and the auth-hint change are true against the head.


Findings (all Low)

L1 — pre-existing, disclosed. appService.ts:1553-1555: the sandboxUiSessionEpoch doc comment states "every token commit bumps it, including the two of each transient team hop." Per §1 above, a hop passes advanceSessionGeneration: false, so those two commits do not bump sessionGeneration; the comment is stale. Not introduced here (the PR doesn't touch it) and accurately captured in the body's Known limits. One note for the record: the comment text does assert the hop bumps the generation, so round‑1's "it says the opposite" reading doesn't match the source — the body's current wording is the correct one.

L2 — introduced, benign. useAgentChatController.ts:3789 + :4244: on every withdraw exit, withdrawUndeliveredTurn calls releaseSendActivity() and the finally calls it again (activityHandedToTask is only set on the success-task path at :4163). The second call is idempotent (id-filter then maybeStopAgentActivityStream on an already-empty list), so it is harmless, but the double call is not obvious — worth a one-line comment that the finally release is intentionally idempotent with the withdraw path. Fix this →

L3 — introduced, edge, no data loss. In the auth and held-by-another-path exits, returnUndeliveredSend runs before await withdrawUndeliveredTurn(). If the store refuses the removal (removeMessages throws), the optimistic turn stays visible marked error while the same text is already back in the composer/snapshot — a transient duplicate, surfaced by the WITHDRAW_REJECTED_TURN_FAILED_MESSAGE toast. Only reachable if the store refuses to remove an unanswered local turn, which the removal rule should never do for a rejected send. Acceptable; noting for completeness.

L4 — nit. useAgentChatController.ts:1698: the local older-page merge (mergeUniqueMessages(chatMessagesRef.current, unseenLocal, …)) is the one merge site that does not apply the withdrawnTurnIdsRef fence. Harmless in practice — removeMessages deletes the turn from the store and a just-sent turn is inside the loaded window, not an older page — but it's the lone unfenced path if you want symmetry.

L5 — test nit. useAppController.rejectedSend.test.tsx:968: a fixed setTimeout(resolve, 100) in the "task keeps the stream open (control)" test. It asserts persistence (the safe direction) and has a positive witness at :967, so it won't produce a false green, but a fixed sleep is a minor smell.


Validation gaps (from the body, unchanged)

Out of scope as stated: #1435, #1436, #1442, and the three #1410 items. The opening line of the PR description still says "Draft." — flip it when you promote the PR.
· branch fix/1409-composer-gate-and-catalog-hop-isolation

@jozer-rami jozer-rami left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving at fc4cef0ca.

Verified

  • The catalog is read and published for one committed session. A team hop holds the commit owner from switch to restore, and the publish re-checks user, generation and team. No deadlock path.
  • The server authorizes every send. The RPC token mint checks team membership and the host grant, and rpc-proxy runs the host-access check on each request. Removing the composer gate opens no authorization gap.
  • chat:removeMessages adds no capability beyond the existing chat handlers.
  • The withdrawn-turn filter covers every write to the store.
  • The touched test files pass locally on Node 24.22.1: 586 of 586. I ran 34 mutations of the production changes, and the tests caught every one.

Non-blocking follow-ups

  1. Silent rejection (#1410). A 401, or a 403 whose follow-up read is allowed, returns the text to the composer with no message (useAgentChatController.ts:4166-4199). On an expired session this lasts until the entity-change stream ends the session. Showing the access sentence while still returning the input would close it.
  2. External logout. onExternalSessionEnded (useAppController.ts:183-190) resets navigation but not the data controllers. ChatPage (ChatPage.tsx:20-28) can then auto-select the previous user's agent from the cached catalog for the next user. The selection stays until the first denied read, and only the agent name is exposed. The stale cache predates this PR. This PR removes the same-team deselect and the send gate that used to cover it. The body lists "the session identity changes" as ending a selection, which this case does not meet.
  3. Empty chat after a rejected first send. When a new chat's first send is rejected, an empty local chat stays in the sidebar, titled with the withdrawn text. The index keeps messageCount: 1.
  4. Refused withdrawal (rare). If an earlier task's late reply from durable recovery (useAgentChatController.ts:2855-2888) is stored after the optimistic turn, removeMessages refuses (chatStore.ts:2266-2276). The text is already back in the composer, so the turn shows twice. Ignoring a later assistant message whose task_id belongs to another stored user turn would fix it.
  5. PR body. The Connectors page (McpServersPage.tsx:165-166) can also show the raw stale_auth_epoch text, not only the Members tab. The opening line still says "Draft."

T2 on the final head is still owed, as the body says.

A reconcile replace and the removal of a withdrawn turn share the chat
queue. Pin both orders: whichever runs first, the withdrawn turn is not
written back. A replace on its own keeps a stored local turn its window
omits, which the test asserts first as the witness that the result
depends on the removal.

Without the queue around removeMessages, this test and the upsert
ordering test fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alfredolopez80
alfredolopez80 merged commit 4fd7b28 into dev Nov 12, 2026
63 checks passed
@alfredolopez80
alfredolopez80 deleted the fix/1409-composer-gate-and-catalog-hop-isolation branch November 12, 2026 11:56
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.

3 participants