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.
This commit is contained in:
Brennan Benson
2026-09-14 23:25:20 -07:00
parent bbd808a63d
commit a935fda023
2 changed files with 75 additions and 8 deletions
@@ -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> = {}
): 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()
})
})
@@ -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