mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 08:02:33 +00:00
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:
+65
-2
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user