diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index c1945503d27..815c1a2c403 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' const mocks = vi.hoisted(() => ({ call: vi.fn() @@ -174,6 +175,80 @@ describe('a send the host rejected because the agent never started', () => { expect(retried.envelope.clientOperationId).not.toBe(id) expect(retried.body.blocks[0]?.text).toBe('hello') }) + + it('says nothing when a Stop withdrew the message', async () => { + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => pendingResultFor(params.envelope.clientOperationId) + ) + const target = { kind: 'local' } as const + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: NO_SUBMISSIONS } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + const id = result.current.outbox[0]!.clientMessageId + + rerender({ + submissions: [ + { + ...pendingResultFor(id).value.submission, + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + } + ] + }) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(result.current.error).toBeNull() + }) + + it('keeps the rejection when the journal settles the message before the send answers', async () => { + const reason = "Codex couldn't restart: spawn codex ENOENT." + let answer: (value: unknown) => void = () => undefined + mocks.call.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const target = { kind: 'local' } as const + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: NO_SUBMISSIONS } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) + const id = result.current.outbox[0]!.clientMessageId + + // A start refused at once: the rejection frame lands before the send's own `pending` answer. + rerender({ + submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] + }) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + await act(async () => answer(pendingResultFor(id))) + + expect(result.current.outbox[0]?.state).toBe('rejected') + expect(result.current.error).toBe(reason) + }) }) const NO_SUBMISSIONS: AgentJournalSubmission[] = [] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 9801caa80ad..c8e2f87420c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -8,8 +8,8 @@ import { type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { - rejectedDispatchingSubmission, - structuredAgentSessionRejectionNotice, + journalAnswersInFlightSend, + reconciledRejectionNotice, type StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' @@ -106,7 +106,7 @@ export function useStructuredAgentSessionOutbox(args: { .map((submission) => submission.clientMessageId) ) const next = reconcileStructuredAgentSessionOutbox(current, submissions) - const admittedInFlight = inFlightIdRef.current !== null && hostOwns.has(inFlightIdRef.current) + const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current) if ( admittedInFlight || next.some((entry, index) => entry !== current[index]) || @@ -124,9 +124,9 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGenerationRef.current += 1 inFlightIdRef.current = null } - const rejected = rejectedDispatchingSubmission(current, submissions) - if (rejected) { - setError(structuredAgentSessionRejectionNotice(rejected.reason)) + const rejection = reconciledRejectionNotice(current, next, submissions) + if (rejection) { + setError(rejection) } else if (blockedIdRef.current !== null && hostOwns.has(blockedIdRef.current)) { blockedIdRef.current = null setError(null) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 54168874636..72368dae921 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -73,6 +73,40 @@ function refusedRedelivery( ) } +/** Whether the journal already answers a send still in flight, so its own reply adds nothing: the + * host holds the message, or rejected it — a later `pending` reply must not undo that. */ +export function journalAnswersInFlightSend( + submissions: readonly AgentJournalSubmission[], + clientMessageId: string | null +): boolean { + return submissions.some( + (submission) => + submission.clientMessageId === clientMessageId && submission.dispatchState !== 'unknown' + ) +} + +/** What to say for a message the reconcile just settled as accepted and then not delivered, which + * keeps its Retry; null when there is none. A Stop's withdrawal is dropped there, so says nothing. */ +export function reconciledRejectionNotice( + previous: readonly StructuredAgentSessionOutboxEntry[], + reconciled: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): string | null { + const was = ( + entries: readonly StructuredAgentSessionOutboxEntry[], + clientMessageId: string, + state: StructuredAgentSessionOutboxEntry['state'] + ): boolean => + entries.some((entry) => entry.clientMessageId === clientMessageId && entry.state === state) + const rejected = submissions.find( + (submission) => + submission.dispatchState === 'rejected' && + was(previous, submission.clientMessageId, 'dispatching') && + was(reconciled, submission.clientMessageId, 'rejected') + ) + return rejected ? structuredAgentSessionRejectionNotice(rejected.reason) : null +} + /** * What to put on screen for a rejection. * @@ -90,21 +124,6 @@ function refusedRedelivery( * Exported because a client without an outbox needs the same copy: the rule about * which reasons a person may read is a property of the reason, not of the queue. */ -/** A message the host accepted and then did not deliver: the chat says why and offers Retry. */ -export function rejectedDispatchingSubmission( - entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): AgentJournalSubmission | undefined { - return submissions.find( - (submission) => - submission.dispatchState === 'rejected' && - entries.some( - (entry) => - entry.clientMessageId === submission.clientMessageId && entry.state === 'dispatching' - ) - ) -} - export function structuredAgentSessionRejectionNotice(reason: string | null): string { if (reason === null) { return 'Message was not sent.'