Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
165 commits
Select commit Hold shift + click to select a range
c867bd2
refactor(native-chat): remove the unused terminal handoff
brennanb2025 Sep 25, 2026
eab4ef1
fix(native-chat): never let the pre-stop snapshot hold a chat's stop
brennanb2025 Sep 25, 2026
abafbbc
refactor(native-chat): drop helpers only the terminal handoff called
brennanb2025 Sep 25, 2026
3cbf6b5
docs(native-chat): stop citing the removed handoff in lifecycle comments
brennanb2025 Sep 25, 2026
e048579
test(native-chat): type the stalled snapshot drain without a cast
brennanb2025 Sep 25, 2026
b50c574
test(native-chat): pin that a start dead before proving owes no settl…
brennanb2025 Sep 25, 2026
12efdef
fix(native-chat): keep the owner-status read behind an in-flight attach
brennanb2025 Sep 25, 2026
42d10ee
refactor(terminal): remove the agent-session PTY write gate
brennanb2025 Sep 25, 2026
0483915
refactor(native-chat): drop the transcript helpers only the handoff c…
brennanb2025 Sep 25, 2026
d0f4dae
fix(native-chat): stop calling a starting chat "mid-handoff"
brennanb2025 Sep 25, 2026
faecece
test(native-chat): type the stand-in roster decoder without a cast
brennanb2025 Sep 25, 2026
250250e
refactor(codex): name the pinned rollout lookup for what it does
brennanb2025 Sep 25, 2026
b588185
refactor(native-chat): type the owner-status reply as the host sends it
brennanb2025 Sep 25, 2026
35742fd
refactor(native-chat): normalize terminal-handoff lease values once a…
brennanb2025 Sep 25, 2026
54ce5fc
refactor(native-chat): stop threading the owner kind through a reserv…
brennanb2025 Sep 25, 2026
713b126
test(native-chat): pin the legacy-lease rewrite with a transaction th…
brennanb2025 Sep 25, 2026
44d7977
fix(native-chat): name a chat write by its target, not the owner gene…
brennanb2025 Sep 25, 2026
6dad39a
fix(native-chat): every journal append reaches the chats that are open
brennanb2025 Sep 25, 2026
97bf3bb
test(native-chat): an epoch replacement reaches the open chat
brennanb2025 Sep 25, 2026
427dc8d
test(native-chat): each row reaches an open chat once, and a live han…
brennanb2025 Sep 25, 2026
2bd44f5
test(native-chat): give the legacy-lease store test a tab id so the b…
brennanb2025 Sep 25, 2026
f79eb9b
test(worktree-activation): restore the OMP surfaced-agent resume test
brennanb2025 Sep 25, 2026
50d2e33
perf(native-chat): a publish behind a delivered commit reads nothing
brennanb2025 Sep 25, 2026
bd72b13
test(native-chat): state why the teardown test's fake journal is safe…
brennanb2025 Sep 25, 2026
fa9b76a
docs(native-chat): say mutation admission checks only the writer lease
brennanb2025 Sep 25, 2026
5435973
docs(native-chat): drop the send rebase from comments that still desc…
brennanb2025 Sep 25, 2026
8fd4558
Merge #22811 into the PR 1 base
brennanb2025 Sep 25, 2026
12dde37
Merge #22812 into the PR 1 base
brennanb2025 Sep 25, 2026
c753e8a
fix(native-chat): a message is accepted, then delivered
brennanb2025 Sep 25, 2026
82f9bfd
fix(native-chat): settle queued messages only for the child that ended
brennanb2025 Sep 25, 2026
f3e52d2
fix(native-chat): an adoption that fails to import keeps the conversa…
brennanb2025 Sep 25, 2026
29a340b
perf(native-chat): the recovering open reads the journal once
brennanb2025 Sep 25, 2026
c7a652f
fix(native-chat): an attach that fails after indexing its child leave…
brennanb2025 Sep 25, 2026
34d0bb0
fix(native-chat): a withdrawn message shows no error, and a rejection…
brennanb2025 Sep 25, 2026
46b6472
fix(orchestration): a structured worker whose agent outlasts the prea…
brennanb2025 Sep 25, 2026
283819d
fix(native-chat): a message rejected while its chat was closed reads …
brennanb2025 Sep 25, 2026
40dabe8
test(orchestration): name why the readiness settlement fakes are cast
brennanb2025 Sep 25, 2026
31444bf
fix(native-chat): keep each pane's own fence on frames so a failed re…
brennanb2025 Sep 25, 2026
494d8c7
docs(native-chat): drop the fence from the admission the send effects…
brennanb2025 Sep 25, 2026
0e74529
docs(native-chat): give the fence move on release the reason that sti…
brennanb2025 Sep 25, 2026
6c16f35
docs(native-chat): stop citing a write fence check in launch and mail…
brennanb2025 Sep 25, 2026
d2f646b
refactor(native-chat): the provider child is its own record
brennanb2025 Sep 25, 2026
25fa38a
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-wri…
brennanb2025 Sep 25, 2026
aa56b50
fix(native-chat): the delivery loop alone settles a message its start…
brennanb2025 Sep 25, 2026
d23cca4
Merge #22812 and main into PR 1
brennanb2025 Sep 25, 2026
4993696
refactor(native-chat): a stopped child ends on the one reading of its…
brennanb2025 Sep 25, 2026
a2ad8f2
feat(native-chat): the host says it accepts a send before any agent h…
brennanb2025 Sep 25, 2026
48def96
refactor(native-chat): an attach never opens a journal of its own
brennanb2025 Sep 25, 2026
da65d57
fix(native-chat): a moved fence resends nothing on a host that accept…
brennanb2025 Sep 25, 2026
0184a3d
refactor(native-chat): a child's end says whether the user or the hos…
brennanb2025 Sep 25, 2026
12577d8
fix(native-chat): a chat whose only work is a queued message is not o…
brennanb2025 Sep 25, 2026
a59eed9
test(native-chat): type the queued-message fixtures in the resume-off…
brennanb2025 Sep 25, 2026
ccaf412
fix(native-chat): a start that dies while a message waits on it is th…
brennanb2025 Sep 25, 2026
0efca93
docs(native-chat): say what an attach's open conversation and unconfi…
brennanb2025 Sep 25, 2026
74d6506
test(native-chat): pin what a failed start settles, and what a resume…
brennanb2025 Sep 25, 2026
9c671f1
test(native-chat): the failed-start pins fail on what the message bec…
brennanb2025 Sep 25, 2026
80ccb33
test(orchestration): the preamble's host stub is typed, not cast
brennanb2025 Sep 25, 2026
227e135
fix(native-chat): a Stop that names no turn stops what the conversati…
brennanb2025 Sep 26, 2026
6fdeb89
fix(native-chat): Stop is there from the moment a message is sent
brennanb2025 Sep 26, 2026
6ceaa52
fix(native-chat): Stop before a turn is gated on its own host capability
brennanb2025 Sep 26, 2026
9de2655
test(native-chat): a build advertises conversation stop exactly where…
brennanb2025 Sep 26, 2026
e6dadd1
Merge main into PR 1
brennanb2025 Sep 26, 2026
6374f35
Merge main into PR 1
brennanb2025 Sep 27, 2026
724b961
Merge main into PR 1
brennanb2025 Sep 27, 2026
c39151d
fix(native-chat): a view never restarts a chat whose last start failed
brennanb2025 Sep 27, 2026
9613178
test(native-chat): start the child the loop waits on with an attach, …
brennanb2025 Sep 27, 2026
0224459
fix(native-chat): settle a gone generation's turn wherever a conversa…
brennanb2025 Sep 27, 2026
2276cef
test(native-chat): prove the next child's start settles the turn an e…
brennanb2025 Sep 27, 2026
9275869
test(native-chat): count a failed start's rows by row, not by text
brennanb2025 Sep 27, 2026
158ca0a
test(native-chat): give the failed-start and stale-turn waits a loade…
brennanb2025 Sep 27, 2026
8f66a25
test(native-chat): pin the open's and the send's start and row counts…
brennanb2025 Sep 27, 2026
ad04c3b
fix(native-chat): settle a gone generation's turn at every open but a…
brennanb2025 Sep 27, 2026
05b8b93
test(native-chat): hold the create's start open until the views bind
brennanb2025 Sep 27, 2026
0477775
Merge PR 1 into S
brennanb2025 Sep 27, 2026
d5716f3
Merge main into PR 1
brennanb2025 Sep 27, 2026
c263d83
fix(native-chat): Stop reads the one working rule every session list …
brennanb2025 Sep 27, 2026
ad5bfa3
test(native-chat): a rate-limit retry pins only that no turn opens, n…
brennanb2025 Sep 27, 2026
f13f6cc
Merge PR 1 into S
brennanb2025 Sep 27, 2026
d5b3a4f
fix(native-chat): Stop leaves a message waiting on its Retry, and doe…
brennanb2025 Sep 27, 2026
5f1f71f
test(native-chat): the chat's Stop and a session list read the main a…
brennanb2025 Sep 27, 2026
ccde2c9
refactor(mobile): the chat reads the main agent's working state throu…
brennanb2025 Sep 27, 2026
f75fe04
fix(codex): a Stop naming no turn never interrupts an earlier turn
brennanb2025 Sep 27, 2026
db36d0a
fix(native-chat): a Stop naming no turn never says a turn had already…
brennanb2025 Sep 27, 2026
740ed1f
Merge main into PR 1
brennanb2025 Sep 28, 2026
8276562
Merge PR 1 into S
brennanb2025 Sep 28, 2026
27a3398
fix(native-chat): one Stop the host could not settle no longer refuse…
brennanb2025 Sep 28, 2026
7002b1b
refactor(native-chat): drop the composer's second error formatter
brennanb2025 Sep 28, 2026
cb5ce82
test(native-chat): pin the reason on a message rejected while its cha…
brennanb2025 Sep 28, 2026
ed6ab4b
Merge PR 1 into S
brennanb2025 Sep 28, 2026
a322c8a
test(native-chat): read Stop operation ids without a cast
brennanb2025 Sep 28, 2026
20402c8
fix(native-chat): a Stop whose answer was lost no longer swallows the…
brennanb2025 Sep 28, 2026
c6e5e65
refactor(native-chat): a Stop naming no target keeps its operation id…
brennanb2025 Sep 28, 2026
cc20a86
test(native-chat): read the Stop fences without a cast
brennanb2025 Sep 28, 2026
61f99b8
test(native-chat): pin the new id for a named cancel the host could n…
brennanb2025 Sep 28, 2026
cf106f1
fix(native-chat): a Stop pressed after a new message stops it, even w…
brennanb2025 Sep 28, 2026
f2e5b04
fix(native-chat): Stop no longer blinks off as Claude opens the turn …
brennanb2025 Sep 28, 2026
04c0885
Merge main into S
brennanb2025 Sep 28, 2026
e207887
fix(native-chat): a message a Stop withdrew comes back to its sender'…
brennanb2025 Sep 28, 2026
1431167
fix(native-chat): withdrawn text put back during an IME composition i…
brennanb2025 Sep 28, 2026
2b49021
test(native-chat): pin that only a withdrawn message comes back to th…
brennanb2025 Sep 28, 2026
c4ddc1f
test(native-chat): set up the composer's window API for every describ…
brennanb2025 Sep 28, 2026
badb424
docs(native-chat): note that the withdrawn check reads the legacy rea…
brennanb2025 Sep 28, 2026
2489cdd
test(native-chat): pin that text put back mid-composition shows once,…
brennanb2025 Sep 28, 2026
1f89467
Merge main into S
brennanb2025 Sep 28, 2026
ba56ccd
feat(native-chat): host-owned queued-message draft store in the sessi…
brennanb2025 Sep 28, 2026
7f7290a
feat(native-chat): queued-messages wire contract, dark capability, an…
brennanb2025 Sep 28, 2026
76f9037
feat(native-chat): queue a capable mid-turn send as a draft, drain it…
brennanb2025 Sep 28, 2026
77aea97
test(native-chat): pin mid-turn queueing against the real host
brennanb2025 Sep 28, 2026
fdc3a48
test(native-chat): read the queued receipt ids before the wait closures
brennanb2025 Sep 28, 2026
6932694
chore(native-chat): SAFETY rationales on the sqlite row casts and a c…
brennanb2025 Sep 28, 2026
0c8c7c8
fix(native-chat): queued-draft bookkeeping never costs a publish, an …
brennanb2025 Sep 28, 2026
45f091d
fix(native-chat): a Stop that withdraws a consumed draft's send gives…
brennanb2025 Sep 28, 2026
3bb4a69
perf(native-chat): the queued-draft drain takes no serialized step wh…
brennanb2025 Sep 28, 2026
f916070
fix(native-chat): a clear withdraws queued text only for a caller tha…
brennanb2025 Sep 28, 2026
3eb7abb
fix(native-chat): a draft pause that commits no journal row still rea…
brennanb2025 Sep 28, 2026
fbbaec3
fix(native-chat): a failed clear wakes the queued drain, a failed Sto…
brennanb2025 Sep 28, 2026
c33375c
refactor(native-chat): the draft store notifies through the journal's…
brennanb2025 Sep 28, 2026
cedee2c
fix(native-chat): a Stop with no drafts writes nothing, and a failed …
brennanb2025 Sep 28, 2026
ddeb509
fix(native-chat): a capable Stop with no drafts writes nothing
brennanb2025 Sep 28, 2026
6e8023f
refactor(native-chat): Stop and /clear never withdraw queued drafts; …
brennanb2025 Sep 28, 2026
3a58616
feat(native-chat): a restart hold lifts like a Stop's, and paused car…
brennanb2025 Sep 28, 2026
6414f7d
fix(native-chat): only a client's own send lifts a Stop's queue pause
brennanb2025 Sep 28, 2026
b8c3d64
fix(native-chat): a Stop's queue pause lifts when the user's send sta…
brennanb2025 Sep 28, 2026
5d2a3c5
fix(native-chat): a consumed draft's turn starting lifts a Stop's que…
brennanb2025 Sep 28, 2026
f740887
fix(native-chat): queue a capable send behind a /compact and re-scope…
brennanb2025 Sep 28, 2026
8a6f0d7
Merge origin/main (with #23026 squash-merged) into mid-turn-queue-host
brennanb2025 Sep 29, 2026
a7d6903
fix(native-chat): a returned queued card carries the typed rejection …
brennanb2025 Sep 29, 2026
0598a54
fix(native-chat): fit the queue to main's typed rejections and compac…
brennanb2025 Sep 29, 2026
5527f12
fix(native-chat): draft bookkeeping can never roll back the journal r…
brennanb2025 Sep 29, 2026
5b234d4
fix(native-chat): a draft a Stop or restart took back waits again ins…
brennanb2025 Sep 29, 2026
e2b11bd
fix(native-chat): Delete and Send on a queued card answer at once dur…
brennanb2025 Sep 29, 2026
fd8ad79
fix(native-chat): a re-sent returned card drops the refusal it came b…
brennanb2025 Sep 29, 2026
5d077cb
perf(native-chat): the queue gate reads pending prompts without rende…
brennanb2025 Sep 29, 2026
5868371
fix(native-chat): a Stop that fails leaves the queued cards as it fou…
brennanb2025 Sep 29, 2026
348a8c9
docs(native-chat): confirmed cancellation is no longer a queue rollou…
brennanb2025 Sep 29, 2026
9ee2441
docs(native-chat): the Claude fold receipt and turn-owner bars have l…
brennanb2025 Sep 29, 2026
3a92694
fix(native-chat): Send on a queued card during a /compact is refused …
brennanb2025 Sep 29, 2026
4f8deb3
fix(native-chat): a Stop that fails after reaching the agent keeps th…
brennanb2025 Sep 29, 2026
0b1fa80
fix(native-chat): a skipped draft settlement heals on the next drain …
brennanb2025 Sep 29, 2026
b9229ac
fix(native-chat): one id is never recorded as a submission twice
brennanb2025 Sep 29, 2026
a9b2d39
fix(native-chat): a waiting draft whose first send the agent echoed i…
brennanb2025 Sep 29, 2026
3316172
feat(native-chat): a submission names the queued draft it hands off
brennanb2025 Sep 29, 2026
f2efb10
refactor(native-chat): every queued draft goes out under a fresh subm…
brennanb2025 Sep 29, 2026
88e4398
fix(native-chat): an echo withdraws a draft only if its rejected hand…
brennanb2025 Sep 29, 2026
8441381
fix(native-chat): a skipped echo withdrawal is re-derived before the …
brennanb2025 Sep 29, 2026
b05dcaf
fix(native-chat): a rolled-back journal append leaves no draft state …
brennanb2025 Sep 29, 2026
0aa2b6d
fix(native-chat): a replay of a deleted queued card answers withdrawn…
brennanb2025 Sep 29, 2026
2737b2b
Merge remote-tracking branch 'origin/main' into mid-turn-queue-host
brennanb2025 Sep 29, 2026
63456cf
refactor(native-chat): name the queue's pause-lift for what it releases
brennanb2025 Sep 29, 2026
0b2a926
chore(native-chat): one import of the mutation helpers
brennanb2025 Sep 29, 2026
aca86ec
feat(native-chat): a Stop pauses the whole queue, derived from the jo…
brennanb2025 Sep 29, 2026
900bb7e
feat(native-chat): a /clear's replacement queue reads paused because …
brennanb2025 Sep 29, 2026
386d3d0
fix(native-chat): a queue pause covers only the cards it paused
brennanb2025 Sep 29, 2026
432e7da
perf(native-chat): the queue's pause reads the latest person's turn i…
brennanb2025 Sep 29, 2026
d9ca984
fix(native-chat): a card handed off after a restart belongs to the pr…
brennanb2025 Sep 29, 2026
98ed5a7
fix(native-chat): a queue pause shows only while Resume would send so…
brennanb2025 Sep 29, 2026
00007c9
fix(native-chat): a queue pause counts only cards Resume would actual…
brennanb2025 Sep 29, 2026
7d4b0c3
fix(native-chat): a returned card that blocks the paused cards hides …
brennanb2025 Sep 29, 2026
248333f
fix(native-chat): a Stop pauses a card its withdrawal sent back even …
brennanb2025 Sep 29, 2026
ce5b984
test(native-chat): pin the per-row hook's pause retirement; skip the …
brennanb2025 Sep 29, 2026
465a501
Merge origin/main (#23059 and 12 more) into mid-turn-queue-host
brennanb2025 Sep 29, 2026
5e16277
Merge remote-tracking branch 'origin/main' into mid-turn-queue-host
brennanb2025 Sep 29, 2026
026e09b
test(native-chat): match main's append and dispatch shapes in the que…
brennanb2025 Sep 29, 2026
7f7df31
fix(native-chat): read a compaction's settled submission through the …
brennanb2025 Sep 29, 2026
4da7df5
Merge remote-tracking branch 'origin/main' into mid-turn-queue-host
brennanb2025 Sep 29, 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
23 changes: 23 additions & 0 deletions mobile/src/session/mobile-structured-send-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,29 @@ describe('mobileStructuredSendDelivery', () => {
}
})

it('classifies a queued draft answer as accepted and spends its id, replays included', () => {
// The host holds the message now; a later identical send is a new message.
for (const state of ['waiting', 'dispatched', 'returned', 'withdrawn'] as const) {
const queued: StructuredAgentSessionMutationCallResult<AgentSessionSendResult> = {
status: 'accepted',
value: {
clientMessageId: 'client-1',
queued: { messageId: 'client-1', position: 1, state }
}
}
expect(mobileStructuredSendDelivery(queued)).toEqual({
outcome: 'accepted',
operationIdSpent: true,
error: null
})
expect(mobileStructuredSendDelivery(queued, true)).toEqual({
outcome: 'accepted',
operationIdSpent: true,
error: null
})
}
})

it('does not report a retained payload replay as a new accepted send', () => {
for (const dispatchState of ['accepted', 'pending'] as const) {
expect(mobileStructuredSendDelivery(accepted(dispatchState), true)).toEqual({
Expand Down
10 changes: 9 additions & 1 deletion mobile/src/session/mobile-structured-send-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
// the retry has to stay a replay. Rotating here is what sent one message to a
// model five times.

import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire'
import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry'
import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition'
Expand Down Expand Up @@ -60,7 +61,14 @@ export function mobileStructuredSendDelivery(
error: result.message
}
}
const submission = result.value.submission as AgentSessionSendResult['submission'] | undefined
if ('queued' in result.value && result.value.queued) {
// The host holds (or already settled) the draft: the send is spent — a
// later identical message is a new message. A withdrawn replay is spent
// too, never unknown: its card was deleted or carried by a /clear.
return { outcome: 'accepted', operationIdSpent: true, error: null }
}
const submission: AgentJournalSubmission | undefined =
'submission' in result.value ? result.value.submission : undefined
if (!submission || submission.dispatchState === 'unknown') {
return { outcome: 'unknown', operationIdSpent: false, error: null }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Database from '../../sqlite/sync-database'
import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files'
import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
import { ensureQueuedMessagesTable } from './queued-message-schema'

export const JOURNAL_BUSY_TIMEOUT_MS = 5000

Expand Down Expand Up @@ -37,6 +38,10 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase {
try {
configureJournalPragmas(probe)
createJournalSchema(probe, stored)
// Outside `createJournalSchema` on purpose: its early return skips a db
// already at the current version, and this table must exist at EVERY
// writable open with no `user_version` bump (see `ensureQueuedMessagesTable`).
ensureQueuedMessagesTable(probe)
hardenSqliteDatabaseFiles(dbPath)
const opened = { db: probe, readOnly: false }
transferred = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,25 @@ import {
type UnreadAgentSessionFailureFact
} from '../../../shared/agent-session-failure'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { journalDispatchRowApplies } from './journal-dispatch-settlement'
import type { JournalReducerState } from './journal-reducer'
import { placeHandedOverMessage } from './journal-submission-fold'
import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold'
import type { JournalRow } from './journal-row-schema'

export function applyJournalDispatchRow(
state: JournalReducerState,
row: Extract<JournalRow, { kind: 'dispatch' }>
): void {
const submission = state.submissions.get(row.clientMessageId)
if (!submission) {
return
}
// `rejected` is terminal; a late `unknown` must not reopen a settled answer.
if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') {
// Shared with the queued-draft returned hook: a row ignored here must not alter a draft.
if (!submission || !journalDispatchRowApplies(submission)) {
return
}
submission.fence = row.fence
submission.dispatchState = row.state
submission.providerItemId = row.providerItemId
submission.reason = row.reason
// Read where it can be placed; a kind it cannot place is kept as written, so the classifier
// still knows a fact was there without this build claiming what it says.
const rejection =
row.state === 'rejected'
? (readAgentSessionFailureFact(row.rejection) ?? unreadFailureFact(row.rejection))
: undefined
const rejection = row.state === 'rejected' ? readStoredRejectionFact(row.rejection) : undefined
if (rejection) {
submission.rejection = rejection
} else {
Expand All @@ -47,6 +40,9 @@ export function applyJournalDispatchRow(
} else {
delete submission.recovered
}
if (row.state === 'accepted') {
notePersonTurnAccepted(state, submission)
}
if (row.state !== 'accepted' || !row.providerItemId) {
return
}
Expand All @@ -59,6 +55,13 @@ export function applyJournalDispatchRow(
})
}

/** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as
* written, so the classifier still knows a fact was there without this build claiming what it
* says. Shared with the queued-draft table, whose returned card mirrors its submission. */
export function readStoredRejectionFact(value: unknown): UnreadAgentSessionFailureFact | undefined {
return readAgentSessionFailureFact(value) ?? unreadFailureFact(value)
}

function unreadFailureFact(value: unknown): UnreadAgentSessionFailureFact | undefined {
return typeof value === 'object' &&
value !== null &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
agentSessionFailureFact,
type SubmissionRejectionKind
} from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
import {
DISPATCH_REJECTED_CANCELLED,
DISPATCH_REJECTED_HOST_RESTARTED
} from '../../../shared/structured-agent-session-dispatch-rejection'
import { rejectedDraftSettlement } from './journal-dispatch-settlement'

function settle(kind: SubmissionRejectionKind) {
return rejectedDraftSettlement(
agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' })
)
}

describe('what a rejection does to the draft it was consumed from', () => {
it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => {
expect(settle('cancelled')).toEqual({ state: 'waiting' })
expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({
state: 'waiting'
})
})

it('a restart or close before hand-over sends it back to waiting too', () => {
for (const kind of ['hostRestarted', 'chatClosed', 'notDelivered'] as const) {
expect(settle(kind)).toEqual({ state: 'waiting' })
}
expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({
state: 'waiting'
})
})

it('a failure returns the card for the user to act on', () => {
for (const kind of [
'providerRejected',
'providerExited',
'hostStopped',
'startFailed',
'writeFailed',
'queueFull'
] as const) {
expect(settle(kind)).toEqual({ state: 'returned' })
}
expect(rejectedDraftSettlement({ reason: 'the provider said no' })).toEqual({
state: 'returned'
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// The one decision for whether a committed dispatch row changes a submission's
// effective delivery answer, and what a rejection does to the draft it was
// consumed from. The reducer folds rows through the first and the queued
// draft's settlement hook fires through it, so the two can never disagree: a
// row the reducer ignores must not alter a draft.

import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection'
import type { JournalDispatchRow } from './journal-row-schema'

/** `rejected` and `accepted` are terminal; a late row for an absent or settled
* submission must not reopen the answer. */
export function journalDispatchRowApplies(
submission: Pick<AgentJournalSubmission, 'dispatchState'> | undefined
): boolean {
return (
submission !== undefined &&
submission.dispatchState !== 'rejected' &&
submission.dispatchState !== 'accepted'
)
}

/** A consumed draft's submission settled `rejected`: the draft is settled by
* `rejectedDraftSettlement`, since its text has no other holder once it left
* the sender's outbox as a draft. */
export function consumedSubmissionWasRejected(
submission: Pick<AgentJournalSubmission, 'dispatchState'> | undefined
): boolean {
return submission?.dispatchState === 'rejected'
}

/** What a consumed draft becomes when its submission is rejected. */
export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' }

/**
* Where no one failed the user — a Stop withdrew it, or a restart or close
* interrupted it before hand-over — the draft goes back to waiting at its own
* position, under whatever pauses the queue: the Stop's own pause, or the
* restart's, derived from the host instance. A returned card would block the
* drafts behind it on a failure that never happened. A failure returns the
* card with its refusal for the user to act on.
*/
export function rejectedDraftSettlement(
rejection: Pick<AgentJournalSubmission, 'reason'> & { rejection?: unknown }
): RejectedDraftSettlement {
return classifyDispatchRejection(rejection).verdict === null
? { state: 'waiting' }
: { state: 'returned' }
}

/** True when committing this row NEWLY settles the submission to `rejected` —
* the only transition that settles a consumed draft. */
export function journalDispatchRowNewlyRejects(
submission: Pick<AgentJournalSubmission, 'dispatchState'> | undefined,
row: Pick<JournalDispatchRow, 'state'>
): boolean {
return row.state === 'rejected' && journalDispatchRowApplies(submission)
}
Loading
Loading