Files
orca/src/shared/structured-agent-session-draft-hand-off.test.ts
T
Brennan Benson 07e9fdfd13 fix(native-chat): a message the chat said was not sent is never sent later on its own (#24232)
* fix(native-chat): keep a message the chat said was not sent held until its Retry

A native-chat send the host refused (for example "Chats were saved by a newer
Orca. Your message was not sent.") or that never reached the host showed "not
sent" with a Retry button, but the hold that stopped it lived only in the
outbox hook's memory. The message itself was saved in the outbox, so the next
launch lifted the hold and sent it with no Retry; a copy the user retyped in
the meantime was held behind it and went out as well.

The hold is now read from the failure the message already saves: a queued
entry that carries its last failure waits for the user's Retry, on this launch
and every later one, and an entry saved by an earlier build in that shape is
held too. The drain passes over a held message instead of stopping behind it,
so what the user sends next goes out as they send it. Retry clears the saved
failure. On a host from before accepted-send, a new agent owner observed while
the chat is open still sends the refused message again, as before; a relaunch
does not.

A held message keeps its operation id, and a host refuses an id older than a
day as expired for good, so its Retry could never go through; a new id could
deliver a message an earlier attempt already delivered. Such a message now
goes back to the composer with a notice to check the chat before sending it
again, and leaves the outbox.

* fix(native-chat): keep refused messages as rows until Retry, and only release them for an older host's new owner

- A message the host refuses as expired under an id it kept stays a saved row
  reading "Orca couldn't confirm what happened. Check the chat.", and its Retry
  sends it under a new id. It no longer moves into the message box, where an
  automatic resend after a relaunch could put text the user never asked for,
  held only in memory.
- An owner change releases a refused message only on a host known to predate
  accepted sends, and only for the refusals such a host gives while it restarts
  the chat's agent. Those rows say Orca will send it again when the agent
  restarts, beside their Retry. A host whose capability check has not answered,
  or failed, no longer releases anything.
- Every failed message ahead of the one the queue stopped on keeps its Retry,
  since that Retry sends it at once.
- A journal row saying the host cannot tell whether a message landed, and the
  unconfirmed probe's resend, replace an earlier attempt's saved failure, so
  the message is probed rather than held.
- The drain stages from the hook's own outbox, so a hold kept only in memory
  after a failed save survives the next send; the hold is written once more
  after that failed save.

* fix(native-chat): a refused message waits for its Retry on every host, and a send is staged from the latest outbox

A message the chat showed as not sent no longer goes out on its own when an
older host's chat gets a new agent owner. Resending it on the owner change
sent it after messages typed later, still delivered a retyped copy twice,
and its "Orca will send it again when the agent restarts" row promised a
resend that often never came. It now waits for the user's Retry, as it does
on every current host. A send still in flight when the owner changes is
still sent again under its id; it was never shown as failed.

The drain admitted and staged the next send from the render's outbox. An
owner change requeues the send it interrupted in an effect earlier in the
same commit, and staging from the render's list wrote the old list back,
leaving that send stuck as sending. The drain now reads the latest list,
which still carries a hold kept only in memory after a failed save.

Tests pass the view's target as one stable object, as the view does: a new
object each render re-ran the owner-change requeue, which hid the drain bug.

* fix(native-chat): keep a not-sent message out of newer turns, and word its saved cause only when seen

A message shown as not sent stays in the outbox and draws below every
newer turn. It was an ordinary user row there, so it counted as the newest
user row: while a new send waited for its turn to open, that turn's
"Working for" clock drew under the old message, and once the turn ended an
empty "Worked for" divider was left under it. The projection now marks
such a bubble (held for its Retry, or rejected) as unsent; turn membership
gives it no turn and never makes it the live one, on hosts that state turn
scopes and on those that do not; and the transcript draws it after the
live activity, as it draws a message waiting behind /compact. Mobile has no
outbox, so its rows never carry the mark and its grouping is unchanged.

A held message read back from storage repeated the cause it was saved with,
which may no longer hold: "Update Orca to keep using them" after the user
updated Orca. The outbox hook now remembers, in memory only, which messages
failed while the chat was open; only those word their cause. Any other held
message reads "Your message was not sent." with its Retry, and a Retry the
cause still stops brings the full words back. An expired id keeps its words,
since that cause cannot clear.

* fix(native-chat): follow the bottom and light a tick for a chat whose only rows are not sent

A message shown as not sent draws after the windowed transcript. When it was
the only row, the windowed list was empty, and following the bottom or "Jump
to latest" asked the virtualizer for an end it computes from its own rows:
the top. It now scrolls to the container's own bottom when no row is
windowed.

A rejected send the journal recorded keeps its place but opens no turn, so
the rail lit no tick when it was the row being read. A user row in no turn
now lights its own tick.

Also pins that a refusal seen while the chat is open reaches the rendered
notice in full, and reads only "not sent" after the chat is reopened until a
Retry is refused again.

* test(native-chat): name the relaunch test parameter for how the refusal arrives

The low-evidence lint rejects "shape" as a symbol name.
2026-09-30 22:49:26 -07:00

73 lines
2.6 KiB
TypeScript

// The link from a submission to the queued draft it hands off (`queuedMessageId`) decides every
// question about who owns a queued send; a draft id is never compared with a submission id.
import { describe, expect, it } from 'vitest'
import type { AgentJournalSubmission } from './agent-session-journal-types'
import {
createStructuredAgentSessionOutboxEntry,
type StructuredAgentSessionOutboxEntry
} from './structured-agent-session-outbox'
import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off'
import {
disposeStructuredAgentSessionSendResult,
journalAnswersInFlightSend
} from './structured-agent-session-send-disposition'
import { hasUnsentStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-stop-withdrawal'
const entry: StructuredAgentSessionOutboxEntry = {
...createStructuredAgentSessionOutboxEntry({
clientMessageId: 'draft',
sessionId: 'session-1',
text: 'hello',
attachments: [],
queuedAt: 1
}),
sentDelivery: 'queue-if-active',
state: 'unconfirmed',
lastAttemptAt: 5
}
function handOff(dispatchState: AgentJournalSubmission['dispatchState']): AgentJournalSubmission {
return {
clientMessageId: 'hand-off',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState,
providerItemId: null,
reason: dispatchState === 'rejected' ? 'refused' : null,
submittedAt: 10,
resolvedAt: null,
queuedMessageId: 'draft'
}
}
describe('a queued draft handed off under a fresh submission id', () => {
it('takes the outbox entry off the client, in every dispatch state', () => {
for (const state of ['pending', 'accepted', 'rejected', 'unknown'] as const) {
expect(reconcileStructuredAgentSessionOutboxWithQueue([entry], [handOff(state)])).toEqual([])
}
})
it('answers the send in flight and leaves nothing a Stop would withdraw', () => {
expect(journalAnswersInFlightSend([handOff('pending')], 'draft')).toBe(true)
expect(journalAnswersInFlightSend([handOff('pending')], null)).toBe(false)
expect(hasUnsentStructuredAgentSessionOutboxEntry([entry], [handOff('pending')])).toBe(false)
})
it('settles a replayed send answered with the hand-off, with no notice', () => {
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
createOperationId: () => 'rotated',
result: {
ok: true,
replayed: true,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 10 },
value: { clientMessageId: 'hand-off', submission: handOff('rejected') }
}
})
expect(disposition).toEqual({ entries: [], error: null })
})
})