From a935fda0232e8a86fc3dc9341a625219b66300f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:24:52 -0700 Subject: [PATCH] fix(native-chat): stop an unsettleable dispatch from blocking /clear and /compact Conversation-command admission refused every command while any submission was `pending` or `unknown`. A recovered `unknown` is terminal: crash recovery skips rows it already marked, restart reconciliation only narrows accepted outcomes, and Retry drops the outbox entry without touching the journal. Nothing settles it, so the refusal named a step the user could not take for the rest of the session. Reuse `hasUnansweredStructuredAgentSessionDispatch`, which already excludes recovered rows and keeps the legacy-host fallback for hosts that publish the recovery reason without the marker. Scope it to the caller's runtime fence: a dispatch from a dead generation is owed by a host that is gone, so no live provider answers it and no user action settles it either. --- ...red-conversation-command-admission.test.ts | 67 ++++++++++++++++++- ...ructured-conversation-command-admission.ts | 16 +++-- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 5a3370c910f..a87ab799c7b 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -1,23 +1,47 @@ import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import { DISPATCH_DOUBT_HOST_RESTARTED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { conversationCommandBlocked } from './structured-conversation-command-admission' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +const CURRENT_FENCE = 4 + function contextWith( - backgroundTasks: AgentSessionBackgroundTaskState | null + backgroundTasks: AgentSessionBackgroundTaskState | null, + submissions: readonly AgentJournalSubmission[] = [] ): AgentSessionTurnContext { return { sessionId: 'session-1', + fence: CURRENT_FENCE, journal: { snapshot: () => ({ items: [] }), - submissions: () => [] + submissions: () => submissions }, adapter: { backgroundTaskState: () => backgroundTasks } } as unknown as AgentSessionTurnContext } +function submission( + dispatchState: AgentJournalSubmission['dispatchState'], + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId: 'm1', + fence: CURRENT_FENCE, + payloadFingerprint: 'm1', + dispatchState, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: dispatchState === 'pending' ? null : 2, + ...overrides + } +} + const RECORD = { lease: {} } as unknown as AgentSessionRecord +const UNSETTLED_REFUSAL = 'Resolve pending or unconfirmed messages before using this command.' describe('conversationCommandBlocked background tasks', () => { it('admits the command when nothing is being monitored', () => { @@ -68,3 +92,42 @@ describe('conversationCommandBlocked background tasks', () => { ) }) }) + +describe('conversationCommandBlocked unsettled submissions', () => { + it('admits the command on a recovered unknown nothing can ever settle', () => { + // Crash recovery skips rows it already marked, the restart reconciler only + // narrows accepted outcomes, and Retry drops the outbox entry without + // touching the journal. Refusing here asks for a step that does not exist. + const ctx = contextWith(null, [submission('unknown', { recovered: true })]) + expect(conversationCommandBlocked(ctx, RECORD)).toBeNull() + }) + + it('admits the command on a legacy host that publishes the reason without the marker', () => { + const ctx = contextWith(null, [ + submission('unknown', { reason: DISPATCH_DOUBT_HOST_RESTARTED }) + ]) + expect(conversationCommandBlocked(ctx, RECORD)).toBeNull() + }) + + it('admits the command on a dead generation the current fence has passed', () => { + const ctx = contextWith(null, [submission('pending', { fence: CURRENT_FENCE - 1 })]) + expect(conversationCommandBlocked(ctx, RECORD)).toBeNull() + }) + + it('still refuses while this generation owes an answer', () => { + expect(conversationCommandBlocked(contextWith(null, [submission('pending')]), RECORD)).toBe( + UNSETTLED_REFUSAL + ) + expect(conversationCommandBlocked(contextWith(null, [submission('unknown')]), RECORD)).toBe( + UNSETTLED_REFUSAL + ) + }) + + it('admits the command once every submission is terminally settled', () => { + const ctx = contextWith(null, [ + submission('accepted', { clientMessageId: 'm1' }), + submission('rejected', { clientMessageId: 'm2' }) + ]) + expect(conversationCommandBlocked(ctx, RECORD)).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 78b9f4a59d0..34fde41f6b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,5 +1,8 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { + activeStructuredAgentSessionTurnId, + hasUnansweredStructuredAgentSessionDispatch +} from '../../../shared/structured-agent-session-projection' import type { AgentSessionTurnContext } from './structured-agent-session-turns' export function conversationCommandBlocked( @@ -47,11 +50,12 @@ export function conversationCommandBlocked( ? 'Stop background tasks before using this command.' : 'Wait for background tasks to finish before using this command.' } - if ( - ctx.journal - .submissions() - .some((entry) => entry.dispatchState === 'pending' || entry.dispatchState === 'unknown') - ) { + // Only a dispatch this generation still owes an answer on. A recovered `unknown` + // outlived the host that sent it and nothing re-derives it, so refusing on one + // named a step the user could never take for the rest of the session. The fence + // says the same thing about a dead generation's rows: no live provider will + // answer them and no user action settles them. + if (hasUnansweredStructuredAgentSessionDispatch(ctx.journal.submissions(), ctx.fence)) { return 'Resolve pending or unconfirmed messages before using this command.' } return null