diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-journal-settlement.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-journal-settlement.ts index 9c3fd0e14ad..3cb40f155d1 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-journal-settlement.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-journal-settlement.ts @@ -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 } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx index 3c038ecbbeb..f39e274d750 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx @@ -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 = { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index a4d288dd1e5..dc2045f682c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -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) }, [