mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer
The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry.
This commit is contained in:
+75
@@ -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[] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'
|
||||
|
||||
Reference in New Issue
Block a user