Skip to content

Agents Manager: recover a first-message chat turn orphaned by a page change - #113591

Draft
AnnaMag wants to merge 16 commits into
trunkfrom
fix/agenttic-delivery-status-reconcile
Draft

Agents Manager: recover a first-message chat turn orphaned by a page change#113591
AnnaMag wants to merge 16 commits into
trunkfrom
fix/agenttic-delivery-status-reconcile

Conversation

@AnnaMag

@AnnaMag AnnaMag commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Part of WOOAI-872, WOOAI-847. Companion to #113926.

Proposed Changes

  • useReconcileDeliveryStatus (new hook): on mount, find a local-* conversation in sessionStorage with an unresolved (pending/streaming) user turn, mark it failed via agenttic's reconcileWithServer, 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; insertRetryAffordances builds 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: export getStoredSessionIds, loadConversation, clearConversation, messageTextContent so the hook reads the conversation store through the package instead of re-implementing its storage format; add AgentManager.getLiveSessionIds so 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: useConversation reloads 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 reaches replaceMessages — 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 raced useConversation'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 /history and back, while the turn keeps streaming — nothing aborts on unmount. Recovery therefore has to skip any local-* 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:

  • A 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.
  • A recovered turn is reduced to its text, so a first message with attachments loses them on Retry (WOOAI-1180).
  • local-* keys carry no site or agent scope, so an orphan can outlive a scope switch within one tab (WOOAI-1183).

Testing Instructions

yarn jest -c test/packages/jest.config.js --testPathPattern=agents-manager
yarn workspace @automattic/agenttic-client test

