diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index dec55369344..00af8a1e178 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -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 = { + 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({ diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index b8d0246f5e7..6c90158a54f 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -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' @@ -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 } } diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 6f1cd60733b..e1c4da54a08 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -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 @@ -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 diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 2af2623e862..89722959799 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -6,8 +6,9 @@ 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( @@ -15,23 +16,15 @@ export function applyJournalDispatchRow( row: Extract ): 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 { @@ -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 } @@ -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 && diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts new file mode 100644 index 00000000000..7f9711151fc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -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' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts new file mode 100644 index 00000000000..7e1d6949bf3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -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 | 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 | 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 & { 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 | undefined, + row: Pick +): boolean { + return row.state === 'rejected' && journalDispatchRowApplies(submission) +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts new file mode 100644 index 00000000000..3fc8c373788 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -0,0 +1,369 @@ +// The journal's draft-store collaborator: every read and write of one session's +// `queued_messages` rows, serialized on the same queue as the journal's own +// appends so a draft mutation can never interleave with the consume that +// converts it. Drafts are NEVER owed work: nothing here feeds the reducer, +// working status, teardown, or the idle sweep. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../../shared/agent-session-host-authority' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import type { JournalSubmissionConsume } from './journal-store-contracts' +import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' +import { + clearQueuePause, + queuePauseHoldsBack, + readQueuePause, + recordQueuePause, + retireQueuePauseIfNothingHeld, + type QueuePauseFact, + type QueuePauseReason +} from './queued-message-pause-table' +import { + consumeQueuedMessageInTransaction, + getQueuedMessage, + insertQueuedMessage, + listQueuedMessages, + queuedMessagesSettledByOp, + withdrawQueuedMessages, + type QueuedMessageHoldReason, + type QueuedMessageRow +} from './queued-message-table' +import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' +import { + owedBackToWaiting, + queuedMessageSettlementOwed, + settleOwedQueuedMessages, + settleQueuedMessagesForRow +} from './queued-message-settlement' +import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' + +/** Tombstones must outlive the window in which their operation id could still be admitted as new. */ +export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + +export type JournalQueuedMessagesDeps = { + sessionId: string + now: () => number + serialize: (run: () => Promise) => Promise + database: () => { db: Database.Database } + readOnly: () => boolean + state: () => JournalReducerState + /** The journal's own commit notification. Every standalone draft-table + * transaction that changed rows fires it after COMMIT, so a draft or hold + * change publishes and wakes the drain through the same path a journal row + * does — no call site can forget. In-transaction consume and the returned + * transition already ride their row's own commit. */ + committed: () => void +} + +export class JournalQueuedMessages { + /** Bumped on every draft-table write, so publication memos recompute only when they must. */ + private changeRevision = 0 + private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null + private paused: { revision: number; fact: QueuePauseFact | null } | null = null + + constructor(private readonly deps: JournalQueuedMessagesDeps) {} + + revision(): number { + return this.changeRevision + } + + /** The submission row of the latest accepted turn a person asked for; 0 when none. What + * ends the queue's pause, read from the reducer in O(1). */ + latestPersonTurnSequence(): number { + return this.deps.state().latestPersonTurnSequence + } + + /** A journal transaction rolled back: nothing read inside it may stay cached. */ + invalidate(): void { + this.changeRevision++ + } + + /** Cached per revision: the drain re-checks on every journal publish, so an + * unchanged table must cost no SQL read or body parse on token streams. */ + list(): readonly QueuedMessageRow[] { + if (this.listed?.revision !== this.changeRevision) { + this.listed = { + revision: this.changeRevision, + rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId) + } + } + return this.listed.rows + } + + get(messageId: string): QueuedMessageRow | null { + return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId) + } + + /** Replay receipts for one caller-scoped operation key. */ + receipts(settledByOp: string): QueuedMessageRow[] { + return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) + } + + /** `pausedBy`: the queue is paused in the SAME transaction as this card lands + * (a /clear's carry), so the drain never sees it unpaused and no pause fact + * exists without a card under it. */ + insert(input: { + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + pausedBy?: QueuePauseReason + }): Promise { + const { pausedBy, ...draft } = input + const { sessionId } = this.deps + let inserted = false + return this.transact( + (db) => { + const existing = getQueuedMessage(db, sessionId, draft.messageId) + if (existing) { + // One id, one draft: admission replays a recorded operation before it + // gets here, so an existing row is the same accept landing twice. + return existing + } + inserted = true + const row = insertQueuedMessage(db, { ...draft, sessionId, now: this.deps.now() }) + if (pausedBy) { + recordQueuePause(db, { sessionId, fact: this.pauseFact(pausedBy) }) + } + return row + }, + () => inserted + ) + } + + /** Hold one waiting draft whose conversion failed. Stored on the row, so it + * survives handle eviction and restart; withdraw and consume clear it in their + * own UPDATE. */ + hold(input: { messageIds: readonly string[]; reason: QueuedMessageHoldReason }): Promise { + return this.transact( + (db) => holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }), + (held) => held > 0 + ).then(() => undefined) + } + + /** Where the user's last Stop took effect, if it is still recorded; cached per revision. */ + pause(): QueuePauseFact | null { + if (this.paused?.revision !== this.changeRevision) { + this.paused = { + revision: this.changeRevision, + fact: readQueuePause(this.deps.database().db, this.deps.sessionId) + } + } + return this.paused.fact + } + + /** A Stop took effect here: the queue is paused from this position on — if, judged in + * the same transaction, it holds back a card at all. Returns whether it recorded. */ + recordPause(reason: QueuePauseReason): Promise { + const fact = this.pauseFact(reason) + return this.transact( + (db) => + queuePauseHoldsBack(db, this.pauseScope()) && + (recordQueuePause(db, { sessionId: this.deps.sessionId, fact }), true), + (recorded) => recorded + ) + } + + /** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */ + private pauseScope() { + return { + sessionId: this.deps.sessionId, + owedToWaiting: owedBackToWaiting(this.deps.state().submissions) + } + } + + private pauseFact(reason: QueuePauseReason): QueuePauseFact { + const { epoch, lastSequence: sequence } = this.deps.state() + return { reason, epoch, sequence, recordedAt: this.deps.now() } + } + + /** Ends the queue's pause: `stop` retires that Stop fact (never a later one), + * `adoptInto` adopts a restart's rows into this host instance. Returns whether + * anything changed. */ + liftPause(input: { stop: QueuePauseFact | null; adoptInto: string | null }): Promise { + const { sessionId } = this.deps + return this.transact( + (db) => + (input.stop ? clearQueuePause(db, { sessionId, fact: input.stop }) : 0) + + (input.adoptInto === null + ? 0 + : adoptQueuedMessages(db, { sessionId, hostInstance: input.adoptInto })), + (changed) => changed > 0 + ).then((changed) => changed > 0) + } + + /** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones, + * kept only so a replay of the settling operation answers "spent". */ + withdraw(input: { + messageIds: readonly string[] + settledByOp: string + }): Promise { + if (input.messageIds.length === 0) { + // Delete races and empty carries land here; neither may cost a write transaction. + return Promise.resolve([]) + } + return this.transact( + (db) => + withdrawQueuedMessages(db, { + ...input, + sessionId: this.deps.sessionId, + now: this.deps.now() + }), + (withdrawn) => withdrawn.length > 0 + ) + } + + /** One standalone draft-table transaction on the journal's queue; one that + * changed rows bumps the revision and notifies after COMMIT. */ + private transact( + run: (db: Database.Database) => T, + changed: (result: T) => boolean + ): Promise { + return this.deps.serialize(async () => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const { db } = this.deps.database() + db.exec('BEGIN IMMEDIATE') + let result: T + let retired: number + try { + result = run(db) + // Any draft write may take the last card a pause holds back. + retired = retireQueuePauseIfNothingHeld(db, this.pauseScope()) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } + if (changed(result) || retired > 0) { + this.changeRevision++ + this.deps.committed() + } + return result + }) + } + + /** The standing writer hook, within the append's transaction + * (`settleQueuedMessagesForRow`). */ + onRowInTransaction(db: Database.Database, row: JournalRow): void { + this.changeRevision += settleQueuedMessagesForRow(db, { + sessionId: this.deps.sessionId, + state: this.deps.state(), + drafts: () => this.list(), + row, + now: this.deps.now() + }) + this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope()) + } + + /** The in-transaction consume for `appendSubmission`; a false compare-and-set + * throws so the whole append — draft transition AND submission row — rolls back. */ + consumeInTransaction( + db: Database.Database, + input: JournalSubmissionConsume & { consumedAs: string } + ): void { + const { db: own } = this.deps.database() + if (own !== db) { + // Same handle only: a second connection could not join the transaction. + throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') + } + const consumed = consumeQueuedMessageInTransaction(db, { + ...input, + sessionId: this.deps.sessionId, + now: this.deps.now() + }) + if (!consumed) { + throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + } + retireQueuePauseIfNothingHeld(db, this.pauseScope()) + this.changeRevision++ + } + + /** A skipped live settlement the journal already decided (`queued-message-settlement.ts`). */ + settlementOwed(): boolean { + return queuedMessageSettlementOwed(this.list(), this.deps.state().submissions) + } + + /** Waiting drafts a skipped echo hook left unwithdrawn; reads every item, so only the drain + * step asks, right before a draft would send. */ + deliveredByEchoOwed(): boolean { + return draftsDeliveredByAppliedEcho(this.deps.state(), this.list()).length > 0 + } + + /** Applies owed settlements now, so a skipped live transition heals without a reopen. */ + settleOwed(): Promise { + return this.transact( + (db) => + settleOwedQueuedMessages(db, { + sessionId: this.deps.sessionId, + state: this.deps.state(), + now: this.deps.now() + }), + (settled) => settled > 0 + ).then(() => undefined) + } + + /** Bookkeeping at open: a failure is reported and retried at the next open, + * never allowed to fail opening the chat. */ + repairAndPruneAtOpen(): Promise { + return this.repairAndPrune().catch((error: unknown) => { + console.warn('[journal-open] queued-message repair skipped:', { + sessionId: this.deps.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + }) + } + + /** + * Open-time reconciliation, a re-derivation behind the stored fact: owed + * settlements apply exactly as the live hook would have (covers consume → + * crash → downgrade → upgrade, where the old build rejected the leftover with + * no hook), then retention runs; a pause left holding back nothing retires. + */ + repairAndPrune(): Promise { + if (this.deps.readOnly()) { + return Promise.resolve() + } + const { sessionId } = this.deps + return this.transact( + (db) => { + const [now, state] = [this.deps.now(), this.deps.state()] + return ( + settleOwedQueuedMessages(db, { sessionId, state, now }) + + pruneQueuedMessages(db, { + sessionId, + now, + replayWindowMs: QUEUED_MESSAGE_REPLAY_WINDOW_MS, + submissionVerdict: retainedSubmissionVerdict(state.submissions) + }) + ) + }, + (changed) => changed > 0 + ).then(() => undefined) + } +} + +/** The per-append hook converting one draft inside the append's own transaction. */ +export function queuedMessageConsumeHook( + queuedMessages: JournalQueuedMessages, + consumedAs: string, + consume: JournalSubmissionConsume +): (db: Database.Database) => void { + return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) +} + +export class QueuedMessageNotConsumableError extends Error { + constructor( + readonly messageId: string, + readonly expected: 'waiting' | 'returned' + ) { + super(`queued message ${messageId} is no longer ${expected}`) + this.name = 'QueuedMessageNotConsumableError' + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 2b83f7665b5..ed12c4a6cba 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -54,6 +54,9 @@ export type JournalReducerState = { appliedSettlementIds: Set /** 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 } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -71,7 +74,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou receipts: new Map(), aliases: new Map(), appliedSettlementIds: new Set(), - derivedTurnScope: new JournalDerivedTurnScope() + derivedTurnScope: new JournalDerivedTurnScope(), + latestPersonTurnSequence: 0 } } @@ -154,16 +158,38 @@ export function resolveJournalItemId( if (aliased) { return aliased } - const identity = parseAgentJournalItemKey(itemId) - if ( - !body || - body.kind !== 'message' || - body.role !== 'user' || - !identity || - identity.provider === 'orca' - ) { + const submissionId = journalEchoClaimant(state, itemId, body) + if (!submissionId) { return itemId } + state.aliases.set(itemId, submissionId) + return submissionId +} + +/** A user message the provider wrote: the only item a submission's echo can be. */ +export function isProviderUserMessageEcho( + itemId: string, + body: AgentJournalRenderItem['body'] +): boolean { + const identity = parseAgentJournalItemKey(itemId) + return ( + body.kind === 'message' && + body.role === 'user' && + identity !== null && + identity.provider !== 'orca' + ) +} + +/** The submission item a provider's echo of a user message would fold into, read without + * claiming it; null when the item is not such an echo, or no submission may claim it. */ +export function journalEchoClaimant( + state: JournalReducerState, + itemId: string, + body?: AgentJournalRenderItem['body'] +): string | null { + if (!body || !isProviderUserMessageEcho(itemId, body)) { + return null + } const fingerprint = structuredAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: state.sessionId, @@ -184,12 +210,7 @@ export function resolveJournalItemId( candidate.payloadFingerprint === fingerprint && state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 ) - if (!submission) { - return itemId - } - const submissionId = agentJournalSubmissionKey(submission.clientMessageId) - state.aliases.set(itemId, submissionId) - return submissionId + return submission ? agentJournalSubmissionKey(submission.clientMessageId) : null } function resolveItemId(state: JournalReducerState, itemId: string): string { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 9a1869bfe03..46d41ecb7cf 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -27,6 +27,7 @@ import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS } from './journal-row-schema' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { assertSubmissionIdUnused } from './journal-write-guards' import type { ResolveDispatchInput } from './journal-store-contracts' type RowBuilder = (seq: number, ts: number) => T @@ -68,10 +69,28 @@ export function journalSubmissionRowBuilder( body: AgentJournalMessageItem fence: number handoverRecorded?: true - } + queuedMessageId?: string + origin?: 'client' | 'host' + }, + /** Present when the append hands off a queued draft: the row names that draft, stamped here + * from the consume itself so no hand-off path can leave the link off. */ + consume?: { messageId: string } ): RowBuilder { - return (seq, ts) => - buildJournalSubmissionRow({ state: state(), providerHandle, ...input, seq, ts }) + return (seq, ts) => { + assertSubmissionIdUnused(state().submissions, input.clientMessageId) + if (consume && (input.queuedMessageId ?? consume.messageId) !== consume.messageId) { + throw new Error(`submission ${input.clientMessageId} names a draft it does not consume`) + } + const queuedMessageId = consume?.messageId ?? input.queuedMessageId + return buildJournalSubmissionRow({ + state: state(), + providerHandle, + ...input, + ...(queuedMessageId !== undefined ? { queuedMessageId } : {}), + seq, + ts + }) + } } export function journalDispatchRowBuilder( @@ -279,6 +298,8 @@ export function buildJournalSubmissionRow(input: { fence: number ts: number handoverRecorded?: true + queuedMessageId?: string + origin?: 'client' | 'host' }): JournalSubmissionRow { return { kind: 'submission', @@ -287,6 +308,8 @@ export function buildJournalSubmissionRow(input: { providerHandle: input.providerHandle, body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), - ...(input.handoverRecorded ? { handoverRecorded: true } : {}) + ...(input.handoverRecorded ? { handoverRecorded: true } : {}), + ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), + ...(input.origin !== undefined ? { origin: input.origin } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 0a4c6317ebc..1c7ea36eaef 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -86,8 +86,18 @@ export type JournalSubmissionRow = JournalRowBase & { /** Accepted to be handed over by a later `dispatch{pending}` row; absent on rows whose writer * dispatched in the same step. Older readers keep the key and ignore it. */ handoverRecorded?: true + /** The queued draft this submission hands off; absent for a direct send. Older readers keep + * the key and ignore it. */ + queuedMessageId?: string + /** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or + * a queued card they sent now); `host` for Orca's own — orchestration mail, a restart + * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it + * was recorded. Older readers keep the key and ignore it. */ + origin?: JournalSubmissionOrigin } +export type JournalSubmissionOrigin = 'client' | 'host' + export type JournalDispatchRow = JournalRowBase & { kind: 'dispatch' clientMessageId: string diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 85ff7da7a3f..b45a841d26e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -1,8 +1,14 @@ import type Database from '../../sqlite/sync-database' import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' +/** Runs between BEGIN IMMEDIATE and COMMIT, on the SAME connection as the row + * insert; a throw rolls the whole append back. Synchronous by construction so + * nothing can interleave inside the transaction. */ +export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void + export type JournalRowWriterDeps = { sessionId: string now: () => number @@ -12,12 +18,23 @@ export type JournalRowWriterDeps = { highestFence: () => number nextSequence: () => number commit: (row: JournalRow) => void + /** Standing hook run for EVERY appended row — the queued-draft returned + * transition rides here so no rejection path can bypass it. Bookkeeping: it + * runs in its own savepoint, so its failure is reported and never vetoes the row. */ + inTransaction?: JournalRowTransactionHook + /** After any rollback, so a cache filled inside the transaction cannot outlive it. */ + rolledBack?: () => void } +const BOOKKEEPING_SAVEPOINT = 'journal_row_bookkeeping' + export class JournalRowWriter { constructor(private readonly deps: JournalRowWriterDeps) {} - enqueue(build: (seq: number, ts: number) => JournalRow): Promise { + enqueue( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): Promise { return this.deps.serialize(async () => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const row = build(this.deps.nextSequence(), this.deps.now()) @@ -27,9 +44,12 @@ export class JournalRowWriter { try { insertJournalRow(db, this.deps.sessionId, row) upsertJournalSessionRow(db, this.deps.sessionId, row.epoch, row.ts) + hook?.(db, row) + this.runBookkeeping(db, row) db.exec('COMMIT') } catch (error) { db.exec('ROLLBACK') + this.deps.rolledBack?.() throw error } // COMMIT landed, so the row is durable: adopt it before anything that can @@ -39,4 +59,36 @@ export class JournalRowWriter { return row }) } + + /** Assign the next sequence, make the row durable, and fold it through the SAME reducer + * replay uses — all inside one serialized step — answering where the row landed. */ + append( + build: (seq: number, ts: number) => JournalRow, + hook?: JournalRowTransactionHook + ): Promise { + return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + } + + private runBookkeeping(db: Database.Database, row: JournalRow): void { + const hook = this.deps.inTransaction + if (!hook) { + return + } + db.exec(`SAVEPOINT ${BOOKKEEPING_SAVEPOINT}`) + try { + hook(db, row) + db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) + } catch (error) { + db.exec(`ROLLBACK TO ${BOOKKEEPING_SAVEPOINT}`) + db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) + this.deps.rolledBack?.() + // The draft store re-derives what this missed from the committed rows: at open, and in + // the drain step before a draft sends. + console.warn('[journal-append] row bookkeeping skipped:', { + sessionId: this.deps.sessionId, + kind: row.kind, + error: error instanceof Error ? error.message : String(error) + }) + } + } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index a4ff455b31f..42d167914ce 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -12,6 +12,7 @@ import { JournalEpochController } from './journal-epoch-controller' import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' +import { JournalQueuedMessages } from './journal-queued-messages' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' @@ -19,6 +20,10 @@ import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' export type JournalStoreHost = { + /** Fires the journal's commit listener for a durable change that appended no + * row — a standalone draft-table transaction — so readers learn of it the + * same way they learn of a row. */ + notifyCommitted: () => void identity: AgentSessionJournalIdentity journalDir: string now: () => number @@ -44,6 +49,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + queuedMessages: JournalQueuedMessages /** Restores the store's state from disk. Owned here because it needs the same * collaborators the constructor just built. */ restore: () => Promise @@ -62,9 +68,24 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal cursor: host.cursor, adopt: host.adopt }) + const queuedMessages = new JournalQueuedMessages({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + state: host.state, + committed: host.notifyCommitted + }) return { epochController, - restore: () => restoreJournalStore(host, { epochController }), + queuedMessages, + // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows + // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. + restore: () => + restoreJournalStore(host, { epochController }).then(() => + queuedMessages.repairAndPruneAtOpen() + ), rowWriter: new JournalRowWriter({ sessionId: host.identity.sessionId, now: host.now, @@ -73,7 +94,11 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, highestFence: () => host.state().highestFence, nextSequence: () => host.state().lastSequence + 1, - commit: host.commit + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() }), itemAppender: new JournalItemAppender({ state: host.state, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 8785be0dc8c..dcb6a4bd5d1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -70,6 +70,21 @@ export type JournalSubmissionInput = { fence: number /** The send is accepted now and handed over later, by a `dispatch{pending}` row. */ handoverRecorded?: true + /** Stamped by `appendSubmission` from its consume; a caller-passed value must match it. */ + queuedMessageId?: string + /** Who asked for this turn (`JournalSubmissionRow.origin`). */ + origin?: 'client' | 'host' +} + +/** A submission append that converts a queued draft, in one transaction. */ +export type JournalSubmissionConsume = { + messageId: string + expect: 'waiting' | 'returned' + /** The operation ledger's caller-scoped key; null for the host's own drain. */ + settledByOp: string | null + /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to + * waiting belongs to the process that sent it, not the one that first wrote the card. */ + hostInstance?: string } export type JournalItemAppendInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 99ec29b34ec..1f6015c3940 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -52,11 +52,13 @@ import type { JournalItemAppendOptions, JournalLifecycleBatchInput, JournalReadSince, + JournalSubmissionConsume, JournalSubmissionInput, JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' +import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { AgentJournalEpochReason } from './journal-row-schema' import { AgentSessionJournalError } from './journal-write-guards' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' @@ -89,6 +91,8 @@ export class AgentSessionJournal { private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender private readonly restore: () => Promise + /** Draft rows queued while the agent works; never reducer input or owed work. */ + readonly queuedMessages: JournalQueuedMessages constructor(options: AgentSessionJournalOptions) { this.identity = options.identity @@ -125,18 +129,20 @@ export class AgentSessionJournal { applyJournalRow(this.state, row) this.onCommitted?.() }, + notifyCommitted: () => this.onCommitted?.(), loaded: () => this.loaded, malformedRows: () => this.malformedRows, setMalformedRows: (count) => { this.malformedRows = count }, journal: () => this, - enqueue: (build) => this.enqueue(build) + enqueue: (build) => this.rowWriter.enqueue(build) }) this.rowWriter = collaborators.rowWriter this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.queuedMessages = collaborators.queuedMessages this.restore = collaborators.restore } @@ -245,6 +251,8 @@ export class AgentSessionJournal { submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] + submission = (clientMessageId: string) => this.state.submissions.get(clientMessageId) + pendingSubmissions = (): AgentJournalSubmission[] => this.submissions().filter((entry) => entry.dispatchState === 'pending') @@ -289,8 +297,8 @@ export class AgentSessionJournal { options: JournalTombstoneInput ): Promise { const itemId = agentJournalItemKey(identity) - return this.enqueue(journalTombstoneRowBuilder(() => this.state, itemId, options.fence)).then( - (row) => ({ epoch: row.epoch, sequence: row.seq }) + return this.rowWriter.append( + journalTombstoneRowBuilder(() => this.state, itemId, options.fence) ) } @@ -303,10 +311,16 @@ export class AgentSessionJournal { * anything, and it doubles as the optimistic user bubble so an accepted echo * reconciles into an existing slot instead of appending a second copy. */ - appendSubmission(input: JournalSubmissionInput): Promise { - return this.enqueue( - journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input) - ).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + appendSubmission( + input: JournalSubmissionInput, + /** Present: this submission is a queued draft's conversion, and the draft's + * state transition commits in the SAME transaction — exactly-once consume. */ + consume?: JournalSubmissionConsume + ): Promise { + return this.rowWriter.append( + journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + ) } /** @@ -317,10 +331,7 @@ export class AgentSessionJournal { * string here would silently give the user a second copy of their own message. */ resolveDispatch(input: ResolveDispatchInput): Promise { - return this.enqueue(journalDispatchRowBuilder(() => this.state, input)).then((row) => ({ - epoch: row.epoch, - sequence: row.seq - })) + return this.rowWriter.append(journalDispatchRowBuilder(() => this.state, input)) } /** Retire unanswered sends after their execution owner ended, without assuming delivery. */ @@ -372,13 +383,4 @@ export class AgentSessionJournal { } return this.database } - - /** - * Assign the next sequence, make the row durable, and fold it through the - * SAME reducer replay uses — all inside one serialized step, so concurrent - * callers cannot interleave and mint the same sequence. - */ - private enqueue(build: (seq: number, ts: number) => JournalRow): Promise { - return this.rowWriter.enqueue(build) - } } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index fa5283d48e6..2c9c6cfcd04 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -10,6 +10,7 @@ import { journalRenderItem } from './journal-render-item' import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' +import { journalDispatchRowApplies } from './journal-dispatch-settlement' export function applyJournalSubmission( state: JournalReducerState, @@ -24,7 +25,12 @@ export function applyJournalSubmission( reason: null, submittedAt: row.ts, resolvedAt: null, - ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), + // A malformed stored link is dropped, never the row. + ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 + ? { queuedMessageId: row.queuedMessageId } + : {}), + ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. @@ -75,15 +81,12 @@ export function acceptSubmissionFromProviderItem( const submission = [...state.submissions.values()].find( (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId ) - if ( - !submission || - submission.dispatchState === 'accepted' || - submission.dispatchState === 'rejected' - ) { + if (!submission || !journalDispatchRowApplies(submission)) { return } submission.fence = row.fence submission.dispatchState = 'accepted' + notePersonTurnAccepted(state, submission) submission.providerItemId = providerItemId submission.reason = null submission.resolvedAt = row.ts @@ -95,3 +98,16 @@ export function acceptSubmissionFromProviderItem( acceptedAt: row.ts }) } + +/** A person's turn the provider accepted: the fact the queue's pause is lifted by. */ +export function notePersonTurnAccepted( + state: JournalReducerState, + submission: Pick +): void { + if (submission.origin === 'client' && submission.acceptedSequence !== undefined) { + state.latestPersonTurnSequence = Math.max( + state.latestPersonTurnSequence, + submission.acceptedSequence + ) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts new file mode 100644 index 00000000000..0d62ed1b232 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -0,0 +1,154 @@ +// A submission that hands off a queued draft names that draft (`queuedMessageId`), +// persisted on its journal row and published on the submission; a direct send +// names none. Clients read the link, never a draft id compared with a submission id. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-schemas' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, applyJournalRow } from './journal-reducer' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function handOff(journal: AgentSessionJournal, draftId: string, submissionId: string) { + await journal.queuedMessages.insert({ + messageId: draftId, + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await journal.appendSubmission( + { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, + { messageId: draftId, expect: 'waiting', settledByOp: null } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-link-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('the submission names the queued draft it hands off', () => { + it('on every hand-off, and never on a direct send', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.appendSubmission({ + clientMessageId: 'direct-1', + payloadFingerprint: 'fp-direct', + body: BODY, + fence: 0 + }) + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect(journal.submission('direct-1')).not.toHaveProperty('queuedMessageId') + }) + + it('survives a reload, which replays the rows through the reducer', async () => { + let journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.close() + journal = await open() + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect( + journal.snapshot().submissions.find((entry) => entry.clientMessageId === 'handoff-1') + ).toMatchObject({ queuedMessageId: 'draft-1' }) + }) + + it('refuses a caller naming a different draft than the one it consumes, and writes nothing', async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await expect( + journal.appendSubmission( + { + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + body: BODY, + fence: 0, + queuedMessageId: 'draft-2' + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('is published: the wire schema keeps the field rather than stripping it', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + const published = AgentJournalSubmissionSchema.parse(journal.submission('handoff-1')) + expect(published.queuedMessageId).toBe('draft-1') + }) +}) + +describe('the persisted row', () => { + const row: JournalRow = { + v: 1, + kind: 'submission', + epoch: 'epoch-1', + seq: 1, + fence: 0, + ts: 1, + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + providerHandle: IDENTITY.providerHandle, + body: BODY, + queuedMessageId: 'draft-1' + } + + it('parses with the key, which a reader that does not know it simply keeps', () => { + const parsed = parseJournalRow(serializeJournalRow(row)) + expect(parsed).toMatchObject({ ok: true, row: { queuedMessageId: 'draft-1' } }) + }) + + it('keeps a row whose stored link is malformed, dropping only the link', () => { + const parsed = parseJournalRow(JSON.stringify({ ...row, queuedMessageId: 42 })) + if (!parsed.ok) { + throw new Error('the row must survive a malformed link') + } + const state = createJournalReducerState('session-q', 'epoch-1') + applyJournalRow(state, parsed.row) + expect(state.submissions.get('handoff-1')).not.toHaveProperty('queuedMessageId') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts index e1869ae0c73..eeab1671649 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -5,7 +5,11 @@ export class AgentSessionJournalError extends Error { constructor( - readonly code: 'journal_read_only' | 'journal_stale_fence' | 'journal_closed', + readonly code: + | 'journal_read_only' + | 'journal_stale_fence' + | 'journal_closed' + | 'journal_submission_exists', message: string ) { super(message) @@ -34,3 +38,17 @@ export function assertJournalFence(fence: number, highestFence: number): void { ) } } + +/** One id, one delivery: a second submission row under an id would reset its + * settled answer to pending and hand the message over again. */ +export function assertSubmissionIdUnused( + submissions: ReadonlyMap, + clientMessageId: string +): void { + if (submissions.has(clientMessageId)) { + throw new AgentSessionJournalError( + 'journal_submission_exists', + `a submission ${clientMessageId} is already recorded` + ) + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts new file mode 100644 index 00000000000..75b2e1ff65d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -0,0 +1,213 @@ +// Draft bookkeeping never vetoes a journal row: a failing draft transition +// rolls back alone and the next open re-derives it, and a draft table an +// earlier build created gains the columns this build writes. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabaseFile } from './journal-paths' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const REFUSAL = agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } + }), + { surface: 'rejection' } +) + +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function queueAndConsume(journal: AgentSessionJournal, messageId: string): Promise { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] + } + await journal.queuedMessages.insert({ + messageId, + body, + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + await journal.appendSubmission( + { + clientMessageId: `sub-${messageId}`, + payloadFingerprint: `fp-${messageId}`, + body, + fence: 0, + handoverRecorded: true + }, + { messageId, expect: 'waiting', settledByOp: null } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-bookkeeping-')) + clock = 1_000 +}) + +afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('draft bookkeeping inside a journal append', () => { + it('a throwing draft transition still commits the rejection row, and the next open recovers the draft', async () => { + let journal = await open() + await queueAndConsume(journal, 'draft-1') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementationOnce(() => { + throw new Error('table queued_messages has no column named returned_rejection') + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + // The journal's own answer stands: Stop, failed starts and refusals depend on it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + expect(warn).toHaveBeenCalledWith( + '[journal-append] row bookkeeping skipped:', + expect.objectContaining({ kind: 'dispatch' }) + ) + await journal.close() + journal = await open() + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedReason: REFUSAL.reason, + returnedRejection: { kind: 'providerRejected' } + }) + }) + + it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => { + const first = await open() + await first.close() + const db = new Database(journalDatabaseFile(root)) + db.exec('DROP TABLE queued_messages') + // The shape an earlier build of the draft table wrote: no returned_rejection. + db.exec(`CREATE TABLE queued_messages ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, position INTEGER NOT NULL, + body_json TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at INTEGER NOT NULL, + host_instance TEXT NOT NULL, state TEXT NOT NULL, hold_reason TEXT, + returned_reason TEXT, settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, + PRIMARY KEY (session_id, message_id))`) + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + await queueAndConsume(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedRejection: { kind: 'providerRejected' } + }) + }) + + describe('a failed COMMIT', () => { + /** The append's own COMMIT fails once, after its hooks ran. */ + function failNextCommit() { + const exec = Database.prototype.exec + let armed = true + return vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database, + sql: string + ) { + if (armed && sql === 'COMMIT') { + armed = false + throw new Error('SQLITE_FULL') + } + return exec.call(this, sql) + }) + } + + async function consumeFailingCommit(journal: AgentSessionJournal): Promise { + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp-draft-1', + hostInstance: 'proc-1' + }) + expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) + const commit = failNextCommit() + try { + await expect( + journal.appendSubmission( + { clientMessageId: 'sub-draft-1', payloadFingerprint: 'fp', body: BODY, fence: 0 }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow('SQLITE_FULL') + } finally { + commit.mockRestore() + } + } + + it('leaves no uncommitted draft state cached: the per-row hook reads drafts only for an echo', async () => { + const journal = await open() + const list = vi.spyOn(JournalQueuedMessages.prototype, 'list') + await consumeFailingCommit(journal) + // Once by the test itself before the append; never inside it. + expect(list).toHaveBeenCalledTimes(1) + list.mockRestore() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + + it('invalidates what any read inside the rolled-back transaction cached', async () => { + const journal = await open() + // Some other bookkeeping reads the list inside the transaction, after the consume wrote. + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation(function ( + this: JournalQueuedMessages + ) { + this.list() + }) + await consumeFailingCommit(journal) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts new file mode 100644 index 00000000000..376d3145a9d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -0,0 +1,206 @@ +// A draft sent back to waiting after a handed-over hand-off was rejected as +// never delivered is withdrawn when the provider echoes that message: the +// first send reached the agent, so sending it again would repeat it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent-session-queued-messages' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' +}) + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function echo(journal: AgentSessionJournal, uuid: string, text: string) { + return journal.appendItem({ provider: 'claude', sessionId: 'native-1', uuid }, message(text), { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) +} + +/** A draft consumed and handed to the agent, then rejected as never delivered (the provider + * confirmed a Stop withdrew it): back to waiting. `handedOver: false` rejects it before + * hand-over instead, which proves it was never written. */ +async function withdrawnDraft( + text: string, + options: { handedOver: boolean } = { handedOver: true } +): Promise { + const journal = await open() + await handOffAndReject(journal, text, options) + return journal +} + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +async function handOffAndReject( + journal: AgentSessionJournal, + text: string, + options: { handedOver: boolean } = { handedOver: true } +): Promise { + const body = message(text) + const fingerprint = queuedMessageFingerprint(IDENTITY.sessionId, body) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint, + hostInstance: 'p' + }) + await journal.appendSubmission( + { + clientMessageId: 'sub-draft-1', + payloadFingerprint: fingerprint, + body, + fence: 0, + handoverRecorded: true + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + if (options.handedOver) { + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'pending', + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...STOP_WITHDRAWAL, + fence: 0 + }) + } else { + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-echo-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("a waiting draft whose 'never delivered' claim an echo disproves", () => { + it('is withdrawn when the provider echoes the message it was withdrawn from', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + settledByOp: null + }) + // The rejection stays terminal: the echo is kept apart, not folded into it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2) + }) + + it('stays waiting when the rejected hand-off never reached the agent: the echo is some other message', async () => { + const journal = await withdrawnDraft('did it land?', { handedOver: false }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('retires the pause in the per-row hook when that echo withdraws the last card it holds back', async () => { + const journal = await withdrawnDraft('did it land?') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it('stays waiting for an echo of some other text', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'something else') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('stays waiting when a live send of the same text claims the echo', async () => { + const journal = await withdrawnDraft('did it land?') + const body = message('did it land?') + await journal.appendSubmission({ + clientMessageId: 'typed-again', + payloadFingerprint: queuedMessageFingerprint(IDENTITY.sessionId, body), + body, + fence: 0, + handoverRecorded: true + }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.submission('typed-again')?.dispatchState).toBe('accepted') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + describe('when the per-row hook was skipped', () => { + it('the re-derivation withdraws it from the echo already in the journal, and at reopen', async () => { + let journal = await withdrawnDraft('did it land?') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await echo(journal, 'echo-1', 'did it land?') + } finally { + hook.mockRestore() + warn.mockRestore() + } + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(true) + // The drain's heal, before the draft could send again. + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('an unclaimed echo from before the hand-off proves nothing about it', async () => { + const journal = await open() + await echo(journal, 'typed-in-the-agent', 'did it land?') + await handOffAndReject(journal, 'did it land?') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts new file mode 100644 index 00000000000..f111b49037f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts @@ -0,0 +1,128 @@ +// A draft sent back to waiting rests on its submission's "never delivered" +// claim. The provider echoing that message proves the claim wrong: the first +// delivery happened. The reducer keeps such an echo apart (a rejected +// submission may not claim it), so it is read here, from the row itself. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + isProviderUserMessageEcho, + journalEchoClaimant, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import type { QueuedMessageRow } from './queued-message-table' + +/** The waiting draft this appended row proves was delivered, or null. Called + * before the row applies, for every row, so a row that is no new provider + * echo of a user message returns before anything else is read. */ +export function draftDeliveredByEcho( + state: JournalReducerState, + drafts: () => readonly QueuedMessageRow[], + row: JournalRow +): string | null { + const echoes = appendedItems(row).filter( + (item) => + isProviderUserMessageEcho(item.itemId, item.body) && + !state.items.has(item.itemId) && + !state.aliases.has(item.itemId) && + journalEchoClaimant(state, item.itemId, item.body) === null + ) + if (echoes.length === 0) { + return null + } + const spent = spentWaitingDrafts(state, drafts()) + for (const item of echoes) { + const delivered = spent.find((draft) => echoProvesDelivered(state, draft, item.body, row.seq)) + if (delivered) { + return delivered.draft.messageId + } + } + return null +} + +/** + * The re-derivation behind that per-row hook, which is bookkeeping and may be + * skipped: waiting drafts an echo ALREADY in the journal proves delivered. An + * unclaimed echo is the item still stored under its provider id — a claimed one + * was folded into its submission's item. Reads every item, so callers run it + * only where a draft is about to send, never per streamed row. + */ +export function draftsDeliveredByAppliedEcho( + state: JournalReducerState, + drafts: readonly QueuedMessageRow[] +): string[] { + const spent = spentWaitingDrafts(state, drafts) + if (spent.length === 0) { + return [] + } + const delivered = new Set() + for (const item of state.items.values()) { + if (!isProviderUserMessageEcho(item.itemId, item.body)) { + continue + } + for (const candidate of spent) { + if (echoProvesDelivered(state, candidate, item.body, item.sequence)) { + delivered.add(candidate.draft.messageId) + } + } + } + return [...delivered] +} + +type SpentDraft = { draft: QueuedMessageRow; since: number } + +/** Waiting drafts some hand-off of which was handed over, then rejected as never delivered; + * `since` is the earliest such hand-off's row. A hand-off rejected before hand-over is + * provably unwritten: an echo matching it is some other message, and must not delete the card. */ +function spentWaitingDrafts( + state: JournalReducerState, + drafts: readonly QueuedMessageRow[] +): SpentDraft[] { + const since = new Map() + for (const submission of state.submissions.values()) { + if ( + submission.queuedMessageId !== undefined && + submission.dispatchState === 'rejected' && + submission.handedOverAt !== undefined + ) { + const sequence = submission.acceptedSequence ?? 0 + const earliest = since.get(submission.queuedMessageId) + since.set(submission.queuedMessageId, Math.min(earliest ?? sequence, sequence)) + } + } + return drafts.flatMap((draft) => { + const from = since.get(draft.messageId) + return draft.state === 'waiting' && from !== undefined ? [{ draft, since: from }] : [] + }) +} + +/** The one predicate both paths share: an unclaimed echo appended after a disproved hand-off, + * carrying the draft's own payload. */ +function echoProvesDelivered( + state: JournalReducerState, + spent: SpentDraft, + body: AgentJournalItemBody, + sequence: number +): boolean { + return ( + sequence > spent.since && + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: state.sessionId, + fields: { body } + }) === spent.draft.fingerprint + ) +} + +function appendedItems(row: JournalRow): { itemId: string; body: AgentJournalItemBody }[] { + if (row.kind === 'item') { + return [{ itemId: row.itemId, body: row.body }] + } + if (row.kind === 'lifecycle-batch') { + return row.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [{ itemId: mutation.itemId, body: mutation.body }] : [] + ) + } + return [] +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts new file mode 100644 index 00000000000..665466d60e7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -0,0 +1,49 @@ +// Per-draft holds: what keeps one card from auto-sending, stored on its row. A +// Stop or a restart pauses the whole queue instead (`queued-message-pause-table.ts`, +// and the host instance each row records); a per-draft hold is only a +// conversion that failed, which an explicit Send releases. + +import type Database from '../../sqlite/sync-database' +import type { QueuedMessageHoldReason } from './queued-message-table' + +/** Hold waiting drafts from auto-sending. The hold retires with the row: consume + * and withdraw clear it in their own UPDATE. Returns how many rows it newly reached. */ +export function holdQueuedMessages( + db: Database.Database, + input: { + sessionId: string + messageIds: readonly string[] + reason: QueuedMessageHoldReason + } +): number { + const update = db.prepare( + `UPDATE queued_messages SET hold_reason = ? + WHERE session_id = ? AND message_id = ? AND state = 'waiting' + AND (hold_reason IS NULL OR hold_reason <> ?)` + ) + let held = 0 + for (const messageId of input.messageIds) { + held += Number(update.run(input.reason, input.sessionId, messageId, input.reason).changes ?? 0) + } + return held +} + +/** Ends a restart's pause: waiting rows another host instance wrote are adopted + * into this one, the same fact the pause is derived from, so no second copy + * exists. Also clears a per-row 'stopped' hold an earlier build of the queue + * wrote, which this build only ever lifts. Returns how many rows it changed. */ +export function adoptQueuedMessages( + db: Database.Database, + input: { sessionId: string; hostInstance: string } +): number { + return Number( + db + .prepare( + `UPDATE queued_messages + SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END + WHERE session_id = ? AND state = 'waiting' + AND (host_instance <> ? OR hold_reason = 'stopped')` + ) + .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 + ) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts new file mode 100644 index 00000000000..41387165de0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts @@ -0,0 +1,161 @@ +// The queue-level pause fact: where in the journal the user's last Stop (or a +// /clear, which starts its replacement paused) took effect. The pause itself is never stored — it is derived from this fact and +// the journal rows after it (a user-requested turn that started ends it); the +// fact only records the one event the journal's closed row kinds cannot carry. +// An explicit Resume retires it. + +import type Database from '../../sqlite/sync-database' + +export type QueuePauseReason = 'stopped' | 'cleared' + +export type QueuePauseFact = { + reason: QueuePauseReason + /** The journal position the Stop took effect at: rows after it are later. */ + epoch: string + sequence: number + recordedAt: number +} + +export function readQueuePause(db: Database.Database, sessionId: string): QueuePauseFact | null { + const row: unknown = db + .prepare( + 'SELECT reason, epoch, sequence, recorded_at FROM queued_message_pauses WHERE session_id = ?' + ) + .get(sessionId) + if ( + typeof row !== 'object' || + row === null || + !('reason' in row) || + (row.reason !== 'stopped' && row.reason !== 'cleared') || + !('epoch' in row) || + typeof row.epoch !== 'string' || + !('sequence' in row) || + typeof row.sequence !== 'number' || + !('recorded_at' in row) || + typeof row.recorded_at !== 'number' + ) { + // A reason this build cannot place reads as no pause rather than a wrong one. + return null + } + return { + reason: row.reason, + epoch: row.epoch, + sequence: row.sequence, + recordedAt: row.recorded_at + } +} + +/** The latest Stop replaces an earlier one: only the last interruption decides. */ +export function recordQueuePause( + db: Database.Database, + input: { sessionId: string; fact: QueuePauseFact } +): void { + db.prepare( + `INSERT INTO queued_message_pauses (session_id, reason, epoch, sequence, recorded_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (session_id) DO UPDATE SET + reason = excluded.reason, epoch = excluded.epoch, + sequence = excluded.sequence, recorded_at = excluded.recorded_at` + ).run( + input.sessionId, + input.fact.reason, + input.fact.epoch, + input.fact.sequence, + input.fact.recordedAt + ) +} + +/** Compare-and-clear: only the fact the caller judged, so a Stop recorded since stands. */ +export function clearQueuePause( + db: Database.Database, + input: { sessionId: string; fact: Pick } +): number { + return Number( + db + .prepare( + 'DELETE FROM queued_message_pauses WHERE session_id = ? AND epoch = ? AND sequence = ?' + ) + .run(input.sessionId, input.fact.epoch, input.fact.sequence).changes ?? 0 + ) +} + +type QueueCardState = { state: string; holdReason: string | null } + +/** A card a pause holds back: waiting, with no hold of its own, wherever it sits. + * While one exists — or a hand-off still owed a return to waiting + * (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is + * retired only once none remains, so deleting a returned card that blocks such + * cards leaves them paused rather than sending them unasked. */ +export function isPausableQueuedMessage(row: QueueCardState): boolean { + return row.state === 'waiting' && row.holdReason === null +} + +/** Whether Resume would send anything: a pausable card not behind a returned one, + * which blocks everything after it until the user acts, exactly as the drain + * reads it. Only then is the kept pause PUBLISHED, so its header never offers a + * Resume that sends nothing. */ +export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): boolean { + for (const row of rows) { + if (row.state === 'returned') { + return false + } + if (isPausableQueuedMessage(row)) { + return true + } + } + return false +} + +/** What a queue pause holds back, judged inside the caller's transaction: a waiting + * card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched + * one whose settlement back to waiting is still owed — its hook was skipped, so the + * row has not caught up with its rejected submission, which only the journal's + * submissions can tell (`owedToWaiting`). */ +export function queuePauseHoldsBack( + db: Database.Database, + input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } +): boolean { + const pausable = db + .prepare( + `SELECT 1 FROM queued_messages + WHERE session_id = ? AND state = 'waiting' AND hold_reason IS NULL LIMIT 1` + ) + .get(input.sessionId) + if (pausable !== undefined) { + return true + } + return db + .prepare( + `SELECT consumed_as FROM queued_messages + WHERE session_id = ? AND state = 'dispatched' AND consumed_as IS NOT NULL` + ) + .all(input.sessionId) + .some( + (row) => + typeof row === 'object' && + row !== null && + 'consumed_as' in row && + typeof row.consumed_as === 'string' && + input.owedToWaiting(row.consumed_as) + ) +} + +/** A pause is over the cards it paused: once it holds back none (`queuePauseHoldsBack`), + * the fact goes too, in the same transaction as the write that took the last one, so + * it can never outlive them and catch a card typed long after. */ +export function retireQueuePauseIfNothingHeld( + db: Database.Database, + input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } +): number { + // Runs on every appended journal row: with no pause recorded there is nothing to judge. + const recorded = db + .prepare('SELECT 1 FROM queued_message_pauses WHERE session_id = ?') + .get(input.sessionId) + if (recorded === undefined || queuePauseHoldsBack(db, input)) { + return 0 + } + return Number( + db.prepare('DELETE FROM queued_message_pauses WHERE session_id = ?').run(input.sessionId) + .changes ?? 0 + ) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-retention.ts b/src/main/native-chat/agent-session-journal/queued-message-retention.ts new file mode 100644 index 00000000000..cf18a1e5006 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-retention.ts @@ -0,0 +1,74 @@ +// Retention for the draft table: which settled rows may be deleted, and when. A row is kept for +// as long as anything could still read it — a replayed operation, or a late refusal returning it. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { listQueuedMessages } from './queued-message-table' + +/** What the loaded journal says about a dispatched draft's consumed submission. */ +export type QueuedMessageSubmissionVerdict = + /** Still owed an answer — a crash leftover the delivery loop will reject; keep the row. */ + | 'pending' + /** `accepted` or `unknown`: terminal and not refused. */ + | 'terminal-not-refused' + /** Absent from the current epoch. */ + | 'absent' + /** Effectively rejected; the open-time repair settles it rather than pruning. */ + | 'rejected' + +/** + * Retention: `withdrawn` tombstones live for the operation-replay window; + * a `dispatched` row only once its consumed submission is terminal-and-not- + * refused or absent AND the window has passed — never while pending, so a slow + * refusal can still return it. `waiting` and `returned` rows are never pruned. + */ +export function pruneQueuedMessages( + db: Database.Database, + input: { + sessionId: string + now: number + replayWindowMs: number + submissionVerdict: (consumedRef: string) => QueuedMessageSubmissionVerdict + } +): number { + const cutoff = input.now - input.replayWindowMs + const tombstones = db + .prepare( + `DELETE FROM queued_messages + WHERE session_id = ? AND state = 'withdrawn' AND settled_at IS NOT NULL AND settled_at < ?` + ) + .run(input.sessionId, cutoff) + let pruned = Number(tombstones.changes ?? 0) + for (const row of listQueuedMessages(db, input.sessionId)) { + if (row.state !== 'dispatched' || row.settledAt === null || row.settledAt >= cutoff) { + continue + } + // A dispatched row always names its hand-off; one that does not has nothing to wait for. + const verdict = row.consumedAs === null ? 'absent' : input.submissionVerdict(row.consumedAs) + if (verdict === 'terminal-not-refused' || verdict === 'absent') { + db.prepare('DELETE FROM queued_messages WHERE session_id = ? AND message_id = ?').run( + input.sessionId, + row.messageId + ) + pruned += 1 + } + } + return pruned +} + +/** How retention reads a consumed submission from the loaded journal. Run after the + * owed settlements, which already settled every rejected row. */ +export function retainedSubmissionVerdict( + submissions: ReadonlyMap +): (consumedRef: string) => QueuedMessageSubmissionVerdict { + return (consumedRef) => { + const submission = submissions.get(consumedRef) + if (!submission) { + return 'absent' + } + if (submission.dispatchState === 'accepted' || submission.dispatchState === 'unknown') { + return 'terminal-not-refused' + } + return submission.dispatchState === 'rejected' ? 'rejected' : 'pending' + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts new file mode 100644 index 00000000000..31806e6b71a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -0,0 +1,72 @@ +// The draft table's shape, created and healed at every writable open. + +import type Database from '../../sqlite/sync-database' + +/** Columns a later build added, so an older draft table can gain them in place. */ +const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ + ['hold_reason', 'TEXT'], + ['returned_reason', 'TEXT'], + ['returned_rejection', 'TEXT'], + ['settled_at', 'INTEGER'], + ['settled_by_op', 'TEXT'], + ['consumed_as', 'TEXT'] +] + +/** + * Created idempotently at EVERY writable open, never lazily at first insert, so + * no reader hits "no such table". Deliberately no `user_version` bump: an old + * build sees stored == supported and stays writable, ignoring the table; a bump + * would latch every opened db read-only after a downgrade (`journal-database.ts`). + * For the same reason a missing column is added here rather than versioned: + * `CREATE TABLE IF NOT EXISTS` never reshapes a table an earlier build created. + */ +export function ensureQueuedMessagesTable(db: Database.Database): void { + db.exec(` +CREATE TABLE IF NOT EXISTS queued_messages ( + session_id TEXT NOT NULL, + message_id TEXT NOT NULL, + position INTEGER NOT NULL, + body_json TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL, + host_instance TEXT NOT NULL, + state TEXT NOT NULL, + hold_reason TEXT, + returned_reason TEXT, + returned_rejection TEXT, + settled_at INTEGER, + settled_by_op TEXT, + consumed_as TEXT, + PRIMARY KEY (session_id, message_id) +); +`) + const names = new Set( + db + .prepare('PRAGMA table_info(queued_messages)') + .all() + .flatMap((column) => + typeof column === 'object' && + column !== null && + 'name' in column && + typeof column.name === 'string' + ? [column.name] + : [] + ) + ) + for (const [name, type] of NULLABLE_COLUMNS) { + if (!names.has(name)) { + db.exec(`ALTER TABLE queued_messages ADD COLUMN ${name} ${type}`) + } + } + db.exec(` +CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as + ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL; +CREATE TABLE IF NOT EXISTS queued_message_pauses ( + session_id TEXT PRIMARY KEY, + reason TEXT NOT NULL, + epoch TEXT NOT NULL, + sequence INTEGER NOT NULL, + recorded_at INTEGER NOT NULL +); +`) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts new file mode 100644 index 00000000000..75e62256780 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -0,0 +1,142 @@ +// What a journal row does to the drafts, and the re-derivation behind it. The +// live hook runs inside each append's transaction; it is bookkeeping and may be +// skipped, so a dispatched draft whose current submission the journal already +// rejected is owed the settlement it would have applied, which the open-time +// repair and the drain both apply. + +import type Database from '../../sqlite/sync-database' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { + consumedSubmissionWasRejected, + journalDispatchRowNewlyRejects, + rejectedDraftSettlement +} from './journal-dispatch-settlement' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { + listQueuedMessages, + settleRejectedQueuedMessage, + withdrawQueuedMessages, + type QueuedMessageRow +} from './queued-message-table' + +type Submissions = ReadonlyMap + +/** Some dispatched draft still waits on a settlement the journal already decided. */ +export function queuedMessageSettlementOwed( + rows: readonly QueuedMessageRow[], + submissions: Submissions +): boolean { + return rows.some( + (row) => + row.state === 'dispatched' && + row.consumedAs !== null && + consumedSubmissionWasRejected(submissions.get(row.consumedAs)) + ) +} + +/** A dispatched draft's consumed submission settled so that the draft is owed a + * return to waiting (a withdrawal, not a refusal): what a queue pause must still + * count as a card it holds back while that settlement is owed. */ +export function owedBackToWaiting(submissions: Submissions): (consumedRef: string) => boolean { + return (consumedRef) => { + const submission = submissions.get(consumedRef) + return ( + submission !== undefined && + consumedSubmissionWasRejected(submission) && + rejectedDraftSettlement(submission).state === 'waiting' + ) + } +} + +/** Applies each owed settlement, and withdraws each waiting draft an applied echo proves + * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ +export function settleOwedQueuedMessages( + db: Database.Database, + input: { sessionId: string; state: JournalReducerState; now: number } +): number { + const { submissions } = input.state + let settled = 0 + for (const row of listQueuedMessages(db, input.sessionId)) { + const consumedRef = row.consumedAs + const submission = consumedRef === null ? undefined : submissions.get(consumedRef) + if ( + row.state !== 'dispatched' || + consumedRef === null || + !consumedSubmissionWasRejected(submission) + ) { + continue + } + const changed = settleRejectedQueuedMessage(db, { + sessionId: input.sessionId, + consumedRef, + reason: submission?.reason ?? null, + rejection: submission?.rejection, + now: input.now + }) + settled += changed ? 1 : 0 + } + const delivered = draftsDeliveredByAppliedEcho( + input.state, + listQueuedMessages(db, input.sessionId) + ) + if (delivered.length > 0) { + settled += withdrawQueuedMessages(db, { + sessionId: input.sessionId, + messageIds: delivered, + settledByOp: null, + now: input.now + }).length + } + return settled +} + +/** + * The live hook, before `row` applies: an echo proving a waiting draft's first + * send was delivered withdraws it; a row that NEWLY settles a dispatched + * draft's current submission to `rejected` settles the draft — a refusal + * returns it, a withdrawal (a Stop, a restart) sends it back to waiting. + * Decided by the same function the reducer folds rows through, so a row the + * journal's settlement rules ignore never alters a draft. Returns how many + * drafts changed. + */ +export function settleQueuedMessagesForRow( + db: Database.Database, + input: { + sessionId: string + state: JournalReducerState + /** Read only once the row holds an unclaimed echo: a list read inside the append's + * transaction must not be cached under state a rollback could undo. */ + drafts: () => readonly QueuedMessageRow[] + row: JournalRow + now: number + } +): number { + const { row } = input + let changed = 0 + const delivered = draftDeliveredByEcho(input.state, input.drafts, row) + if (delivered !== null) { + // Its first send reached the agent after all; sending it again would repeat it. + changed += withdrawQueuedMessages(db, { + sessionId: input.sessionId, + messageIds: [delivered], + settledByOp: null, + now: input.now + }).length + } + if (row.kind !== 'dispatch' || row.state !== 'rejected') { + return changed + } + if (!journalDispatchRowNewlyRejects(input.state.submissions.get(row.clientMessageId), row)) { + return changed + } + const settled = settleRejectedQueuedMessage(db, { + sessionId: input.sessionId, + consumedRef: row.clientMessageId, + reason: row.reason, + rejection: row.rejection, + now: input.now + }) + return changed + (settled ? 1 : 0) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts new file mode 100644 index 00000000000..4f9b6f81e03 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -0,0 +1,835 @@ +// The draft store's contract: exactly-once consume in one transaction, the +// rejected-draft settlement following the journal's EFFECTIVE settlement, retention +// that never outruns a slow refusal, and rows that survive epoch replacement. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabaseFile } from './journal-paths' +import { + JournalQueuedMessages, + QUEUED_MESSAGE_REPLAY_WINDOW_MS, + QueuedMessageNotConsumableError +} from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-store-test-open' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +const journals = createTrackedJournalOpener() +const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' +}) +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) +const PROVIDER_REFUSAL = agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } +}) +/** A provider refusal as a settled rejection stores it: its sentence and typed fact. */ +function refusal(text: string) { + return agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: { text, audience: 'person' } }), + { surface: 'rejection' } + ) +} + +async function open(): Promise { + const journal = await journals.open({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) + return journal +} + +async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { + return journal.queuedMessages.insert({ + messageId, + body: message(text), + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) +} + +async function consumeDraft( + journal: AgentSessionJournal, + messageId: string, + options: { as?: string; expect?: 'waiting' | 'returned'; settledByOp?: string | null } = {} +) { + const draft = journal.queuedMessages.get(messageId) + await journal.appendSubmission( + { + clientMessageId: options.as ?? `sub-${messageId}`, + payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, + body: draft?.body ?? message('queued text'), + fence: 0, + handoverRecorded: true + }, + { + messageId, + expect: options.expect ?? 'waiting', + settledByOp: options.settledByOp ?? null + } + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-message-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('draft rows', () => { + it('creates the table at open without bumping user_version, so an old build stays writable', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.close() + const db = new Database(journalDatabaseFile(root), { readonly: true }) + try { + const version = Number(db.pragma('user_version', { simple: true })) + // An old build compares stored == supported and keeps writing; a bump + // would latch it read-only after downgrade. + expect(version).toBe(2) + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('queued_messages') + expect(table).toBeDefined() + } finally { + db.close() + } + }) + + it('opens a database an older build shaped (no drafts table) and creates the table', async () => { + const first = await open() + await first.appendItem( + { provider: 'orca', clientMessageId: 'seed' }, + { kind: 'status', text: 'seed' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await first.close() + const db = new Database(journalDatabaseFile(root)) + db.exec('DROP TABLE queued_messages') + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + const row = await queueDraft(journal, 'draft-1') + expect(row.position).toBe(1) + }) + + it('assigns monotonic positions and lists in order', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await queueDraft(journal, 'draft-2') + const listed = journal.queuedMessages.list() + expect(listed.map((row) => [row.messageId, row.position, row.state])).toEqual([ + ['draft-1', 1, 'waiting'], + ['draft-2', 2, 'waiting'] + ]) + }) + + it('replays an insert under an already-used id instead of duplicating', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const again = await queueDraft(journal, 'draft-1') + expect(again.position).toBe(1) + expect(journal.queuedMessages.list()).toHaveLength(1) + }) + + it('drafts survive epoch replacement, which deletes only journal rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.replaceEpochItems('handle_forked', 0, []) + expect(journal.queuedMessages.list().map((row) => row.messageId)).toEqual(['draft-1']) + }) +}) + +describe('consume', () => { + it('converts waiting → dispatched and appends the submission in one transaction', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('dispatched') + expect(row?.consumedAs).toBe('sub-draft-1') + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('never hands a draft off under its own id: the submission names it by link, not id equality', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'draft-1' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('never records a second submission under an id it already holds, whatever state it settled in', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const cursor = journal.cursor() + await expect( + journal.appendSubmission({ + clientMessageId: 'sub-draft-1', + payloadFingerprint: 'fp-draft-1', + body: message('queued text'), + fence: 0, + handoverRecorded: true + }) + ).rejects.toMatchObject({ code: 'journal_submission_exists' }) + // The refusal stands: re-appending would reset it to pending and hand it over again. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.cursor()).toEqual(cursor) + }) + + it('a second consume of the same draft fails and appends nothing (exactly-once)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'second-id' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('a consume racing a withdraw loses and appends nothing', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000op-1' + }) + await expect(consumeDraft(journal, 'draft-1')).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('a failed submission insert rolls the draft transition back', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const cursor = journal.cursor() + // Occupy the next sequence directly so the append's INSERT violates the + // primary key inside the transaction, after the draft was transitioned. + const db = new Database(journalDatabaseFile(root)) + db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}') + db.close() + await expect(consumeDraft(journal, 'draft-1')).rejects.toThrow() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) +}) + +describe('returned transition (D1/N4)', () => { + it('a non-withdrawn rejection returns the consumed draft with its reason', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('Claude refused this payload'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('Claude refused this payload').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('a late rejection row after acceptance settles nothing and returns no card', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('late duplicate'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + }) + + it("a Stop's withdrawal before the agent received it sends the draft back to waiting, held like the rest, and it survives a restart", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + // The Stop's own withdrawal path: the queued (not handed over) submission. + expect(await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)).toEqual(['sub-draft-1']) + // Nothing failed: no refusal to show, its position kept, its spent id recorded. + const requeued = { + state: 'waiting', + position: 1, + holdReason: null, + consumedAs: null, + returnedReason: null, + returnedRejection: null + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + // Atomic with the rejection row: a crash before the Stop answered keeps the text. + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + }) + + it('a draft sent back to waiting hands off again under a fresh id, never a spent one', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + // The spent id already names a rejected submission: one id, one delivery. + await expect(consumeDraft(journal, 'draft-1')).rejects.toMatchObject({ + code: 'journal_submission_exists' + }) + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + await consumeDraft(journal, 'draft-1', { as: 'fresh-1' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'fresh-1' + }) + // Both hand-offs name the draft. + expect(journal.submission('fresh-1')).toMatchObject({ + dispatchState: 'pending', + queuedMessageId: 'draft-1' + }) + expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') + }) + + it("a restart between consume and handover sends the draft back to waiting under the restart's pause", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + journal.wroteBeforeOpen(submission.acceptedSequence) + ) + // No stored hold: the restart's pause derives from the row's host instance. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + hostInstance: 'proc-1', + consumedAs: null, + returnedReason: null + }) + }) + + it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('first refusal'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('returned') + // Send on the returned card re-consumes under a fresh submission id. + await consumeDraft(journal, 'draft-1', { as: 'resend-1', expect: 'returned' }) + // The earlier refusal retires with the card: the row now describes the re-send. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'resend-1', + returnedReason: null, + returnedRejection: null + }) + // A duplicate resolution of the FIRST submission is ignored by the journal + // and must not alter the draft's current relation. + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('duplicate of first refusal'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The re-send's own refusal returns the card, matched via consumed_as. + await journal.resolveDispatch({ + clientMessageId: 'resend-1', + state: 'rejected', + ...refusal('second refusal'), + fence: 0 + }) + const returned = journal.queuedMessages.get('draft-1') + expect(returned?.state).toBe('returned') + expect(returned?.returnedReason).toBe(refusal('second refusal').reason) + // The first refusal's fact does not outlive it: the pair is the second submission's. + expect(returned?.returnedRejection).toEqual(refusal('second refusal').rejection) + expect(returned?.returnedRejection).not.toEqual(PROVIDER_REFUSAL) + }) + + it('a rejection never revives a withdrawn draft', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('again'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('the returned row and its stored reason survive epoch replacement and reopen (B1)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('stored refusal'), + fence: 0 + }) + await journal.replaceEpochItems('handle_forked', 0, []) + await journal.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('stored refusal').reason) + }) +}) + +describe('withdraw', () => { + it('withdraws waiting and returned rows together into op-stamped receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1', 'first text') + await queueDraft(journal, 'draft-2', 'second text') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const withdrawn = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1', 'draft-2'], + settledByOp: 'caller\u0000stop-1' + }) + expect(withdrawn.map((row) => [row.messageId, row.body.blocks])).toEqual([ + ['draft-1', [{ type: 'text', text: 'first text' }]], + ['draft-2', [{ type: 'text', text: 'second text' }]] + ]) + // A lost acknowledgement replays from the tombstones, keyed by the + // caller-scoped operation key — never from the ledger. + const receipts = journal.queuedMessages.receipts('caller\u0000stop-1') + expect(receipts.map((row) => row.messageId)).toEqual(['draft-1', 'draft-2']) + // Pending or dispatched rows stay outside the withdrawable set. + const second = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000stop-2' + }) + expect(second).toHaveLength(0) + }) + + it('two callers reusing one operation id read only their own receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller-a\u0000op-1' + }) + expect(journal.queuedMessages.receipts('caller-b\u0000op-1')).toHaveLength(0) + expect(journal.queuedMessages.receipts('caller-a\u0000op-1')).toHaveLength(1) + }) +}) + +describe('open-time repair and retention', () => { + it('a failed repair is reported and skipped, never failing the open', async () => { + const repair = vi + .spyOn(JournalQueuedMessages.prototype, 'repairAndPrune') + .mockRejectedValueOnce(new Error('SQLITE_FULL')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const journal = await open() + expect(warn).toHaveBeenCalledWith( + '[journal-open] queued-message repair skipped:', + expect.objectContaining({ error: 'SQLITE_FULL' }) + ) + await queueDraft(journal, 'draft-1') + expect(journal.queuedMessages.list()).toHaveLength(1) + } finally { + repair.mockRestore() + warn.mockRestore() + } + }) + + it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused while downgraded'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + await journal.close() + // Simulate the old build having written the rejection with no hook: put the + // draft back to dispatched behind the stored fact. + const db = new Database(journalDatabaseFile(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?" + ).run('draft-1') + db.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('refused while downgraded').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('sends back to waiting a dispatched row whose submission a Stop withdrew with no hook, never leaving it dispatched', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + await journal.close() + const db = new Database(journalDatabaseFile(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" + ).run('draft-1') + db.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + // The same settlement the live hook applies. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + consumedAs: null, + returnedReason: null + }) + }) + + it('keeps a dispatched row while its submission is still pending, even past the window, so a late rejection still settles it (N5)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.close() + // Reopen "25 hours" later: the submission is still queued/pending. + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 60 * 60 * 1000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The delivery loop's leftover rejection now sends it back to waiting. + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) + }) + + it('prunes accepted and withdrawn rows once the replay window passes, and never waiting or returned rows', async () => { + let journal = await open() + await queueDraft(journal, 'accepted-1') + await consumeDraft(journal, 'accepted-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-accepted-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await queueDraft(journal, 'waiting-1') + await queueDraft(journal, 'returned-1') + await consumeDraft(journal, 'returned-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-returned-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.state])).toEqual([ + ['waiting-1', 'waiting'], + ['returned-1', 'returned'] + ]) + }) + + it('keeps fresh tombstones inside the replay window', async () => { + let journal = await open() + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await journal.close() + clock += 1_000 + journal = await open() + expect(journal.queuedMessages.get('withdrawn-1')?.state).toBe('withdrawn') + }) +}) + +describe('holds', () => { + it('a hold is stored on the row, survives reopen, and withdraw clears it', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + holdReason: null + }) + }) + + it('consume clears the hold in the same transaction (Send-now overrides it)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + await consumeDraft(journal, 'draft-1') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + holdReason: null + }) + }) + + it('a withdraw naming no drafts touches nothing — a Delete race with no rows costs no write', async () => { + const journal = await open() + await journal.close() + await expect( + journal.queuedMessages.withdraw({ messageIds: [], settledByOp: 'c\u0000op' }) + ).resolves.toEqual([]) + }) + + it('holds reach only waiting rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + holdReason: null + }) + }) +}) + +describe("the queue's Stop fact", () => { + it('records where the Stop took effect, survives reopen, and the latest Stop replaces an earlier one', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('stopped') + const first = journal.queuedMessages.pause() + expect(first).toMatchObject({ reason: 'stopped', sequence: journal.cursor().sequence }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'later' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.queuedMessages.recordPause('stopped') + expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) + // The pause is the queue's, never a row's. + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBeNull() + }) + + it("a /clear's replacement records its pause as 'cleared', read back the same way", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('cleared') + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'cleared' }) + }) + + it('retires in the write that takes the last card it holds back: a hold of its own', async () => { + const journal = await open() + await queueDraft(journal, 'draft-held') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await journal.queuedMessages.hold({ messageIds: ['draft-held'], reason: 'send_failed' }) + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it('records nothing over a queue with no card it holds back, judged in its own transaction', async () => { + const journal = await open() + expect(await journal.queuedMessages.recordPause('stopped')).toBe(false) + expect(journal.queuedMessages.pause()).toBeNull() + await queueDraft(journal, 'draft-1') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + + it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => { + const journal = await open() + await queueDraft(journal, 'draft-sent') + await queueDraft(journal, 'draft-other') + await consumeDraft(journal, 'draft-sent') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + } finally { + hook.mockRestore() + warn.mockRestore() + } + expect(journal.queuedMessages.get('draft-sent')?.state).toBe('dispatched') + // The only waiting card goes; the owed one is still a card the pause holds back. + await journal.queuedMessages.withdraw({ messageIds: ['draft-other'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.pause()).not.toBeNull() + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-sent')?.state).toBe('waiting') + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + + it('lifting retires only the Stop fact it judged, never one recorded since', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.recordPause('stopped') + const judged = journal.queuedMessages.pause() + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'later' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.queuedMessages.recordPause('stopped') + expect(await journal.queuedMessages.liftPause({ stop: judged, adoptInto: null })).toBe(false) + expect(journal.queuedMessages.pause()).not.toBeNull() + expect( + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: null + }) + ).toBe(true) + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-restart', + body: message('written before the restart'), + fingerprint: 'fp-draft-restart', + hostInstance: 'proc-0' + }) + await queueDraft(journal, 'draft-legacy') + await queueDraft(journal, 'draft-failed') + await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) + const db = new Database(journalDatabaseFile(root)) + db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( + 'draft-legacy' + ) + db.close() + journal.queuedMessages.invalidate() + expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(true) + expect( + journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) + ).toEqual([ + ['draft-restart', 'proc-1', null], + ['draft-legacy', 'proc-1', null], + ['draft-failed', 'proc-1', 'send_failed'] + ]) + }) + + it('a lift with nothing to lift changes nothing and fires no commit notification', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const revision = journal.queuedMessages.revision() + expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(false) + expect(journal.queuedMessages.revision()).toBe(revision) + }) +}) + +describe('the commit listener', () => { + it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { + const journal = await open() + let commits = 0 + journal.observeCommits(() => { + commits += 1 + }) + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + // An idempotent replay changes nothing and stays silent. + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(commits).toBe(3) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op-2' }) + expect(commits).toBe(3) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts new file mode 100644 index 00000000000..ffc9b240987 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -0,0 +1,326 @@ +// Host-owned draft rows for messages queued while the main agent is working. +// +// A queued message is NOT a journal row: it becomes one — an ordinary +// submission — only when consume converts it, in the same transaction as the +// submission's append. Until then it lives here, `session_id`-keyed so it +// survives epoch rollover and replacement (`journal-row-table.ts` deletes only +// `journal_rows`). After a refusal its text survives as a `returned` row a +// rewind cannot delete; after a withdrawal it waits again. + +import type Database from '../../sqlite/sync-database' +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { rejectedDraftSettlement } from './journal-dispatch-settlement' +import { readStoredRejectionFact } from './journal-dispatch-reducer' + +export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' + +/** Why ONE waiting draft is held from auto-sending: its conversion failed. + * Stored on the row, so it survives handle eviction and restart; a wire marker + * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue + * instead. A reader treats an unknown stored value as a plain hold. */ +export type QueuedMessageHoldReason = 'send_failed' + +/** Definitively unsettled: what Stop, /clear, Edit and the budget count, and + * what the published list shows. Pending/unknown/accepted deliveries and + * tombstones stay outside it. */ +export function isUnsettledQueuedMessage(row: Pick): boolean { + return row.state === 'waiting' || row.state === 'returned' +} + +export type QueuedMessageRow = { + sessionId: string + messageId: string + position: number + body: AgentJournalMessageItem + fingerprint: string + createdAt: number + hostInstance: string + state: QueuedMessageState + /** Non-null holds this one waiting draft from auto-sending; typed values in + * `QueuedMessageHoldReason`, unknown strings read as a plain hold. */ + holdReason: string | null + /** A returned card's refusal, mirroring its submission's `reason` and `rejection` pair. */ + returnedReason: string | null + returnedRejection: UnreadAgentSessionFailureFact | null + settledAt: number | null + /** The operation ledger's caller-scoped key, making settled rows mutation receipts. */ + settledByOp: string | null + /** The submission that last handed it off: set on every dispatched row, kept on a returned + * card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is + * the submission's `queuedMessageId`. */ + consumedAs: string | null +} + +const COLUMNS = + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as' + +export function insertQueuedMessage( + db: Database.Database, + input: { + sessionId: string + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + now: number + } +): QueuedMessageRow { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. + const highest = db + .prepare('SELECT COALESCE(MAX(position), 0) AS p FROM queued_messages WHERE session_id = ?') + .get(input.sessionId) as { p?: number } | undefined + const position = Number(highest?.p ?? 0) + 1 + db.prepare( + `INSERT INTO queued_messages (${COLUMNS}) + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL)` + ).run( + input.sessionId, + input.messageId, + position, + JSON.stringify(input.body), + input.fingerprint, + input.now, + input.hostInstance + ) + return { + sessionId: input.sessionId, + messageId: input.messageId, + position, + body: input.body, + fingerprint: input.fingerprint, + createdAt: input.now, + hostInstance: input.hostInstance, + state: 'waiting', + holdReason: null, + returnedReason: null, + returnedRejection: null, + settledAt: null, + settledByOp: null, + consumedAs: null + } +} + +export function listQueuedMessages(db: Database.Database, sessionId: string): QueuedMessageRow[] { + return db + .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC`) + .all(sessionId) + .flatMap((row) => toStoredRow(row) ?? []) +} + +export function getQueuedMessage( + db: Database.Database, + sessionId: string, + messageId: string +): QueuedMessageRow | null { + const row = db + .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?`) + .get(sessionId, messageId) + return row === undefined ? null : toStoredRow(row) +} + +/** + * The one waiting→dispatched (or returned→dispatched) transition, always under + * a fresh submission id — never the draft's own — so no reader can mistake id + * equality for the hand-off link. MUST run inside the caller's transaction — the journal + * writer's, between BEGIN IMMEDIATE and COMMIT — so a failed submission append + * rolls the consume back and a failed consume rolls the append back. Returns + * false when the draft was not in the expected state, in which case the caller + * throws to abort the append. + */ +export function consumeQueuedMessageInTransaction( + db: Database.Database, + input: { + sessionId: string + messageId: string + expect: 'waiting' | 'returned' + /** The fresh submission id; never the draft's own id. */ + consumedAs: string + settledByOp: string | null + /** The handing-off process; absent keeps the row's own. */ + hostInstance?: string + now: number + } +): boolean { + if (input.consumedAs === input.messageId) { + return false + } + const changed = db + .prepare( + `UPDATE queued_messages + SET state = 'dispatched', hold_reason = NULL, returned_reason = NULL, returned_rejection = NULL, + settled_at = ?, settled_by_op = ?, consumed_as = ?, host_instance = COALESCE(?, host_instance) + WHERE session_id = ? AND message_id = ? AND state = ?` + ) + .run( + input.now, + input.settledByOp, + input.consumedAs, + input.hostInstance ?? null, + input.sessionId, + input.messageId, + input.expect + ) + return Number(changed.changes ?? 0) === 1 +} + +/** Compare-and-transition unsettled rows (waiting ∪ returned) to withdrawn + * tombstones stamped with the operation's caller-scoped key, kept only so a + * replay of the settling operation answers "spent"; null when the host itself + * withdrew it. Returns the rows actually transitioned; their text stays in + * this database, never on the wire. */ +export function withdrawQueuedMessages( + db: Database.Database, + input: { + sessionId: string + messageIds: readonly string[] + settledByOp: string | null + now: number + } +): QueuedMessageRow[] { + const withdrawn: QueuedMessageRow[] = [] + for (const messageId of input.messageIds) { + const row = getQueuedMessage(db, input.sessionId, messageId) + if (!row || !isUnsettledQueuedMessage(row)) { + continue + } + db.prepare( + `UPDATE queued_messages + SET state = 'withdrawn', hold_reason = NULL, settled_at = ?, settled_by_op = ? + WHERE session_id = ? AND message_id = ? AND state IN ('waiting', 'returned')` + ).run(input.now, input.settledByOp, input.sessionId, messageId) + withdrawn.push({ + ...row, + state: 'withdrawn', + holdReason: null, + settledAt: input.now, + settledByOp: input.settledByOp + }) + } + return withdrawn +} + +/** + * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), + * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused + * again still settles while a late duplicate of an earlier refusal matches + * nothing. A draft back to waiting keeps its position and carries no refusal; + * its spent submissions stay findable by their `queuedMessageId` link. + */ +export function settleRejectedQueuedMessage( + db: Database.Database, + input: { + sessionId: string + consumedRef: string + reason: string | null + rejection: UnreadAgentSessionFailureFact | undefined + now: number + } +): boolean { + const settlement = rejectedDraftSettlement({ reason: input.reason, rejection: input.rejection }) + const changed = + settlement.state === 'waiting' + ? db + .prepare( + `UPDATE queued_messages + SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, + returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL + WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` + ) + .run(input.sessionId, input.consumedRef) + : db + .prepare( + `UPDATE queued_messages + SET state = 'returned', returned_reason = ?, returned_rejection = ?, settled_at = ? + WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` + ) + .run( + input.reason, + input.rejection ? JSON.stringify(input.rejection) : null, + input.now, + input.sessionId, + input.consumedRef + ) + return Number(changed.changes ?? 0) > 0 +} + +/** Replay receipts: every row a given caller-scoped operation settled. */ +export function queuedMessagesSettledByOp( + db: Database.Database, + sessionId: string, + settledByOp: string +): QueuedMessageRow[] { + return db + .prepare( + `SELECT ${COLUMNS} FROM queued_messages + WHERE session_id = ? AND settled_by_op = ? ORDER BY position ASC` + ) + .all(sessionId, settledByOp) + .flatMap((row) => toStoredRow(row) ?? []) +} + +function toStoredRow(row: unknown): QueuedMessageRow | null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. + const record = row as { + session_id: string + message_id: string + position: number + body_json: string + fingerprint: string + created_at: number + host_instance: string + state: string + hold_reason: string | null + returned_reason: string | null + returned_rejection: string | null + settled_at: number | null + settled_by_op: string | null + consumed_as: string | null + } + let body: AgentJournalMessageItem + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. + body = JSON.parse(record.body_json) as AgentJournalMessageItem + } catch { + // Our own writer stringified it; an unreadable body is corruption, and a + // row we cannot re-materialize must not masquerade as an empty message. + return null + } + const state = record.state + if ( + state !== 'waiting' && + state !== 'dispatched' && + state !== 'returned' && + state !== 'withdrawn' + ) { + return null + } + return { + sessionId: record.session_id, + messageId: record.message_id, + position: record.position, + body, + fingerprint: record.fingerprint, + createdAt: record.created_at, + hostInstance: record.host_instance, + state, + holdReason: record.hold_reason, + returnedReason: record.returned_reason, + returnedRejection: storedRejection(record.returned_rejection), + settledAt: record.settled_at, + settledByOp: record.settled_by_op, + consumedAs: record.consumed_as + } +} + +function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { + if (json === null) { + return null + } + try { + return readStoredRejectionFact(JSON.parse(json)) ?? null + } catch { + // The refusal stays readable from `returned_reason`; a bad fact must not lose the card. + return null + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts new file mode 100644 index 00000000000..5ff159ce2e6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -0,0 +1,135 @@ +// One subscriber's catch-up: page it forward to the journal head, or hand it +// the empty caught-up frame its per-emit fields still owe it. Split from the +// subscriber registry so the registry stays the bookkeeping and this stays the +// paging policy. + +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionBackgroundTaskState, + type AgentSessionSlashCommand, + type AgentSessionSubscribeEvent, + type AgentSessionTurnActivity +} from '../../../shared/agent-session-wire' +import { sameJournalCursor } from '../agent-session-journal/journal-cursor' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { emptyAgentSessionBatch } from './agent-session-empty-batch' +import { createAgentSessionCatchUpReader } from './agent-session-history-page' +import { + subscriberQueuedMessagesChanged, + type SubscriberFieldHooks +} from './agent-session-subscriber-frame-fields' +import type { Subscriber } from './structured-agent-session-subscribers' + +export type SubscriberDeliveryPort = { + hooks: SubscriberFieldHooks & { + readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + } + emit: ( + subscriber: Subscriber, + event: AgentSessionSubscribeEvent, + options?: { withholdQueued?: boolean } + ) => void + isActive: (subscriber: Subscriber) => boolean + activity: (sessionId: string) => AgentSessionTurnActivity | null +} + +export function deliverToSubscriber( + port: SubscriberDeliveryPort, + input: { + subscriber: Subscriber + journal: AgentSessionJournal + hostNow: number + emitCheckpoint: boolean + backgroundTasks?: AgentSessionBackgroundTaskState | null | undefined + activity?: AgentSessionTurnActivity | null | undefined + } +): void { + const { subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity } = input + const checkpointActivity = emitCheckpoint ? port.activity(subscriber.sessionId) : undefined + const publishedActivity = activity !== undefined ? activity : checkpointActivity + const shared = { + hostNow, + ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), + ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) + } + // Caught up, so there are no rows to read: every publish behind a commit's own delivery. + if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + emitCaughtUp(port, subscriber, emitCheckpoint, shared) + return + } + const readPage = createAgentSessionCatchUpReader(journal) + while (true) { + const result = readPage({ + sessionId: subscriber.sessionId, + direction: 'after', + cursor: subscriber.cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + if (!result.ok) { + const page = { ...result.page, fence: subscriber.fence } + port.emit(subscriber, { + type: 'reset', + sessionId: subscriber.sessionId, + reset: result.reset, + page, + fence: subscriber.fence, + ...shared + }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + return + } + const page = result.page + const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence + if (!advanced) { + emitCaughtUp(port, subscriber, emitCheckpoint, shared) + return + } + port.emit( + subscriber, + { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + }, + fence: subscriber.fence, + ...shared + }, + // On a multi-page catch-up the draft list rides only the final page, or a + // consumed card would vanish pages before its bubble arrives. + { withholdQueued: page.hasNewer } + ) + subscriber.cursor = page.window.nextCursor + if (!page.hasNewer || !port.isActive(subscriber)) { + return + } + } +} + +function emitCaughtUp( + port: SubscriberDeliveryPort, + subscriber: Subscriber, + emitCheckpoint: boolean, + shared: { + hostNow: number + backgroundTasks?: AgentSessionBackgroundTaskState | null + activity?: AgentSessionTurnActivity | null + } +): void { + const commandsChanged = + port.hooks.readCommands !== undefined && + (port.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands + const queuedChanged = subscriberQueuedMessagesChanged(port.hooks, subscriber) + if (emitCheckpoint || shared.activity !== undefined || commandsChanged || queuedChanged) { + port.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: emptyAgentSessionBatch(subscriber.cursor), + fence: subscriber.fence, + ...shared + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts new file mode 100644 index 00000000000..eebec1e6d1b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts @@ -0,0 +1,77 @@ +// Which per-emit fields ride one subscriber frame: the provider command catalog +// and the queue publication (the draft list with the queue's pause). Both are +// identity-deduplicated against the LAST VALUE SENT — never advanced on a frame +// that withheld the field, or the final replacement would be suppressed — and +// both attach whole to hydrating frames. + +import type { + AgentSessionSlashCommand, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import type { QueuePublication } from './structured-agent-session-queued-publication' + +export type SubscriberFieldState = { + sessionId: string + commands?: AgentSessionSlashCommand[] | null + /** The last queue publication actually SENT. */ + queuePublication?: QueuePublication +} + +export type SubscriberFieldHooks = { + readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + readQueuePublication?: (sessionId: string) => QueuePublication | undefined +} + +export type SubscriberFrame = { + frame: AgentSessionSubscribeEvent + commands: AgentSessionSlashCommand[] | null + attachedQueued: boolean + queued: QueuePublication | undefined +} + +/** Builds the frame to emit; the caller stores the returned refs only after the + * emit succeeded, so a dropped subscriber never advances its dedup state. */ +export function buildSubscriberFrame( + hooks: SubscriberFieldHooks, + subscriber: SubscriberFieldState, + event: AgentSessionSubscribeEvent, + withholdQueued: boolean +): SubscriberFrame { + const commands = hooks.readCommands?.(subscriber.sessionId) ?? null + const includeCommands = + hooks.readCommands !== undefined && + event.type !== 'end' && + (event.type !== 'batch' || commands !== subscriber.commands) + // Withheld on intermediate catch-up pages (the caller says so), attached to + // every hydrating frame, and to batches only when the list changed. + const queued = withholdQueued ? undefined : hooks.readQueuePublication?.(subscriber.sessionId) + const attachedQueued = + queued !== undefined && + event.type !== 'end' && + (event.type !== 'batch' || queued !== subscriber.queuePublication) + return { + frame: { + ...event, + ...(includeCommands ? { commands: commands ?? null } : {}), + ...(attachedQueued && queued + ? { queuedMessages: queued.queuedMessages, queuePause: queued.queuePause } + : {}) + }, + commands, + attachedQueued, + queued + } +} + +/** Whether a caught-up publish with no rows still owes this subscriber a frame: + * draft inserts and pause changes write no journal row, so an unchanged cursor + * must still deliver the changed publication. */ +export function subscriberQueuedMessagesChanged( + hooks: SubscriberFieldHooks, + subscriber: SubscriberFieldState +): boolean { + return ( + hooks.readQueuePublication !== undefined && + hooks.readQueuePublication(subscriber.sessionId) !== subscriber.queuePublication + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index a88949a296f..08ee51f49f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -4,6 +4,7 @@ import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' +import { tryReadQueuePublication } from './structured-agent-session-queued-publication' import type { AgentSessionSubscribers, AgentSessionSubscribeInput @@ -29,18 +30,25 @@ export class StructuredAgentSessionBackgroundTaskChannel { ) {} async history(request: AgentSessionHistoryRequest): Promise { + const journal = (await this.conversation(request.sessionId)).journal const result = readStructuredAgentSessionHistoryResult({ - journal: (await this.conversation(request.sessionId)).journal, + journal, record: this.deps.store.getRecord(request.sessionId), request }) const backgroundTasks = this.state(request.sessionId) + const queue = tryReadQueuePublication(journal) const hostNow = this.deps.now?.() ?? Date.now() return { ...result, page: { ...result.page, hostNow, + // A stale history answer never replaces newer live subscription state; + // the client's reducer keeps live-over-history precedence. + ...(queue !== undefined + ? { queuedMessages: queue.queuedMessages, queuePause: queue.queuePause } + : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index f5c7fb825f3..fee7f9e1d3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -2,6 +2,7 @@ import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-ref import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { tryReadQueuePublication } from './structured-agent-session-queued-publication' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -49,6 +50,8 @@ export class StructuredAgentSessionClientDelivery { this.waitForSendSettlement = this.sendSettlement.wait this.subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId), + readQueuePublication: (sessionId) => + tryReadQueuePublication(sessions.get(sessionId)?.journal), onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index 6fcaef07483..c312d6013fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -22,6 +22,8 @@ export class StructuredAgentSessionConversations extends Map< private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void onDeliveryError: (sessionId: string, error: unknown) => void + /** A conversation became held: state that waited on it (queued drafts) re-derives. */ + onOpened?: (sessionId: string) => void now: () => number } ) { @@ -49,7 +51,9 @@ export class StructuredAgentSessionConversations extends Map< }) }) this.activity.set(sessionId, this.delivery.now()) - return super.set(sessionId, session) + const adopted = super.set(sessionId, session) + this.delivery.onOpened?.(sessionId) + return adopted } override delete(sessionId: string): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index f7f26ead440..2a4e282ec23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -49,6 +49,8 @@ import { setOptionPlan, type MutationPlan } from './structured-agent-session-mutation-plans' +import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' +import { runStopWithQueuePause } from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -75,6 +77,10 @@ export type StructuredAgentSessionMutationContext = { wakeDelivery: (sessionId: string) => void /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ stopAgent: (sessionId: string) => Promise + /** Only for gate inputs living in the RECORD store, which can settle with no + * journal commit (a conversation command). Draft-table changes need no call: + * the draft store notifies through the journal's own commit listener. */ + wakeQueuedDrain?: (sessionId: string) => void now: () => number } @@ -110,6 +116,13 @@ export function sendStructuredAgentSessionTurn( envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem retryUnknown?: true + delivery?: 'queue-if-active' + /** Host-local, set only by the client-facing `agentSession.send` RPC (the + * renderer's launch prompt included): recorded as the submission's `client` + * origin, whose started turn ends a Stop's or a restart's queue pause. + * Orchestration mail, a restart continuation and `agent.launch`'s host-sent + * prompt never set it. */ + userSend?: true beforeRun?: () => void } ): Promise> { @@ -120,17 +133,15 @@ export function sendStructuredAgentSessionTurn( params.envelope, { ...plan, - run: async (ctx) => { - const blocked = structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) - if (blocked) { - return blocked - } - const accepted = await plan.run(ctx) - if (accepted.ok) { - context.wakeDelivery(ctx.sessionId) - } - return accepted - } + run: (ctx) => + runQueueableStructuredAgentSessionSend( + context, + ctx, + params, + async () => + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + (await plan.run(ctx)) + ) }, sendPreparation(context, params.envelope) ) @@ -166,36 +177,46 @@ export function cancelStructuredAgentSessionTurn( params.envelope, { ...plan, - run: async (ctx) => { - // Stop withdraws every queued message first, whatever the start or the child is doing. - const withdrawn = await ctx.journal.rejectQueuedSubmissions( - ctx.fence, - agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - ) - const named = params.turnId !== undefined ? { turnId: params.turnId } : {} - const child = context.sessions.get(ctx.sessionId)?.child - if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - await context.stopAgent(ctx.sessionId) - return { ok: true, value: { ...named, cancelled: true } } - } - // A Stop naming no turn ends nothing more unless the session reads working, by the rule - // every session list and the chat's own Stop read it. - const inFlight = - params.turnId !== undefined || - isStructuredAgentSessionMainAgentWorking( - ctx.journal.activeTurnId(), - ctx.journal.submissions(), - ctx.fence + // Stop's queue step, the same for every client: once the Stop takes effect + // the queue is paused. The cards stay published; nothing is withdrawn and no + // text ever rides the answer. + run: (ctx) => + runStopWithQueuePause(ctx, async (tookEffect) => { + // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. + const withdrawn = await ctx.journal.rejectQueuedSubmissions( + ctx.fence, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) ) - const record = context.deps.store.getRecord(ctx.sessionId) - return child && inFlight - ? plan.run({ - ...ctx, - failureTextContext: structuredAgentSessionFailureWordsContext(record) - }) - : { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } - } + const named = params.turnId !== undefined ? { turnId: params.turnId } : {} + const child = context.sessions.get(ctx.sessionId)?.child + if (child?.phase === 'starting') { + // A start that may never land is the one thing here Stop has to end; the chat stays. + await tookEffect() + await context.stopAgent(ctx.sessionId) + return { ok: true, value: { ...named, cancelled: true } } + } + // A Stop naming no turn ends nothing more unless the session reads working, by the rule + // every session list and the chat's own Stop read it. + const inFlight = + params.turnId !== undefined || + isStructuredAgentSessionMainAgentWorking( + ctx.journal.activeTurnId(), + ctx.journal.submissions(), + ctx.fence + ) + const record = context.deps.store.getRecord(ctx.sessionId) + if (!child || !inFlight) { + if (withdrawn.length > 0) { + await tookEffect() + } + return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } + } + await tookEffect() + return plan.run({ + ...ctx, + failureTextContext: structuredAgentSessionFailureWordsContext(record) + }) + }) }, openForWrite(context, params.envelope) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 9faabccfb1e..efdd83d9f14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -55,6 +55,7 @@ import { import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -68,14 +69,18 @@ export class StructuredAgentSessionHost { this.conversationDelivery.afterCommit(sessionId, journal) }, onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), + onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() }) + private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () => + this.mutationContext() + ) // Every journal publish is activity: the one renewal the idle sweep reads. private readonly clientDelivery = new StructuredAgentSessionClientDelivery( this.sessions, () => this.now(), () => this.deps, - (sessionId) => this.sessions.touch(sessionId), + (sessionId) => this.queued.onJournalActivity(sessionId), (sessionId) => this.restartResume.onAgentStarted(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers @@ -276,12 +281,17 @@ export class StructuredAgentSessionHost { ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: this.lifetime.stopAgent, + wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } } send = this.conversationCommands.send + queuedMessageSend = this.queued.queuedMessageSend + queuedMessageDelete = this.queued.queuedMessageDelete + queuedMessagesResume = this.queued.queuedMessagesResume + waitForSendSettlement = this.clientDelivery.waitForSendSettlement private mutations = structuredAgentSessionMutationDelegates(() => this.mutationContext()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a88359e121d..388fabd7bd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -16,6 +16,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, structuredAgentSessionCompactBody @@ -29,6 +30,17 @@ import { type TurnOutcome } from './structured-agent-session-turns' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' +import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' + +/** The body-only hash: what the reducer recomputes to alias a provider echo + * onto its submission, so the stored value must never include control fields. */ +function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} export type MutationPlan = { method: string @@ -48,6 +60,8 @@ export function sendPlan(params: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem retryUnknown?: true + delivery?: 'queue-if-active' + userSend?: true beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -58,8 +72,9 @@ export function sendPlan(params: { operationIdScope: 'global', conversationWrite: true, markUnknownBeforeRun: true, - // A control signal is not payload; it cannot alter durable replay. - fields: { body: params.body }, + // `delivery` joins the OPERATION fingerprint only; the submission row keeps + // the body-only fingerprint the reducer's echo-aliasing recomputes. + fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. @@ -67,12 +82,19 @@ export function sendPlan(params: { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() return performSend(ctx, { + origin: params.userSend ? 'client' : 'host', clientMessageId, - payloadFingerprint: params.envelope.payloadFingerprint, + payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body }) }, replay: (ctx, outcome) => { + // A send this host queued answers from its draft, then its hand-off; a + // withdrawn draft replays as spent — never as missing-submission doubt. + const queued = queuedSendAnswer(ctx.journal, clientMessageId) + if (queued) { + return queued + } const submission = ctx.journal .submissions() .find((entry) => entry.clientMessageId === clientMessageId) @@ -122,6 +144,8 @@ export function conversationCommandPlan(params: { run: async (ctx) => { const sent = await performSend(ctx, { clientMessageId, + // Only a client asks through the command RPC: the person's own turn. + origin: 'client', payloadFingerprint: params.envelope.payloadFingerprint, body: structuredAgentSessionCompactBody() }) @@ -168,7 +192,7 @@ export function cancelPlan(params: { ...(params.stopChild ? { stopChild: params.stopChild } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a - // replay reports the turn as already handled instead. + // replay reports the turn as already handled. replay: () => ({ ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), cancelled: false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts new file mode 100644 index 00000000000..080cd7acc4d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -0,0 +1,167 @@ +// Queued drafts across conversation commands: a /compact is a queued message +// and then a turn, so a capable send during it becomes a card that waits for it +// like any turn, while Delete and Send-now answer at once; a /clear in flight +// admits no draft onto the source it is superseding; and a draft /clear carries +// to its replacement is fingerprinted for the replacement, so the provider's +// echo folds into its sent bubble. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +const WAIT_REFUSAL = { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'conversationCommandInFlight' }, + message: 'Wait for the conversation operation to finish.' + } +} + +function command(name: 'compact' | 'clear') { + const fields = { command: name } + return rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) +} + +async function queuedId( + result: ReturnType['result'] +): Promise { + const queued = await result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +describe('a /compact in flight', () => { + /** A /compact the provider took: a queued message, then its own turn, running until the + * provider ends it (`finishCompact`). */ + async function compactRunning(): Promise { + expect(await command('compact')).toMatchObject({ ok: true, value: { command: 'compact' } }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + } + + it('turns a capable send into a card, which waits for the compaction and then drains', async () => { + await compactRunning() + const draftId = await queuedId(rig.send('sent while compacting', 'queue-if-active').result) + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + // The compaction's turn owes work, so the drain waits behind it like any turn. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(await rig.handoff(draftId)).toBeUndefined() + rig.finishCompact() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect(await rig.drafts()).toHaveLength(0) + }) + + it('answers Delete at once, and Send-now sends its card behind the compaction like any send', async () => { + await compactRunning() + const deletedId = await queuedId(rig.send('deleted while compacting', 'queue-if-active').result) + const sentId = await queuedId(rig.send('sent now while compacting', 'queue-if-active').result) + const hung = new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)) + // Nothing holds the session's lane for the compaction's length any more. + expect(await Promise.race([rig.deleteQueued(deletedId), hung])).toMatchObject({ + ok: true, + value: { deleted: true } + }) + expect(await Promise.race([rig.sendNow(sentId), hung])).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: sentId } } + }) + // Accepted, then handed over only once the compaction ends — the order every send keeps. + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(rig.dispatch).toHaveBeenCalledTimes(0) + rig.finishCompact() + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + }) +}) + +describe('/clear', () => { + it('in flight, refuses a capable send as today: no card lands on the source it supersedes', async () => { + const attach = rig.host.attach.bind(rig.host) + let release: (() => void) | undefined + const released = new Promise((resolve) => { + release = resolve + }) + const spy = vi.spyOn(rig.host, 'attach').mockImplementationOnce(async (...args) => { + await released + return attach(...args) + }) + try { + const cleared = command('clear') + await eventually(() => + expect(rig.store.getRecord(SESSION)?.conversationCommand).toMatchObject({ + command: 'clear', + phase: 'prepared', + replacementSessionId: expect.any(String) + }) + ) + expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) + release?.() + const done = await cleared + const replacementId = done.ok ? done.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.drafts()).toHaveLength(0) + expect(await rig.drafts(replacementId)).toHaveLength(0) + } finally { + spy.mockRestore() + } + }) + + it("a carried draft sent on the replacement: the provider's echo folds into its one bubble", async () => { + const working = await rig.workingSend() + const draftId = await queuedId(rig.send('carried text', 'queue-if-active').result) + await rig.stop() + await rig.settleAccepted(working, 'a') + const cleared = await command('clear') + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.sendNow(draftId, hostTestOperationId(), replacementId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + const journal = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal + const sent = journal?.submissions().findLast((entry) => entry.queuedMessageId === draftId) + if (!journal || !sent) { + throw new Error('expected the carried draft sent on the replacement') + } + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-echo', ordinal: 0 }, + hostTestMessage('carried text'), + { fence: sent.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const snapshot = await rig.host.journalSnapshot(replacementId) + const userBubbles = snapshot.items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'user' + ) + expect(userBubbles).toHaveLength(1) + expect(snapshot.submissions.find((entry) => entry.queuedMessageId === draftId)).toMatchObject({ + dispatchState: 'accepted' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts new file mode 100644 index 00000000000..710d4c73f6d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -0,0 +1,303 @@ +// The one queue gate: admission, the drain step and Send-now consume a single +// typed hold decision, so the lists cannot drift — pinned here with a clear in +// doubt, Send-now's override set, and the replay-preference rule for a refused +// draft. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { structuredQueueHold } from './structured-agent-session-queued-messages' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +let host: QueuedMessageTestRig['host'] +let store: QueuedMessageTestRig['store'] + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() + ;({ host, store } = rig) +}) + +afterEach(() => rig.dispose()) + +const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args) +const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args) +const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args) +const submission: QueuedMessageTestRig['submission'] = (...args) => rig.submission(...args) +const drafts: QueuedMessageTestRig['drafts'] = () => rig.drafts() +const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend() +const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) => + rig.settleAccepted(...args) +const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) => + rig.settleRejected(...args) + +describe('the one queue gate', () => { + it('Send-now refuses while a clear is in doubt, with the refusal any send gets', async () => { + const working = await workingSend() + const queued = await send('queued behind the clear', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await store.setConversationCommand(SESSION, 1, { + command: 'clear', + runtimeFence: 1, + operationId: hostTestOperationId(), + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + }) + expect(await sendNow(draftId)).toMatchObject({ ok: false }) + await settleAccepted(working, 'a') + await new Promise((resolve) => setTimeout(resolve, 150)) + expect(await rig.handoff(draftId)).toBeUndefined() + }) + + it('Send-now refuses on a pending prompt and overrides a running turn', async () => { + const working = await workingSend() + const queued = await send('queued mid-turn', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + await journal.appendItem( + { provider: 'orca', clientMessageId: 'prompt-1' }, + { + kind: 'approval', + title: 'Allow the tool?', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(await sendNow(draftId)).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('pending request') } + }) + // Read in place: the gate runs on every admission and drain step. + const snapshot = vi.spyOn(journal, 'snapshot') + expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe( + 'prompt' + ) + expect(snapshot).not.toHaveBeenCalled() + snapshot.mockRestore() + await journal.appendItem( + { provider: 'orca', clientMessageId: 'prompt-1' }, + { + kind: 'approval', + title: 'Allow the tool?', + detail: null, + options: [], + resolution: { + state: 'resolved', + selectedOptionId: 'allow', + resolvedBy: 'client-1', + resolvedAt: 1 + } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + // The turn still runs (`working`), which Send-now alone may override. + expect(await submission(working)).toMatchObject({ dispatchState: 'pending' }) + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) +}) + +describe('replay preference', () => { + it('a replayed send whose draft was refused answers with the returned card, never the rejected submission', async () => { + const working = await workingSend() + const body = hostTestMessage('refused later') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }]) + ) + // The original reply was lost; the retry must agree with the card, or the + // same text renders twice — once on a Retry row, once on the card. + const replay = await host.send(CALLER, params) + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { messageId: clientOperationId, state: 'returned' } } + }) + if (replay.ok && 'submission' in replay.value) { + throw new Error('replay answered with the rejected submission') + } + }) +}) + +describe('replay of a deleted card', () => { + it('answers withdrawn once its row is pruned, never with the rejected hand-off it came back from', async () => { + const working = await workingSend() + const body = hostTestMessage('refused, then deleted') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }]) + ) + expect(await rig.deleteQueued(clientOperationId)).toMatchObject({ + ok: true, + value: { deleted: true } + }) + // Retention later drops the tombstone; the rejected hand-off still names the draft. + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + vi.spyOn(journal.queuedMessages, 'get').mockReturnValue(null) + const replay = await host.send(CALLER, params) + expect(replay).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { messageId: clientOperationId, state: 'withdrawn' } } + }) + if (replay.ok && 'submission' in replay.value) { + throw new Error('replay answered with the rejected hand-off') + } + }) +}) + +describe('the hand-off link on answers', () => { + it('a replayed queued send, once drained, answers with the hand-off that names its draft', async () => { + const working = await workingSend() + const body = hostTestMessage('drained later') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => + expect((await rig.handoff(clientOperationId))?.queuedMessageId).toBe(clientOperationId) + ) + const replayed = await host.send(CALLER, params) + expect(replayed).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { queuedMessageId: clientOperationId } } + }) + // Handed off under a fresh id, never the draft's (the send operation's) own. + expect( + replayed.ok && 'submission' in replayed.value && replayed.value.submission.clientMessageId + ).not.toBe(clientOperationId) + // The first send, direct, names no draft. + expect(await submission(working)).not.toHaveProperty('queuedMessageId') + }) + + it('a queued send asked again after its ledger row is gone answers with its hand-off, never sending twice', async () => { + const working = await workingSend() + const body = hostTestMessage('asked again') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const, + userSend: true as const + } + await host.send(CALLER, params) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) + const handedOffAs = await rig.handoffId(clientOperationId) + // The ledger forgot the id, so the send runs again rather than replaying. + const operations = store['transactions'].state.operations + for (const [key, row] of operations) { + if (row.operationId === clientOperationId) { + operations.delete(key) + } + } + const count = (await host.journalSnapshot(SESSION)).submissions.length + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { clientMessageId: handedOffAs, queuedMessageId: clientOperationId } } + }) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count) + }) +}) + +describe('Send-now rerun', () => { + it('a Send whose answer never settled answers again with the submission it made, never re-sending it', async () => { + const working = await workingSend() + const queued = await send('refused twice', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'first refusal') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + const operationId = hostTestOperationId() + expect(await sendNow(draftId, operationId)).toMatchObject({ ok: true }) + await settleRejected(operationId, 'second refusal') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + // The host died before the Send's answer settled: its ledger row is still pending, so it reruns. + for (const row of store['transactions'].state.operations.values()) { + if (row.operationId === operationId) { + row.outcome = { status: 'pending' } + } + } + const count = (await host.journalSnapshot(SESSION)).submissions.length + expect(await sendNow(draftId, operationId)).toMatchObject({ + ok: true, + value: { clientMessageId: operationId, submission: { dispatchState: 'rejected' } } + }) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count) + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts new file mode 100644 index 00000000000..fcc167cbb67 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -0,0 +1,299 @@ +// One real-host rig for the mid-turn queue suites: store, journal, adapter +// mocks, and the send/stop/draft helpers every suite shares. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi, type Mock } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } + +export function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +export type QueuedMessageTestRig = Awaited> + +export async function createQueuedMessageTestRig() { + const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) + resetHostTestOperationIds() + // Admitted: the message is written and unanswered, so the session owes work + // until the test settles it. + const dispatch: Mock = vi.fn(async () => ({ + state: 'admitted' as const + })) + const awaitStarted: Mock> = vi.fn( + async () => undefined + ) + // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. + const compact: Mock> = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: null + })) + let events: StructuredAgentSessionEventSink | undefined + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken, events: sink }) => { + events = sink + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + compact, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + + function envelope( + fields: Record, + method: string, + clientOperationId: string, + sessionId = SESSION + ) { + return { + sessionId, + clientOperationId, + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId, + fields + }) + } + } + + /** A client's send, as the `agentSession.send` RPC hands it to the host; + * `internal` is a host-side sender (orchestration mail, a restart continuation). */ + function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) { + const body = hostTestMessage(text) + const clientOperationId = hostTestOperationId() + const fields = { body, ...(delivery ? { delivery } : {}) } + const result = host.send(QUEUED_RIG_CALLER, { + envelope: envelope(fields, 'agentSession.send', clientOperationId), + body, + ...(delivery ? { delivery } : {}), + ...(options?.internal ? {} : { userSend: true as const }) + }) + return { id: clientOperationId, result } + } + + function stop(clientOperationId = hostTestOperationId(), caller = QUEUED_RIG_CALLER) { + return host.cancel(caller, { + envelope: envelope({}, 'agentSession.cancel', clientOperationId) + }) + } + + function sendNow( + messageId: string, + clientOperationId = hostTestOperationId(), + sessionId = SESSION + ) { + return host.queuedMessageSend(QUEUED_RIG_CALLER, { + envelope: envelope( + { messageId }, + 'agentSession.queuedMessageSend', + clientOperationId, + sessionId + ), + messageId + }) + } + + function deleteQueued(messageId: string, clientOperationId = hostTestOperationId()) { + return host.queuedMessageDelete(QUEUED_RIG_CALLER, { + envelope: envelope({ messageId }, 'agentSession.queuedMessageDelete', clientOperationId), + messageId + }) + } + + async function submission(id: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === id + ) + } + + /** The latest submission that hands off this draft, found by its link. */ + async function handoff(draftId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.findLast( + (entry) => entry.queuedMessageId === draftId + ) + } + + /** The submission id a draft went out under: never the draft's own id. */ + async function handoffId(draftId: string): Promise { + const sent = await handoff(draftId) + if (!sent) { + throw new Error(`draft ${draftId} has not been handed off`) + } + return sent.clientMessageId + } + + async function drafts( + sessionId = SESSION + ): Promise<{ messageId: string; state: string; paused?: true }[]> { + const page = await host.history({ sessionId, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return (page.page.queuedMessages ?? []).map(({ messageId, state, paused }) => ({ + messageId, + state, + ...(paused ? { paused } : {}) + })) + } + + /** A first send that keeps the session working until the test settles it. */ + async function workingSend(): Promise { + const { id, result } = send('work on this') + await result + await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) + return id + } + + async function settleAccepted(id: string, itemId: string): Promise { + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: `turn-${itemId}`, + ordinal: 0 + } + }) + } + + /** A provider refusal, written as the host writes one: the sentence and the typed fact. */ + async function settleRejected(id: string, providerText: string): Promise { + const detail = { text: providerText, audience: 'person' as const } + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: id, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected', { detail }), { + surface: 'rejection' + }) + }) + } + + /** What the provider's translator writes when a /compact's turn ends, as a success. */ + function finishCompact(): void { + const { command } = compact.mock.calls.at(-1)![0] + events!.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) + } + + /** A host-process restart, as the queue sees it: the conversation closes, and + * opens afresh under a new instance id while its rows survive. */ + async function restartHostProcess(): Promise { + await host.close(SESSION) + rotateStructuredAgentSessionHostInstanceForTests() + } + + /** The queue's published pause: null when it sends on its own. */ + async function queuePause(sessionId = SESSION): Promise { + const page = await host.history({ sessionId, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return page.page.queuePause ?? null + } + + function resume(clientOperationId = hostTestOperationId()) { + return host.queuedMessagesResume(QUEUED_RIG_CALLER, { + envelope: envelope({}, 'agentSession.queuedMessagesResume', clientOperationId) + }) + } + + async function dispose(): Promise { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) + } + + return { + root, + store, + host, + dispatch, + awaitStarted, + compact, + finishCompact, + envelope, + send, + stop, + sendNow, + deleteQueued, + submission, + handoff, + handoffId, + drafts, + workingSend, + settleAccepted, + settleRejected, + restartHostProcess, + queuePause, + resume, + dispose + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts new file mode 100644 index 00000000000..c10e9760ffa --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -0,0 +1,879 @@ +// Mid-turn queueing against the real host, store and journal: a capable send +// while the session owes work becomes a draft, the drain converts exactly one +// draft when the work settles, Stop holds the queue (never withdrawing text) +// until a user send starts its turn and lifts the pause, /clear carries the +// cards to its replacement session, and a refused conversion comes back as a +// returned card while a withdrawn one waits again. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +let host: QueuedMessageTestRig['host'] +let store: QueuedMessageTestRig['store'] +let dispatch: QueuedMessageTestRig['dispatch'] +let awaitStarted: QueuedMessageTestRig['awaitStarted'] + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() + ;({ host, store, dispatch, awaitStarted } = rig) +}) + +afterEach(() => rig.dispose()) + +const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args) +const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args) +const stop: QueuedMessageTestRig['stop'] = (...args) => rig.stop(...args) +const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args) +const deleteQueued: QueuedMessageTestRig['deleteQueued'] = (...args) => rig.deleteQueued(...args) +const drafts: QueuedMessageTestRig['drafts'] = (...args) => rig.drafts(...args) +const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend() +const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) => + rig.settleAccepted(...args) +const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) => + rig.settleRejected(...args) + +describe('accept', () => { + it('queues a capable send while the session owes work; an ordinary send still dispatches', async () => { + await workingSend() + const queued = await send('queued behind', 'queue-if-active').result + expect(queued).toMatchObject({ + ok: true, + value: { queued: { position: 1, state: 'waiting' } } + }) + // The draft is not a submission, feeds no reducer, and owes no work. + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(await drafts()).toMatchObject([{ state: 'waiting' }]) + }) + + it('replays the same queued answer for the same operation id', async () => { + await workingSend() + const body = hostTestMessage('queued behind') + const clientOperationId = hostTestOperationId() + const params = { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' as const + } + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { queued: { state: 'waiting' } } + }) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { state: 'waiting' } } + }) + expect(await drafts()).toHaveLength(1) + }) + + it('routes an image send to the immediate path even while working (text-only v1)', async () => { + await workingSend() + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'image-ref' as const, path: '/tmp/shot.png' }] + } + const clientOperationId = hostTestOperationId() + const result = await host.send(CALLER, { + envelope: envelope( + { body, delivery: 'queue-if-active' }, + 'agentSession.send', + clientOperationId + ), + body, + delivery: 'queue-if-active' + }) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + + it('a send without the delivery field never queues, whatever the session is doing', async () => { + await workingSend() + const { result } = send('old client send') + expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + + it('refuses past the draft-count budget with a readable message', async () => { + await workingSend() + for (let index = 0; index < 20; index += 1) { + expect(await send(`draft ${index}`, 'queue-if-active').result).toMatchObject({ ok: true }) + } + expect(await send('one too many', 'queue-if-active').result).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('queue is full') } + }) + }) +}) + +describe('drain', () => { + it('drains a single draft when the owed work settles, and one of two drafts per settle (A1)', async () => { + const working = await workingSend() + const first = await send('first queued', 'queue-if-active').result + const second = await send('second queued', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + const firstId = first.value.queued.messageId + const secondId = second.value.queued.messageId + await settleAccepted(working, 'a') + // The drain converts the OLDEST actionable draft; the consumed submission + // owes work again, which holds the second draft (one message per turn). + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + expect(await rig.handoff(secondId)).toBeUndefined() + expect(await drafts()).toMatchObject([{ messageId: secondId, state: 'waiting' }]) + await settleAccepted(await rig.handoffId(firstId), 'b') + await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined()) + expect(await drafts()).toHaveLength(0) + }) + + it('takes no serialized drain step while the session is working, then drains when the work settles', async () => { + const working = await workingSend() + const flush = vi.spyOn(host, 'flushStreamedEvents') + const queued = await send('waits for the turn', 'queue-if-active').result + await send('and another', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + // Every wake during the turn is answered by the pre-check, not a step. + expect(flush).not.toHaveBeenCalled() + await settleAccepted(working, 'a') + const draftId = queued.value.queued.messageId + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect(flush).toHaveBeenCalled() + }) + + it('a refused conversion returns the card with its stored reason, and an idle send overtakes a lone returned card (N1)', async () => { + const working = await workingSend() + const queued = await send('will be refused', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + // Classified like a rejected submission: from the fact, not the sentence. + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.returnedRejection).toEqual({ + kind: 'providerRejected', + detail: { text: 'provider refused this payload', audience: 'person' } + }) + // The lone returned card traps nothing: a new capable send goes immediately. + const overtaking = await send('sent past the card', 'queue-if-active').result + expect(overtaking).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + // And the card still offers Send: a fresh submission id re-delivers it. + const resent = await sendNow(draftId) + expect(resent).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + if (!resent.ok || !('submission' in resent.value)) { + throw new Error('expected the submission arm') + } + expect(resent.value.submission.clientMessageId).not.toBe(draftId) + // The answer names the card it sent; clients read that, never id equality. + expect(resent.value.submission.queuedMessageId).toBe(draftId) + expect(await drafts()).toHaveLength(0) + // Refused again: the card returns, matched through its current submission (N4). + await settleRejected(resent.value.submission.clientMessageId, 'refused again') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + }) + + it('a skipped settlement hook heals on the next drain step, not only at the next open', async () => { + const working = await workingSend() + const queued = await send('refused while the hook fails', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + } finally { + hook.mockRestore() + warn.mockRestore() + } + }) + + it('a waiting draft behind a returned card does not drain until the card is acted on (S5)', async () => { + const working = await workingSend() + const first = await send('to be refused', 'queue-if-active').result + const second = await send('waits behind the card', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + await settleAccepted(working, 'a') + const firstId = first.value.queued.messageId + const secondId = second.value.queued.messageId + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + await settleRejected(await rig.handoffId(firstId), 'refused') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: firstId, state: 'returned' }, + { messageId: secondId, state: 'waiting' } + ]) + ) + // Deleting the card unblocks the one behind it. + expect(await deleteQueued(firstId)).toMatchObject({ ok: true, value: { deleted: true } }) + await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined()) + }) +}) + +describe('held drafts', () => { + it('a restart pauses the queue, reason restarted, and it survives a reopen; never auto-sent', async () => { + const working = await workingSend() + const queued = await send('written before the restart', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + await settleAccepted(working, 'a') + // The queue is paused, not the card: it carries no hold of its own. + expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await host.close(SESSION) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it("a restart's pause also lifts when the user's next send starts its turn, exactly like a Stop's", async () => { + const working = await workingSend() + const queued = await send('written before the restart', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + await settleAccepted(working, 'a') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + // The user's send starting its turn lifts it, and adopts the row into this instance. + const next = send('user starts a new turn') + await next.result + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await settleAccepted(next.id, 'b') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a failed conversion leaves the draft waiting and paused with its error; Send retries', async () => { + const working = await workingSend() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const original = AgentSessionJournal.prototype.appendSubmission + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: draftId, state: 'waiting', paused: true } + ]) + ) + } finally { + append.mockRestore() + } + expect(AgentSessionJournal.prototype.appendSubmission).toBe(original) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + // A marker the client localizes, never host-authored copy. + expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + // The marker is stored on the row, so a host restart keeps "Couldn't send" + // instead of downgrading the card to a plain pause. + rotateStructuredAgentSessionHostInstanceForTests() + const restarted = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(restarted.ok && restarted.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + expect(await drafts()).toHaveLength(0) + }) +}) + +describe('Stop and Delete', () => { + it('Stop pauses the queue — from ANY client — and the cards stay published; no text rides the answer', async () => { + await workingSend() + const first = await send('first text', 'queue-if-active').result + const second = await send('second text', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + // The Stop comes from a DIFFERENT client than the one that typed the + // drafts: it must never move their text anywhere. + const operationId = hostTestOperationId() + const stopped = await stop(operationId, { callerKey: 'client-2' }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(stopped.ok && Object.keys(stopped.value).sort()).toEqual(['cancelled']) + expect(await drafts()).toEqual([ + { messageId: first.value.queued.messageId, state: 'waiting' }, + { messageId: second.value.queued.messageId, state: 'waiting' } + ]) + // One pause for the whole queue: "Queue paused because you interrupted". + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // A lost acknowledgement replays the settled Stop; still no text, no field. + const replayed = await stop(operationId, { callerKey: 'client-2' }) + expect(replayed).toMatchObject({ ok: true, replayed: true, value: { cancelled: false } }) + expect(replayed.ok && Object.keys(replayed.value).sort()).toEqual(['cancelled']) + expect(await drafts()).toHaveLength(2) + }) + + /** A draft consumed into a submission the delivery loop has not handed over: + * the loop is held at the child's start proof until the returned release. */ + async function consumedButNotHandedOver(): Promise<{ draftId: string; release: () => void }> { + const working = await workingSend() + const queued = await send('stopped in flight', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + let release: () => void = () => undefined + awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await settleAccepted(working, 'a') + const draftId = queued.value.queued.messageId + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + expect((await rig.handoff(draftId))?.handedOverAt).toBeUndefined() + return { draftId, release: () => release() } + } + + it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => { + const { draftId, release } = await consumedButNotHandedOver() + const stopped = await stop() + release() + expect(stopped).toMatchObject({ ok: true }) + expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Nothing failed, so the card carries no refusal: it reads like any other paused card. + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + const card = page.ok ? page.page.queuedMessages?.[0] : undefined + expect(card).not.toHaveProperty('returnedReason') + expect(card).not.toHaveProperty('returnedRejection') + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('the Stop pause survives eviction and reopen, and Send-now overrides it', async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await settleAccepted(working, 'a') + // Evict the handle and reopen (the history read opens the conversation at + // rest): the pause is derived from what the journal holds, so nothing drains. + await host.close(SESSION) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + // Send-now overrides the pause — the user acting is a release. + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it("the user's next send lifts the stopped hold once its turn starts, and the held draft drains after that turn", async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + await settleAccepted(working, 'a') + // Settling the stopped turn is not the user starting one: still paused. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The host accepting the send is not yet a turn: the pause lifts when the + // provider accepts it, and the draft drains after that turn. + const next = send('user starts a new turn') + await next.result + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await settleAccepted(next.id, 'b') + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) never lifts the pause', async () => { + const working = await workingSend() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await stop() + await settleAccepted(working, 'a') + // All three reach the host as a send the client send RPC did not make. + const mail = send('coordinator mail', undefined, { internal: true }) + expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + // The journal records who asked, which is what the pause reads. + expect(await rig.submission(mail.id)).toMatchObject({ origin: 'host' }) + expect(await rig.submission(working)).toMatchObject({ origin: 'client' }) + await settleAccepted(mail.id, 'b') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { + const working = await workingSend() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(async () => + expect(await drafts()).toMatchObject([ + { messageId: draftId, state: 'waiting', paused: true } + ]) + ) + } finally { + append.mockRestore() + } + const next = send('user starts a new turn') + await next.result + await settleAccepted(next.id, 'b') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( + QUEUED_MESSAGE_PAUSED_SEND_FAILED + ) + // The explicit Send is still the release. + expect(await sendNow(draftId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it('a Stop whose pause record fails still interrupts; only the pause is lost, and it is reported', async () => { + await workingSend() + const queued = await send('kept by the stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const record = vi + .spyOn(JournalQueuedMessages.prototype, 'recordPause') + .mockRejectedValueOnce(new Error('disk full')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(warned).toHaveBeenCalledWith(expect.stringContaining('queue pause'), expect.anything()) + } finally { + record.mockRestore() + warned.mockRestore() + } + expect(await rig.queuePause()).toBeNull() + // The draft is intact (never withdrawn), merely unpaused. + expect(await drafts()).toEqual([{ messageId: queued.value.queued.messageId, state: 'waiting' }]) + }) + + it('Delete returns no body, replays from the receipt, and a fresh delete reports the disposition', async () => { + await workingSend() + const queued = await send('delete me', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const operationId = hostTestOperationId() + const deleted = await deleteQueued(draftId, operationId) + expect(deleted).toMatchObject({ + ok: true, + replayed: false, + value: { deleted: true, messageId: draftId } + }) + // The card leaving the published list is the whole answer. + expect(deleted.ok && Object.keys(deleted.value).sort()).toEqual(['deleted', 'messageId']) + expect(await drafts()).toHaveLength(0) + expect(await deleteQueued(draftId, operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { deleted: true, messageId: draftId } + }) + // A FRESH delete of the already-withdrawn draft reports the disposition. + expect(await deleteQueued(draftId)).toMatchObject({ + ok: true, + value: { deleted: false, disposition: 'withdrawn' } + }) + }) +}) + +describe('/clear', () => { + function clear(clientOperationId: string) { + const fields = { command: 'clear' as const } + return host.conversationCommand(CALLER, { + envelope: envelope(fields, 'agentSession.conversationCommand', clientOperationId), + ...fields + }) + } + + /** Two drafts paused by a Stop, then the work settled so command admission + * has nothing pending. */ + async function pausedDrafts(): Promise<[string, string]> { + const working = await workingSend() + const first = await send('first text', 'queue-if-active').result + const second = await send('second text', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + await stop() + await settleAccepted(working, 'a') + return [first.value.queued.messageId, second.value.queued.messageId] + } + + it('the clear schema refuses the never-shipped withdraw opt-in', () => { + const base = { envelope: envelope({}, 'agentSession.conversationCommand', 'op-schema') } + expect(ConversationCommandParams.safeParse({ ...base, command: 'clear' }).success).toBe(true) + expect( + ConversationCommandParams.safeParse({ ...base, command: 'clear', withdrawQueued: true }) + .success + ).toBe(false) + }) + + it('carries the drafts to the replacement session as visible cards on a paused queue — the same for every client', async () => { + const [firstId, secondId] = await pausedDrafts() + const operationId = hostTestOperationId() + const cleared = await clear(operationId) + expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + // The source's cards are spent tombstones; the replacement shows them on a + // queue paused by the clear — not "because you interrupted" — until the user + // acts: Resume, or their next send starting its turn. + expect(await drafts()).toHaveLength(0) + expect(await drafts(replacementId)).toEqual([ + { messageId: firstId, state: 'waiting' }, + { messageId: secondId, state: 'waiting' } + ]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Paused from before the first carried card lands: the idle replacement auto-sends nothing. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) + // A lost acknowledgement's replay re-runs nothing and duplicates nothing. + const replayed = await clear(operationId) + expect(replayed).toMatchObject({ ok: true, replayed: true }) + expect(await drafts(replacementId)).toHaveLength(2) + // The carried card still answers Send-now, on the replacement. + expect(await rig.sendNow(firstId, hostTestOperationId(), replacementId)).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + }) + + it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + const [firstId] = await pausedDrafts() + const cleared = await clear(hostTestOperationId()) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + const resumed = await host.queuedMessagesResume(CALLER, { + envelope: envelope( + {}, + 'agentSession.queuedMessagesResume', + hostTestOperationId(), + replacementId + ) + }) + expect(resumed).toMatchObject({ ok: true, value: { resumed: true } }) + expect(await rig.queuePause(replacementId)).toBeNull() + await eventually(async () => + expect( + (await host.journalSnapshot(replacementId)).submissions.some( + (entry) => entry.queuedMessageId === firstId + ) + ).toBe(true) + ) + }) + + it('a carry whose insert fails leaves no pause over the empty replacement', async () => { + await pausedDrafts() + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const insert = vi + .spyOn(JournalQueuedMessages.prototype, 'insert') + .mockRejectedValueOnce(new Error('disk full')) + let replacementId: string | undefined + try { + const cleared = await clear(hostTestOperationId()) + replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + } finally { + insert.mockRestore() + warned.mockRestore() + } + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await drafts(replacementId)).toHaveLength(0) + const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal + expect(journal?.queuedMessages.pause()).toBeNull() + }) + + it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { + const working = await workingSend() + const queued = await send('refused then cleared', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) + ) + const cleared = await clear(hostTestOperationId()) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + // The refusal belonged to the source's submissions; on the replacement the + // text is simply a waiting draft again, behind the replacement's pause. + expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await drafts()).toHaveLength(0) + }) + + it('a draft held by a clear left prepared drains when the retried clear fails with no journal commit', async () => { + const working = await workingSend() + const queued = await send('behind the clear', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + // A clear that threw left its prepared phase behind; the settling turn's drain step meets it. + const operationId = hostTestOperationId() + await store.setConversationCommand(SESSION, 1, { + command: 'clear', + runtimeFence: 1, + operationId, + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + }) + await settleAccepted(working, 'a') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + // The retried clear fails definitively: it settles on the record alone. + const attach = vi.spyOn(host, 'attach').mockResolvedValueOnce({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported', message: 'unsupported' } + }) + try { + expect(await clear(operationId)).toMatchObject({ + ok: true, + value: { command: 'clear', state: 'completed', error: expect.any(String) } + }) + } finally { + attach.mockRestore() + } + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a clear with no drafts carries nothing and answers exactly as before', async () => { + const cleared = await clear(hostTestOperationId()) + expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await drafts(replacementId)).toHaveLength(0) + }) +}) + +describe('publication', () => { + async function subscribeEvents(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + return events + } + + function queuedFrames(events: AgentSessionSubscribeEvent[]): AgentSessionQueuedMessage[][] { + return events.flatMap((event) => + event.type !== 'end' && event.queuedMessages !== undefined && event.queuedMessages !== null + ? [event.queuedMessages] + : [] + ) + } + + it('hydrates the list on subscribe, publishes draft inserts at an unchanged cursor, and carries the shrunk list with the consumed submission in one frame', async () => { + const working = await workingSend() + const events = await subscribeEvents() + // Hydration: the opening snapshot carries the (empty) list. + expect(events[0]).toMatchObject({ type: 'snapshot', queuedMessages: [] }) + const queued = await send('queued behind', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + // The insert writes no journal row, yet the caught-up publish delivers it. + await eventually(() => { + const lists = queuedFrames(events) + expect(lists.at(-1)).toMatchObject([{ messageId: draftId, state: 'waiting' }]) + }) + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + // The frame that carries the consumed submission also carries the shrunk list. + const consumeFrame = events.find( + (event) => + event.type === 'batch' && + event.batch.submissions.some((entry) => entry.queuedMessageId === draftId) + ) + expect(consumeFrame).toBeDefined() + if (consumeFrame?.type === 'batch') { + expect(consumeFrame.queuedMessages).toEqual([]) + } + }) + + it('a failed conversion reaches live subscribers as a paused card, with no further journal commit', async () => { + const working = await workingSend() + const events = await subscribeEvents() + const queued = await send('conversion fails once', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await settleAccepted(working, 'a') + await eventually(() => + expect(queuedFrames(events).at(-1)).toMatchObject([ + { messageId: draftId, paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } + ]) + ) + } finally { + append.mockRestore() + } + // Send releases the process-level pause, which later tests' reused ids would otherwise inherit. + expect(await sendNow(draftId)).toMatchObject({ ok: true }) + }) + + it("live frames carry the queue's pause with the list, and Resume's lift", async () => { + await workingSend() + const events = await subscribeEvents() + const queued = await send('paused by stop', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const pauses = () => + events.flatMap((event) => + event.type !== 'end' && event.queuePause !== undefined ? [event.queuePause] : [] + ) + await eventually(() => expect(pauses().at(-1)).toBeNull()) + await stop() + await eventually(() => expect(pauses().at(-1)).toEqual({ reason: 'stopped' })) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(pauses().at(-1)).toBeNull()) + }) + + it('an idle Stop that takes no effect pauses nothing', async () => { + const working = await workingSend() + const first = await send('to be refused', 'queue-if-active').result + const second = await send('waits behind the card', 'queue-if-active').result + if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) { + throw new Error('expected queued receipts') + } + const firstId = first.value.queued.messageId + await settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined()) + await settleRejected(await rig.handoffId(firstId), 'refused') + await eventually(async () => + expect(await drafts()).toMatchObject([{ messageId: firstId, state: 'returned' }, {}]) + ) + // Idle, nothing in flight and nothing withdrawn: the Stop changes nothing. + expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(await rig.queuePause()).toBeNull() + }) + + it('an unchanged list is not re-sent on later frames', async () => { + await workingSend() + const events = await subscribeEvents() + await send('queued behind', 'queue-if-active').result + await eventually(() => expect(queuedFrames(events).length).toBeGreaterThan(0)) + const framesAfterInsert = queuedFrames(events).length + // Another journal commit with no draft change re-sends nothing. + const { result } = send('another working send') + await result + await eventually(async () => { + const last = events.at(-1) + expect(last?.type).toBe('batch') + }) + expect(queuedFrames(events).length).toBe(framesAfterInsert) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts new file mode 100644 index 00000000000..81676bfa5e0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -0,0 +1,378 @@ +// Mid-turn queueing: the accept decision that turns a send into a host-held +// draft, the serialized drain that converts one draft into an ordinary +// submission when the session stops owing work, and the published draft list. +// +// Drafts are never owed work: they feed no reducer, no working status, no +// teardown and no idle sweep. The drain re-reads every gate inside its own +// serialized step, so there is no loop state to disagree with the journal. + +import { randomUUID } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionSendResult, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../shared/structured-agent-session-mutation' +import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' +import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' +import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + structuredAgentSessionHostInstance, + structuredQueuePause +} from './structured-agent-session-queued-pause' + +/** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the + * serialized blocks); refused readably rather than trimmed. */ +export const QUEUED_MESSAGES_MAX_COUNT = 20 +export const QUEUED_MESSAGES_MAX_TOTAL_BYTES = 1024 * 1024 + +/** Text-only v1: any image block routes to the immediate path. */ +export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): boolean { + return body.blocks.every((block) => block.type === 'text') +} + +/** Walks the reduced items in place: the gate runs on every admission and + * drain step, so it must not render a snapshot of the whole journal. */ +export function pendingPromptExists(journal: Pick): boolean { + let pending = false + journal.visitItems((_itemId, _sequence, body) => { + if ( + !pending && + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) { + pending = true + } + }) + return pending +} + +/** Waiting, not held on its own, not positioned behind a returned card, and the + * queue not paused. The admission rule (§accept) and the drain's selection + * both read it. */ +function oldestActionableQueuedMessage( + journal: Pick +): QueuedMessageRow | null { + const rows = journal.queuedMessages.list() + // Nothing waiting costs no pause derivation: this runs on every journal publish. + if (!rows.some((row) => row.state === 'waiting') || structuredQueuePause(journal) !== null) { + return null + } + for (const row of rows) { + if (row.state === 'returned') { + // A returned card blocks everything after it until the user acts. + return null + } + if (row.state === 'waiting' && row.holdReason === null) { + return row + } + } + return null +} + +/** + * Why the queue is not sending right now — ONE decision for admission, the + * drain step and Send-now, so the lists cannot drift. Each caller's override + * policy sits next to its use: + * + * admission: `blocked` refuses (the immediate path's own refusal); any other + * hold, or an actionable backlog, queues the send as a draft. + * drain step: any hold returns early; whatever clears it publishes or + * commits, which re-derives. + * Send-now: overrides only `working` (plus FIFO order and the stored hold); + * `blocked` and `prompt` refuse readably. + * + * `blocked` is whatever refuses any send (an uncertain rewind, a clear in doubt, + * a cleared source); the rest are waits. A /compact is a queued message and then a turn, + * so it holds the queue as `working`; an older build's compaction record belongs + * to a child this host no longer runs and holds nothing. Host-local vocabulary — + * never on the wire. + */ +export type StructuredQueueHold = 'blocked' | 'working' | 'prompt' + +export function structuredQueueHold(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + fence: number +}): StructuredQueueHold | null { + // Whatever refuses any send refuses the queue too: an uncertain rewind, a clear in + // doubt, or a source a clear superseded. One rule, the immediate path's own. + if (structuredAgentSessionSendBlock(input.record)) { + return 'blocked' + } + const { journal } = input + // `prompt` outranks `working`: it is the one wait Send-now may not override, + // so a prompt raised mid-turn must not read as merely `working`. + if (pendingPromptExists(journal)) { + return 'prompt' + } + if ( + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + input.fence + ) + ) { + return 'working' + } + return null +} + +/** + * Whether a `queue-if-active` send becomes a draft: any queue hold short of + * `blocked`, or an actionable draft already exists (FIFO backlog — an + * ADMISSION rule only, never a drain gate). A lone returned card, or a paused + * queue, does not trap a new send: the user acting now wins, and that send's + * turn starting is what lifts the pause — Orca's own queue policy, a stated + * deviation from held-head backlog counting. + */ +export function shouldQueueStructuredAgentSessionSend(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + fence: number +}): boolean { + const hold = structuredQueueHold(input) + if (hold === 'blocked') { + // The immediate path's own refusal (`structuredAgentSessionSendBlock`) + // answers; queueing behind a fence would strand the draft. + return false + } + if (hold !== null) { + return true + } + return oldestActionableQueuedMessage(input.journal) !== null +} + +/** A draft's payload fingerprint in the session that will send it: the reducer + * aliases the provider's echo to the submission by recomputing exactly this. */ +export function queuedMessageFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} + +/** The accept-side budget refusal, or null when the draft fits. */ +export function queuedMessageBudgetRefusal( + journal: AgentSessionJournal, + body: AgentJournalMessageItem +): AgentSessionWireRefusal | null { + const unsettled = journal.queuedMessages.list().filter(isUnsettledQueuedMessage) + const bytes = unsettled.reduce( + (sum, row) => sum + Buffer.byteLength(JSON.stringify(row.body.blocks), 'utf8'), + Buffer.byteLength(JSON.stringify(body.blocks), 'utf8') + ) + if (unsettled.length >= QUEUED_MESSAGES_MAX_COUNT || bytes > QUEUED_MESSAGES_MAX_TOTAL_BYTES) { + return { + code: 'agent_session_operation_invalid', + message: 'The message queue is full. Send again after the current turn ends.' + } + } + return null +} + +/** + * The accept branch: a capable send while the session is working (or behind an + * actionable backlog) becomes a draft instead of a submission. Returns null for + * the immediate path — an incapable client, an image body (text-only v1), a + * replayed id the journal already answers, or an idle session. + */ +export async function maybeQueueStructuredAgentSessionSend( + context: { + deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } + }, + ctx: { + sessionId: string + journal: AgentSessionJournal + fence: number + }, + params: { + envelope: { clientOperationId: string } + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + } +): Promise< + | { ok: true; value: AgentSessionSendResult } + | { ok: false; refusal: AgentSessionWireRefusal } + | null +> { + const clientMessageId = params.envelope.clientOperationId + if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + return null + } + // Asked again with no ledger answer: a send this host queued answers as its replay would — + // its hand-off goes out under a fresh id, so no submission under this id guards it. + const queuedBefore = queuedSendAnswer(ctx.journal, clientMessageId) + if (queuedBefore) { + return { ok: true, value: queuedBefore } + } + // A recorded direct submission under this id replays through today's path. + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return null + } + if ( + !shouldQueueStructuredAgentSessionSend({ + journal: ctx.journal, + record: context.deps.store.getRecord(ctx.sessionId), + fence: ctx.fence + }) + ) { + return null + } + const refusal = queuedMessageBudgetRefusal(ctx.journal, params.body) + if (refusal) { + return { ok: false, refusal } + } + // The insert notifies through the journal's commit listener: publication and + // the drain re-derive with no call here to forget. + const row = await ctx.journal.queuedMessages.insert({ + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() + }) + return { + ok: true, + value: { + clientMessageId, + queued: { messageId: row.messageId, position: row.position, state: row.state } + } + } +} + +export type QueuedMessageDrainDeps = { + sessions: ReadonlyMap + getRecord: (sessionId: string) => AgentSessionRecord | null + serialize: (sessionId: string, task: () => Promise) => Promise + /** The streamed-event barrier: a turn-open already accepted by the host is + * committed before the gates are read, so no stored busy flag is needed. */ + flushStreamedEvents: (sessionId: string) => Promise + conversationFence: (sessionId: string) => number + /** The consumed submission is ordinary #22821 work from here on. */ + wakeDelivery: (sessionId: string) => void + onError: (sessionId: string, error: unknown) => void +} + +/** + * The serialized drain. Woken by every journal commit (turn, submission, prompt, + * command and Stop settlements are all commits), by draft mutations, and by the + * conversation opening; each step re-derives everything and consumes at most one + * draft — the consumed submission then owes work, which gates the next. + */ +export class StructuredAgentSessionQueuedMessageDrain { + private readonly scheduled = new Set() + + constructor(private readonly deps: QueuedMessageDrainDeps) {} + + schedule(sessionId: string): void { + const journal = this.deps.sessions.get(sessionId)?.journal + if (!journal || journal.isReadOnly) { + return + } + // Cheap pre-check so token streams do not pay a serialized step per delta. + // Skipping while working is safe: whatever ends the work is itself a commit + // that schedules again, and the step re-reads every gate after its flush. + try { + if ( + !journal.queuedMessages.settlementOwed() && + (oldestActionableQueuedMessage(journal) === null || + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + this.deps.conversationFence(sessionId) + )) + ) { + return + } + } catch { + // The handle is opening or closing; the next commit re-schedules. + return + } + if (this.scheduled.has(sessionId)) { + return + } + this.scheduled.add(sessionId) + void this.deps + .serialize(sessionId, () => { + this.scheduled.delete(sessionId) + return this.step(sessionId) + }) + .catch((error: unknown) => { + this.scheduled.delete(sessionId) + this.deps.onError(sessionId, error) + }) + } + + private async step(sessionId: string): Promise { + const session = this.deps.sessions.get(sessionId) + if (!session || session.journal.isReadOnly) { + return + } + await this.deps.flushStreamedEvents(sessionId) + const journal = session.journal + if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) { + // A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen. + await journal.queuedMessages.settleOwed().catch((error: unknown) => { + this.deps.onError(sessionId, error) + }) + } + const next = oldestActionableQueuedMessage(journal) + if (!next) { + return + } + const record = this.deps.getRecord(sessionId) + const fence = this.deps.conversationFence(sessionId) + // Live facts only, through the one gate; the backlog is never a gate, so a + // lone draft drains. Whatever clears a hold publishes or commits, which + // re-derives this step. + if (structuredQueueHold({ journal, record, fence }) !== null) { + return + } + // Always a fresh id: the submission names its draft by `queuedMessageId`, never by id equality. + const submissionId = createStructuredAgentSessionOperationId(randomUUID) + try { + await journal.appendSubmission( + { + clientMessageId: submissionId, + // The queue's own automatic send: it never ends a pause. + origin: 'host', + payloadFingerprint: next.fingerprint, + body: next.body, + fence, + handoverRecorded: true + }, + { + messageId: next.messageId, + expect: 'waiting', + settledByOp: null, + hostInstance: structuredAgentSessionHostInstance() + } + ) + } catch (error) { + if (error instanceof QueuedMessageNotConsumableError) { + // Lost a race with a Send-now or Delete; their transition stands. + return + } + // Pre-consume failure: the draft stays waiting, held with the marker on + // the card (a stored fact, so it survives eviction and restart). The + // hold's own commit notification publishes it. An explicit Send retries; + // no automatic retry loop. + await journal.queuedMessages + .hold({ messageIds: [next.messageId], reason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }) + .catch(() => {}) + throw error + } + this.deps.wakeDelivery(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts new file mode 100644 index 00000000000..aa2c39d5930 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -0,0 +1,317 @@ +// `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`, and +// /clear's carry of the source's drafts to its replacement session. Settling +// operations stamp op-scoped tombstone receipts, so a lost acknowledgement +// replays from the rows themselves — never from the operation ledger, which +// records only that an operation happened. No mutation returns draft text: +// the published list is the one authority a client renders. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' +import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { + queuedMessageFingerprint, + structuredQueueHold +} from './structured-agent-session-queued-messages' +import { + resumeStructuredQueue, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' +import { unsettledQueuedMessages } from './structured-agent-session-queued-stop' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { + openForWrite, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +function invalid(message: string): { + ok: false + refusal: { code: 'agent_session_operation_invalid'; message: string } +} { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +function submissionFor( + ctx: AgentSessionTurnContext, + clientMessageId: string +): AgentJournalSubmission | undefined { + return ctx.journal.submissions().find((entry) => entry.clientMessageId === clientMessageId) +} + +/** One transaction, stamped with the operation's caller-scoped key so a replay + * answers "spent" from the receipts. Withdrawal retires any hold in the same + * UPDATE, and the store's commit notification publishes the change. */ +export async function withdrawQueuedMessagesForOperation( + journal: AgentSessionJournal, + input: { + sessionId: string + messageIds: readonly string[] + callerKey: string + operationId: string + } +): Promise { + return journal.queuedMessages.withdraw({ + messageIds: input.messageIds, + settledByOp: agentSessionOperationKey(input.callerKey, input.operationId) + }) +} + +/** + * /clear's carry: the source's unsettled drafts become rows on the replacement + * session — the SAME for every client version, with no text on the wire — so the + * cards stay visible where the user now is. The replacement's queue starts + * paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just + * discarded, so they wait for the user's next turn there, or Resume, rather than + * sending into the fresh context unasked. Each card lands with the pause in one + * transaction, so the drain never sees a carried card unpaused and no pause is + * left over an empty queue if an insert fails. Runs after the + * replacement's attach succeeded and before the clear commits. Each insert is + * idempotent on (session, message), so the clear's rerun-while-prepared replays + * it safely; the source rows are then tombstoned. Bookkeeping around the clear: + * a failure leaves the cards on the superseded source — whose supersession + * fence already blocks the drain — reported, never gating the clear. A crash + * between the copy and the tombstone leaves both, which the fence also makes + * harmless: nothing is lost and nothing runs. + */ +export async function carryQueuedMessagesToClearReplacement( + ctx: AgentSessionTurnContext, + input: { + replacementSessionId: string + replacementJournal: AgentSessionJournal | undefined + callerKey: string + operationId: string + } +): Promise { + try { + const rows = unsettledQueuedMessages(ctx.journal) + if (rows.length === 0) { + return + } + const replacement = input.replacementJournal + if (!replacement) { + throw new Error('the replacement journal is not open') + } + for (const row of rows) { + // A returned card carries over as a plain waiting draft — its refusal + // belonged to the source's submissions. The fingerprint is re-scoped to the + // replacement, or its echo could never alias the sent bubble. + await replacement.queuedMessages.insert({ + messageId: row.messageId, + body: row.body, + fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), + hostInstance: structuredAgentSessionHostInstance(), + pausedBy: 'cleared' + }) + } + await withdrawQueuedMessagesForOperation(ctx.journal, { + sessionId: ctx.sessionId, + messageIds: rows.map((row) => row.messageId), + callerKey: input.callerKey, + operationId: input.operationId + }) + } catch (error) { + console.warn("[agent-session] /clear's queued-draft carry skipped:", { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** Draft actions run like any mutation: admitted on the session's lane, the + * conversation opened for the write. */ +function mutateQueued( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): Promise> { + return mutateStructuredAgentSession( + context, + caller, + envelope, + plan, + openForWrite(context, envelope) + ) +} + +/** + * Send-now. It overrides ONLY queue policy — FIFO order, pause, the busy-turn + * wait — through the same send block and pending-prompt gates as any send; + * supersession, Stop and prepared commands are never overridden. The card goes + * out under this operation's id, never its own, and the submission names it by + * `queuedMessageId`; one id still means one delivery. + */ +export function sendQueuedStructuredAgentMessage( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; messageId: string } +): Promise> { + const { messageId } = params + const operationId = params.envelope.clientOperationId + const plan: MutationPlan = { + method: 'agentSession.queuedMessageSend', + fields: { messageId }, + conversationWrite: true, + run: async (ctx): Promise> => { + // A rerun of this operation after it consumed the card (its answer never + // settled): answer with the submission it made, never append it again. + const consumedHere = submissionFor(ctx, operationId) + if (consumedHere?.queuedMessageId === messageId) { + return { ok: true, value: { clientMessageId: operationId, submission: consumedHere } } + } + // The one queue gate; Send-now's override set is exactly `working` (plus + // FIFO order and the stored hold, which the consume below clears). + const record = context.deps.store.getRecord(ctx.sessionId) + const hold = structuredQueueHold({ journal: ctx.journal, record, fence: ctx.fence }) + if (hold === 'blocked') { + return structuredAgentSessionSendBlock(record) ?? invalid('This conversation cannot send.') + } + if (hold === 'prompt') { + return invalid('Answer the pending request before sending this message.') + } + const row = ctx.journal.queuedMessages.get(messageId) + if (!row) { + return invalid('No queued message by that id.') + } + if (row.state === 'withdrawn') { + return invalid('This queued message was withdrawn.') + } + if (row.state === 'dispatched') { + // Already a submission — answer with it rather than sending twice. + const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) + return submission + ? { ok: true, value: { clientMessageId: submission.clientMessageId, submission } } + : invalid('This queued message was already sent.') + } + const submissionId = operationId + try { + await ctx.journal.appendSubmission( + { + clientMessageId: submissionId, + // The person asked for this turn, so it ends a Stop's pause once it starts. + origin: 'client', + payloadFingerprint: row.fingerprint, + body: row.body, + fence: ctx.fence, + handoverRecorded: true + }, + { + messageId, + expect: row.state, + settledByOp: agentSessionOperationKey(ctx.resolvedBy, operationId), + hostInstance: structuredAgentSessionHostInstance() + } + ) + } catch (error) { + if (error instanceof QueuedMessageNotConsumableError) { + return invalid('The queued message changed underneath this Send; try again.') + } + throw error + } + const submission = submissionFor(ctx, submissionId) + if (!submission) { + throw new Error('agent_session_submission_lost') + } + context.wakeDelivery(ctx.sessionId) + return { ok: true, value: { clientMessageId: submissionId, submission } } + }, + replay: (ctx) => { + const opKey = agentSessionOperationKey(ctx.resolvedBy, operationId) + const row = ctx.journal.queuedMessages + .receipts(opKey) + .find((receipt) => receipt.messageId === messageId) + if (!row || row.state !== 'dispatched') { + return null + } + const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) + return submission ? { clientMessageId: submission.clientMessageId, submission } : null + } + } + return mutateQueued(context, caller, params.envelope, plan) +} + +/** Delete = discard, with no body in the answer: the card leaving the published + * list IS the outcome, so a lost answer needs no re-ask. An Edit is the client + * copying the text it already renders, then this Delete. */ +export function deleteQueuedStructuredAgentMessage( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; messageId: string } +): Promise> { + const { messageId } = params + const operationId = params.envelope.clientOperationId + const plan: MutationPlan = { + method: 'agentSession.queuedMessageDelete', + fields: { messageId }, + conversationWrite: true, + run: async (ctx): Promise> => { + const row = ctx.journal.queuedMessages.get(messageId) + if (!row) { + return { ok: true, value: { deleted: false, messageId, disposition: 'missing' } } + } + if (row.state === 'dispatched') { + return { ok: true, value: { deleted: false, messageId, disposition: 'dispatched' } } + } + if (row.state === 'withdrawn') { + return { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } + } + // The withdrawal notifies through the journal's commit listener, which + // also re-derives the drain — deleting a returned card can unblock the + // drafts behind it. + const withdrawn = await withdrawQueuedMessagesForOperation(ctx.journal, { + sessionId: ctx.sessionId, + messageIds: [messageId], + callerKey: ctx.resolvedBy, + operationId + }) + return withdrawn.length > 0 + ? { ok: true, value: { deleted: true, messageId } } + : { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } + }, + replay: (ctx) => { + const replayed = ctx.journal.queuedMessages + .receipts(agentSessionOperationKey(ctx.resolvedBy, operationId)) + .some((row) => row.messageId === messageId && row.state === 'withdrawn') + return replayed ? { deleted: true, messageId } : null + } + } + return mutateQueued(context, caller, params.envelope, plan) +} + +/** Resume: ends the queue's pause — a Stop's, or a restart's — so the cards send + * again, oldest first, as the session goes idle. A no-op when nothing is paused, + * and a per-card `send_failed` hold stays for its own Send. */ +export function resumeStructuredAgentQueue( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope } +): Promise> { + const plan: MutationPlan = { + method: 'agentSession.queuedMessagesResume', + fields: {}, + conversationWrite: true, + // The lift notifies through the journal's commit listener, which publishes the + // cleared pause and wakes the drain. + run: async (ctx) => ({ + ok: true, + value: { resumed: await resumeStructuredQueue(ctx.journal) } + }), + // Like Stop's replay: the Resume already ran, so this one lifts nothing. + replay: () => ({ resumed: false }) + } + return mutateQueued(context, caller, params.envelope, plan) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts new file mode 100644 index 00000000000..1349a6a178e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -0,0 +1,476 @@ +// A Stop pauses the whole queue, derived from the journal: it lasts until a turn +// a person asked for (a send over the client RPC, or a card they sent now) +// starts — the provider accepts it, never merely the host — or they Resume. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + HOST_TEST_SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + QUEUED_RIG_CALLER, + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { sameQueuePause } from './structured-agent-session-queued-publication' +import { + structuredAgentSessionHostInstance, + structuredQueuePause +} from './structured-agent-session-queued-pause' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +/** No drain step may convert the drafts, and the queue reads paused. */ +async function expectPaused(...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** A draft behind a Stop, with the stopped turn settled so the session is idle. */ +async function stoppedDraft(): Promise { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + return draftId +} + +/** A queued draft handed off and handed over, found by its hand-off link. */ +async function handedOver(draftId: string): Promise { + await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) +} + +/** A user send the host accepted and handed over, still unanswered by the provider. */ +async function handedOverUserSend(text: string): Promise { + const { id, result } = rig.send(text) + expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) + return id +} + +describe("a Stop's queue pause", () => { + it('outlives a user send the provider accepts and then refuses; a later send that starts lifts it', async () => { + const draftId = await stoppedDraft() + const refused = await handedOverUserSend('the start fails') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleRejected(refused, 'turn/start refused') + await expectPaused(draftId) + const started = await handedOverUserSend('this one starts') + await rig.settleAccepted(started, 'started') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('a Stop after the user send supersedes it: that send starting its turn lifts nothing', async () => { + const draftId = await stoppedDraft() + const earlier = await handedOverUserSend('sent before the second stop') + await rig.stop() + await rig.settleAccepted(earlier, 'late') + await expectPaused(draftId) + }) + + it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => { + const draftId = await stoppedDraft() + const inFlight = await handedOverUserSend('sent before the restart') + // Derived from the journal, not remembered: a restart forgets nothing it needs. + await rig.restartHostProcess() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleAccepted(inFlight, 'after-restart') + // The Stop's pause is over; the restart's own lasts until a turn asked for since it. + await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) + const next = rig.send('sent after the restart') + await next.result + await rig.settleAccepted(next.id, 'next') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { + const working = await rig.workingSend() + const olderId = await queuedDraft('paused by the stop') + await rig.stop() + const typedId = await queuedDraft('typed while stopping') + await rig.settleAccepted(working, 'stopped') + await expectPaused(olderId, typedId) + expect(await rig.drafts()).toEqual([ + { messageId: olderId, state: 'waiting' }, + { messageId: typedId, state: 'waiting' } + ]) + }) + + it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now') + const heldId = await queuedDraft('held until that turn starts') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect(await rig.sendNow(sentId)).toMatchObject({ + ok: true, + value: { submission: { origin: 'client', queuedMessageId: sentId } } + }) + await handedOver(sentId) + // Only the card the user asked for went: the queue is still paused. + await expectPaused(heldId) + expect(await rig.drafts()).toEqual([{ messageId: heldId, state: 'waiting' }]) + // A turn the user asked for has now started, which ends the pause. + await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') + await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) + }) + + it('a card sent now that the provider refuses lifts nothing', async () => { + const working = await rig.workingSend() + // Ahead of the refused card, so its return blocks nothing Resume would send. + const heldId = await queuedDraft('held by the stop') + const sentId = await queuedDraft('sent now, refused') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await rig.sendNow(sentId) + await handedOver(sentId) + await rig.settleRejected(await rig.handoffId(sentId), 'turn/start refused') + await expectPaused(heldId) + }) + + it('an old send answered again after its ledger row is gone lifts nothing from a later Stop', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + // The ledger forgot the id, so the send runs again and answers with its accepted submission. + const operations = rig.store['transactions'].state.operations + for (const [key, row] of operations) { + if (row.operationId === working) { + operations.delete(key) + } + } + const body = hostTestMessage('work on this') + const replayed = await rig.host.send(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ body }, 'agentSession.send', working), + body, + userSend: true + }) + expect(replayed).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + // A later journal commit re-derives the pause; the old send was accepted before the Stop. + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') + await expectPaused(draftId) + }) +}) + +describe('the pause read', () => { + it("costs no scan of the submissions: the reducer keeps the latest person's accepted turn", async () => { + const draftId = await stoppedDraft() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + const scan = vi.spyOn(journal, 'submissions') + // Read on every publish, per subscriber: it must not walk the submissions. + expect(structuredQueuePause(journal)).toEqual({ reason: 'stopped' }) + expect(scan).not.toHaveBeenCalled() + scan.mockRestore() + const before = journal.queuedMessages.latestPersonTurnSequence() + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') + // Orca's own turn moves nothing; a person's does, and lifts the pause. + expect(journal.queuedMessages.latestPersonTurnSequence()).toBe(before) + const next = rig.send('user starts a new turn') + await next.result + await rig.settleAccepted(next.id, 'next') + expect(journal.queuedMessages.latestPersonTurnSequence()).toBe( + journal.submission(next.id)?.acceptedSequence + ) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) +}) + +describe('a pause is over the cards it paused', () => { + it('a Stop over an empty queue pauses nothing: a card typed during a later mail turn drains', async () => { + const working = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const followUp = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(followUp)).toBeDefined()) + expect(await rig.queuePause()).toBeNull() + }) + + it('a Stop over an empty queue pauses nothing: a correction typed before the turn ends drains', async () => { + const working = await rig.workingSend() + await rig.stop() + const correction = await queuedDraft('typed right after the stop') + await rig.settleAccepted(working, 'stopped') + await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) + }) + + it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => { + const working = await rig.workingSend() + const only = await queuedDraft('paused, then deleted') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } }) + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const later = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + }) +}) + +describe('a pause only over cards Resume could send', () => { + it('a Stop that leaves only a returned card publishes no pause and keeps no fact', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('refused before the stop') + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) + ) + // A lone returned card traps nothing: this send goes now, and the Stop interrupts it. + const next = await handedOverUserSend('sent past the card') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(next, 'stopped') + expect(await rig.queuePause()).toBeNull() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + expect(journal?.queuedMessages.pause()).toBeNull() + }) + + it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => { + const working = await rig.workingSend() + const refusedId = await queuedDraft('refused after the stop') + const behindId = await queuedDraft('waits behind the card') + await rig.settleAccepted(working, 'a') + await handedOver(refusedId) + // The Stop interrupts the refused card's turn and pauses the card behind it. + await rig.stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleRejected(await rig.handoffId(refusedId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([ + { messageId: refusedId, state: 'returned' }, + { messageId: behindId, state: 'waiting' } + ]) + ) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + // Resume would send nothing past the returned card, so no header offers it; the pause stays. + expect(await rig.queuePause()).toBeNull() + expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // Deleting the blocking card shows the pause again: the card behind it does not send unasked. + expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } }) + await expectPaused(behindId) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(behindId)).toBeDefined()) + }) + + it('a restart over only a card held by its own failed send publishes no pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('conversion fails once') + const append = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockImplementationOnce(async () => { + throw new Error('disk full') + }) + try { + await rig.settleAccepted(working, 'a') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting', paused: true }]) + ) + } finally { + append.mockRestore() + } + await rig.restartHostProcess() + // Only its own Send releases that card: a queue-level Resume would send nothing. + expect(await rig.queuePause()).toBeNull() + }) + + it('compares a pause by presence before reason, so appearing or clearing is always a change', () => { + expect(sameQueuePause(null, {})).toBe(false) + expect(sameQueuePause({}, null)).toBe(false) + expect(sameQueuePause(null, null)).toBe(true) + expect(sameQueuePause({ reason: 'stopped' }, { reason: 'stopped' })).toBe(true) + expect(sameQueuePause({ reason: 'stopped' }, { reason: 'cleared' })).toBe(false) + }) +}) + +describe("a restart's pause", () => { + it("once a person's turn ends it, stays ended when the conversation reopens", async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + const next = rig.send('user starts a new turn') + await next.result + await rig.settleAccepted(next.id, 'b') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + // Reopened, that turn is "before this open", yet the pause it ended stays ended: + // the lift adopted the rows into this process. + await rig.host.close(HOST_TEST_SESSION) + expect(await rig.queuePause()).toBeNull() + expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) + }) +}) + +describe('a card handed off after a restart', () => { + it('belongs to the process that sent it: withdrawn back to waiting, it raises no restart pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('refused, then re-sent after a restart') + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) + ) + await rig.restartHostProcess() + // Sent again in this process, then withdrawn by a Stop before the agent had it. + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) + await rig.stop() + release() + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + ) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + expect(journal.queuedMessages.get(draftId)?.hostInstance).toBe( + structuredAgentSessionHostInstance() + ) + // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: null + }) + expect(structuredQueuePause(journal)).toBeNull() + }) +}) + +describe('Resume', () => { + it('lifts the pause and the queue drains, oldest first; a second Resume is a no-op', async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await expectPaused(first, second) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + // Nothing is paused now: another Resume changes nothing. + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) + + it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused twice') + await rig.stop() + const operationId = hostTestOperationId() + expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } }) + await rig.stop() + expect(await rig.resume(operationId)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + await rig.settleAccepted(working, 'stopped') + await expectPaused(draftId) + }) + + it("lifts a restart's pause too", async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('written before the restart') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('is a no-op on a queue that is not paused', async () => { + await rig.workingSend() + await queuedDraft('waiting behind the turn') + expect(await rig.queuePause()).toBeNull() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + }) +}) + +describe('a failed Stop', () => { + it('records nothing when it fails before taking effect, so the queue sends as if no Stop was pressed', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('queued before the stop') + const reject = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockRejectedValueOnce(new Error('disk full')) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + reject.mockRestore() + } + expect(await rig.queuePause()).toBeNull() + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) + }) + + it('keeps its pause when it fails after the interrupt reached the agent', async () => { + await rig.workingSend() + const draftId = await queuedDraft('paused by stop') + const append = AgentSessionJournal.prototype.appendItem + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'appendItem') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + // The status note written after the provider was asked to stop. + if (args[1].kind === 'status') { + throw new Error('disk full') + } + return append.apply(this, args) + }) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + failing.mockRestore() + } + await expectPaused(draftId) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts new file mode 100644 index 00000000000..1428de75fe6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -0,0 +1,108 @@ +// Whether the queue is paused, and why — DERIVED, never stored as a flag. The +// queue is paused when: +// - 'stopped': the user's last Stop took effect (its recorded journal +// position) and no turn a person asked for has started since — or +// 'cleared', the same for a /clear's replacement, whose carried cards +// start paused; or +// - 'restarted': a waiting draft was written by another host process and no +// turn a person asked for has started since this conversation opened. +// A person's turn is a submission whose recorded origin is `client` (a send over +// the client send RPC, or a card they sent now) that the provider accepted. +// Orchestration mail, a restart continuation, a host-sent launch prompt and the +// queue's own drain are `host` and never lift it. An explicit Resume lifts any. + +import { randomUUID } from 'node:crypto' +import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table' + +/** A per-process id, minted once per host process like the runtime's own + * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another + * instance pauses the queue rather than auto-sending after a restart. */ +let hostInstance = randomUUID() + +export function structuredAgentSessionHostInstance(): string { + return hostInstance +} + +/** Simulates a host-process restart. Tests only. */ +export function rotateStructuredAgentSessionHostInstanceForTests(): string { + hostInstance = randomUUID() + return hostInstance +} + +type PauseJournal = Pick + +// Both read the reducer's latest accepted person turn (its submission row), so a +// derivation on every publish costs no scan of the submissions. + +function stopEnded(journal: PauseJournal, stop: QueuePauseFact): boolean { + const latest = journal.queuedMessages.latestPersonTurnSequence() + // Sent after the Stop: a send made before it no longer lifts it, even if its turn starts later. + return stop.epoch !== journal.cursor().epoch ? latest > 0 : latest > stop.sequence +} + +function restartPending(journal: PauseJournal): boolean { + return journal.queuedMessages + .list() + .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) +} + +function restartEnded(journal: PauseJournal): boolean { + const latest = journal.queuedMessages.latestPersonTurnSequence() + return latest > 0 && !journal.wroteBeforeOpen(latest) +} + +/** The queue's pause, derived; null when the queue sends on its own. */ +export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePause | null { + const stop = journal.queuedMessages.pause() + if (stop && !stopEnded(journal, stop)) { + return { reason: stop.reason } + } + if (restartPending(journal) && !restartEnded(journal)) { + return { reason: 'restarted' } + } + return null +} + +/** + * Every journal publish: retire what a person's started turn already ended — the + * Stop fact it superseded, and a restart's rows, adopted into this instance. The + * derivation already reads them as lifted; the write keeps that answer when the + * handle reopens (the restart's "since this conversation opened" moves) and spares + * later derivations the submission scan. Bookkeeping: a failure is reported. + */ +export async function retireEndedQueuePause( + sessionId: string, + journal: PauseJournal +): Promise { + try { + const stop = journal.queuedMessages.pause() + const retireStop = stop !== null && stopEnded(journal, stop) ? stop : null + const adopt = restartPending(journal) && restartEnded(journal) + if (retireStop === null && !adopt) { + return + } + await journal.queuedMessages.liftPause({ + stop: retireStop, + adoptInto: adopt ? hostInstance : null + }) + } catch (error) { + console.warn("[agent-session] a started turn's queue-pause retirement skipped:", { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** Resume: ends whichever pause holds the queue. Returns whether it was paused. */ +export async function resumeStructuredQueue(journal: PauseJournal): Promise { + if (structuredQueuePause(journal) === null) { + return false + } + await journal.queuedMessages.liftPause({ + stop: journal.queuedMessages.pause(), + adoptInto: hostInstance + }) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts new file mode 100644 index 00000000000..10b1f75fc3e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -0,0 +1,112 @@ +// The published view of a conversation's queue: its whole draft list and the +// queue's pause, on the `commands` precedent — read per emit, reference-stable +// while unchanged, so the subscribers' identity dedup keeps token streams from +// re-sending it. The two ride together: a client never sees one without the other. + +import { + QUEUED_MESSAGE_PAUSED_SEND_FAILED, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { hasResumableQueuedMessage } from '../agent-session-journal/queued-message-pause-table' +import { structuredQueuePause } from './structured-agent-session-queued-pause' + +export type QueuePublication = { + queuedMessages: AgentSessionQueuedMessage[] + queuePause: AgentSessionQueuePause | null +} + +/** Waiting and returned rows only. `paused` is a per-card hold (a failed + * conversion); a Stop or a restart pauses the queue, published once beside it. */ +function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const published: AgentSessionQueuedMessage[] = [] + for (const row of journal.queuedMessages.list()) { + if (row.state !== 'waiting' && row.state !== 'returned') { + continue + } + const held = row.state === 'waiting' && row.holdReason !== null + published.push({ + messageId: row.messageId, + position: row.position, + body: row.body, + state: row.state, + ...(held ? { paused: true as const } : {}), + // The stored reason is a typed marker; an unknown one reads as a plain hold. + ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED + ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } + : {}), + ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), + ...(row.state === 'returned' && row.returnedRejection + ? { returnedRejection: row.returnedRejection } + : {}) + }) + } + return published +} + +type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } + +/** Reference-stable per journal handle: an unchanged list is never + * re-serialized onto token-stream frames, and any draft-table write changes + * the reference by construction. */ +const listMemos = new WeakMap() +const publications = new WeakMap() + +function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const key = String(journal.queuedMessages.revision()) + const memo = listMemos.get(journal) + if (memo && memo.key === key) { + return memo.list + } + const list = computePublishedQueuedMessages(journal) + // Belt for the identity dedup: equal recomputed content keeps the previous reference. + const serialized = JSON.stringify(list) + if (memo && memo.serialized === serialized) { + listMemos.set(journal, { key, serialized, list: memo.list }) + return memo.list + } + listMemos.set(journal, { key, serialized, list }) + return list +} + +/** Presence first: a pause appearing or clearing is a change even when neither side + * names a reason this build can read. */ +export function sameQueuePause( + previous: { reason?: string } | null, + next: { reason?: string } | null +): boolean { + return (previous === null) === (next === null) && previous?.reason === next?.reason +} + +export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { + const queuedMessages = readPublishedQueuedMessages(journal) + // Read per emit: the pause also turns on submissions (a person's turn starting). + // Kept over any card it holds back, but shown only over one Resume would send, so its + // header never offers to send nothing; deleting a blocking returned card shows it again. + const pausable = hasResumableQueuedMessage(journal.queuedMessages.list()) + const queuePause = pausable ? structuredQueuePause(journal) : null + const previous = publications.get(journal) + if ( + previous && + previous.queuedMessages === queuedMessages && + sameQueuePause(previous.queuePause, queuePause) + ) { + return previous + } + const publication = { queuedMessages, queuePause } + publications.set(journal, publication) + return publication +} + +/** For readers that must never fail on drafts — a subscriber stream, a history + * page: a closing handle answers "no claim" (absent) instead of throwing. */ +export function tryReadQueuePublication( + journal: AgentSessionJournal | undefined +): QueuePublication | undefined { + try { + return journal ? readQueuePublication(journal) : undefined + } catch { + return undefined + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts new file mode 100644 index 00000000000..e0eb83e0a0b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -0,0 +1,42 @@ +// What a send this host queued answers with when it is asked again — a lost +// acknowledgement's replay, or a rerun the operation ledger no longer covers: +// from its draft first, then from the hand-off that names it. + +import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** Null for a send this host never queued. A refused conversion answers with + * its returned card, never the rejected submission, or the same text would + * render twice — on a Retry row AND the card. */ +export function queuedSendAnswer( + journal: Pick, + clientMessageId: string +): AgentSessionSendResult | null { + const draft = journal.queuedMessages.get(clientMessageId) + if (draft) { + const consumed = + draft.state === 'dispatched' && draft.consumedAs !== null + ? journal.submission(draft.consumedAs) + : undefined + return consumed + ? { clientMessageId, submission: consumed } + : { + clientMessageId, + queued: { messageId: draft.messageId, position: draft.position, state: draft.state } + } + } + // The draft row was pruned; its last hand-off still names it. Only a withdrawn row is pruned + // while its last hand-off stands rejected — a card the user deleted — so it answers withdrawn, + // never as a refused send. + const handoff = journal + .submissions() + .findLast((entry) => entry.queuedMessageId === clientMessageId) + if (handoff?.dispatchState === 'rejected') { + // The pruned row's position went with it; a withdrawn receipt names no place in the queue. + return { + clientMessageId, + queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' } + } + } + return handoff ? { clientMessageId, submission: handoff } : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts new file mode 100644 index 00000000000..d411efc4239 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -0,0 +1,34 @@ +// A send's queue step around its immediate path: the queue decision before it. A +// person's send lifts a paused queue through its recorded origin once its turn +// starts (`structured-agent-session-queued-pause.ts`), not through anything here. + +import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function runQueueableStructuredAgentSessionSend( + context: StructuredAgentSessionMutationContext, + ctx: AgentSessionTurnContext, + params: { + envelope: { clientOperationId: string } + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + userSend?: true + }, + immediate: () => Promise> +): Promise> { + // The queue decision runs first: a capable send while the session owes work (a + // /compact included — it is a queued message like any other) becomes a draft; + // only a `blocked` hold, which never queues, falls through to the refusal. + const queued = await maybeQueueStructuredAgentSessionSend(context, ctx, params) + if (queued) { + return queued + } + const accepted = await immediate() + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts new file mode 100644 index 00000000000..16a9b4aeaf7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -0,0 +1,66 @@ +// Stop's pause on the queue. A Stop never withdraws a draft and no text ever +// travels back over the wire: it records WHERE in the journal it took effect, +// and the queue is paused from there (`structured-agent-session-queued-pause.ts` +// derives it) until a turn a person asked for starts, or they Resume. The cards +// stay published, and Send-now sends one card without lifting the pause for the +// rest until that card's turn starts. The record is bookkeeping: a failure is +// reported and never gates the interrupt. + +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + isUnsettledQueuedMessage, + type QueuedMessageRow +} from '../agent-session-journal/queued-message-table' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +/** The one unsettled-card predicate /clear's carry and the budget share: + * waiting or returned. Pending/unknown/accepted deliveries stay outside it. */ +export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMessageRow[] { + return journal.queuedMessages.list().filter(isUnsettledQueuedMessage) +} + +/** + * Runs a Stop and records its queue pause at the point it takes effect — after + * it withdrew the queued sends, as it reaches the agent, or, reaching no agent, + * once it withdrew something — and only over cards it then holds back. The Stop + * calls `tookEffect` there. A Stop that throws before then changed nothing and + * recorded nothing, so there is nothing to undo; one that fails after it keeps + * the pause, since the interrupt may have landed. A draft whose hand-off the + * Stop withdrew is back to waiting in its own place, under this same pause. The + * drain cannot slip a draft in between: it runs on the same serialized lane as + * the Stop. + */ +export async function runStopWithQueuePause( + ctx: AgentSessionTurnContext, + stop: (tookEffect: () => Promise) => Promise> +): Promise> { + let attempted = false + return stop(async () => { + if (attempted) { + return + } + attempted = true + const { queuedMessages } = ctx.journal + // A hand-off this Stop's withdrawal sent back may not have caught up yet (its hook + // was skipped): heal it first, as the drain would, so the pause sees it waiting. + try { + if (queuedMessages.settlementOwed()) { + await queuedMessages.settleOwed() + } + } catch (error) { + report(ctx, 'owed settlement', error) + } + // Recorded only over a card it holds back — judged in its own transaction, which + // still counts an owed return to waiting if that heal failed. + await queuedMessages + .recordPause('stopped') + .catch((error: unknown) => report(ctx, 'queue pause', error)) + }) +} + +function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { + console.warn(`[agent-session] Stop's ${step} skipped:`, { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts new file mode 100644 index 00000000000..2b193d5cb82 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -0,0 +1,64 @@ +// Host wiring for mid-turn queueing: builds the serialized drain from the +// host's mutation context and exposes the draft actions (Send, Delete, Resume), so the host +// class stays a description of its surface. + +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' +import { retireEndedQueuePause } from './structured-agent-session-queued-pause' +import { + deleteQueuedStructuredAgentMessage, + resumeStructuredAgentQueue, + sendQueuedStructuredAgentMessage +} from './structured-agent-session-queued-mutations' + +/** `sessions` are the live conversations (their `touch` is the idle sweep's activity renewal, + * which the drain's schedule rides); everything else comes from the host's mutation context, + * read lazily because the host's fields are still initializing when this is built. */ +export function wireStructuredAgentSessionQueuedMessages( + sessions: ReadonlyMap & { + touch: (sessionId: string) => void + }, + context: () => StructuredAgentSessionMutationContext +) { + const drain = new StructuredAgentSessionQueuedMessageDrain({ + sessions, + getRecord: (sessionId) => context().deps.store.getRecord(sessionId), + serialize: (sessionId, task) => context().serialize(sessionId, task), + flushStreamedEvents: (sessionId) => context().flushStreamedEvents(sessionId), + conversationFence: (sessionId) => + structuredAgentSessionConversationFence(context().deps.store, sessionId), + wakeDelivery: (sessionId) => context().wakeDelivery(sessionId), + onError: (sessionId, error) => context().deps.onEventSinkError?.({ sessionId, error }) + }) + return { + drain, + /** Every journal publish: turn, submission, prompt, command and Stop + * settlements are all commits, and each re-derives the drain's gates — + * and retires a queue pause a person's started turn already ended. */ + onJournalActivity: (sessionId: string) => { + sessions.touch(sessionId) + const journal = sessions.get(sessionId)?.journal + if (journal && !journal.isReadOnly) { + void retireEndedQueuePause(sessionId, journal) + } + drain.schedule(sessionId) + }, + queuedMessageSend: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => sendQueuedStructuredAgentMessage(context(), caller, params), + queuedMessageDelete: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => deleteQueuedStructuredAgentMessage(context(), caller, params), + queuedMessagesResume: ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ) => resumeStructuredAgentQueue(context(), caller, params) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts new file mode 100644 index 00000000000..403cb08a405 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -0,0 +1,209 @@ +// A consumed draft whose submission a Stop withdrew before the agent had it is +// not a failure: it waits again at its own position under the Stop's hold, so +// it never blocks the paused cards behind it. After the user's next turn the +// whole queue drains one per turn in queue order, the withdrawn draft first, +// under a fresh submission id. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +async function submissionIds(): Promise { + return (await rig.host.journalSnapshot(SESSION)).submissions.map((entry) => entry.clientMessageId) +} + +async function handedOver(id: string): Promise { + await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined()) +} + +it('Stop, then a user send: the withdrawn draft and the paused cards behind it drain one per turn, in queue order', async () => { + const working = await rig.workingSend() + const a = await queuedDraft('A') + const b = await queuedDraft('B') + const c = await queuedDraft('C') + // The turn ends and the drain consumes A; the agent's start is held, so A is not handed over. + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + // Never under the draft's own id: the submission names A by its link. + const firstA = await rig.handoffId(a) + expect(firstA).not.toBe(a) + expect((await rig.submission(firstA))?.handedOverAt).toBeUndefined() + + expect(await rig.stop()).toMatchObject({ ok: true }) + release() + // A is back in its place, behind the same queue pause as B and C: no returned card blocks them. + const paused = [a, b, c].map((messageId) => ({ messageId, state: 'waiting' })) + expect(await rig.drafts()).toEqual(paused) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + const d = rig.send('D') + await d.result + await handedOver(d.id) + expect(await rig.drafts()).toEqual(paused) + await rig.settleAccepted(d.id, 'd') + + // After D's turn, A drains first, under another fresh id: the first names the withdrawn submission. + const before = new Set([working, firstA, d.id]) + let resentA = '' + await eventually(async () => { + const fresh = (await submissionIds()).filter((id) => !before.has(id)) + expect(fresh).toHaveLength(1) + resentA = fresh[0] ?? '' + }) + expect((await rig.submission(firstA))?.dispatchState).toBe('rejected') + // Both hand-offs of A name it; D, a direct send, names no draft. + expect((await rig.submission(resentA))?.queuedMessageId).toBe(a) + expect((await rig.submission(firstA))?.queuedMessageId).toBe(a) + expect(await rig.submission(d.id)).not.toHaveProperty('queuedMessageId') + expect((await rig.submission(resentA))?.payloadFingerprint).toBe( + (await rig.submission(firstA))?.payloadFingerprint + ) + expect(await rig.drafts()).toEqual([ + { messageId: b, state: 'waiting' }, + { messageId: c, state: 'waiting' } + ]) + + await handedOver(resentA) + await rig.settleAccepted(resentA, 'a') + await eventually(async () => expect((await rig.handoff(b))?.queuedMessageId).toBe(b)) + expect(await rig.handoff(c)).toBeUndefined() + await handedOver(await rig.handoffId(b)) + await rig.settleAccepted(await rig.handoffId(b), 'b') + await eventually(async () => expect(await rig.handoff(c)).toBeDefined()) + expect(await rig.drafts()).toEqual([]) +}) + +it('a Stop that fails after withdrawing a consumed draft releases it, and it sends again under a fresh id', async () => { + const working = await rig.workingSend() + const a = await queuedDraft('A') + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + const firstA = await rig.handoffId(a) + const withdraw = AgentSessionJournal.prototype.rejectQueuedSubmissions + const failing = vi + .spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions') + .mockImplementation(async function (this: AgentSessionJournal, ...args) { + const withdrawn = await withdraw.apply(this, args) + // Only the Stop's own withdrawal fails, after it landed; the delivery loop's pass through. + if (args[1].rejection.kind === 'cancelled') { + throw new Error('disk full') + } + return withdrawn + }) + try { + await expect(rig.stop()).rejects.toThrow('disk full') + } finally { + failing.mockRestore() + release() + } + expect((await rig.submission(firstA))?.dispatchState).toBe('rejected') + const before = new Set([working, firstA]) + await eventually(async () => { + expect((await submissionIds()).filter((id) => !before.has(id))).toHaveLength(1) + expect(await rig.drafts()).toEqual([]) + }) +}) + +/** A consumed card whose delivery is held at the agent's start, so a Stop withdraws it; + * the settlement hook throws on that withdrawal's row, leaving the card owed a return. */ +async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> { + const working = await rig.workingSend() + const a = await queuedDraft('A') + let release: () => void = () => undefined + rig.awaitStarted.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + await rig.settleAccepted(working, 'working') + await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) + const settle = JournalQueuedMessages.prototype.onRowInTransaction + let skipped = false + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementation(function (this: JournalQueuedMessages, db, row) { + if (!skipped && row.kind === 'dispatch' && row.state === 'rejected') { + skipped = true + throw new Error('bookkeeping failed') + } + return settle.call(this, db, row) + }) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + expect(await rig.stop()).toMatchObject({ ok: true }) + } finally { + hook.mockRestore() + warned.mockRestore() + release() + } + expect(skipped).toBe(true) + return { a, working } +} + +it("a Stop whose withdrawal's settlement was skipped still pauses the card it sent back", async () => { + const { a } = await stopWithSkippedSettlement() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await new Promise((resolve) => setTimeout(resolve, 250)) + // Healed back to waiting, but the Stop's pause holds it: it does not send. + expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === a + ) + ).toHaveLength(2) + ) +}) + +it('the pause is recorded even when healing the skipped settlement fails, since the card is still owed', async () => { + const heal = vi + .spyOn(JournalQueuedMessages.prototype, 'settleOwed') + .mockRejectedValueOnce(new Error('disk full')) + try { + const { a } = await stopWithSkippedSettlement() + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // The drain heals it later; the pause recorded over the owed card holds it then. + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) + ) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect( + (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === a + ) + ).toHaveLength(1) + } finally { + heal.mockRestore() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 823b5926897..8038e90e1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -15,10 +15,11 @@ import { type UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionSendResult +import { + agentSessionSendSubmission, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -95,9 +96,13 @@ export function restartContinuationDeps( } }), awaitSettlement: async (sessionId, clientMessageId) => - (await host.awaitSendSettlement(sessionId, clientMessageId))?.value.submission, + agentSessionSendSubmission( + (await host.awaitSendSettlement(sessionId, clientMessageId))?.value + ), awaitHandedOver: async (sessionId, clientMessageId) => - (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value.submission, + agentSessionSendSubmission( + (await host.awaitSendHandedOver(sessionId, clientMessageId))?.value + ), onNoteFailed: host.onNoteFailed, note: restartNoteWriter(host) } @@ -155,8 +160,10 @@ export type StructuredAgentSessionContinuationDeps = { }) => Promise<{ ok: boolean refusal?: { code: string } - /** The submission is where the provider's answer lives; the envelope only says Orca took it. */ - value?: { submission?: { dispatchState?: string; reason?: string | null } } + /** The submission is where the provider's answer lives; the envelope only says Orca took it. + * A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so + * the host's union return stays assignable. */ + value?: { submission?: { dispatchState?: string; reason?: string | null }; queued?: unknown } }> /** * Waits for that send's dispatch to stop being `pending`, through the host's existing settlement diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts index 8155da7a2bb..677963ab3f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -62,8 +62,11 @@ describe('a wait that also ends behind a running command', () => { settlements.publish('session-1', queuedJournal('compact:cmd-1')) const settled = await pending - expect(settled?.value.submission.dispatchState).toBe('pending') - expect(settled?.value.submission).not.toHaveProperty('handedOverAt') + if (!settled || !('submission' in settled.value)) { + throw new Error('expected the submission arm') + } + expect(settled.value.submission.dispatchState).toBe('pending') + expect(settled.value.submission).not.toHaveProperty('handedOverAt') }) it('keeps waiting for the handover behind anything that is not a command', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index d2b9d27c1a0..f0163b22791 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -67,6 +67,9 @@ describe('send', () => { if (!result.ok) { throw new Error(`expected a send, got ${result.refusal.code}`) } + if (!('submission' in result.value)) { + throw new Error('expected the submission arm') + } // Answered once accepted; the delivery loop hands it over after. expect(result.value.submission).toMatchObject({ dispatchState: 'pending', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index f600d6dd9bb..ae795674564 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -8,20 +8,18 @@ import type { AgentJournalCursor, AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import { - AGENT_SESSION_HISTORY_MAX_LIMIT, - type AgentSessionBackgroundTaskState, - type AgentSessionSlashCommand, - type AgentSessionSubscribeEvent, - type AgentSessionTurnActivity +import type { + AgentSessionBackgroundTaskState, + AgentSessionSlashCommand, + AgentSessionSubscribeEvent, + AgentSessionTurnActivity } from '../../../shared/agent-session-wire' -import { sameJournalCursor } from '../agent-session-journal/journal-cursor' +import { buildSubscriberFrame } from './agent-session-subscriber-frame-fields' +import type { QueuePublication } from './structured-agent-session-queued-publication' +import { deliverToSubscriber } from './agent-session-subscriber-catch-up' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { emptyAgentSessionBatch } from './agent-session-empty-batch' -import { - createAgentSessionCatchUpReader, - readAgentSessionHydrationPage -} from './agent-session-history-page' +import { readAgentSessionHydrationPage } from './agent-session-history-page' import { rememberSessionActivity } from './structured-agent-session-activity-retention' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void @@ -32,17 +30,23 @@ export type AgentSessionSubscribeInput = { cursor?: AgentJournalCursor } -type Subscriber = { +export type Subscriber = { id: string sessionId: string emit: AgentSessionSubscriberEmit cursor: AgentJournalCursor fence: number commands?: AgentSessionSlashCommand[] | null + /** The last draft list actually SENT — never advanced on a page that withheld + * it, or the final replacement would be suppressed by the identity dedup. */ + queuePublication?: QueuePublication } export type AgentSessionSubscribersHooks = { readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined + /** Revision-stable per emit: an unchanged list keeps its reference, so token + * streams never re-serialize it; any draft-table write changes it. */ + readQueuePublication?: (sessionId: string) => QueuePublication | undefined /** Fires after publications that can change journal content. */ onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void now?: () => number @@ -214,86 +218,15 @@ export class AgentSessionSubscribers { backgroundTasks?: AgentSessionBackgroundTaskState | null, activity?: AgentSessionTurnActivity | null ): void { - const checkpointActivity = emitCheckpoint - ? this.activityField(subscriber.sessionId).activity - : undefined - const publishedActivity = activity !== undefined ? activity : checkpointActivity - // Caught up, so there are no rows to read: every publish behind a commit's own delivery. - if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { - this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) - return - } - const readPage = createAgentSessionCatchUpReader(journal) - while (true) { - const result = readPage({ - sessionId: subscriber.sessionId, - direction: 'after', - cursor: subscriber.cursor, - limit: AGENT_SESSION_HISTORY_MAX_LIMIT - }) - if (!result.ok) { - const page = { ...result.page, fence: subscriber.fence } - this.emit(subscriber, { - type: 'reset', - sessionId: subscriber.sessionId, - reset: result.reset, - page, - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - subscriber.cursor = page.liveCursor ?? page.window.nextCursor - return - } - const page = result.page - const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence - if (!advanced) { - this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity) - return - } - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: { - cursor: page.window.nextCursor, - items: page.items, - removedItemIds: page.removedItemIds, - submissions: page.submissions - }, - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) - }) - subscriber.cursor = page.window.nextCursor - if (!page.hasNewer || !this.isActive(subscriber)) { - return - } - } - } - - private emitCaughtUp( - subscriber: Subscriber, - hostNow: number, - emitCheckpoint: boolean, - backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, - activity: AgentSessionTurnActivity | null | undefined - ): void { - const commandsChanged = - this.hooks.readCommands !== undefined && - (this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands - if (emitCheckpoint || activity !== undefined || commandsChanged) { - this.emit(subscriber, { - type: 'batch', - sessionId: subscriber.sessionId, - batch: emptyAgentSessionBatch(subscriber.cursor), - fence: subscriber.fence, - hostNow, - ...(backgroundTasks !== undefined ? { backgroundTasks } : {}), - ...(activity !== undefined ? { activity } : {}) - }) - } + deliverToSubscriber( + { + hooks: this.hooks, + emit: (target, event, options) => this.emit(target, event, options), + isActive: (target) => this.isActive(target), + activity: (sessionId) => this.activityField(sessionId).activity + }, + { subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity } + ) } private now = (): number => this.hooks.now?.() ?? Date.now() @@ -303,15 +236,23 @@ export class AgentSessionSubscribers { /** A dead transport cannot be allowed to turn a durable mutation into an * unknown outcome or poison every later publication. */ - private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void { + private emit( + subscriber: Subscriber, + event: AgentSessionSubscribeEvent, + options?: { withholdQueued?: boolean } + ): void { try { - const commands = this.hooks.readCommands?.(subscriber.sessionId) ?? null - const includeCommands = - this.hooks.readCommands !== undefined && - event.type !== 'end' && - (event.type !== 'batch' || commands !== subscriber.commands) - subscriber.emit(includeCommands ? { ...event, commands: commands ?? null } : event) - subscriber.commands = commands + const built = buildSubscriberFrame( + this.hooks, + subscriber, + event, + options?.withholdQueued === true + ) + subscriber.emit(built.frame) + subscriber.commands = built.commands + if (built.attachedQueued) { + subscriber.queuePublication = built.queued + } } catch { this.drop(subscriber) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 61d47b4f408..0ce27596966 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -116,6 +116,8 @@ export async function performSend( clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem + /** Who asked for the turn; absent on callers that predate it. */ + origin?: 'client' | 'host' } ): Promise> { const existing = ctx.journal diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index d8b4b39164b..4e4e42c46e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -45,6 +45,9 @@ export class StructuredConversationCommandController { if (--entry.count === 0 && this.pending.get(params.envelope.sessionId) === entry) { this.pending.delete(params.envelope.sessionId) } + // A clear can settle with no journal commit (a failed attach), and drafts held behind + // its prepared phase would otherwise wait for an unrelated commit. + this.context().wakeQueuedDrain?.(params.envelope.sessionId) } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index d547f7f9a81..d58f1929f2d 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -37,6 +37,7 @@ import { type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' +import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations' /** A command's `error` is the sentence its row shows. */ export function conversationCommandFailure( @@ -265,6 +266,15 @@ export function runStructuredConversationCommand( await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } } + // Carry the source's drafts to the replacement, the same for every client version: + // the cards stay visible where the user now is, and no text rides the wire. + // Bookkeeping — a failure is reported and never fails the clear. + await carryQueuedMessagesToClearReplacement(ctx, { + replacementSessionId, + replacementJournal: context.sessions.get(replacementSessionId)?.journal, + callerKey: caller.callerKey, + operationId: clientOperationId + }) } const completed = { ...base, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 352497fe482..7ad7f9bb1bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -75,10 +75,14 @@ export async function runStructuredCompaction( return { ...accepted, ...(settled ? { cursor: settled.cursor } : {}), - value: compactionReply(settled?.value.submission, { - ...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)), - command: 'compact' - }) + // A /compact is a command send, never a queued draft, so its settlement always carries the submission. + value: compactionReply( + settled && 'submission' in settled.value ? settled.value.submission : undefined, + { + ...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)), + command: 'compact' + } + ) } } diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index 183879abe1a..c5b21f9027f 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -75,7 +75,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', 'agentSession.close', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index c85fcf6fc20..ecfdff0dff5 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -7,6 +7,7 @@ */ import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' +import { agentSessionSendSubmission } from '../../../shared/agent-session-wire' import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { @@ -115,17 +116,20 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo } // `pending` is not yet an acknowledgement; only `accepted` may consume mail. Accepted is not // delivered, so wait out a start; a wait that runs out parks for the next journal edge. + const answered = agentSessionSendSubmission(result.value) const submission = - result.value.submission.dispatchState === 'pending' - ? (( - await host - .waitForSendSettlement(input.sessionId, result.value.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS - }) - .catch(() => undefined) - )?.value.submission ?? result.value.submission) - : result.value.submission - const state = submission.dispatchState + answered?.dispatchState === 'pending' + ? (agentSessionSendSubmission( + ( + await host + .waitForSendSettlement(input.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value + ) ?? answered) + : answered + const state = submission?.dispatchState return { kind: 'sent', state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : 'unknown' diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index dad93face82..f5684f1ba1b 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' +import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -245,20 +246,23 @@ export async function sendStructuredWorkerPreamble(args: { throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`) } // Accepted is not delivered: the worker's agent may still be starting. + const answered = agentSessionSendSubmission(result.value) const submission = - result.value.submission.dispatchState === 'pending' - ? (( - await args.host - .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS - }) - .catch(() => undefined) - )?.value.submission ?? result.value.submission) - : result.value.submission - if (submission.dispatchState === 'accepted' || submission.dispatchState === 'pending') { + answered?.dispatchState === 'pending' + ? (agentSessionSendSubmission( + ( + await args.host + .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { + budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS + }) + .catch(() => undefined) + )?.value + ) ?? answered) + : answered + if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') { return submission.dispatchState } - if (submission.dispatchState === 'rejected') { + if (submission?.dispatchState === 'rejected') { // A rejection is a verdict, not a mystery: the preamble provably did not happen. // `dispatch_preamble_undelivered` says exactly that, and says it as a code rather // than as prose, so a coordinator can tell "we could not send it" apart from @@ -277,7 +281,7 @@ export async function sendStructuredWorkerPreamble(args: { // `outcome_unknown` receipt whose nextCommands send the coordinator to look. throw new OrchestrationError( 'operation_unknown', - `The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${reasonClause(submission.reason)}.` + `The dispatch preamble was submitted but not acknowledged (${submission?.dispatchState ?? 'unknown'}): ${reasonClause(submission?.reason)}.` ) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 7b388f5eca2..a2876f284db 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -56,6 +56,15 @@ export const ADMISSION_METHODS = [ }, { method: 'agentSession.ensure', params: attachParams() }, { method: 'agentSession.send', params: sendParams() }, + { + method: 'agentSession.queuedMessageSend', + params: { envelope: envelope(), messageId: 'queued-1' } + }, + { + method: 'agentSession.queuedMessageDelete', + params: { envelope: envelope(), messageId: 'queued-1' } + }, + { method: 'agentSession.queuedMessagesResume', params: { envelope: envelope() } }, { method: 'agentSession.rewind', params: { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'epoch' } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts new file mode 100644 index 00000000000..a4b68ccbcd3 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -0,0 +1,31 @@ +// The queued-message actions: Send-now and Delete on one card, and Resume on a +// paused queue. All gated on agent-session.queued-messages.v1; an older host +// lacks the methods entirely. + +import { defineMethod } from '../core' +import { + requireStructuredHost as requireHost, + structuredCallerFor as callerFor +} from './structured-agent-session-gate' +import { + QueuedMessageActionParams, + QueuedMessagesResumeParams +} from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [ + defineMethod({ + name: 'agentSession.queuedMessageSend', + params: QueuedMessageActionParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessageSend(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.queuedMessageDelete', + params: QueuedMessageActionParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessageDelete(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.queuedMessagesResume', + params: QueuedMessagesResumeParams, + handler: async (params, ctx) => requireHost(ctx).queuedMessagesResume(callerFor(ctx), params) + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 345fdf5bf47..5930fa1e80f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -16,6 +16,8 @@ export { ModelCatalogParams, MutationEnvelope, OptionsParams, + QueuedMessageActionParams, + QueuedMessagesResumeParams, RespondParams, RespondToQuestionParams, RestartResumableParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index 4b3bd989c87..59bc914558e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -39,6 +39,12 @@ describe('agentSession.send reply timing', () => { }) }) + it("marks a client's send as the user's own, which alone lifts a Stop's queue pause", async () => { + hostCalls.send.mockResolvedValueOnce(pendingSendResult()) + await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(hostCalls.send.mock.calls[0]?.[1]).toMatchObject({ userSend: true }) + }) + it('answers at acceptance for the local desktop and paired desktop clients (W2)', async () => { for (const clientCapabilities of [ DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index c7c8d2cc4ce..47afaa8ac73 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { RpcContext } from '../core' @@ -19,11 +20,13 @@ export async function sendStructuredAgentSessionForClient( context: RpcContext ) { const host = requireStructuredHost(context) - const result = await host.send(structuredCallerFor(context), params) + // Only a client's own send lifts a Stop's queue pause; host-internal senders never do. + const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) const capabilities = context.clientCapabilities ?? [] if ( !result.ok || - result.value.submission.dispatchState !== 'pending' || + // A queued answer only ever reaches a capable client, which renders it as-is. + agentSessionSendSubmission(result.value)?.dispatchState !== 'pending' || context.clientKind === undefined || capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) ) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index f1e27aec3de..d9d430f08e3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -174,7 +174,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(29) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(32) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index dd7c9ee7115..534d4967779 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -34,6 +34,7 @@ import { } from './structured-agent-session-create' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal' +import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods' import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' import { @@ -206,6 +207,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: CancelParams, handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params) }), + ...STRUCTURED_AGENT_SESSION_QUEUED_METHODS, defineMethod({ // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the // same session can be attached again. Only the provider child and the in-memory entry go. diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index a543a8da45d..d544dc6720b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -218,6 +218,9 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', 'agentSession.close', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 9f91b9f9b3b..c42121a2b5c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -118,10 +118,17 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { createOperationId: args.createOperationId }) ) - return result.ok - ? result.value.submission.dispatchState === 'accepted' || - result.value.submission.dispatchState === 'pending' - : false + if (!result.ok) { + return false + } + // A queued answer retired the entry; the queue keeps moving. + if ('queued' in result.value) { + return true + } + return ( + result.value.submission.dispatchState === 'accepted' || + result.value.submission.dispatchState === 'pending' + ) } catch (caught) { if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { return false diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 84c8fa6e191..ba9bef0b8d7 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -119,6 +119,11 @@ async function dispatchStructuredLaunchPrompt( ) return false } + if ('queued' in result.value) { + // The host holds the draft; the outbox entry is spent. + mutateEntry(entry, () => null) + return true + } const dispatchState = result.value.submission.dispatchState mutateEntry(entry, (current) => dispatchState === 'accepted' diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index c6d3fc7f5e6..035bb7d1d01 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -328,7 +328,9 @@ export const AgentJournalSubmissionSchema = z.object({ recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), - rejection: FailureFact.optional() + rejection: FailureFact.optional(), + // Listed, or the parse strips it: this schema drops unknown keys. + queuedMessageId: z.string().min(1).optional() }) export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 2ca143b8681..43ed994964f 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -418,6 +418,12 @@ export type AgentJournalSubmission = { handedOverAt?: number /** Host-only: the submission row's sequence, which tells which host process accepted it. */ acceptedSequence?: number + /** The queued draft this submission hands off; absent for a direct send. Read this, never + * a draft id compared with `clientMessageId`. */ + queuedMessageId?: string + /** Host-only: who asked for this turn — a person over the client send RPC, or Orca itself. + * A person's turn is what ends a Stop's queue pause. */ + origin?: 'client' | 'host' } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts new file mode 100644 index 00000000000..f7659c95873 --- /dev/null +++ b/src/shared/agent-session-queued-message-wire.ts @@ -0,0 +1,54 @@ +// The queued-message part of the agent-session wire: the published draft cards, +// the queue's pause beside them, and the draft mutations' answers. + +import type { UnreadAgentSessionFailureFact } from './agent-session-failure' +import type { AgentJournalMessageItem } from './agent-session-journal-types' + +/** The draft could not be converted into a send; an explicit Send retries it. */ +export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const + +export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED + +/** The whole queue is paused and sends nothing on its own: 'stopped' — the user + * interrupted ("Queue paused because you interrupted") — 'cleared' — a /clear + * carried the cards into a fresh conversation — or 'restarted' — Orca restarted + * with cards waiting. Resume (`agentSession.queuedMessagesResume`), or the user's own next + * turn starting, lifts it; Send-now on one card sends that card and leaves the + * rest paused until its turn starts. A client treats an unknown reason as a + * plain pause, so a newer host can add one. */ +export type AgentSessionQueuePause = { reason: 'stopped' | 'restarted' | 'cleared' } + +export type AgentSessionQueuedMessagesResumeResult = { + /** False when nothing was paused, and on a replay of an already-run Resume. */ + resumed: boolean +} + +/** One draft the host holds for this conversation, published whole-list on the + * subscribe stream and on history pages. Text-only v1. */ +export type AgentSessionQueuedMessage = { + messageId: string + position: number + body: AgentJournalMessageItem + state: 'waiting' | 'returned' + /** This one card is held, whatever the queue's pause: its conversion failed. */ + paused?: true + /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't + * send"; only an explicit Send releases it). A client must treat an unknown + * marker as a plain hold, so a newer host can add one. The queue-level + * pause is `queuePause`, published beside the list. */ + pausedReason?: AgentSessionQueuedMessagePausedReason + /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. + * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host + * wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g. + * `classifyDispatchRejection({ reason: returnedReason, rejection: returnedRejection })`. */ + returnedReason?: string | null + returnedRejection?: UnreadAgentSessionFailureFact +} + +/** No body: the card leaving the published list IS the outcome, so a lost + * answer needs no re-ask and no text ever rides the wire back. */ +export type AgentSessionQueuedMessageDeleteResult = + | { deleted: true; messageId: string } + /** `dispatched` means it already became a submission; `missing` covers a + * pruned tombstone. Replays answer from tombstone receipts. */ + | { deleted: false; messageId: string; disposition: 'dispatched' | 'withdrawn' | 'missing' } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 7306f0b4266..311cfcfdade 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -4,8 +4,13 @@ import type { } from './agent-session-background-task-wire' import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind' import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from './agent-session-queued-message-wire' export * from './agent-session-wire-refusals' +export * from './agent-session-queued-message-wire' import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import type { AgentSessionContextUsage } from './agent-session-context-usage' // ─── Structured agent-session wire contract ───────────────────────────────── @@ -103,6 +108,12 @@ export type AgentSessionHistoryPage = { hasNewer: boolean /** Present on hosts that expose provider-owned background task lifecycle. */ backgroundTasks?: AgentSessionBackgroundTaskState | null + /** The host's queued drafts. Absent = no claim (older host); `[]`/null = empty. + * Live subscription state stays authoritative over a stale history answer. */ + queuedMessages?: AgentSessionQueuedMessage[] | null + /** The queue's pause, published with the list: present whenever `queuedMessages` is, null + * when the queue sends on its own. */ + queuePause?: AgentSessionQueuePause | null /** Host wall clock (ms epoch) when the page was read, so a client attaching mid-turn * can anchor a live counter on the real start. Absent from older hosts. */ hostNow?: number @@ -140,6 +151,10 @@ export type AgentSessionSubscribeEvent = page: AgentSessionHistoryPage fence: number backgroundTasks?: AgentSessionBackgroundTaskState | null + /** Whole-list draft publication; omitted when unchanged since the last frame sent. */ + queuedMessages?: AgentSessionQueuedMessage[] | null + /** Rides with `queuedMessages`; null when the queue sends on its own. */ + queuePause?: AgentSessionQueuePause | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null /** Latest provider-authored turn activity; optional for mixed-version hosts. */ @@ -152,6 +167,11 @@ export type AgentSessionSubscribeEvent = /** Optional so mixed-version cursors retain the ownership fence. */ fence?: number backgroundTasks?: AgentSessionBackgroundTaskState | null + /** Whole-list draft publication. On a multi-page catch-up it rides only the + * final page, so a consumed card never vanishes before its bubble arrives. */ + queuedMessages?: AgentSessionQueuedMessage[] | null + /** Rides with `queuedMessages`; null when the queue sends on its own. */ + queuePause?: AgentSessionQueuePause | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null /** Additive ephemeral state; it never creates or advances journal rows. */ @@ -164,6 +184,10 @@ export type AgentSessionSubscribeEvent = page: AgentSessionHistoryPage fence: number backgroundTasks?: AgentSessionBackgroundTaskState | null + /** Whole-list draft publication; a reset re-hydrates it with the page. */ + queuedMessages?: AgentSessionQueuedMessage[] | null + /** Rides with `queuedMessages`; null when the queue sends on its own. */ + queuePause?: AgentSessionQueuePause | null /** Omitted when unchanged; null clears a previous provider catalog. */ commands?: AgentSessionSlashCommand[] | null activity?: AgentSessionTurnActivity | null @@ -308,9 +332,30 @@ export type AgentSessionAttachResult = { tabId?: string } -export type AgentSessionSendResult = { - clientMessageId: string - submission: AgentJournalSubmission +/** The host queued the send as a draft instead of submitting it. Only clients + * that sent `delivery: 'queue-if-active'` — gated on + * `agent-session.queued-messages.v1` — ever receive this arm; `state` other + * than `waiting` appears only on replays of an already-settled draft. */ +export type AgentSessionQueuedSendReceipt = { + messageId: string + position: number + state: 'waiting' | 'dispatched' | 'returned' | 'withdrawn' +} + +export type AgentSessionSendResult = + | { + clientMessageId: string + submission: AgentJournalSubmission + } + | { clientMessageId: string; queued: AgentSessionQueuedSendReceipt } + +/** The submission arm's payload; undefined for a queued answer. For callers that + * never send `delivery` the queued arm cannot arrive, and `undefined` reads as + * delivery-unknown rather than as an error. */ +export function agentSessionSendSubmission( + result: AgentSessionSendResult | undefined +): AgentJournalSubmission | undefined { + return result !== undefined && 'submission' in result ? result.submission : undefined } export type AgentSessionCancelResult = { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index cdd920d7922..d3880313897 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -176,6 +176,17 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = // only Stop a client can send before the provider has opened a turn. export const AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY = 'agent-session.conversation-stop.v1' as const +// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a +// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON +// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer +// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. +// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and +// never compares a draft id with a submission id. It also publishes the queue's pause once, as +// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next +// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant +// gates the rollout. +export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = + 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 326962eae58..ca4932528b0 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -473,6 +473,8 @@ import { HoldParams, ModelCatalogParams, OptionsParams, + QueuedMessageActionParams, + QueuedMessagesResumeParams, RespondParams, RespondToQuestionParams, RestartResumableParams, @@ -578,6 +580,9 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.hold': HoldParams, 'agentSession.modelCatalog': ModelCatalogParams, 'agentSession.options': OptionsParams, + 'agentSession.queuedMessageDelete': QueuedMessageActionParams, + 'agentSession.queuedMessageSend': QueuedMessageActionParams, + 'agentSession.queuedMessagesResume': QueuedMessagesResumeParams, 'agentSession.release': HoldParams, 'agentSession.respondToApproval': RespondParams, 'agentSession.respondToQuestion': RespondToQuestionParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 7b259b21bed..b44f7204168 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -168,6 +168,10 @@ export const SendParams = z .object({ envelope: MutationEnvelope, retryUnknown: z.literal(true).optional(), + /** Queue the send as a host-held draft while the main agent is working. Strict object, so an + * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is + * advertised. Participates in the operation fingerprint, never the body fingerprint. */ + delivery: z.literal('queue-if-active').optional(), body: z .object({ kind: z.literal('message'), @@ -210,6 +214,19 @@ export const CancelParams = z } }) +/** `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`. Gated on + * `agent-session.queued-messages.v1`; an older host lacks the methods entirely. */ +export const QueuedMessageActionParams = z + .object({ + envelope: MutationEnvelope, + messageId: Identifier('Invalid queued message id') + }) + .strict() + +/** `agentSession.queuedMessagesResume`: ends the queue's pause (a Stop's, or a + * restart's) so the cards send again. Gated like the draft actions above. */ +export const QueuedMessagesResumeParams = z.object({ envelope: MutationEnvelope }).strict() + export const RespondParams = z .object({ envelope: MutationEnvelope, diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index e95a21937d1..c905584fd80 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -71,10 +71,54 @@ function notice(reason: string | null, rejection?: AgentSessionFailureFact): str ) } +describe('a queued draft answer', () => { + it('retires the outbox entry: the host-held draft carries any later refusal', () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { + clientMessageId: 'client-1', + queued: { messageId: 'client-1', position: 1, state: 'waiting' } + } + }, + createOperationId: () => 'unused' + }) + expect(disposition.entries).toEqual([]) + expect(disposition.error).toBeNull() + }) + + it('a withdrawn replay is spent, not unknown', () => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [entry], + entry, + blockedClientMessageId: null, + result: { + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { + clientMessageId: 'client-1', + queued: { messageId: 'client-1', position: 1, state: 'withdrawn' } + } + }, + createOperationId: () => 'unused' + }) + expect(disposition.entries).toEqual([]) + expect(disposition.error).toBeNull() + }) +}) + describe('what a rejection shows the user', () => { it('removes a queued message the provider confirms Stop cancelled', () => { const result = rejectedWith(DISPATCH_REJECTED_CANCELLED) - if (!result.ok) { + if (!result.ok || !('submission' in result.value)) { throw new Error('expected rejected submission fixture') } @@ -128,7 +172,7 @@ describe('what a rejection shows the user', () => { it('reads a withdrawal off the typed fact whatever the reason says', () => { const result = rejectedWith('Withdrawn.', { rejection: { kind: 'cancelled' } }) - if (!result.ok) { + if (!result.ok || !('submission' in result.value)) { throw new Error('expected rejected submission fixture') } expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) @@ -316,7 +360,7 @@ describe('what a refusal shows the user', () => { lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' } } const result = rejectedWith(null) - if (!result.ok) { + if (!result.ok || !('submission' in result.value)) { throw new Error('expected a send result') } result.value.submission = { ...result.value.submission, dispatchState: 'accepted' } @@ -360,7 +404,7 @@ describe('ambiguous operation refusals', () => { it('parks a recovered missing submission without polling forever', () => { const result = rejectedWith(null) - if (!result.ok) { + if (!result.ok || !('submission' in result.value)) { throw new Error('expected a send result') } result.value.submission = { diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 11e2b45fc0b..ce6143369ec 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -85,7 +85,7 @@ function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEnt */ function refusedRedelivery( entry: StructuredAgentSessionOutboxEntry, - submission: AgentSessionSendResult['submission'] + submission: AgentJournalSubmission ): boolean { return ( entry.retryAfterUnknownSubmittedAt !== null && @@ -229,6 +229,16 @@ export function disposeStructuredAgentSessionSendResult( : refused.clientMessageId } } + if ('queued' in result.value) { + // The host holds the draft (or already settled it, on a replay). Either way + // the send is spent and the queue owns it now: the outbox entry retires, + // and the draft card — not this queue — carries any later refusal. + return { + entries: dropEntry(input), + error: null, + blockedClientMessageId: input.blockedClientMessageId + } + } const submission = result.value.submission if (refusedRedelivery(input.entry, submission)) { return { diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 320bb7b4925..681c65f50c1 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -53,6 +53,9 @@ export function structuredHostStub( })), waitForSendSettlement: vi.fn(), cancel: vi.fn(async () => ({ ok: true, replayed: false })), + queuedMessageSend: vi.fn(async () => ({ ok: true, replayed: false })), + queuedMessageDelete: vi.fn(async () => ({ ok: true, replayed: false })), + queuedMessagesResume: vi.fn(async () => ({ ok: true, replayed: false })), rewind: vi.fn(async () => ({ ok: true, replayed: false, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index f827d3cda5f..7eb4920deec 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -64,6 +64,23 @@ export const STRUCTURED_CALLS: { }, { method: 'agentSession.send', hostMethod: 'send', result: { ok: true, replayed: false } }, { method: 'agentSession.cancel', hostMethod: 'cancel', result: { ok: true, replayed: false } }, + // Draft mutations for mid-turn queueing. Methods exist ahead of the + // capability's advertisement; only capability-gated clients ever call them. + { + method: 'agentSession.queuedMessageSend', + hostMethod: 'queuedMessageSend', + result: { ok: true, replayed: false } + }, + { + method: 'agentSession.queuedMessageDelete', + hostMethod: 'queuedMessageDelete', + result: { ok: true, replayed: false } + }, + { + method: 'agentSession.queuedMessagesResume', + hostMethod: 'queuedMessagesResume', + result: { ok: true, replayed: false } + }, { method: REWIND_METHOD, hostMethod: 'rewind', @@ -250,6 +267,13 @@ export function paramsFor(method: string): unknown { envelope: envelope({ method: 'agentSession.cancel', fields: { turnId: 'turn-1' }, fence }), turnId: 'turn-1' } + case 'agentSession.queuedMessageSend': + case 'agentSession.queuedMessageDelete': { + const fields = { messageId: 'queued-1' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.queuedMessagesResume': + return { envelope: envelope({ method, fields: {}, fence }) } case 'agentSession.respondToApproval': case 'agentSession.respondToQuestion': { const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }