Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
d4981a0
test(native-chat): a Stop over a card sent now into the running turn …
brennanb2025 Sep 30, 2026
943bbe4
fix(native-chat): a Stop's queue pause holds a card whose hand-off is…
brennanb2025 Sep 30, 2026
071e2c0
test(native-chat): the Claude re-send case fails on its diff, inside …
brennanb2025 Sep 30, 2026
7de5fca
test(native-chat): a pause held by an unanswered hand-off ends on eve…
brennanb2025 Sep 30, 2026
c07320f
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Sep 30, 2026
6ce9464
fix(native-chat): narrow the pause's settled hand-off, and assert the…
brennanb2025 Sep 30, 2026
d9e9b97
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Sep 30, 2026
2af16ab
refactor(native-chat): derive the queue's pause from Stop and Resume …
brennanb2025 Sep 30, 2026
438240a
fix(native-chat): a card queued after a Stop sends normally, never ah…
brennanb2025 Sep 30, 2026
6de6b32
fix(native-chat): the queue's own send re-checks the no-overtake rule…
brennanb2025 Sep 30, 2026
340f4a6
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Sep 30, 2026
d8b9e48
fix(native-chat): stop creating the unused queue pause table
brennanb2025 Sep 30, 2026
44e5184
fix(native-chat): a Stop's pause never hides the restart pause
brennanb2025 Sep 30, 2026
a90bd87
test(native-chat): pin the Stop's no-resend, lift and held-card rules
brennanb2025 Sep 30, 2026
fbd56c6
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Sep 30, 2026
99589e9
refactor(native-chat): a Stop writes one Stop event with its reason, …
brennanb2025 Sep 30, 2026
e0087ee
test(native-chat): a rewind keeps a lifted /clear pause lifted and re…
brennanb2025 Sep 30, 2026
e447899
test(native-chat): pin that Stop and Resume rows never reach apps or …
brennanb2025 Sep 30, 2026
60500d8
test(native-chat): only a person's Stop event pauses the queue
brennanb2025 Sep 30, 2026
839f6f4
test(native-chat): pin that a Stop's event precedes the interrupt and…
brennanb2025 Sep 30, 2026
d5b48ef
test(native-chat): a card held at a starting agent is checked before …
brennanb2025 Sep 30, 2026
5689a9d
test(native-chat): a released build keeps and folds a journal holding…
brennanb2025 Sep 30, 2026
8c5523e
style(native-chat): format the Stop event changes
brennanb2025 Sep 30, 2026
5c38ea6
test(native-chat): type the released build's exports through one chec…
brennanb2025 Sep 30, 2026
a12b65a
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Sep 30, 2026
26e9419
fix(native-chat): the Stop/Resume row guard narrows to those tombston…
brennanb2025 Sep 30, 2026
977d500
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Sep 30, 2026
806765b
test(native-chat): run the Stop-event downgrade test in CI, and cover…
brennanb2025 Sep 30, 2026
8ec0fbf
fix(native-chat): a Stop that stops nothing new writes no Stop event
brennanb2025 Sep 30, 2026
722e964
test(native-chat): pin that a later host, eviction or close Stop neve…
brennanb2025 Sep 30, 2026
74adc16
chore(native-chat): put each Stop-row doc on its own declaration, and…
brennanb2025 Sep 30, 2026
4750645
fix(native-chat): any later Stop event ends a person's Stop pause
brennanb2025 Sep 30, 2026
c0d9689
fix(native-chat): a second Stop press is a repeat even when the first…
brennanb2025 Sep 30, 2026
989f90b
fix(native-chat): the queue's pause ignores a Stop or Resume row hold…
brennanb2025 Oct 1, 2026
be2be9f
fix(native-chat): a Stop still reads as yours after Orca restarts bef…
brennanb2025 Oct 1, 2026
935d24a
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Oct 1, 2026
cc32533
refactor(native-chat): a stop no longer carries its cause; the turn's…
brennanb2025 Oct 1, 2026
93e2f55
test(native-chat): a turn a later send opened is no Stop's that named…
brennanb2025 Oct 1, 2026
4be2b8b
test(native-chat): the restart test's death proof carries its detail
brennanb2025 Oct 1, 2026
c98b280
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Oct 1, 2026
8276989
Merge #24072's branch (with #24235) into stop-event part B
brennanb2025 Oct 1, 2026
20852ee
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Oct 1, 2026
d388b9c
refactor(native-chat): a refused Stop leaves no record; a Stop only e…
brennanb2025 Oct 1, 2026
f85a984
fix(native-chat): a Stop pressed before any turn showed stops only th…
brennanb2025 Oct 1, 2026
2ba34a9
fix(native-chat): an older Claude's error end after a Stop pressed be…
brennanb2025 Oct 1, 2026
4fdd9ad
fix(native-chat): a person's Stop and /clear each name why they end t…
brennanb2025 Oct 1, 2026
98ee326
fix(native-chat): a host stop judges whether it ends work after the p…
brennanb2025 Oct 1, 2026
d0e4cfc
fix(native-chat): a Claude Stop naming a turn that just ended still m…
brennanb2025 Oct 1, 2026
771ad35
fix(native-chat): the idle sweep reads working by the same rule as a …
brennanb2025 Oct 1, 2026
a51a1ee
test(native-chat): an aborted eviction's injected drain failure lands…
brennanb2025 Oct 1, 2026
eb27559
fix(native-chat): the idle sweep's rest writes no Stop event; it evic…
brennanb2025 Oct 1, 2026
693583d
fix(native-chat): stopping a start that carries no send writes no Sto…
brennanb2025 Oct 1, 2026
3b8c6e7
test(native-chat): a Stop's pause across a restart is tested with a r…
brennanb2025 Oct 1, 2026
506c4fa
Merge remote-tracking branch 'origin/main' into brennanb2025/queue-st…
brennanb2025 Oct 1, 2026
37ed748
fix(native-chat): a host stop of a turn a person's Stop is still endi…
brennanb2025 Oct 1, 2026
69b4dec
fix(native-chat): an older Claude's error on a later turn keeps its e…
brennanb2025 Oct 1, 2026
ac4a987
Merge remote-tracking branch 'origin/brennanb2025/queue-stop-keeps-st…
brennanb2025 Oct 1, 2026
5ead1f6
fix(native-chat): a Stop of a start that never landed binds no later …
brennanb2025 Oct 1, 2026
aa7f3a7
test(native-chat): a rewind's restated turnless Stop binds no turn op…
brennanb2025 Oct 1, 2026
a5960f7
fix(native-chat): a relaunch settles a person's stopped turn with no …
brennanb2025 Oct 1, 2026
78369ee
test(native-chat): the unexpected-exit settle's journal fake answers …
brennanb2025 Oct 1, 2026
e9c8af8
fix(native-chat): a host stop whose sink drain fails reads the journa…
brennanb2025 Oct 1, 2026
8550aa1
fix(native-chat): a Stop that named no turn applies only to a turn a …
brennanb2025 Oct 1, 2026
ee54861
fix(native-chat): a host stop whose sink drain runs long reads the ag…
brennanb2025 Oct 1, 2026
bd16848
test(native-chat): a host stop with no turn running defers only while…
brennanb2025 Oct 1, 2026
ec0533b
fix(native-chat): a slow sink drain reads working only while an accep…
brennanb2025 Oct 1, 2026
3fba742
test(native-chat): the host-stop control keeps the stopped send unans…
brennanb2025 Oct 1, 2026
6a1f65b
fix(native-chat): a steer is no send owed a turn when a slow drain ju…
brennanb2025 Oct 1, 2026
b10fb50
test(native-chat): type the Stop test envelope's fields narrowly
brennanb2025 Oct 1, 2026
3bf1013
Merge remote-tracking branch 'origin/main' into brennanb2025/stop-eve…
brennanb2025 Oct 1, 2026
fc600d3
Merge remote-tracking branch 'origin/main' into brennanb2025/stop-eve…
brennanb2025 Oct 1, 2026
32c945f
test(native-chat): the retry of a close whose exit was unproven write…
brennanb2025 Oct 1, 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
2 changes: 1 addition & 1 deletion src/main/claude/claude-api-retry-idle-sweep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('a Claude retrying a refused request', () => {
// Once the frames stop, the same clock does let the sweep close it.
clock += STRUCTURED_AGENT_SESSION_IDLE_MS
await vi.waitFor(() => {
expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict')
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
})
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Closing a Claude child settles its open turn in the adapter, with the cause the host handed the
// close. Only a stop the user aimed at this chat reads as their cancellation; an exit the adapter
// saw before the close settles as that exit.
// Closing a Claude child settles its open turn in the adapter as interrupted, with no verdict: the
// close names no cause. Whether the end was a person's is the journal's Stop event to say; an exit
// the adapter saw before the close settles as that exit.

import { describe, expect, it } from 'vitest'
import type {
Expand Down Expand Up @@ -53,33 +53,24 @@ async function childInsideTurn() {
return { adapter, connection, events, turns }
}

describe('a Claude close settles the open turn with the host-named cause', () => {
it("records the user's close of this chat as their cancellation", async () => {
describe('a Claude close settles the open turn with no verdict of its own', () => {
// Whose end it was is the journal's Stop event to say (`turnEndAfterStop`), never the close's.
it('ends it interrupted, with no outcome', async () => {
const { adapter, events, turns } = await childInsideTurn()

await expect(adapter.closeSession('session-1', 'user-close')).resolves.toBe(true)

expect(turns.at(-1)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
expect(events.find((event) => event.type === 'ended')).toMatchObject({
stopCause: 'user-close'
})
})

it('leaves an eviction as news', async () => {
const { adapter, turns } = await childInsideTurn()

await adapter.closeSession('session-1', 'evict')
await expect(adapter.closeSession('session-1')).resolves.toBe(true)

expect(turns.at(-1)).toMatchObject({ state: 'interrupted' })
expect(turns.at(-1)).not.toHaveProperty('outcome')
expect(events.find((event) => event.type === 'ended')).not.toHaveProperty('stopCause')
})

it('leaves a crash it saw before the user closed the chat as news', async () => {
it('settles a crash it saw before the close once, as that exit', async () => {
const { adapter, connection, events, turns } = await childInsideTurn()
// The child dies on its own first; the user's close arrives while that exit is still settling.
// The child dies on its own first; the close arrives while that exit is still settling.
connection.handlers.onExit?.(new Error('provider exited'))

await adapter.closeSession('session-1', 'user-close')
await adapter.closeSession('session-1')
await tick()

const settled = turns.filter((turn) => turn.state !== 'running')
Expand Down
4 changes: 0 additions & 4 deletions src/main/claude/claude-journal-translator-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage'
import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeChildToolQueries } from './claude-child-tool-queries'
import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts'
import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts'
Expand All @@ -15,9 +14,6 @@ export type ClaudeJournalTranslator = {
/** The open turn's provider id — the same id its journal row carries, and the one
* a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */
readonly currentTurnId: string | null
/** Orca is stopping this turn; its error end reads as the user's cancellation when they asked.
* False when the turn is no longer open. */
recordTurnStop: (turnId: string, cause: StructuredAgentSessionStopCause) => boolean
/** The open turn's id while it is a conversation command's. */
readonly commandTurnId: string | null
/** Makes the host's command turn the open one until the command's result ends it. */
Expand Down
5 changes: 4 additions & 1 deletion src/main/claude/claude-message-journaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export function journalClaudeMessage(
startsTurn: boolean,
observedAt: number,
/** Host clock on the submission that produced this send, when known. */
requestedAt?: number
requestedAt?: number,
/** The submission this send echo acknowledged. */
openedBy?: string
): boolean {
const envelope = readClaudeMessageEnvelope(message)
if (!envelope) {
Expand Down Expand Up @@ -176,6 +178,7 @@ export function journalClaudeMessage(
startsTurn,
observedAt,
...(requestedAt === undefined ? {} : { requestedAt }),
...(openedBy === undefined ? {} : { openedBy }),
userItemId: agentJournalItemKey(identity)
})
if (sendEchoTurn) {
Expand Down
22 changes: 5 additions & 17 deletions src/main/claude/claude-open-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
type AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
claudeCurrentTurnIdentity,
claudeTurnLifecycleItem,
Expand All @@ -33,9 +32,6 @@ export type ClaudeOpenTurnDeps = {

export class ClaudeOpenTurn {
private current: ClaudeCurrentTurn | null = null
/** The stop Orca sent, held against the turn it was sent to: it reads only while that turn is open. */
private sentStop: { turn: ClaudeCurrentTurn; cause: StructuredAgentSessionStopCause } | null =
null
/** Provider output may not reopen a turn after the session ended or a turn
* failed: nothing would ever close the turn it opened, and the row would read
* working for the life of the session. Only an accepted send lifts it. */
Expand Down Expand Up @@ -64,6 +60,11 @@ export class ClaudeOpenTurn {
}

/** The open turn's row, where a fact about the running turn lands. */
/** The submission that opened the open turn, when known (`ClaudeCurrentTurn.openedBy`). */
get openedBy(): string | null {
return this.current?.openedBy ?? null
}

get identity(): AgentJournalItemIdentity | null {
return this.current ? claudeCurrentTurnIdentity(this.current) : null
}
Expand All @@ -90,19 +91,6 @@ export class ClaudeOpenTurn {
return this.current !== null
}

get stop(): StructuredAgentSessionStopCause | null {
return this.current && this.sentStop?.turn === this.current ? this.sentStop.cause : null
}

/** Orca is stopping `turnId`. False when that turn is no longer the open one. */
recordStop(turnId: string, cause: StructuredAgentSessionStopCause): boolean {
if (this.current?.turnId !== turnId) {
return false
}
this.sentStop = { turn: this.current, cause }
return true
}

/** Whether a turn is open inside a provider request cycle that has already
* done work — the state in which the CLI folds an arriving send into it. A
* cycle's first send is its opener, never a fold. */
Expand Down
20 changes: 15 additions & 5 deletions src/main/claude/claude-replay-turn-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import { claudeDispatchContentKey } from './claude-structured-dispatch-content'
/** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */
export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void

export type ClaudeReplayTurnOrigin = { requestedAt: number | null }
export type ClaudeReplayTurnOrigin = {
requestedAt: number | null
/** The submission this replay acknowledged, which opens the turn; null for a provider-control turn. */
clientMessageId: string | null
}

export function resolveClaudeReplayTurn(
session: ClaudeSession,
Expand Down Expand Up @@ -89,7 +93,9 @@ export function resolveClaudeReplayTurn(
if (exact) {
const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid)
settleWaiter(session, exact, uuid, onSettledLate)
return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null
return isUserReplay && !foldReceipt
? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId }
: null
}
const retired = session.retiredDispatchWaiters.find(
(candidate) => candidate.sentUuid === userMessageUuid
Expand All @@ -111,7 +117,9 @@ export function resolveClaudeReplayTurn(
if (exact) {
const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid)
settleWaiter(session, exact, uuid, onSettledLate)
return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null
return isUserReplay && !foldReceipt
? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId }
: null
}
const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid)
if (retired) {
Expand All @@ -133,7 +141,7 @@ export function resolveClaudeReplayTurn(
if (compatible.length === 1) {
const [candidate] = compatible
settleWaiter(session, candidate!, uuid, onSettledLate)
return { requestedAt: candidate!.requestedAt }
return { requestedAt: candidate!.requestedAt, clientMessageId: candidate!.clientMessageId }
}
} else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) {
const lateCompatible = session.retiredDispatchWaiters.filter(
Expand Down Expand Up @@ -164,7 +172,9 @@ export function resolveClaudeReplayTurn(
const waiter = uuid ? session.dispatchWaiters.shift() : undefined
if (waiter && uuid) {
settleWaiter(session, waiter, uuid, onSettledLate)
return isUserReplay ? { requestedAt: waiter.requestedAt } : null
return isUserReplay
? { requestedAt: waiter.requestedAt, clientMessageId: waiter.clientMessageId }
: null
}
return null
}
Expand Down
12 changes: 8 additions & 4 deletions src/main/claude/claude-result-journaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,27 @@ export function journalClaudeResult(
}
// Read before the settle below closes it: the result reports that turn's end.
const endedTurnScope = turn.turnScope
// The stop Orca sent that turn: after the user's own, an error end is their cancellation.
const stop = settlesTurn ? turn.stop : null
// Read before the settle below closes the turn: an error end the journal's Stop rule makes a
// person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`).
const turnId = settlesTurn ? turn.id : null
const leftToStop =
turnId !== null &&
sink.journalStopDecidesTurn?.(turnId, observedAt, turn.openedBy ?? undefined) === true
if (settlesTurn) {
prompts.retryPendingCancellations()
turn.suppressReopenOnFailure(message.is_error === true)
// The turn is over however it ended, so a foreground child still
// reported as working will never be settled by an event.
subagents.settleTurn(turn.groupKey)
context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, stop))
context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, leftToStop))
// The turn is over. A block still awaiting its final keeps the text the
// flush above journaled, but its live state goes: an interrupted turn
// would otherwise retain that text for the life of the session.
streamedBlocks.clear()
streamedText.settle()
}
const kind = claudeProviderFrameKind(message)
const failure = claudeResultFailure(message, stop)
const failure = claudeResultFailure(message, leftToStop)
if (failure || !isSettledClaudeResultKind(kind)) {
providerFallback.append(
kind,
Expand Down
17 changes: 8 additions & 9 deletions src/main/claude/claude-result-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,26 @@
// the user's or the provider's.

import type { AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { stopIsTheUsers } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict'
import { claudeText } from './claude-structured-item-translation'

/** The SDK reports the user's stop as an error result, so `is_error` alone cannot
* tell a cancellation from a fault; only these reasons do. */
const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools'])

/** A success-subtype result still carries `is_error` for an API error, so the flag
* is what decides, never the subtype. `stop` is the stop Orca sent for this turn: an error
* end after the user's own is their cancellation, since older CLIs name no reason. */
* is what decides, never the subtype. `leftToStop`: the journal's Stop rule makes this turn's end
* a person's cancellation (`personStopDecidesTurn`), so an error end with no abort reason gives no
* verdict (undefined): older CLIs name no reason, and that rule writes it with the end. */
export function claudeResultOutcome(
message: Record<string, unknown>,
stop: StructuredAgentSessionStopCause | null = null
): AgentJournalTurnOutcome {
leftToStop = false
): AgentJournalTurnOutcome | undefined {
if (message.is_error !== true) {
return 'success'
}
if (stop !== null && stopIsTheUsers(stop)) {
const reason = claudeText(message.terminal_reason)
if (reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason)) {
return 'cancellation'
}
const reason = claudeText(message.terminal_reason)
return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure'
return leftToStop ? undefined : 'failure'
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('Claude child work from captured frame orders', () => {
const connection = run.claude.connections[0]!
connection.exitVerdict = { root: 'exited', tree: 'live' }
connection.close = async () => false
await expect(run.adapter.closeSession('session-1', 'user-stop')).rejects.toMatchObject({
await expect(run.adapter.closeSession('session-1')).rejects.toMatchObject({
name: 'AgentSessionAcquisitionRootExitObservedError'
})
expect(run.records().map(({ membership, outcome }) => ({ membership, outcome }))).toEqual([
Expand Down
1 change: 0 additions & 1 deletion src/main/claude/claude-structured-control-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,6 @@ describe('answerClaudePrompt', () => {
cancel: () => ({ accepted: true })
},
currentTurnId: null,
recordTurnStop: () => true,
commandTurnId: null,
beginCommand: vi.fn(),
forgetCommand: vi.fn(),
Expand Down
10 changes: 2 additions & 8 deletions src/main/claude/claude-structured-control-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-req
import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch'
import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution'
import type { ClaudeSession } from './claude-structured-session-state'
import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter'

const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1'

Expand All @@ -27,19 +26,14 @@ export async function cancelClaudeTurn(
session: ClaudeSession,
timeoutMs: number | undefined,
isCurrent: ClaudeTurnCancellationGuard = () => true,
onDispatchSettledLate?: ClaudeLateDispatchSettlement,
stopped?: { turnId: string; cause: StructuredAgentSessionStopCause }
onDispatchSettledLate?: ClaudeLateDispatchSettlement
): Promise<{ cancelled: boolean }> {
// The SDK interrupt is session-scoped. Re-check the caller's turn/fence
// immediately before issuing it so a delayed request cannot stop a later turn.
if (!isCurrent()) {
return { cancelled: false }
}
const cancelQueued = supportsClaudeQueuedInterruptCancellation(session)
// Recorded before the interrupt goes out, so the result it provokes finds it.
if (stopped) {
session.translator?.recordTurnStop(stopped.turnId, stopped.cause)
}
try {
const receipt = await session.connection.interrupt({
...(cancelQueued ? { cancelQueued: true } : {}),
Expand All @@ -59,7 +53,7 @@ export async function cancelClaudeTurn(
return { cancelled: true }
} catch (error) {
// The CLI refused. Any other error leaves the interrupt's effect unknown. Either way the Stop
// ends the child next, so the stop recorded on the turn stands.
// ends the child next, so its Stop event stands.
if (error instanceof ClaudeControlRequestError) {
return { cancelled: false }
}
Expand Down
11 changes: 7 additions & 4 deletions src/main/claude/claude-structured-dispatch-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function resolveClaudeReplayWaiter(...args: Parameters<typeof resolveClaudeRepla
}

describe('Claude structured dispatch admission', () => {
it('opens queued exact replays with the origin owned by each send', async () => {
it('opens queued exact replays with the origin and the send owned by each send', async () => {
const session = sessionFor()
await dispatchClaudeTurn(session, {
clientMessageId: 'client-a',
Expand All @@ -25,7 +25,8 @@ describe('Claude structured dispatch admission', () => {
})
const aUuid = session.dispatchWaiters[0]!.sentUuid
expect(resolveClaudeReplayTurn(session, userReplayFrame(aUuid, 'a'))).toEqual({
requestedAt: 100
requestedAt: 100,
clientMessageId: 'client-a'
})

await dispatchClaudeTurn(session, {
Expand All @@ -41,10 +42,12 @@ describe('Claude structured dispatch admission', () => {
const [b, c] = session.dispatchWaiters

expect(resolveClaudeReplayTurn(session, userReplayFrame(b!.sentUuid, 'b'))).toEqual({
requestedAt: 200
requestedAt: 200,
clientMessageId: 'client-b'
})
expect(resolveClaudeReplayTurn(session, userReplayFrame(c!.sentUuid, 'c'))).toEqual({
requestedAt: 300
requestedAt: 300,
clientMessageId: 'client-c'
})
})

Expand Down
Loading
Loading