orchestrator-chat.test.tsx covers 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".

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.

  1. In wp-admin, open the chat → ⋮ → New chat. Send a message and change admin page within ~0.5s.
  2. The new page shows the question with "This message didn't reach the assistant." and Retry.
  3. Click Retry: the notice disappears, a single user bubble remains, the reply arrives.
  4. Repeat step 1, then ⋮ → New chat before typing: neither the old transcript nor the Retry reappears, and the panel reports itself empty.
  5. Send a first message and, while it is still streaming, close the chat and reopen it (or open ⋮ → History and come back). No retry notice appears and the answer keeps streaming.
  6. Resume an existing session with a transcript, then trigger recovery: the server transcript loads normally and is never overwritten by the recovered turn.
  7. Existing session (not a first message): navigate mid-answer — this PR does nothing; the transcript reloads via useConversation (and Agents Manager: poll for the reply after a page change mid-turn #113926 polls for the reply).

Pre-merge Checklist

  • Has the general commit checklist been followed? (PCYsg-hS-p2)
  • Have you written new tests for your changes?
  • Have you tested the feature in Simple (P9HQHe-k8-p2), Atomic (P9HQHe-jW-p2), and self-hosted Jetpack sites (PCYsg-g6b-p2)? — Simple, via sandboxed widgets.wp.com
  • Have you checked for TypeScript, React or other console errors?
  • For UI changes, have you tested the affected components in dark mode?
  • Have you tested accessibility for your changes? Ensure the feature remains usable with various user agents (e.g., browsers), interfaces (e.g., keyboard navigation), and assistive technologies (e.g., screen readers) (PCYsg-S3g-p2). — the retry notice carries role="status" so it is announced
  • Have you used memoizing on expensive computations? More info in Memoizing with create-selector and Using memoizing selectors and Our Approach to Data
  • Have we added the "[Status] String Freeze" label as soon as any new strings were ready for translation (p4TIVU-5Jq-p2)?
    • For UI changes, have we tested the change in various languages (for example, ES, PT, FR, or DE)? The length of text and words vary significantly between languages.
  • For changes affecting Jetpack: Have we added the "[Status] Needs Privacy Updates" label if this pull request changes what data or activity we track or use (p4TIVU-aUh-p2)?

@AnnaMag AnnaMag self-assigned this Aug 20, 2026
@AnnaMag
AnnaMag force-pushed the fix/agenttic-delivery-status-reconcile branch from 586f54f to 0c985ba Compare August 25, 2026 14:45
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

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
Name Parsed Gzip
async-load-automattic-agents-manager +3.63 kB +0.2% +1.16 kB +0.2%

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.
Gzip Size: Compressed size of the JS and CSS files. This much data needs to be downloaded over network.

@AnnaMag
AnnaMag force-pushed the fix/agenttic-delivery-status-reconcile branch 2 times, most recently from c4809fb to 064febc Compare August 25, 2026 15:08
@AnnaMag
AnnaMag marked this pull request as ready for review August 25, 2026 15:11
@AnnaMag
AnnaMag force-pushed the fix/agenttic-delivery-status-reconcile branch from 6c33b14 to f5eb93f Compare August 27, 2026 13:16
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.
@AnnaMag
AnnaMag force-pushed the fix/agenttic-delivery-status-reconcile branch from f5eb93f to 9548f08 Compare August 27, 2026 13:24
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.
@AnnaMag
AnnaMag force-pushed the fix/agenttic-delivery-status-reconcile branch from 74486c8 to c674ac8 Compare August 29, 2026 11:47
@AnnaMag AnnaMag changed the title Reconcile orphaned in-flight chat turns on panel mount Agents Manager: recover a first-message chat turn orphaned by a page change Aug 29, 2026
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.
@AnnaMag
AnnaMag requested a review from wellyshen August 29, 2026 13:40
@matticbot matticbot added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Aug 29, 2026
@AnnaMag
AnnaMag requested a review from a team August 29, 2026 13:40
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.

Copilot AI 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.

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 OrchestratorChat merely remounts (for example, closing and reopening the panel). The agent remains alive across that UI unmount, but its pending local-* 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.

Comment on lines +34 to +39
for ( const storageKey of ( await getStoredSessionIds() ).filter( ( key ) =>
key.startsWith( 'local-' )
) ) {
const { messages } = await loadConversation( storageKey );
if ( getUnresolvedMessages( messages ).length > 0 ) {
return { storageKey, messages };
Comment on lines +1558 to +1560
clearMessages: () => {
reconcileDismissedRef.current = true;
loadMessages( [] );
jest.mock(
'@automattic/agenttic-client',
() => ( {
getStoredSessionIds: async () => [],

Copilot AI 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.

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 these local-* 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, so displayedMessages immediately synthesizes a trailing Retry row even when messages is 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 () => [],

Comment on lines +24 to +27
export interface ReconcileResult {
messages: Message[];
/** Text of each user turn marked `failed`, for the retry affordance. */
failedTexts: string[];
Comment on lines +1412 to +1415
async ( text: string, failedMessageId?: string ) => {
setFailedRetries( ( previous ) => previous.filter( ( retry ) => retry !== text ) );
if ( failedMessageId ) {
setDeletedMessageIds( ( previous ) => new Set( previous ).add( failedMessageId ) );

Copilot AI 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.

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 failedRetries even though reconcileDismissedRef was 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 failedRetries intact, and displayedMessages renders 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( [] );
		},

Comment on lines +813 to +817
messages.length === 0 &&
! hasUserSentMessage &&
! reconcileDismissedRef.current
) {
loadMessages( reconcileResult.messages );
Comment on lines +1417 to +1419
submitDispatchedRef.current = false;
await submitChatMessage( text );
if ( submitDispatchedRef.current ) {

Copilot AI 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.

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

  • clearMessages prevents the transcript from being reloaded, but it leaves failedRetries populated. The rendering loop appends every remaining retry even when messages is 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 current pendingImages. 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 by failedTexts and 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 transformMessages has rewritten the transcript. That provider contract explicitly supports replacing a machine-facing user prompt with shorter display text, so the transformed text may not equal failedTexts; the notice then trails the transcript and Retry has no failedMessageId, 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 current agentId/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 getStoredSessionIds to 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(

Comment thread packages/agents-manager/src/components/orchestrator-chat/index.tsx Outdated

Copilot AI 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.

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

  • clearMessages marks reconciliation dismissed but leaves failedRetries populated. Because displayedMessages appends every remaining retry even when messages is 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 ) );

@wellyshen wellyshen 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.

  • 🔴 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: handleRetryFailed in the displayedMessages memo deps depends on inputValue (via submitChatMessage), 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 (like resolved-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() or hasAgent guard — any rejection there silently loses the orphaned message.
  • (Suggestion) Copilot reviews sometimes catch real issues — it might be worth double-checking them, thanks!

  • (Suggestion) orchestrator-chat is 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

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.

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.
@AnnaMag AnnaMag added [Status] Needs Author Reply and removed [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. labels Aug 31, 2026
@matticbot matticbot added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Aug 31, 2026
@AnnaMag
AnnaMag marked this pull request as draft August 31, 2026 15:34
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.
@AnnaMag

AnnaMag commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review!
I addressed most of the issues, but those need retesting before I ask for another review. I cannot complete it today and I'll be AFK until next week, so will put this in a draft mode for the time being.

@wellyshen

Copy link
Copy Markdown
Contributor

Thanks for the review! I addressed most of the issues, but those need retesting before I ask for another review. I cannot complete it today and I'll be AFK until next week, so will put this in a draft mode for the time being.

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 /wp-admin, and verify that everything still behaves as expected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Status] Needs Author Reply [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants