fix(native-chat): the host-window timer waits only for windows still open

A window that had already closed while the journal was still loading would arm a zero-delay timer
that re-armed itself every tick. Only windows still open arm one now; a closed one is settled when
the journal loads. Adds a test that a Stop-outrun send comes back once its window closes.
This commit is contained in:
Brennan Benson
2026-10-04 03:35:56 -07:00
parent 04f3468f15
commit 5bd5879ab7
3 changed files with 30 additions and 5 deletions
@@ -63,16 +63,18 @@ export function settleStructuredAgentSessionOutboxFromJournal(
return entries
}
/** When the soonest host window closes among entries only an owed answer settles, or null. */
/** When the soonest host window still open closes among entries only an owed answer settles, or
* null. One already closed waits for the journal to load, which re-runs the settlement anyway. */
export function nextStructuredAgentSessionHostWindowEnd(
entries: readonly StructuredAgentSessionOutboxEntry[]
entries: readonly StructuredAgentSessionOutboxEntry[],
now: number
): number | null {
let soonest: number | null = null
for (const entry of entries) {
const endsAt = structuredAgentSessionEntryAwaitsSettlement(entry)
? structuredAgentSessionEntryHostWindowEndsAt(entry)
: null
if (endsAt !== null && (soonest === null || endsAt < soonest)) {
if (endsAt !== null && endsAt >= now && (soonest === null || endsAt < soonest)) {
soonest = endsAt
}
}
@@ -11,6 +11,9 @@ import type {
AgentJournalSubmission
} from '../../../../shared/agent-session-journal-types'
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
import { AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } from '../../../../shared/agent-session-host-authority'
import { createBrowserUuid } from '@/lib/browser-uuid'
type SentParams = { envelope: { clientOperationId: string }; delivery?: string }
@@ -260,6 +263,26 @@ describe('a Stop answered while the journal is already read through it', () => {
})
})
describe("the host's window for a send a Stop outran", () => {
it('hands it back to check the chat once the window closes, though nothing else moved', async () => {
const id = createStructuredAgentSessionOperationId(
createBrowserUuid,
Date.now() - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS + 5_000
)
seedAttempted({ clientMessageId: id, stoppedBy: { operationId: 'stop-1' } })
const view = mount({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 3 } })
expect(view.result.current.outbox).toHaveLength(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(6_000)
})
expect(view.result.current.outbox).toEqual([])
expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up')
expect(view.result.current.error).toBe(CHECK_THE_CHAT)
expect(mocks.call).not.toHaveBeenCalled()
})
})
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 = {
@@ -171,13 +171,13 @@ export function useStructuredAgentSessionOutbox(args: {
}
// A send only an owed answer settles must still end when the host's window for it closes,
// though nothing else moves by then.
const windowEnd = nextStructuredAgentSessionHostWindowEnd(entries)
const windowEnd = nextStructuredAgentSessionHostWindowEnd(entries, now)
if (windowEnd === null) {
return
}
const timer = setTimeout(
() => setHostWindowsClosed((count) => count + 1),
Math.min(Math.max(windowEnd - now + 1, 0), MAX_TIMER_DELAY_MS)
Math.min(windowEnd - now + 1, MAX_TIMER_DELAY_MS)
)
return () => clearTimeout(timer)
}, [