mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
fix(native-chat): sends a Stop outran no longer stall the chat
Two ways a Stop could leave messages stuck on "Sending…": - the resend timer picked the first message in doubt even when it was one the Stop outran (never resent), so a later message in doubt behind it was never resent either; it now resends the message the queue is actually waiting on; - a Stop answered while the agent was idle writes nothing to the journal, so its answer's position was one the chat had already read, and the settling step only ran when the journal moved. It now also runs when the outbox changes, so the outrun send comes back to the composer at once.
This commit is contained in:
+5
-3
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { admitStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-admission'
|
||||
import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend'
|
||||
import {
|
||||
STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS,
|
||||
@@ -32,9 +33,10 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: {
|
||||
|
||||
// A send with no answer may never have reached the host, and nothing else moves it out of
|
||||
// `unconfirmed`, so one would wedge the whole FIFO queue. The same id again is idempotent: the
|
||||
// host replays a recorded answer or performs a genuine first delivery. The first `unconfirmed`
|
||||
// entry is the one holding the queue, at whatever index it sits.
|
||||
const blocker = outbox.find((entry) => entry.state === 'unconfirmed')
|
||||
// host replays a recorded answer or performs a genuine first delivery. The entry the queue's
|
||||
// admission is blocked on is the one resent, never one a Stop outran or an older build left.
|
||||
const admission = admitStructuredAgentSessionOutboxEntry(outbox)
|
||||
const blocker = admission.state === 'blocked' ? admission.entry : undefined
|
||||
// The delivery notices read the same rule: while it is resent here, its row says it is sending.
|
||||
// Primitives only: `submissions` is rebuilt on every streaming batch, and an array-identity dep
|
||||
// would reset the backoff forever while the agent is working.
|
||||
|
||||
+58
@@ -227,6 +227,64 @@ describe('a send a Stop outran', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a Stop answered while the journal is already read through it', () => {
|
||||
it('an idle Stop answered at the cursor already held settles the send with no new journal batch', async () => {
|
||||
seedAttempted()
|
||||
const props: Props = { fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 7 } }
|
||||
const view = mount(props)
|
||||
act(() => view.result.current.stop('stop-1'))
|
||||
// An idle host writes nothing for a Stop, so its answer's cursor is the head already loaded.
|
||||
act(() =>
|
||||
view.result.current.recordStopAnswer('stop-1', {
|
||||
kind: 'answered',
|
||||
cursor: { epoch: 'e', sequence: 7 }
|
||||
})
|
||||
)
|
||||
view.rerender(props)
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
|
||||
expect(view.result.current.error).toBeNull()
|
||||
expect(mocks.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a refused Stop with the journal already loaded settles the send with no new journal batch', async () => {
|
||||
seedAttempted()
|
||||
const props: Props = { fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 7 } }
|
||||
const view = mount(props)
|
||||
act(() => view.result.current.stop('stop-1'))
|
||||
act(() => view.result.current.recordStopAnswer('stop-1', { kind: 'unanswerable' }))
|
||||
view.rerender(props)
|
||||
expect(view.result.current.outbox).toEqual([])
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
|
||||
expect(view.result.current.error).toBe(CHECK_THE_CHAT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a send in doubt behind one a Stop outran', () => {
|
||||
it('is the one resent: the queue waits on it, not on the stamped one', async () => {
|
||||
const later = {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'later',
|
||||
sessionId: 'session-1',
|
||||
text: 'later message',
|
||||
attachments: [],
|
||||
queuedAt: 2
|
||||
}),
|
||||
lastAttemptAt: 6,
|
||||
state: 'unconfirmed' as const
|
||||
}
|
||||
seedAttempted({ stoppedBy: { operationId: 'stop-1' } })
|
||||
writeOutbox('session-1', [...readOutbox('session-1'), later])
|
||||
mount({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 3 } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
})
|
||||
const sent = mocks.call.mock.calls.map((call) => call[2].envelope.clientOperationId)
|
||||
expect(sent).toContain('later')
|
||||
expect(sent).not.toContain('out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a message an older build held for a Retry', () => {
|
||||
it.each([
|
||||
['outlived by a Stop', { outlivedStop: true, state: 'queued' }],
|
||||
|
||||
@@ -137,8 +137,9 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
const [drains, setDrains] = useState(0)
|
||||
const drainAgain = useCallback(() => setDrains((count) => count + 1), [])
|
||||
|
||||
// Everything the journal settles, every time it moves: a row, a published card, the Stop's
|
||||
// answer read through, or an entry an older build left waiting for a Retry.
|
||||
// Everything the journal settles, every time it or the outbox moves: a row, a published card, the
|
||||
// Stop's answer read through, or an entry an older build left. The outbox too, since an idle
|
||||
// Stop answers at a cursor the journal already reached, and nothing else would move.
|
||||
useEffect(() => {
|
||||
const reading = {
|
||||
submissions,
|
||||
@@ -189,7 +190,7 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
// Nothing above may have written the outbox, so the drain is told to look again.
|
||||
drainAgain()
|
||||
}
|
||||
}, [drainAgain, journalCursor, queuedMessageIds, sessionId, submissions])
|
||||
}, [drainAgain, journalCursor, outbox, queuedMessageIds, sessionId, submissions])
|
||||
|
||||
const journalHasRow = useCallback(
|
||||
(clientMessageId: string) =>
|
||||
|
||||
Reference in New Issue
Block a user