Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5f44ff7
fix(native-chat): drop the queue-paused header and Resume button
brennanb2025 Oct 2, 2026
dc181bc
test(native-chat): type the unknown hold reason a newer host may publish
brennanb2025 Oct 2, 2026
289a345
fix(native-chat): a held card offers Send, not Steer, when no turn runs
brennanb2025 Oct 2, 2026
faaefcc
fix(native-chat): the queue sends past held cards instead of stalling…
brennanb2025 Oct 2, 2026
f91c1ae
fix(native-chat): the queue's send of a card is the person's turn, so…
brennanb2025 Oct 2, 2026
7a28cd5
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-que…
brennanb2025 Oct 4, 2026
4ef532e
fix(native-chat): a queued card carries its author, so the queue's se…
brennanb2025 Oct 4, 2026
0809787
docs(native-chat): state why an unrecorded card author reads as a per…
brennanb2025 Oct 4, 2026
ccbe202
fix(native-chat): a restart holds only cards written before it, and a…
brennanb2025 Oct 4, 2026
bf82077
fix(native-chat): the host publishes which pause holds each queued card
brennanb2025 Oct 4, 2026
71d7c62
test(native-chat): Resume needs the queue capability and is disabled …
brennanb2025 Oct 4, 2026
4c3a31d
docs(native-chat): describe per-card holds in the queue contract and …
brennanb2025 Oct 4, 2026
34d5ba9
fix(native-chat): the composer goes from Resume straight to Stop, and…
brennanb2025 Oct 4, 2026
d6b5c5e
fix(native-chat): the host names the card its queue sends next, so th…
brennanb2025 Oct 4, 2026
5cf6eba
test(native-chat): the queue's next card survives the coalescer, the …
brennanb2025 Oct 4, 2026
31995e4
test(native-chat): build the snapshot that names the next card throug…
brennanb2025 Oct 4, 2026
a78ff91
feat(native-chat): a held queue keeps its header row, and a new messa…
brennanb2025 Oct 4, 2026
8ef6ea4
fix(native-chat): the paused row goes while your own message is on it…
brennanb2025 Oct 4, 2026
dfe1738
fix(native-chat): a "Send message?" choice is taken once, and a faile…
brennanb2025 Oct 4, 2026
9f8f45a
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-que…
brennanb2025 Oct 4, 2026
818ec9a
fix(native-chat): Clear queue's message goes out once, and keeps text…
brennanb2025 Oct 4, 2026
14b3522
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-que…
brennanb2025 Oct 5, 2026
af021db
fix(native-chat): the queue carries on after any turn, in order, and …
brennanb2025 Oct 5, 2026
8252a23
fix(native-chat): after a restart no queue pause shows, and a card wr…
brennanb2025 Oct 5, 2026
7a61051
fix(native-chat): a quit hands no queued card off, and the paused row…
brennanb2025 Oct 5, 2026
d1170f3
refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-w…
brennanb2025 Oct 5, 2026
a9191b0
Merge commit 'a3bd6a6a1e7209c8a870724df3557f0813a4573c' into brennanb…
brennanb2025 Oct 5, 2026
7c561ee
refactor(native-chat): the composer's queue Resume rides the structur…
brennanb2025 Oct 5, 2026
4b50771
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-que…
brennanb2025 Oct 6, 2026
439545e
fix(native-chat): the "Send message?" choice ends with the pause it a…
brennanb2025 Oct 6, 2026
ac624ba
Merge commit 'bfd1e9c579e4f442059bf99951af22b28292787a' into brennanb…
brennanb2025 Oct 6, 2026
533c535
Merge origin/main 670c59d23a1 into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 6, 2026
738270b
Merge origin/main 4de9f9f85a8 into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 6, 2026
7cc68c2
Merge origin/main 5b8a982f8f7 into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 6, 2026
64961aa
Merge origin/main 468e4e1167b into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 6, 2026
954187a
Merge origin/main 83cf7cf5e28 into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 6, 2026
70a37e3
test(mobile): a host-kept card's test stands in a Stop's pause, as th…
brennanb2025 Oct 6, 2026
1c38d18
Merge origin/main c893048aebc into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 6, 2026
93c1c5f
Merge origin/main 1eeb8f3f72c into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 6, 2026
2d7187e
Merge origin/main 86d03ff9085 into brennanb2025/chat-queue-continues-…
brennanb2025 Oct 7, 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: 2 additions & 0 deletions config/tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts",
"../src/renderer/src/components/native-chat/structured-agent-question-projection.ts",
"../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts",
"../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts",
"../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts",
"../src/preload/**/*",
"../src/shared/**/*",
"../src/relay/**/*",
Expand Down
3 changes: 1 addition & 2 deletions mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ describe('MobileNativeChatQueuedMessages', () => {
it('heads the box with why the queue is paused, for each reason', async () => {
const rows: readonly [AgentSessionQueuePause['reason'], string][] = [
['stopped', 'Queue paused because you interrupted'],
['restarted', 'Queue paused because Orca restarted'],
['cleared', 'Queue paused after you cleared the conversation']
]
for (const [reason, label] of rows) {
Expand Down Expand Up @@ -318,7 +317,7 @@ describe('MobileNativeChatQueuedMessages', () => {
it('Resume asks the host to lift the pause once, however fast it is tapped twice', async () => {
let answer: (resumed: boolean) => void = () => undefined
const onResume = vi.fn(() => new Promise<boolean>((resolve) => (answer = resolve)))
const mounted = await mountPaused({ pause: { reason: 'restarted' }, onResume })
const mounted = await mountPaused({ pause: { reason: 'stopped' }, onResume })
// Both taps land in one frame, before the disabled state can render.
await act(async () => {
resumeButton(mounted).props.onPress()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,6 @@ describe('mobileQueuedMessageCards', () => {
expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe(
'Queue paused because you interrupted'
)
expect(mobileQueuePauseLabel({ reason: 'restarted' })).toBe(
'Queue paused because Orca restarted'
)
expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe(
'Queue paused after you cleared the conversation'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ function pausedCaption(reason: string | undefined): string {

const QUEUE_PAUSE_LABELS: Readonly<Record<string, string>> = {
stopped: 'Queue paused because you interrupted',
restarted: 'Queue paused because Orca restarted',
cleared: 'Queue paused after you cleared the conversation'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,8 @@ describe('mobile structured queued messages', () => {
queuedDraft({ messageId: 'behind', position: 2 })
],
[],
{ reason: 'restarted' }
// This host publishes no restart pause; a Stop's stands in for any queue-wide one.
{ reason: 'stopped' }
)
)
)
Expand All @@ -590,7 +591,7 @@ describe('mobile structured queued messages', () => {
{ messageId: 'behind', caption: null }
])
// The kept card is held on its own, so Resume would send the card behind it.
expect(hook!.queued.pause).toEqual({ reason: 'restarted' })
expect(hook!.queued.pause).toEqual({ reason: 'stopped' })
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { AgentJournalAnsweredTurn } from '../../../shared/agent-session-jou
import { journalDispatchRowApplies } from './journal-dispatch-settlement'
import type { JournalReducerState } from './journal-reducer'
import {
notePersonTurnAccepted,
noteTurnAccepted,
placeHandedOverMessage,
placeRejectedMessage
} from './journal-submission-fold'
Expand Down Expand Up @@ -60,7 +60,7 @@ export function applyJournalDispatchRow(
delete submission.recovered
}
if (row.state === 'accepted') {
notePersonTurnAccepted(state, submission)
noteTurnAccepted(state, submission)
}
if (row.state !== 'accepted' || !row.providerItemId) {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export class JournalQueuedMessages {
/** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */
userStopInForce(): JournalQueuePauseMarks['latestStop'] {
const state = this.deps.state()
return journalUserStopInForce(state.queuePauseMarks, state.latestPersonTurnSequence)
return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence)
}

private derivePauses(
Expand All @@ -175,16 +175,16 @@ export class JournalQueuedMessages {
return deriveQueuePauses({
epoch: state.epoch,
marks: state.queuePauseMarks,
latestPersonTurnSequence: state.latestPersonTurnSequence,
latestAcceptedTurnSequence: state.latestAcceptedTurnSequence,
cards,
hostInstance,
restartEnded: this.restartEnded()
})
}

/** A person's turn started since this handle opened, which ends a restart's pause. */
/** A turn started since this handle opened, which ends a restart's pause. */
restartEnded(): boolean {
const latest = this.deps.state().latestPersonTurnSequence
const latest = this.deps.state().latestAcceptedTurnSequence
return latest > 0 && !this.deps.wroteBeforeOpen(latest)
}

Expand Down
8 changes: 4 additions & 4 deletions src/main/native-chat/agent-session-journal/journal-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ export type JournalReducerState = {
appliedSettlementIds: Set<string>
/** Scope for rows stored without one; rebuilt by replay, never persisted. */
derivedTurnScope: JournalDerivedTurnScope
/** The submission row of the latest turn a person asked for (`origin: 'client'`) that the
* provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */
latestPersonTurnSequence: number
/** The submission row of the latest turn the provider accepted, whoever sent it; 0 when none.
* Kept as it folds so the queue's pause reads it in O(1). */
latestAcceptedTurnSequence: number
/** The latest person's Stop event and Resume, what the queue's pause is derived from. */
queuePauseMarks: JournalQueuePauseMarks
}
Expand All @@ -83,7 +83,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou
aliases: new Map(),
appliedSettlementIds: new Set(),
derivedTurnScope: new JournalDerivedTurnScope(),
latestPersonTurnSequence: 0,
latestAcceptedTurnSequence: 0,
queuePauseMarks: createJournalQueuePauseMarks()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
queuePauseRestatement: () =>
journalQueuePauseRestatement(
host.state().queuePauseMarks,
host.state().latestPersonTurnSequence
host.state().latestAcceptedTurnSequence
),
cursor: host.cursor,
adopt: host.adopt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function acceptSubmissionFromProviderItem(
}
submission.fence = row.fence
submission.dispatchState = 'accepted'
notePersonTurnAccepted(state, submission)
noteTurnAccepted(state, submission)
submission.providerItemId = providerItemId
submission.reason = null
submission.resolvedAt = row.ts
Expand All @@ -125,14 +125,14 @@ export function acceptSubmissionFromProviderItem(
})
}

/** A person's turn the provider accepted: the fact the queue's pause is lifted by. */
export function notePersonTurnAccepted(
/** A turn the provider accepted, whoever sent it: the fact the queue's pause is lifted by. */
export function noteTurnAccepted(
state: JournalReducerState,
submission: Pick<AgentJournalSubmission, 'origin' | 'acceptedSequence'>
submission: Pick<AgentJournalSubmission, 'acceptedSequence'>
): void {
if (submission.origin === 'client' && submission.acceptedSequence !== undefined) {
state.latestPersonTurnSequence = Math.max(
state.latestPersonTurnSequence,
if (submission.acceptedSequence !== undefined) {
state.latestAcceptedTurnSequence = Math.max(
state.latestAcceptedTurnSequence,
submission.acceptedSequence
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, a person's
// accepted turn is a row, and a /clear's carried card names its source. Nothing is stored beside
// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, an accepted
// turn is a row, and a /clear's carried card names its source. Nothing is stored beside
// them, so nothing has to retire.

import { mkdtemp, rm } from 'node:fs/promises'
Expand Down Expand Up @@ -68,16 +68,10 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom
})
}

/** A turn sent, then accepted by the provider; `origin` says who asked for it. */
async function turn(
journal: AgentSessionJournal,
id: string,
origin: 'client' | 'host',
accept = true
): Promise<void> {
/** A turn sent, then accepted by the provider. */
async function turn(journal: AgentSessionJournal, id: string, accept = true): Promise<void> {
await journal.appendSubmission({
clientMessageId: id,
origin,
payloadFingerprint: `fp-${id}`,
body: message(id),
fence: 0,
Expand Down Expand Up @@ -181,24 +175,24 @@ describe("the queue's pause, derived from the journal", () => {
expect(pauseTables()).toBe(0)
})

it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => {
it("any turn sent after the Stop and accepted lifts it, Orca's own mail included", async () => {
const journal = await open()
await turn(journal, 'before-stop', 'client', false)
await turn(journal, 'before-stop', false)
await userStop(journal)
// Sent before the Stop: its acceptance now does not end a Stop that came after it.
await acceptTurn(journal, 'before-stop')
await turn(journal, 'mail', 'host')
expect(reason(journal)).toBe('stopped')
await turn(journal, 'typed', 'client', false)
// Orchestration mail: sent, not yet accepted, lifts nothing; accepted, it lifts the pause.
await turn(journal, 'mail', false)
expect(reason(journal)).toBe('stopped')
await acceptTurn(journal, 'typed')
await acceptTurn(journal, 'mail')
expect(reason(journal)).toBeNull()
})

it('a later Stop is the latest, and a Resume row lifts it', async () => {
const journal = await open()
await userStop(journal)
await turn(journal, 'typed', 'client')
await turn(journal, 'typed')
expect(reason(journal)).toBeNull()
await userStop(journal)
expect(reason(journal)).toBe('stopped')
Expand All @@ -221,18 +215,18 @@ describe("the queue's pause, derived from the journal", () => {
expect(pauseTables()).toBe(0)
})

it("a person's accepted turn on the replacement lifts 'cleared'; a host turn does not", async () => {
it("any accepted turn on the replacement lifts 'cleared', a launch prompt Orca sent included", async () => {
const journal = await open()
await queueDraft(journal, 'carried', 'source-session')
await turn(journal, 'launch', 'host')
await turn(journal, 'launch', false)
expect(reason(journal)).toBe('cleared')
await turn(journal, 'typed', 'client')
await acceptTurn(journal, 'launch')
expect(reason(journal)).toBeNull()
})

it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => {
const journal = await open()
await turn(journal, 'typed', 'client')
await turn(journal, 'typed')
await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 0)
const db = new Database(journalDatabasePath(root), { readonly: true })
const stored = liveTestJournalRows(db, IDENTITY.sessionId)
Expand Down Expand Up @@ -304,7 +298,6 @@ describe("the queue's pause, derived from the journal", () => {
journal.appendSubmission(
{
clientMessageId: id,
origin: automatic ? 'host' : 'client',
payloadFingerprint: 'fp-draft-1',
body: message('draft-1'),
fence: 0,
Expand All @@ -325,12 +318,12 @@ describe("the queue's pause, derived from the journal", () => {
expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched')
})

it("a rewind's epoch replacement restates a Stop still pausing, and not one a person ended", async () => {
it("a rewind's epoch replacement restates a Stop still pausing, and not one a turn ended", async () => {
const journal = await open()
await userStop(journal)
await journal.replaceEpochItems('handle_forked', 0, [])
expect(reason(journal)).toBe('stopped')
await turn(journal, 'typed', 'client')
await turn(journal, 'typed')
await journal.replaceEpochItems('handle_forked', 0, [])
expect(reason(journal)).toBeNull()
})
Expand Down Expand Up @@ -380,7 +373,7 @@ describe("the queue's pause, derived from the journal", () => {
})

it.each([
["a person's turn", (journal: AgentSessionJournal) => turn(journal, 'typed', 'client')],
['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'typed')],
['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)]
])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => {
const journal = await open()
Expand All @@ -394,7 +387,7 @@ describe("the queue's pause, derived from the journal", () => {
it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => {
const journal = await open()
await queueDraft(journal, 'carried', 'source-session')
await turn(journal, 'typed', 'client')
await turn(journal, 'typed')
await userStop(journal)
await journal.replaceEpochItems('handle_forked', 0, [])
expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped'])
Expand Down Expand Up @@ -427,7 +420,6 @@ describe('which cards a pause holds', () => {
await journal.appendSubmission(
{
clientMessageId: 'send-now-1',
origin: 'client',
payloadFingerprint: 'fp-steered',
body: message('steered'),
fence: 0,
Expand Down Expand Up @@ -458,7 +450,6 @@ describe('which cards a pause holds', () => {
journal.appendSubmission(
{
clientMessageId: 'drain-newer',
origin: 'host',
payloadFingerprint: 'fp-newer',
body: message('newer'),
fence: 0,
Expand Down Expand Up @@ -555,15 +546,15 @@ describe('which cards the pauses in force hold', () => {
return { messageId, ...base, queuedAt, ...fields }
}

/** A Stop at sequence 5 unless `stopped` is 0; no person's turn or Resume since. */
/** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */
function pausesOver(cards: readonly Card[], stopped = 5) {
return deriveQueuePauses({
epoch: 'epoch-1',
marks: {
latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null,
resumedSequence: 0
},
latestPersonTurnSequence: 0,
latestAcceptedTurnSequence: 0,
cards,
hostInstance: HOST,
restartEnded: false
Expand Down
Loading
Loading