Agents Manager: recover a first-message chat turn orphaned by a page change - #113591
Agents Manager: recover a first-message chat turn orphaned by a page change#113591AnnaMag wants to merge 16 commits into
Conversation
|
WordPress.com
Automattic for Agencies
|
586f54f to
0c985ba
Compare
|
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser: Async-loaded Components (~1164 bytes added 📈 [gzipped]) Details
React components that are loaded lazily, when a certain part of UI is displayed for the first time. Legend What is parsed and gzip size?Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory. |
c4809fb to
064febc
Compare
6c33b14 to
f5eb93f
Compare
When the page changes before an assistant reply comes back, the user's turn was silently orphaned. On mount the chat panel now reconciles any unresolved turn with the server: it adopts the real transcript when the turn landed, or marks it failed with a retry affordance when it never did.
- Stop re-loading the reconciled transcript after a provider `clearMessages` (a "new chat" before the merchant typed resurrected the old turn). - Scope the sessionStorage scan to this agent: only `local-*` orphans or the session the agent is configured to resume are candidates, so another agent's in-flight turn can no longer be grafted onto this session. - Clear the stored entry whenever a turn is surfaced as `failed`, including the server-outcome case, so it does not re-reconcile on every mount. - Export `CONVERSATION_STORAGE_KEY` from agenttic-client instead of duplicating the storage prefix with a keep-in-sync comment. - Tests for the scoping and clear cases; the offline test now pins the resumed session id and asserts reconciliation actually ran.
f5eb93f to
9548f08
Compare
agenttic-client is a workspace package, so export getStoredSessionIds, loadConversation, clearConversation and messageTextContent instead of re-implementing its sessionStorage format in conversation-storage-read. Also drop the redundant serverSessionId field, key retries by text, and flatten the thinkingMessage IIFE.
Retry re-sends the prompt as a fresh turn, which left the original failed user bubble on screen next to an identical new one. Mark the failed message deleted before re-sending.
Turns that already belong to a server session are reloaded by useConversation on mount, and the reply is now polled for separately (#113926). Keep only the local-* case: the first send of a session, orphaned before the server assigned an id, is marked failed and offered a retry.
74486c8 to
c674ac8
Compare
Reconciliation is now a local read that settles in milliseconds, so the "Picking up your previous question" indicator and its state can never be seen: drop them, the async guards, the unused storage key in the result and the newest-first sort over multiple orphans.
Use Agents Manager color tokens so the retry control paints in wp-admin, catch a failed reconcile so the orphan stays in storage for the next mount, and put the failed bubble back if Retry never dispatches.
There was a problem hiding this comment.
Pull request overview
Recovers orphaned first chat messages after navigation and offers an inline retry flow.
Changes:
- Exposes agenttic conversation-storage helpers.
- Adds orphan reconciliation with unit tests.
- Adds failed-message retry UI and orchestration.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
packages/agenttic-client/src/react/conversationStorage.ts |
Exports text extraction helper. |
packages/agenttic-client/src/index.ts |
Exposes conversation-storage APIs. |
packages/agents-manager/src/hooks/use-reconcile-delivery-status.ts |
Recovers unresolved local conversations. |
packages/agents-manager/src/hooks/__tests__/use-reconcile-delivery-status.test.ts |
Tests reconciliation behavior. |
packages/agents-manager/src/components/retry-failed-message/style.scss |
Styles retry notice and button. |
packages/agents-manager/src/components/retry-failed-message/index.tsx |
Renders failed-message retry UI. |
packages/agents-manager/src/components/orchestrator-chat/index.tsx |
Integrates recovery and retry behavior. |
packages/agents-manager/src/components/__tests__/orchestrator-chat.test.tsx |
Updates agenttic test mock. |
Suppressed comments (1)
packages/agents-manager/src/hooks/use-reconcile-delivery-status.ts:38
- This scan also includes agenttic's in-memory cache, so it can classify a still-live first request as orphaned when
OrchestratorChatmerely remounts (for example, closing and reopening the panel). The agent remains alive across that UI unmount, but its pendinglocal-*entry is returned here and immediately marked failed, allowing a duplicate Retry while the original stream is active. Exclude the live agent/session or gate recovery on evidence that the previous request owner is gone.
for ( const storageKey of ( await getStoredSessionIds() ).filter( ( key ) =>
key.startsWith( 'local-' )
) ) {
const { messages } = await loadConversation( storageKey );
if ( getUnresolvedMessages( messages ).length > 0 ) {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| for ( const storageKey of ( await getStoredSessionIds() ).filter( ( key ) => | ||
| key.startsWith( 'local-' ) | ||
| ) ) { | ||
| const { messages } = await loadConversation( storageKey ); | ||
| if ( getUnresolvedMessages( messages ).length > 0 ) { | ||
| return { storageKey, messages }; |
| clearMessages: () => { | ||
| reconcileDismissedRef.current = true; | ||
| loadMessages( [] ); |
| jest.mock( | ||
| '@automattic/agenttic-client', | ||
| () => ( { | ||
| getStoredSessionIds: async () => [], |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
packages/agents-manager/src/hooks/use-reconcile-delivery-status.ts:39
getStoredSessionIds()enumerates every Agenttic conversation in the tab, but theselocal-*keys contain no agent/site/user ownership. Agents Manager otherwise scopes sessions by those values and explicitly discards the previous agent on a scope switch, so switching sites or agents can surface and retry a prompt from the wrong scope. Persist ownership with the temporary conversation (or use a scope-specific storage key) and filter against the current context before recovering it.
for ( const storageKey of ( await getStoredSessionIds() ).filter( ( key ) =>
key.startsWith( 'local-' )
) ) {
const { messages } = await loadConversation( storageKey );
if ( getUnresolvedMessages( messages ).length > 0 ) {
return { storageKey, messages };
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1560
- Clearing the transcript does not clear
failedRetries, sodisplayedMessagesimmediately synthesizes a trailing Retry row even whenmessagesis empty. This means a provider “new chat” does not actually dismiss the recovered notice and still reports the chat as non-empty. Clear the retry state here and prevent a late reconciliation result from repopulating it after dismissal.
clearMessages: () => {
reconcileDismissedRef.current = true;
loadMessages( [] );
packages/agents-manager/src/components/tests/orchestrator-chat.test.tsx:293
- This stub forces reconciliation to be a no-op in every OrchestratorChat test, leaving the newly added transcript loading, Retry success/failure, duplicate handling, and provider-clear behavior untested. Add component tests that return a recovered result and exercise those user-visible paths.
getStoredSessionIds: async () => [],
| export interface ReconcileResult { | ||
| messages: Message[]; | ||
| /** Text of each user turn marked `failed`, for the retry affordance. */ | ||
| failedTexts: string[]; |
| async ( text: string, failedMessageId?: string ) => { | ||
| setFailedRetries( ( previous ) => previous.filter( ( retry ) => retry !== text ) ); | ||
| if ( failedMessageId ) { | ||
| setDeletedMessageIds( ( previous ) => new Set( previous ).add( failedMessageId ) ); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
packages/agents-manager/src/components/orchestrator-chat/index.tsx:805
- A provider can clear the chat while orphan reconciliation is still pending. When the result arrives afterward, this effect repopulates
failedRetrieseven thoughreconcileDismissedRefwas set, so the dismissed Retry notice reappears without the transcript. Honor the dismissal flag here as well.
useEffect( () => {
setFailedRetries( reconcileResult?.failedTexts ?? [] );
}, [ reconcileResult ] );
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1561
- Clearing only Agenttic's messages leaves
failedRetriesintact, anddisplayedMessagesrenders those entries independently at the end of an empty transcript. Consequently, a provider “new chat” still shows Retry and can resend the supposedly dismissed prompt. Clear the retry state in this callback too.
clearMessages: () => {
reconcileDismissedRef.current = true;
loadMessages( [] );
},
| messages.length === 0 && | ||
| ! hasUserSentMessage && | ||
| ! reconcileDismissedRef.current | ||
| ) { | ||
| loadMessages( reconcileResult.messages ); |
| submitDispatchedRef.current = false; | ||
| await submitChatMessage( text ); | ||
| if ( submitDispatchedRef.current ) { |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1560
clearMessagesprevents the transcript from being reloaded, but it leavesfailedRetriespopulated. The rendering loop appends every remaining retry even whenmessagesis empty, so a provider-driven clear/new-chat still shows the orphaned retry notice. Clear the retry state here as well.
clearMessages: () => {
reconcileDismissedRef.current = true;
loadMessages( [] );
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1418
- This retry goes through
onSubmitWithImages, which reads the composer's currentpendingImages. If the merchant has begun another draft with attachments before clicking Retry, those unrelated images are uploaded and sent with the recovered prompt; conversely, attachments from the failed turn are not represented byfailedTextsand cannot be resent. Preserve the failed turn's payload and dispatch it independently of current composer state.
submitDispatchedRef.current = false;
await submitChatMessage( text );
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1788
- The match happens after
transformMessageshas rewritten the transcript. That provider contract explicitly supports replacing a machine-facing user prompt with shorter display text, so the transformed text may not equalfailedTexts; the notice then trails the transcript and Retry has nofailedMessageId, leaving the old failed bubble visible beside the resent turn. Carry the restored message ID with each retry and match by ID rather than post-transform text.
const text = message.content?.find( ( content ) => content.type === 'text' )?.text;
const matchIndex = remainingRetries.indexOf( text ?? '' );
if ( matchIndex !== -1 ) {
const [ retry ] = remainingRetries.splice( matchIndex, 1 );
messagesWithRetries.push( makeRetryMessage( retry, message, message.id ) );
packages/agents-manager/src/hooks/use-reconcile-delivery-status.ts:39
- This scans every agenttic
local-*entry in the tab, but those generic storage records contain no agent, site, or user scope. On shared origins such as Calypso, an orphan created for another site or another agent can therefore be loaded into the current transcript and retried against the wrong site. Persist scope ownership (or a scoped pointer to the minted local ID) and recover only an entry matching the currentagentId/site/user.
for ( const storageKey of ( await getStoredSessionIds() ).filter( ( key ) =>
key.startsWith( 'local-' )
) ) {
const { messages } = await loadConversation( storageKey );
if ( getUnresolvedMessages( messages ).length > 0 ) {
return { storageKey, messages };
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1411
- The existing orchestrator-chat suite now only mocks
getStoredSessionIdsto return no entries, so none of the new rendering and retry behavior is exercised. Add component tests covering recovery display, successful and rejected Retry, and provider clearing; these paths coordinate several state variables and currently contain regressions that the hook-only tests cannot detect.
// Retry a failed turn: drop its affordance and the failed bubble, then re-send
// the original prompt as a fresh turn. Deliberately does not repopulate the
// composer. If the send never dispatches, put both back so the question is
// not lost a second time.
const handleRetryFailed = useCallback(
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1561
clearMessagesmarks reconciliation dismissed but leavesfailedRetriespopulated. BecausedisplayedMessagesappends every remaining retry even whenmessagesis empty, a provider “new chat” still renders the old Retry notice (and can resend the dismissed prompt). Clear the retry state together with the transcript.
clearMessages: () => {
reconcileDismissedRef.current = true;
loadMessages( [] );
},
packages/agents-manager/src/hooks/use-reconcile-delivery-status.ts:36
- This scans every agenttic
local-*entry without checking the current agent/site/user scope. Agents Manager explicitly treats a site/user change as a context switch, so navigating between sites during the first send can recover Site A’s prompt into Site B and Retry it against the wrong site. Persist a scope/agent identifier with the temporary conversation (or use a scope-specific key) and only reconcile entries matching the current context.
for ( const storageKey of ( await getStoredSessionIds() ).filter( ( key ) =>
key.startsWith( 'local-' )
) ) {
packages/agents-manager/src/components/orchestrator-chat/index.tsx:1415
- Retry text is being used as identity: filtering here removes every retry with the same prompt, while only one failed bubble ID is deleted. A recovered history can contain an earlier failed turn plus a newly unresolved retry with identical text, leaving the other failed bubble visible with no affordance; the generated retry messages also receive duplicate IDs. Track retries as
{ messageId, text }records and remove one by message ID instead.
async ( text: string, failedMessageId?: string ) => {
setFailedRetries( ( previous ) => previous.filter( ( retry ) => retry !== text ) );
if ( failedMessageId ) {
setDeletedMessageIds( ( previous ) => new Set( previous ).add( failedMessageId ) );
There was a problem hiding this comment.
- 🔴 I'm getting the retry message when starting a new chat:
2026-08-31.3.44.58.mov
2026-08-31.4.34.42.mov
- 🔴 The mechanism causes duplicated past chats:
2026-08-31.5.38.30.mov
- 🟡 I've noticed duplicated retry messages when quickly changing pages mid-retry. This is an edge case, and I don't think it's a blocker, so feel free to defer or skip it if the fix isn't trivial and you're confident it won't cause any issues :)
2026-08-31.4.23.02.mov
-
(Suggestion) I had Claude run a code review with several rounds of validation, and it flagged the issues below — might be worth double-checking them, thanks!
- Keystroke re-render:
handleRetryFailedin thedisplayedMessagesmemo deps depends oninputValue(viasubmitChatMessage), so the whole transcript recomputes and re-renders on every keystroke. Routing the submit through a ref would avoid it. - No screen-reader announcement: the retry notice is never announced — it has no text parts for the live region to read. Adding
role="status"to it (likeresolved-edit-action.tsx) would fix it. - Fragile recovery: the hook clears sessionStorage before the transcript is shown, and the
loadMessages( reconcileResult.messages )call has no.catch()orhasAgentguard — any rejection there silently loses the orphaned message.
- Keystroke re-render:
-
(Suggestion) Copilot reviews sometimes catch real issues — it might be worth double-checking them, thanks!
-
(Suggestion)
orchestrator-chatis a huge component, and I'm planning to refactor it for better maintainability (AM-19). Since the logic in this PR all belongs to the same mechanism, do you think we could group it into a React hook (plus a small utility) — something like below, or any better alternative you have in mind? Thanks!
const { failedRetries, handleRetryFailed, dismissRecovery } = useOrphanedTurnRecovery( {
messages,
hasUserSentMessage,
loadMessages,
submitChatMessage,
wasSubmitDispatched: () => submitDispatchedRef.current,
setDeletedMessageIds,
} );
// in the messages useMemo, replacing the ~50-line weaving block
if ( failedRetries.length > 0 ) {
currentMessages = insertRetryAffordances( currentMessages, failedRetries, handleRetryFailed );
}
// in the provider API
clearMessages: () => {
dismissRecovery();
loadMessages( [] );
},| <p className="agents-manager__retry-failed-message-text"> | ||
| { __( "This message didn't reach the assistant.", __i18n_text_domain__ ) } | ||
| </p> | ||
| <button |
There was a problem hiding this comment.
We have been using @wordpress/components > <Button> to maintain a consistent UI. Could you double-check whether we could use the component here as well, where appropriate? Thanks.
`useReconcileDeliveryStatus` handed back a full transcript, and `OrchestratorChat` pushed it through `loadMessages`. That reaches `replaceMessages`, which replaces the agent's conversation history *and* persists it under whatever session the panel currently holds — so a recovered orphan overwrote a resumed session's stored transcript and was replayed with the next turn's history, duplicating a conversation the merchant never reopened. It also raced `useConversation`'s own hydration for the same slot, with no ordering between the two. The hook now returns just the orphaned user turns as descriptors, and the panel appends a question bubble plus its retry affordance to `displayedMessages`. They are display-only: they enter no session and no storage. Retry re-sends the text as a fresh turn, so nothing downstream needed them in history. Retries are keyed by the restored message id rather than by prompt text, so two identical prompts stay distinct instead of collapsing into one affordance and one React key.
An agent outlives the panel that shows it. Closing the chat unmounts the whole route subtree, and so does stepping through `/history` and back, while the turn keeps streaming — nothing aborts on unmount. Recovery ran again on the remount, found the first message still `pending` under its `local-*` key, reported a live question as failed, and cleared the running turn's storage. Retry then sent a duplicate alongside it. `getLiveSessionIds` reports the sessions agents currently hold, and the scan skips those keys. The manager is per page load, so a turn orphaned by an actual page change still has no agent behind it and is recovered as before.
`clearMessages` set a dismissal flag and emptied the transcript, but left `failedRetries` populated, and the render path appends those entries whatever the transcript holds. A provider "new chat" therefore blanked the conversation and left the recovered question and its Retry sitting on an otherwise empty panel, still reporting the chat as non-empty. Nothing consulted the flag either, so a reconciliation that settled after the clear put the notice straight back. `dismissRecovery` clears the state and the effect honours the flag, so a dismissal is final in both directions.
A rejected dispatch puts the message back in the composer so it can be sent again. Retry went through that same path, so a retry that failed to dispatch restored the composer *and* the failed bubble with its Retry button, leaving two ways to send one question and contradicting the promise that retrying never repopulates the composer. The dispatch path takes a `restoreComposerOnFailure` option, and the retry caller opts out because it owns its own affordance. `AgentUI`'s `onSubmit` contract is `( message, files? )`, so the option lives on `dispatchChatMessage` and `onSubmitWithImages` stays a thin wrapper.
`handleRetryFailed` rides in the transcript, and it closed over the dispatch path, which closes over the composer draft. Every keystroke gave it a new identity and rebuilt the whole `displayedMessages` list. It reads the dispatch path through a ref instead, so the callback is stable and the transcript only recomputes when the transcript changes.
The notice arrives as a component part, so the chat's live region has no text to read and the failure is never announced. `role="status"` on the message matches how `resolved-edit-action` announces its own state.
The suite stubbed `getStoredSessionIds` to an empty list, so reconciliation was a no-op in every OrchestratorChat test and none of the recovery UI was exercised. Both bugs a reviewer hit by hand were in that blind spot. The agenttic mock now serves a stored conversation, and the AgentChat stub renders the transcript so component parts can be driven. Six tests cover the recovered question and its retry, the live-session guard, that recovery never reaches `loadMessages`, a retry that dispatches, one that does not, and a provider "new chat". Each was checked against the behaviour it describes by reverting that behaviour.
`OrchestratorChat` is already large, and this mechanism had its state, its retry callback and a block of transcript weaving spread across three places in it. `useOrphanedTurnRecovery` owns the state machine and `insertRetryAffordances` builds the display-only messages, leaving the component with a `sendRetry` adapter and one call in the memo. The hook takes `sendRetry` as "send this and tell me whether it dispatched", so the composer semantics stay with the component that owns the composer. No behaviour change.
The reviewer saw two retry rows for one question after retrying and navigating again inside the orphan window. Recovery used to load the recovered turn into the agent's history, so the retry's send appended to it and persisted both copies under the new session; the next page then found two user turns that both reconciled to `failed` and drew a retry for each. The test simulates that sequence against the store and fails on the pre-fix code with two Retry buttons.
|
Thanks for the review! |
Thanks for addressing the feedback! Feel free to ping me for another review once the PR is ready. One edge case I haven’t tested yet, but I’d suggest covering in both PRs, is switching across contexts (e.g., agent and abilities). For example, in the Site Editor, ask it to change a color palette, then navigate to a non-editor page, such as |
Part of WOOAI-872, WOOAI-847. Companion to #113926.
Proposed Changes
useReconcileDeliveryStatus(new hook): on mount, find alocal-*conversation insessionStoragewith an unresolved (pending/streaming) user turn, mark itfailedvia agenttic'sreconcileWithServer, and hand back the failed user turns as descriptors. Keys a live agent still holds are skipped, so a turn that is merely mid-stream is never reported as failed.useOrphanedTurnRecovery(new hook) holds those turns, retries one, and dismisses them;insertRetryAffordancesbuilds the messages that show them.OrchestratorChat: appends each recovered question and an inline notice with Retry to the transcript. These are display-only — they enter no session and no storage. Retry re-sends the prompt as a fresh turn (the composer is not repopulated, and a retry that fails to dispatch does not repopulate it either). A provider "new chat" dismisses them for good.@automattic/agenttic-client: exportgetStoredSessionIds,loadConversation,clearConversation,messageTextContentso the hook reads the conversation store through the package instead of re-implementing its storage format; addAgentManager.getLiveSessionIdsso a consumer can tell a dead orphan from a running turn.Why are these changes being made?
The first message of a chat session is persisted under a client-minted
local-<id>key until the server assigns a session id. If the page changes before that (a merchant navigating within ~0.5s of sending), the destination page mounts with no session and never loads the turn — the question silently disappears, with no error and no spinner.Turns that already belong to a server session are not handled here:
useConversationreloads that transcript on mount, and #113926 polls for a reply that lands afterwards.The recovered turn is rendered, never loaded. An earlier revision passed it to
loadMessages, which reachesreplaceMessages— that replaces the agent's history and persists it under whatever session the panel currently holds, so a recovered orphan overwrote a resumed session's stored transcript and was replayed with the next turn's history, duplicating a conversation the merchant never reopened. It also raceduseConversation's own hydration for the same slot. Nothing downstream needs the orphan in history, since Retry sends the text as a fresh turn.An agent outlives the panel that shows it. Closing the chat unmounts the whole route subtree, and so does stepping through
/historyand back, while the turn keeps streaming — nothing aborts on unmount. Recovery therefore has to skip anylocal-*key a live agent still holds, or a remount reports a live question as failed and clears the running turn's storage. The agent manager is per page load, so a turn orphaned by an actual page change still has no agent behind it.Known limitations:
local-*turn is treated as never having reached the server. Past ~1s it usually did, so Retry can produce a duplicate turn server-side — in a new session, so nothing visible to the merchant.local-*keys carry no site or agent scope, so an orphan can outlive a scope switch within one tab (WOOAI-1183).Testing Instructions
orchestrator-chat.test.tsxcovers the recovered question and its retry, the live-session guard, that recovery never reachesloadMessages, a retry that dispatches, one that does not, and a provider "new chat".Sandbox (Simple/Atomic): sandbox
widgets.wp.com,cd apps/agents-manager && yarn dev --sync, enable Unified Chat under Automattician Options on/wp-admin/profile.php.useConversation(and Agents Manager: poll for the reply after a page change mid-turn #113926 polls for the reply).Pre-merge Checklist
widgets.wp.comrole="status"so it is announced