mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
fix(native-chat): a message the host keeps refusing past its window comes back
A host that refuses a send before looking up its id (a host with structured chats turned off) refuses every resend the same way. Since a message past the host's window is no longer handed back on wake, such a message was resent forever and held up everything queued behind it. Now, past the host's window for the id, an answer the host itself gave that settles nothing (a refusal returned or thrown, or a call turned away) hands the message back with "couldn't confirm" words, unless the journal shows its row. Only a lost connection keeps it resending, since that says nothing about the host. Derived from that answer and the id's own time; nothing new is stored. Also corrects the host-window docstring, which still said an answer that never comes ends it.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
applyStructuredAgentSessionOutboxSettlement,
|
||||
settleStructuredAgentSessionSendAnswer,
|
||||
structuredAgentSessionEntryOutlivedHostWindow,
|
||||
type StructuredAgentSessionSendAnswer,
|
||||
type StructuredAgentSessionOutboxSettlement,
|
||||
type StructuredAgentSessionSettledOutbox
|
||||
@@ -279,7 +280,8 @@ export async function sendStructuredAgentSessionOutboxEntry(args: {
|
||||
const settlement = settleStructuredAgentSessionSendAnswer(answer, next.clientMessageId, {
|
||||
firstAttempt: onlyAttempt,
|
||||
answersProve,
|
||||
journalHasRow: args.journalHasRow?.(next.clientMessageId) ?? false
|
||||
journalHasRow: args.journalHasRow?.(next.clientMessageId) ?? false,
|
||||
outlivedHostWindow: structuredAgentSessionEntryOutlivedHostWindow(next, Date.now())
|
||||
})
|
||||
settle(settlement)
|
||||
return settlement
|
||||
|
||||
+43
@@ -21,6 +21,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox'
|
||||
import { useStructuredAgentSessionConversationStop } from './use-structured-agent-session-conversation-stop'
|
||||
import type {
|
||||
@@ -149,4 +150,46 @@ describe('while the journal is not live', () => {
|
||||
"Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed."
|
||||
)
|
||||
})
|
||||
|
||||
// A host that refuses before looking the id up refuses it every time: past its window that
|
||||
// answer settles nothing, so the message comes back to be checked instead of blocking the queue.
|
||||
it('a message past the window the host keeps refusing comes back after the next attempt', async () => {
|
||||
seedSent('host refuses', Date.now() - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 60_000)
|
||||
// As the client receives a refusal the host threw: an RPC error carrying it.
|
||||
mocks.call.mockImplementation(async () => {
|
||||
throw new RuntimeRpcCallError({
|
||||
id: 'rpc-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_error',
|
||||
message: 'structured_agent_session_unsupported',
|
||||
data: {
|
||||
refusal: {
|
||||
code: 'structured_agent_session_unsupported',
|
||||
details: { reason: 'hostDisabled' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
mountChat(asWriteAs(async () => LOST))
|
||||
|
||||
await vi.waitFor(() => expect(readNativeChatDraftCache(SCOPE)).toBe('host refuses'), {
|
||||
timeout: 3000
|
||||
})
|
||||
expect(mocks.call).toHaveBeenCalledOnce()
|
||||
expect(readOutbox('session-1')).toEqual([])
|
||||
})
|
||||
|
||||
it('a message past the window that only meets a lost connection keeps being sent', async () => {
|
||||
seedSent('no contact', Date.now() - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 60_000)
|
||||
mocks.call.mockRejectedValue(new Error('socket closed'))
|
||||
mountChat(asWriteAs(async () => LOST))
|
||||
|
||||
await vi.waitFor(() => expect(mocks.call.mock.calls.length).toBeGreaterThanOrEqual(2), {
|
||||
timeout: 5000
|
||||
})
|
||||
expect(readOutbox('session-1')).toHaveLength(1)
|
||||
expect(readNativeChatDraftCache(SCOPE)).toBe('')
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('a queued draft handed off under a fresh submission id', () => {
|
||||
}
|
||||
},
|
||||
'draft',
|
||||
{ firstAttempt: false, answersProve: true, journalHasRow: false }
|
||||
{ firstAttempt: false, answersProve: true, journalHasRow: false, outlivedHostWindow: false }
|
||||
)
|
||||
).toEqual({ kind: 'recorded' })
|
||||
})
|
||||
|
||||
@@ -90,7 +90,8 @@ function refused(
|
||||
const FIRST: StructuredAgentSessionOutboxSettlementContext = {
|
||||
firstAttempt: true,
|
||||
answersProve: true,
|
||||
journalHasRow: false
|
||||
journalHasRow: false,
|
||||
outlivedHostWindow: false
|
||||
}
|
||||
const RESEND_PROVING: StructuredAgentSessionOutboxSettlementContext = {
|
||||
...FIRST,
|
||||
@@ -617,6 +618,48 @@ describe('applying a settlement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("past the host's window, a host answer that settles nothing", () => {
|
||||
const PAST = { ...RESEND_OLD_HOST, outlivedHostWindow: true }
|
||||
const hostAnswers: [string, StructuredAgentSessionSendAnswer][] = [
|
||||
['a refusal it returned', refused('agent_session_journal_unreadable')],
|
||||
[
|
||||
'a refusal it threw',
|
||||
{
|
||||
kind: 'thrown',
|
||||
refusal: { kind: 'refused', code: 'structured_agent_session_unsupported' },
|
||||
rpcCode: undefined
|
||||
}
|
||||
],
|
||||
['a call it turned away', { kind: 'thrown', refusal: undefined, rpcCode: 'method_not_found' }]
|
||||
]
|
||||
|
||||
it.each(hostAnswers)(
|
||||
'%s: comes back to check the chat, or is the row it shows',
|
||||
(_label, answer) => {
|
||||
expect(settleStructuredAgentSessionSendAnswer(answer, ID, RESEND_OLD_HOST).kind).toBe(
|
||||
'unanswered'
|
||||
)
|
||||
expect(settleStructuredAgentSessionSendAnswer(answer, ID, PAST)).toEqual({
|
||||
kind: 'returned',
|
||||
words: ['sendOutcomeLost']
|
||||
})
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(answer, ID, { ...PAST, journalHasRow: true })
|
||||
).toEqual({ kind: 'recorded' })
|
||||
}
|
||||
)
|
||||
|
||||
it('but a lost connection is no answer from the host, so it still goes again', () => {
|
||||
expect(
|
||||
settleStructuredAgentSessionSendAnswer(
|
||||
{ kind: 'thrown', refusal: undefined, rpcCode: 'runtime_timeout' },
|
||||
ID,
|
||||
PAST
|
||||
)
|
||||
).toEqual({ kind: 'unanswered' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the host window bounds every entry', () => {
|
||||
const pastWindow = MADE_AT + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS + 1
|
||||
const reading = {
|
||||
|
||||
@@ -57,6 +57,9 @@ export type StructuredAgentSessionOutboxSettlementContext = {
|
||||
answersProve: boolean
|
||||
/** The loaded journal holds a row for this id. */
|
||||
journalHasRow: boolean
|
||||
/** The host's window for this id has closed, by the id's own time
|
||||
* (`structuredAgentSessionEntryOutlivedHostWindow`). */
|
||||
outlivedHostWindow: boolean
|
||||
}
|
||||
|
||||
/** Words for a message Orca can no longer settle with the host: an earlier attempt may already be
|
||||
@@ -135,10 +138,34 @@ function thrownSettlement(
|
||||
}
|
||||
|
||||
/** The host's answer to one attempt, as one of the three ends. */
|
||||
/** Whether the host itself answered: a refusal it returned or threw, or a code it turned the call
|
||||
* away with. Anything else is the transport, which says nothing about the host. */
|
||||
function answeredByHost(answer: StructuredAgentSessionSendAnswer): boolean {
|
||||
return (
|
||||
answer.kind === 'result' ||
|
||||
answer.refusal !== undefined ||
|
||||
agentSessionRpcErrorFailure(answer.rpcCode).kind === 'refused'
|
||||
)
|
||||
}
|
||||
|
||||
/** The host's answer to one attempt, as one of the three ends. Past the host's window for the id,
|
||||
* an answer the host gave that settles nothing never will (a host that refuses before looking the
|
||||
* id up refuses it every time), so the journal decides; lost contact still only goes again. */
|
||||
export function settleStructuredAgentSessionSendAnswer(
|
||||
answer: StructuredAgentSessionSendAnswer,
|
||||
clientMessageId: string,
|
||||
context: StructuredAgentSessionOutboxSettlementContext
|
||||
): StructuredAgentSessionOutboxSettlement {
|
||||
const settlement = settleAnswer(answer, clientMessageId, context)
|
||||
return settlement.kind === 'unanswered' && context.outlivedHostWindow && answeredByHost(answer)
|
||||
? settledByJournal(context)
|
||||
: settlement
|
||||
}
|
||||
|
||||
function settleAnswer(
|
||||
answer: StructuredAgentSessionSendAnswer,
|
||||
clientMessageId: string,
|
||||
context: StructuredAgentSessionOutboxSettlementContext
|
||||
): StructuredAgentSessionOutboxSettlement {
|
||||
if (answer.kind === 'thrown') {
|
||||
return thrownSettlement(answer, context)
|
||||
@@ -177,8 +204,9 @@ export function structuredAgentSessionEntryHostWindowEndsAt(
|
||||
return madeAt === null ? null : madeAt + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS
|
||||
}
|
||||
|
||||
/** Whether the host can no longer settle this id, so a host that keeps failing the request, or an
|
||||
* answer that never comes, can't hold the entry forever. */
|
||||
/** Whether the host can no longer settle this id. Past it, a host answer that settles nothing
|
||||
* hands the entry back for the journal to decide, and a live journal settles one only an owed
|
||||
* answer would; lost contact alone keeps it, since it says nothing about the host. */
|
||||
export function structuredAgentSessionEntryOutlivedHostWindow(
|
||||
entry: Pick<StructuredAgentSessionOutboxEntry, 'clientMessageId'>,
|
||||
now: number
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('outbox queue delivery', () => {
|
||||
}
|
||||
},
|
||||
'client-1',
|
||||
{ firstAttempt: true, answersProve: false, journalHasRow: false }
|
||||
{ firstAttempt: true, answersProve: false, journalHasRow: false, outlivedHostWindow: false }
|
||||
)
|
||||
const stopped = stopStructuredAgentSessionOutbox([staged], [], 'client-1', 'stop-1').entries
|
||||
for (const entries of [stopped, [staged]]) {
|
||||
|
||||
Reference in New Issue
Block a user