Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ba0681b
fix(native-chat): a refused queued message still blocks the queue whe…
brennanb2025 Oct 18, 2026
e42b90d
refactor(native-chat): keep each queued message in the chat's journal…
brennanb2025 Oct 19, 2026
4daf94e
test(native-chat): adapt the queue tests to cards kept in the journal
brennanb2025 Oct 19, 2026
62a0bcf
test(native-chat): a refused card holds the queue through rewind, a n…
brennanb2025 Oct 19, 2026
4db1581
test(native-chat): an earlier build's queued cards move into the jour…
brennanb2025 Oct 19, 2026
6d22d29
test(native-chat): adapt typechecked tests to cards kept in the journal
brennanb2025 Oct 19, 2026
a89bca3
refactor(native-chat): drop the journal row writer's unused transacti…
brennanb2025 Oct 19, 2026
627e8a3
test(native-chat): downgrade probes write rows at the version older b…
brennanb2025 Oct 19, 2026
9e535d9
test(native-chat): unit tests follow the queue row's version 4
brennanb2025 Oct 19, 2026
db68669
fix(native-chat): one batch echoing two refused queued messages spend…
brennanb2025 Oct 19, 2026
0d31aca
fix(native-chat): a rewind moves an earlier build's cards into the jo…
brennanb2025 Oct 19, 2026
e096d5f
fix(native-chat): keep an earlier build's card whose hand-off is stil…
brennanb2025 Oct 19, 2026
6b66b65
Merge remote-tracking branch 'origin/main' into brennanb2025/send-lif…
brennanb2025 Oct 19, 2026
88c44a9
fix(native-chat): one delivery echo withdraws at most one queued card
brennanb2025 Oct 19, 2026
f4f9442
fix(native-chat): a queued message handed to the agent is sent again …
brennanb2025 Oct 19, 2026
8f84e49
fix(native-chat): Claude's report that it removed a queued message un…
brennanb2025 Oct 19, 2026
41a28fd
fix(native-chat): an earlier build's card stopped after hand-over fol…
brennanb2025 Oct 19, 2026
14c1a02
fix(native-chat): read a queued row's hand-off before the lookup that…
brennanb2025 Oct 19, 2026
ff11457
test(native-chat): a card steered into a turn a Stop ends stays sent …
brennanb2025 Oct 19, 2026
6b91762
Merge remote-tracking branch 'origin/main' into brennanb2025/send-lif…
brennanb2025 Oct 19, 2026
fb72fc8
fix(native-chat): a queued message stopped before Orca handed it to t…
brennanb2025 Oct 19, 2026
e19bd95
fix(native-chat): a queued message stopped while the agent prepares i…
brennanb2025 Oct 19, 2026
f60d1b3
Merge remote-tracking branch 'origin/main' into brennanb2025/send-lif…
brennanb2025 Oct 20, 2026
7f25a0d
test(native-chat): the usage-limit Continue backlog case writes its c…
brennanb2025 Oct 20, 2026
e9513f8
test(claude): the real-CLI queued Stop expects the unrun mark Claude'…
brennanb2025 Oct 20, 2026
aeeec4b
Merge origin/main into the journal queue branch
brennanb2025 Oct 20, 2026
0512fff
Merge origin/main for the daemon backpressure test fix
brennanb2025 Oct 21, 2026
e9bfdaf
fix(native-chat): retain spent legacy messages when import fails
brennanb2025 Oct 21, 2026
293bb82
Merge origin/main into queued message journal branch
brennanb2025 Oct 21, 2026
ef47118
Merge origin/main before landing
brennanb2025 Oct 22, 2026
b99ed06
Merge remote-tracking branch 'origin/main' into brennanb2025/send-lif…
brennanb2025 Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/scripts/vitest-node-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ const NODE_RUNTIME_INCLUDE = [
'src/main/native-chat/structured-chat-naming.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-queued-legacy-import.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
'src/main/runtime/structured-session-mail-redrive-wiring.test.ts',
'src/main/runtime/rpc/ws-transport.test.ts',
Expand Down
3 changes: 2 additions & 1 deletion src/main/claude/claude-command-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ async function replayCapture(

const WITHDRAWN = {
state: 'rejected',
unrun: true,
reason: DISPATCH_REJECTED_CANCELLED,
rejection: { kind: 'cancelled' }
}
Expand Down Expand Up @@ -514,7 +515,7 @@ describe('a send the CLI ended before starting it', () => {
})

expect(replay.settlementsFor('client-B')).toEqual([
{ state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact(kind)) }
{ state: 'rejected', unrun: true, ...claudeDispatchRejection(agentSessionFailureFact(kind)) }
])
expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))])
})
Expand Down
3 changes: 3 additions & 0 deletions src/main/claude/claude-structured-control-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe('cancelClaudeTurn', () => {
{
clientMessageId: 'client-1',
state: 'rejected',
unrun: true,
reason: 'provider_cancelled_before_start',
rejection: { kind: 'cancelled' }
}
Expand Down Expand Up @@ -136,6 +137,7 @@ describe('cancelClaudeTurn', () => {
expect(settled).toHaveBeenNthCalledWith(1, {
clientMessageId: 'client-0',
state: 'rejected',
unrun: true,
reason: 'provider_cancelled_before_start',
rejection: { kind: 'cancelled' }
})
Expand Down Expand Up @@ -170,6 +172,7 @@ describe('cancelClaudeTurn', () => {
expect(settled).toHaveBeenCalledWith({
clientMessageId: 'client-ambiguous',
state: 'rejected',
unrun: true,
reason: 'provider_cancelled_before_start',
rejection: { kind: 'cancelled' }
})
Expand Down
9 changes: 8 additions & 1 deletion src/main/claude/claude-structured-dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto'
import { isAbortSignalReason } from '../../shared/abort-signal-reason'
import {
forgetRetiredWaiter,
forgetWaiter,
Expand Down Expand Up @@ -65,6 +66,7 @@ export function rejectClaudeDispatchWaiters(
onSettledLate?.({
clientMessageId: waiter.clientMessageId,
state: 'rejected',
unrun: true,
...claudeDispatchRejection(fact)
})
}
Expand Down Expand Up @@ -121,6 +123,7 @@ export async function dispatchClaudeTurn(
requestedAt?: number
/** The frame's uuid, for a caller that correlates the provider's answer to it. */
sentUuid?: string
signal?: AbortSignal
},
beforeDispatch?: () => Promise<void>
): Promise<AgentSessionDispatchOutcome> {
Expand Down Expand Up @@ -174,7 +177,11 @@ export async function dispatchClaudeTurn(
} catch (error) {
const replay = pending.replay
if (!replay) {
if (error instanceof AgentSessionPreDispatchError) {
// A Stop during authorization, before the SDK took the frame, stays the abort the host reads.
if (
error instanceof AgentSessionPreDispatchError ||
isAbortSignalReason(input.signal, error)
) {
throw error
}
if (isAgentSessionOptionRejectedError(error)) {
Expand Down
41 changes: 41 additions & 0 deletions src/main/claude/claude-structured-message-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options'
import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
import { ClaudeControlRequestError } from './claude-stream-json-connection'

const MODELS = [
{ value: 'default', resolvedModel: 'claude-sonnet-5' },
Expand Down Expand Up @@ -217,6 +218,46 @@ describe('Claude accepted message choices', () => {
expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt'])
})

// The host reads the abort itself as never written, so a Stop there must not surface as a refusal.
it('surfaces a Stop while it applies a choice as the abort, and a refusal as a refusal', async () => {
let refuse = false
const claude = fakeClaude({
initModels: MODELS,
routes: {
list_models: () => MODELS,
set_model: () => {
if (refuse) {
throw new ClaudeControlRequestError('set_model', 'unknown model')
}
return new Promise(() => {})
}
}
})
const adapter = await acquired(claude)
const stop = new AbortController()
const dispatch = (clientMessageId: string, signal?: AbortSignal) =>
adapter.dispatch({
sessionId: 'session-1',
clientMessageId,
body: USER_MESSAGE,
executionOptions: { model: 'opus' },
fence: 7,
...(signal ? { signal } : {})
})
const stopped = dispatch('stopped', stop.signal)
await vi.waitFor(() =>
expect(claude.connections[0]!.calls.some((call) => call.subtype === 'set_model')).toBe(true)
)
stop.abort(new Error('stopped while starting'))
await expect(stopped).rejects.toBe(stop.signal.reason)
refuse = true
await expect(dispatch('refused')).resolves.toMatchObject({
state: 'rejected',
rejection: { kind: 'providerRejected' }
})
expect(claude.connections[0]!.sent).toHaveLength(0)
})

it('restores A after an applied unanswered B and an uncorrelated old init', async () => {
let actual: string | undefined = 'sonnet'
const claude = fakeClaude({ initModels: MODELS, routes: { list_models: () => MODELS } })
Expand Down
9 changes: 7 additions & 2 deletions src/main/claude/claude-structured-message-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
import { listedModels } from './claude-structured-model-catalog'
import { setClaudeStructuredOption } from './claude-structured-options'
import type { ClaudeSession } from './claude-structured-session-state'
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import {
isAbortSignalReason,
throwIfSignalAborted,
waitForPromiseWithSignal
} from '../../shared/abort-signal-reason'

const defaultLogger = createStructuredAgentSessionLogger()

Expand Down Expand Up @@ -110,7 +114,8 @@ export async function dispatchClaudeMessage(
error
}
)
throw error instanceof AgentSessionOptionRejectedError
throw error instanceof AgentSessionOptionRejectedError ||
isAbortSignalReason(input.signal, error)
? error
: new AgentSessionOptionRejectedError(error)
}
Expand Down
1 change: 1 addition & 0 deletions src/main/claude/claude-structured-queued-stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ const WITHDRAWN = [
sessionId: 'session-1',
clientMessageId: 'client-b',
state: 'rejected',
unrun: true,
reason: DISPATCH_REJECTED_CANCELLED,
rejection: { kind: 'cancelled' }
}
Expand Down
4 changes: 3 additions & 1 deletion src/main/claude/claude-structured-real-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => {
clientMessageId: 'real-cli-queued-stop-b',
state: 'rejected',
reason: 'provider_cancelled_before_start',
rejection: { kind: 'cancelled' }
rejection: { kind: 'cancelled' },
// Claude cancelled it unrun: a queued card's hand-off records that as proof.
unrun: true
})
} finally {
await adapter.closeAll()
Expand Down
3 changes: 2 additions & 1 deletion src/main/claude/claude-structured-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ export type ClaudeLateDispatchOutcome =
clientMessageId: string
providerIdentity: AgentJournalItemIdentity
}
| ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection)
/** `unrun`: the CLI reported it removed the send without running it. */
| ({ clientMessageId: string; state: 'rejected'; unrun?: true } & AgentJournalDispatchRejection)
/** The CLI took the send and let it go unanswered: it may have run, so it is never re-sent. */
| { clientMessageId: string; state: 'unknown'; reason: string }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,6 @@ describe('codex turn lifecycle rows', () => {
expect(appended.ok && appended.rows).toEqual([
expect.objectContaining({
kind: 'item',
v: 3,
ts: 1_000,
body: {
kind: 'turn',
Expand Down
11 changes: 3 additions & 8 deletions src/main/codex/codex-structured-thread-catch-up-echo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
applyJournalRow,
createJournalReducerState
} from '../native-chat/agent-session-journal/journal-reducer'
import { draftsDeliveredByAppliedEcho } from '../native-chat/agent-session-journal/queued-message-delivered-echo'

export function registerCodexCatchUpEchoTests(input: {
chat: typeof catchUpChat
Expand All @@ -37,9 +36,7 @@ export function registerCodexCatchUpEchoTests(input: {
const fingerprint = agentSessionSendBodyFingerprint(SESSION, body)
await c.journal.queuedMessages.insert({
messageId: 'draft',
body,
fingerprint,
hostInstance: 'p'
body
})
const states = ['unknown', 'rejected'] as const
for (const state of states) {
Expand All @@ -51,9 +48,7 @@ export function registerCodexCatchUpEchoTests(input: {
fence: 1,
handoverRecorded: true
},
state === 'rejected'
? { messageId: 'draft', expect: 'waiting', settledByOp: null }
: undefined
state === 'rejected' ? { messageId: 'draft', expect: 'waiting' } : undefined
)
await c.journal.resolveDispatch({
clientMessageId: state,
Expand Down Expand Up @@ -135,7 +130,7 @@ export function registerCodexCatchUpEchoTests(input: {
for (const row of replay.rows) {
applyJournalRow(state, row)
}
expect(draftsDeliveredByAppliedEcho(state, [draft])).toEqual([])
expect(state.queue.cards.get('draft')).toEqual(draft)
})

it('keeps native display identity after a real retained cut and disk reopen', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
agentSessionAttachmentStoreRoot
} from './agent-session-attachment-references'
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'

const UPLOAD_A = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01'
const UPLOAD_B = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02'
Expand Down Expand Up @@ -216,8 +217,6 @@ describe('claims written with the message', () => {
.insert({
messageId: 'draft-0',
body: missing,
fingerprint: 'fp',
hostInstance: 'p',
requireAttachments: true
})
.catch((caught: unknown) => caught)
Expand All @@ -229,18 +228,15 @@ describe('claims written with the message', () => {
await journal.queuedMessages.insert({
messageId: 'draft-1',
body,
fingerprint: 'fp',
hostInstance: 'p',
requireAttachments: true
})
await journal.context.clear(
{ operationId: 'clear', afterFence: 0, clearedAt: 1000 },
{ write: () => {}, committed: () => {} },
'caller-clear'
{ write: () => {}, committed: () => {} }
)
await journals.closeAll()
const reopened = await open()
expect(reopened.queuedMessages.get('draft-1')).toMatchObject({ body, carriedFrom: null })
expect(reopened.queuedMessages.get('draft-1')).toMatchObject({ body, state: 'waiting' })
expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }])
})

Expand All @@ -251,14 +247,18 @@ describe('claims written with the message', () => {
await journal.queuedMessages.insert({
messageId: 'draft-1',
body,
fingerprint: 'fp',
hostInstance: 'p',
requireAttachments: true
})
await rm(join(storeRoot, UPLOAD_A), { recursive: true })
await journal.appendSubmission(
{ clientMessageId: 'handoff-1', payloadFingerprint: 'fp', body, fence: 0, origin: 'client' },
{ messageId: 'draft-1', expect: 'waiting', settledByOp: null }
{
clientMessageId: 'handoff-1',
payloadFingerprint: agentSessionSendBodyFingerprint(IDENTITY.sessionId, body),
body,
fence: 0,
origin: 'client'
},
{ messageId: 'draft-1', expect: 'waiting' }
)
expect(journal.submission('handoff-1')).toBeTruthy()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ export function registerCommandReceiptTransactionTests(): void {
})
await writer.enqueue(
epochRow,
undefined,
buildCommandReceiptTransaction(scope, (row) => {
if (!row) {
throw new Error('expected the committed journal row')
Expand Down Expand Up @@ -177,6 +176,7 @@ export function registerCommandReceiptTransactionTests(): void {
fence: 0,
items: [],
queuePause: { lifted: false, liveStop: null, reopened: false },
queue: { cards: [], receipt: { write: () => {}, committed: () => {} } },
now: () => 2000,
mintEpoch: () => 'epoch-2',
onPublished: () => undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,17 @@ function journalClosedSets() {

/** Recorded with the row version it holds for. Change it only together with that decision. */
const SNAPSHOT = {
v: 3,
v: 4,
closed: {
'row.kind': ['dispatch', 'epoch', 'item', 'lifecycle-batch', 'submission', 'tombstone'],
'row.kind': [
'dispatch',
'epoch',
'item',
'lifecycle-batch',
'queue',
'submission',
'tombstone'
],
'row.mutations[].kind': ['item', 'tombstone'],
'body.contextUsage.used.kind': ['estimate', 'report', 'unknown'],
'body.contextUsage.used.categories[].deferred': [true]
Expand Down
31 changes: 17 additions & 14 deletions src/main/native-chat/agent-session-journal/journal-context-clear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AgentSessionProviderContextBoundary } from '../../../shared/agent-
import type { JournalReducerState } from './journal-reducer'
import { journalItemRowBuilder } from './journal-row-builders'
import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer'
import type { JournalQueuedMessages } from './journal-queued-messages'
import type { JournalQueuedMessages, JournalQueuePlan } from './journal-queued-messages'
import type { JournalTombstoneRow } from './journal-row-schema'
import { buildJournalQueueClearRow } from './journal-stop-and-resume-rows'

Expand All @@ -12,19 +12,21 @@ export function appendJournalContextClear(input: {
cards: JournalQueuedMessages
boundary: AgentSessionProviderContextBoundary
receipt: JournalOperationReceipt
settledByOp: string
}) {
const { boundary, state } = input
let commandIds: string[] = []
let commands: JournalQueuePlan | undefined
return input.writer
.enqueueRows(
() => {
const cards = input.cards.list()
commandIds = cards
.filter(
(card) => card.body.command && (card.state === 'waiting' || card.state === 'returned')
)
.map((card) => card.messageId)
// A command is about the context the clear just ended: its card goes with the divider.
commands = input.cards.plan(
cards
.filter(
(card) => card.body.command && (card.state === 'waiting' || card.state === 'returned')
)
.map((card) => ({ messageId: card.messageId, change: { op: 'withdrawn' as const } }))
)
const messageIds = cards
.filter((card) => card.state === 'waiting' && !card.body.command)
.map((card) => card.messageId)
Expand All @@ -47,18 +49,19 @@ export function appendJournalContextClear(input: {
ts,
fence: boundary.afterFence,
clear: { operationId: boundary.operationId, messageIds }
})
}),
...commands.rows
]
},
{
write: (db) => {
input.cards.withdrawInTransaction(db, {
messageIds: commandIds,
settledByOp: input.settledByOp
})
commands?.receipt.write(db)
input.receipt.write(db)
},
committed: input.receipt.committed
committed: () => {
commands?.receipt.committed()
input.receipt.committed()
}
}
)
.then((rows) => ({
Expand Down
Loading
Loading