fix(native-chat): edit a queued message in place, even while a question is open (#26886)

* feat(native-chat): edit a queued message in place on the host

A queued card's Edit used to copy its text into the composer and delete the
card, so the message lost its place, an agent-mail card lost its sender, and
while a question or approval replaced the composer the text vanished.

The execution host now edits the card itself:
- agentSession.queuedMessageUpdate replaces only the card's text, as a
  compare-and-set on the body fingerprint the client last saw. It keeps the
  id, position, sender, kind, send mode and attachments; a command card is not
  editable. A resent Save answers from the card as it stands (unchanged or
  changed), so a lost answer never overwrites newer text.
- agentSession.queuedMessageEditHold acquires, renews and releases an
  in-memory edit lease per caller and edit. While one is live, automatic
  delivery stops at the card (earlier cards still send, later ones wait); an
  explicit Send still overrides. Leases expire after two minutes on their own,
  re-derive validity from the card on every read, and die with the
  conversation. Release uses cleanup admission.
- The published card carries an optional editHeld marker, never `paused`.
- Both methods sit behind the new agent-session.queued-message-edit.v1
  capability, independent of the dark mid-turn queue capability, and are on
  the mobile allowlist for the phone follow-up.

* feat(native-chat): edit a queued card inline on desktop

Edit message now opens an editor inside the queued card instead of moving the
text into the composer. The card keeps its place and sender, and the editor
stays usable while a question or approval holds the composer's slot (it does
not lose focus to the prompt card).

- Enter or Cmd/Ctrl+Enter saves, Shift+Enter is a newline, Escape cancels;
  IME composition keys never save or cancel. Save and Cancel replace the
  card's own actions while it is edited; one editor per pane.
- The editor holds the host's edit lease, renewing every 30 seconds and taking
  it again after a lapse. Lease failures are only logged: they never block
  typing, Save or Cancel.
- If the card is sent or removed elsewhere mid-edit, a clean edit just
  closes; changed text goes into the chat box after any existing draft, with
  one toast. A Save that lands and then sends counts as success.
- Against a host without agent-session.queued-message-edit.v1 the card offers
  no Edit (no copy-and-delete fallback); an empty overflow menu is hidden.

* fix(native-chat): name every queued-edit notice in its switch

* fix(native-chat): keep queued-edit typing safe and stop disabling Send elsewhere

- Save with an unreadable answer (dropped for a moved runtime fence, or lost)
  now reads the published card: saved text shown -> close; card gone -> the
  sent-as-saved / chat-box path; otherwise keep the editor quietly.
- Send and Steer stay enabled on a card edited on another device; the
  published editHeld marker is gone (no reader left). Automatic delivery
  still stops at an edited card on the host.
- Save answering changed moves the typing to the chat box and reopens the
  editor on the newer text. Notices say when the chat box is hidden by a
  question or approval.
- Closing the pane or switching chats with unsaved typing appends it to that
  chat's box quietly.
- Lease renewal no longer publishes, so an open editor is not idle-sweep
  activity.
- The consume compare-and-set also matches the body fingerprint.
- Saving unchanged text keeps the original blocks.

* fix(native-chat): keep Agent in the Japanese queued-edit notices, as the catalog does

* fix(native-chat): close a queued edit its own Save already landed, wake the queue for a pruned lease, keep typing across a reload

A Save whose answer was lost now closes once the card shows the saved text,
and a card that went out with exactly the typed text closes quietly instead
of copying it to the chat box. A renew that prunes another editor's lapsed
lease wakes the queue in place of the timer it cancels. A reload, which
unmounts nothing, hands unsaved typing to the chat box like closing the pane.

* refactor(native-chat): move the queued-edit notices to their own module

* fix(native-chat): unref the edit-lease timer in a way the phone's typecheck accepts

The lease module reaches the mobile test program through journal-store, where
setTimeout answers a number, so `timer.unref?.()` failed mobile's tests
typecheck ratchet.

* test(cross-version-wire): call the queued-edit methods in both skew directions

The manifest must name every agentSession method; the two edit methods
reach their host methods on a new build and are only called by clients
on a host advertising agent-session.queued-message-edit.v1.

* test(rpc): count the two queued-edit methods on the structured surface

* fix(native-chat): lay out the inline queued-message editor as a header, field and Save footer

While a card is edited, its row shows a pencil, "Editing message", the sender and an X that
cancels; the field at full width; and a footer with the key hint and Save. The card's queue icon
and its status line are hidden until editing ends.

* fix(native-chat): show the Enter key on the queued-message editor's Save button

The editor footer's muted key hint is gone. Save now carries the same inline
key badge as the new-workspace composer's Create button, extracted into a
shared ButtonKeyHint so both buttons render one badge.
This commit is contained in:
Brennan Benson
2026-10-09 17:31:28 -07:00
committed by GitHub
parent 6cf1aa00f4
commit 59631b8b26
63 changed files with 3216 additions and 259 deletions
@@ -5,19 +5,14 @@
// working status, teardown, or the idle sweep.
import type Database from '../../sqlite/sync-database'
import type {
AgentJournalCursor,
AgentJournalMessageItem
} from '../../../shared/agent-session-journal-types'
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 { JournalHostDatabase } from './journal-host-database'
import type { JournalReducerState } from './journal-reducer'
import type { JournalRow } from './journal-row-schema'
import type { JournalOperationReceipt } from './journal-row-writer'
import type { JournalSubmissionConsume } from './journal-store-contracts'
import type { JournalQueuedMessagesDeps, JournalSubmissionConsume } from './journal-store-contracts'
import { holdQueuedMessages } from './queued-message-holds'
import {
deriveQueuePauses,
@@ -41,38 +36,22 @@ import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-mes
import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention'
import {
queuedMessageSettlementOwed,
queuedMessagesAwaitReopenMark,
settleOwedQueuedMessages,
settleQueuedMessagesForRow
} from './queued-message-settlement'
import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards'
import type { JournalAttachmentClaim } from './journal-submission-hook'
import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue'
import type { JournalWriteResult } from './journal-write-queue'
import { QueuedMessageNotConsumableError } from './queued-message-consume-error'
export { QueuedMessageNotConsumableError } from './queued-message-consume-error'
import { QueuedMessageEditLeases } from './queued-message-edit-leases'
import { updateQueuedMessageText, type QueuedMessageTextUpdate } from './queued-message-text-update'
/** 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: <T>(run: JournalWriteBody<T>) => Promise<T>
database: () => JournalHostDatabase
readOnly: () => boolean
state: () => JournalReducerState
/** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */
reopenFloor: () => AgentJournalCursor | null
/** 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
claimAttachments: JournalAttachmentClaim
}
export class JournalQueuedMessages {
readonly editLeases = new QueuedMessageEditLeases(() => this.list())
/** 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
@@ -108,6 +87,15 @@ export class JournalQueuedMessages {
return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId)
}
/** An in-place text edit; a changed card spends every edit lease on its old text. */
update(input: QueuedMessageTextUpdate): Promise<ReturnType<typeof updateQueuedMessageText>> {
return this.transact(
(db) => updateQueuedMessageText(db, this.deps.sessionId, input),
(result) => result.status === 'updated',
() => this.editLeases.retire(input.messageId)
)
}
/** Replay receipts for one caller-scoped operation key. */
receipts(settledByOp: string): QueuedMessageRow[] {
return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp)
@@ -168,17 +156,9 @@ export class JournalQueuedMessages {
return this.derivePauses(this.list())
}
/** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running
* marks it (`AgentSessionJournal.markQueueReopen`). */
/** `queuedMessagesAwaitReopenMark`. */
awaitReopenMark(): boolean {
const { submissions } = this.deps.state()
return this.list().some((row) => {
if (row.state === 'waiting') {
return true
}
const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined
return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown')
})
return queuedMessagesAwaitReopenMark(this.list(), this.deps.state().submissions)
}
/** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */
@@ -289,7 +269,7 @@ export class JournalQueuedMessages {
* throws so the whole append — draft transition AND submission row — rolls back. */
consumeInTransaction(
db: Database.Database,
input: JournalSubmissionConsume & { consumedAs: string }
input: JournalSubmissionConsume & { consumedAs: string; fingerprint: string }
): void {
const { db: own } = this.deps.database()
if (own !== db) {
@@ -302,7 +282,8 @@ export class JournalQueuedMessages {
// this guards any pause-relevant row written off that lane from overtaking a held card.
const cards = listQueuedMessages(db, this.deps.sessionId)
const pauses = this.derivePauses(cards)
if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) {
const held = this.editLeases.heldIds(cards)
if (nextSendableQueuedCard(pauses, cards, held)?.messageId !== input.messageId) {
throw new QueuedMessageNotConsumableError(input.messageId, input.expect)
}
}
@@ -15,6 +15,9 @@ import type { AgentSessionMessageSource } from '../../../shared/agent-session-me
import type { JournalHostDatabase } from './journal-host-database'
import type { JournalLifecycleMutationInput } from './journal-row-builders'
import type { JournalRow } from './journal-row-schema'
import type { JournalReducerState } from './journal-reducer'
import type { JournalAttachmentClaim } from './journal-submission-hook'
import type { JournalWriteBody } from './journal-write-queue'
export type AgentSessionJournalOptions = {
identity: AgentSessionJournalIdentity
@@ -124,3 +127,22 @@ export type JournalItemAppendInput = {
body: AgentJournalItemBody
options: JournalItemAppendOptions
}
/** What `JournalQueuedMessages` reads from the journal that owns it. */
export type JournalQueuedMessagesDeps = {
sessionId: string
now: () => number
serialize: <T>(run: JournalWriteBody<T>) => Promise<T>
database: () => JournalHostDatabase
readOnly: () => boolean
state: () => JournalReducerState
/** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */
reopenFloor: () => AgentJournalCursor | null
/** 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
claimAttachments: JournalAttachmentClaim
}
@@ -181,6 +181,7 @@ export class AgentSessionJournal {
/** Refuses every later write and resolves once the admitted ones have landed. Holds no
* connection, so there is nothing to release and nothing that can fail. */
close(): Promise<void> {
this.queuedMessages.editLeases.dispose()
this.queue.markClosed()
return this.queue.drain()
}
@@ -18,12 +18,16 @@ export type JournalAttachmentClaim = (
export function journalSubmissionHook(
queuedMessages: JournalQueuedMessages,
claimAttachments: JournalAttachmentClaim,
input: Pick<JournalSubmissionInput, 'clientMessageId' | 'body' | 'origin'>,
input: Pick<JournalSubmissionInput, 'clientMessageId' | 'body' | 'origin' | 'payloadFingerprint'>,
consume: JournalSubmissionConsume | undefined
): JournalRowTransactionHook {
return (db) => {
if (consume) {
queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId })
queuedMessages.consumeInTransaction(db, {
...consume,
consumedAs: input.clientMessageId,
fingerprint: input.payloadFingerprint
})
}
claimAttachments(db, input.body, input.origin === 'client' && !consume)
}
@@ -179,7 +179,12 @@ describe('draft bookkeeping inside a journal append', () => {
try {
await expect(
journal.appendSubmission(
{ clientMessageId: 'sub-draft-1', payloadFingerprint: 'fp', body: BODY, fence: 0 },
{
clientMessageId: 'sub-draft-1',
payloadFingerprint: 'fp-draft-1',
body: BODY,
fence: 0
},
{ messageId: 'draft-1', expect: 'waiting', settledByOp: null }
)
).rejects.toThrow('SQLITE_FULL')
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QueuedMessageEditLeases, QUEUED_MESSAGE_EDIT_LEASE_MS } from './queued-message-edit-leases'
import type { QueuedMessageRow } from './queued-message-table'
function card(messageId = 'card'): QueuedMessageRow {
return {
sessionId: 'folder-session',
messageId,
position: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
fingerprint: 'base',
createdAt: 1,
hostInstance: 'host-a',
state: 'waiting',
holdReason: null,
returnedReason: null,
returnedRejection: null,
settledAt: null,
settledByOp: null,
consumedAs: null,
carriedFrom: null,
queuedAt: null
}
}
const desktop = { callerKey: 'desktop', editId: 'edit-1' }
const base = { messageId: 'card', fingerprint: 'base' }
let rows: QueuedMessageRow[]
let leases: QueuedMessageEditLeases
let clock: number
const wake = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
clock = 0
rows = [card()]
wake.mockReset()
leases = new QueuedMessageEditLeases(
() => rows,
() => clock
)
leases.onDeadline(wake)
})
afterEach(() => {
leases.dispose()
vi.useRealTimers()
})
function advance(ms: number): void {
clock += ms
vi.advanceTimersByTime(ms)
}
describe('queued edit leases', () => {
it('a repeated acquire answers the same lease without extending it', () => {
expect(leases.acquire(desktop, base)).toMatchObject({ status: 'held', remainingMs: 120_000 })
advance(30_000)
expect(leases.acquire(desktop, base)).toMatchObject({ status: 'held', remainingMs: 90_000 })
})
it('acquire refuses a card that is gone or whose text moved on', () => {
expect(leases.acquire(desktop, { ...base, fingerprint: 'stale' })).toEqual({
status: 'changed'
})
expect(leases.acquire(desktop, { ...base, messageId: 'other' })).toEqual({ status: 'gone' })
rows[0]!.state = 'dispatched'
expect(leases.acquire(desktop, base)).toEqual({ status: 'gone' })
expect(leases.heldIds().size).toBe(0)
})
it('expires on its own and wakes the queue with no other activity', () => {
leases.acquire(desktop, base)
advance(QUEUED_MESSAGE_EDIT_LEASE_MS - 1)
expect(wake).not.toHaveBeenCalled()
expect(leases.heldIds().has('card')).toBe(true)
advance(1)
expect(wake).toHaveBeenCalledOnce()
expect(leases.heldIds().size).toBe(0)
})
it('renewal extends the deadline; a lapsed lease is not revived', () => {
leases.acquire(desktop, base)
advance(100_000)
expect(leases.renew(desktop, 'card')).toMatchObject({ status: 'held', remainingMs: 120_000 })
advance(119_999)
expect(wake).not.toHaveBeenCalled()
advance(1)
expect(wake).toHaveBeenCalledOnce()
expect(leases.renew(desktop, 'card')).toEqual({ status: 'expired' })
})
it("a renew that prunes another editor's lapsed lease before its timer runs still wakes the queue", () => {
rows.push({ ...card('second'), position: 2, fingerprint: 'second-base' })
const phone = { callerKey: 'phone', editId: 'edit-9' }
leases.acquire(desktop, base)
clock = 10_000
leases.acquire(phone, { messageId: 'second', fingerprint: 'second-base' })
// The first deadline has passed on the host clock; its timer is late and has not run.
clock = QUEUED_MESSAGE_EDIT_LEASE_MS + 1
expect(leases.renew(phone, 'second')).toMatchObject({ status: 'held' })
expect([...leases.heldIds()]).toEqual(['second'])
vi.advanceTimersByTime(QUEUED_MESSAGE_EDIT_LEASE_MS - 1)
expect(wake).toHaveBeenCalledOnce()
})
it('release frees only the exact caller and edit, never another editor on the same card', () => {
leases.acquire(desktop, base)
leases.acquire({ callerKey: 'phone', editId: 'edit-1' }, base)
leases.acquire({ ...desktop, editId: 'edit-2' }, base)
leases.release(desktop, 'card')
leases.release(desktop, 'card')
expect(leases.heldIds().has('card')).toBe(true)
leases.release({ callerKey: 'phone', editId: 'edit-1' }, 'card')
expect(leases.heldIds().has('card')).toBe(true)
leases.release({ ...desktop, editId: 'edit-2' }, 'card')
expect(leases.heldIds().size).toBe(0)
})
it.each(['text changed', 'sent', 'deleted', 'handed off and returned'] as const)(
'every read re-derives: a card whose %s holds nothing',
(change) => {
leases.acquire(desktop, base)
const row = rows[0]!
if (change === 'text changed') {
row.fingerprint = 'newer'
} else if (change === 'sent') {
row.state = 'dispatched'
} else if (change === 'deleted') {
rows = []
} else {
Object.assign(row, { state: 'returned', consumedAs: 'submission-2' })
}
expect(leases.heldIds().size).toBe(0)
expect(leases.renew(desktop, 'card')).toEqual({ status: 'expired' })
}
)
it('a save retires every lease on the card; others stay', () => {
rows.push(card('second'))
leases.acquire(desktop, base)
leases.acquire({ callerKey: 'phone', editId: 'edit-9' }, base)
leases.acquire({ ...desktop, editId: 'edit-2' }, { ...base, messageId: 'second' })
leases.retire('card')
expect([...leases.heldIds()]).toEqual(['second'])
})
it('reads the rows it is given inside a transaction', () => {
leases.acquire(desktop, base)
expect(leases.heldIds([{ ...card(), state: 'withdrawn' }]).size).toBe(0)
})
it('dispose drops every lease and deadline; a closed handle grants none', () => {
leases.acquire(desktop, base)
leases.dispose()
advance(QUEUED_MESSAGE_EDIT_LEASE_MS)
expect(wake).not.toHaveBeenCalled()
expect(leases.acquire(desktop, base)).toEqual({ status: 'gone' })
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -0,0 +1,187 @@
// In-memory edit leases for queued cards: while a person edits a card in place, automatic
// delivery stops at it (`nextSendableQueuedCard`). Nothing here is durable. Each lease dies on
// its own deadline, and every read re-derives validity from the card it holds, so a lost
// release, a send, a delete or another client's save never leaves a card held.
import type { AgentSessionQueuedMessageEditHoldResult } from '../../../shared/agent-session-wire'
import { isUnsettledQueuedMessage, type QueuedMessageRow } from './queued-message-table'
export const QUEUED_MESSAGE_EDIT_LEASE_MS = 120_000
type EditLeaseKey = { callerKey: string; editId: string }
type EditLease = Pick<QueuedMessageRow, 'messageId' | 'fingerprint' | 'state' | 'consumedAs'> & {
until: number
}
/** One per journal handle: closing the conversation drops every lease with it. */
export class QueuedMessageEditLeases {
private readonly leases = new Map<string, EditLease>()
private timer: ReturnType<typeof setTimeout> | undefined
private timerAt: number | null = null
private wake: (() => void) | undefined
private disposed = false
constructor(
private readonly rows: () => readonly QueuedMessageRow[],
private readonly now: () => number = () => performance.now()
) {}
/** Called once a deadline passes, so the queue re-derives with no other journal activity. */
onDeadline(wake: () => void): void {
this.wake = wake
}
/** Cards a live lease holds. `rows` is the caller's own read inside a transaction. */
heldIds(rows: readonly QueuedMessageRow[] = this.rows()): ReadonlySet<string> {
if (this.leases.size === 0) {
return new Set()
}
const byId = new Map(rows.map((row) => [row.messageId, row]))
const now = this.now()
let lapsed = false
for (const [key, lease] of this.leases) {
const row = byId.get(lease.messageId)
lapsed ||= lease.until <= now
if (lease.until <= now || !row || !leaseMatchesRow(lease, row)) {
this.leases.delete(key)
}
}
this.schedule()
// A deadline pruned here before its timer ran: that timer is gone, so this wakes in its place.
if (lapsed) {
this.wake?.()
}
return new Set([...this.leases.values()].map((lease) => lease.messageId))
}
/** Idempotent per caller and edit: a repeat answers the same lease without extending it. */
acquire(
key: EditLeaseKey,
input: { messageId: string; fingerprint: string }
): AgentSessionQueuedMessageEditHoldResult {
this.heldIds()
const existing = this.leases.get(leaseKey(key))
if (existing) {
return existing.messageId === input.messageId && existing.fingerprint === input.fingerprint
? this.answer(existing)
: { status: 'changed' }
}
const row = this.rows().find((entry) => entry.messageId === input.messageId)
if (this.disposed || !row || !isUnsettledQueuedMessage(row)) {
return { status: 'gone' }
}
if (row.fingerprint !== input.fingerprint) {
return { status: 'changed' }
}
const lease: EditLease = {
messageId: row.messageId,
fingerprint: row.fingerprint,
state: row.state,
consumedAs: row.consumedAs,
until: this.now() + QUEUED_MESSAGE_EDIT_LEASE_MS
}
this.leases.set(leaseKey(key), lease)
this.schedule()
return this.answer(lease)
}
/** A lease that lapsed or lost its card is not revived: the editor acquires again. */
renew(key: EditLeaseKey, messageId: string): AgentSessionQueuedMessageEditHoldResult {
this.heldIds()
const lease = this.leases.get(leaseKey(key))
if (!lease || lease.messageId !== messageId) {
return { status: 'expired' }
}
lease.until = this.now() + QUEUED_MESSAGE_EDIT_LEASE_MS
this.schedule()
return this.answer(lease)
}
/** Only the exact caller and edit release: never another editor's lease on the same card. */
release(key: EditLeaseKey, messageId: string): void {
if (this.leases.get(leaseKey(key))?.messageId === messageId) {
this.leases.delete(leaseKey(key))
this.schedule()
}
}
/** A save changed the card: every lease on its old text is spent. */
retire(messageId: string): void {
for (const [key, lease] of this.leases) {
if (lease.messageId === messageId) {
this.leases.delete(key)
}
}
this.schedule()
}
dispose(): void {
this.disposed = true
this.leases.clear()
this.schedule()
}
private answer(lease: EditLease): AgentSessionQueuedMessageEditHoldResult {
return {
status: 'held',
fingerprint: lease.fingerprint,
leaseDurationMs: QUEUED_MESSAGE_EDIT_LEASE_MS,
remainingMs: Math.max(0, lease.until - this.now())
}
}
/** One timer, at the nearest deadline; unchanged when that deadline is. */
private schedule(): void {
const next =
this.leases.size === 0 ? null : Math.min(...[...this.leases.values()].map((l) => l.until))
if (next === this.timerAt) {
return
}
clearTimeout(this.timer)
this.timer = undefined
this.timerAt = next
if (next === null) {
return
}
this.timer = setTimeout(
() => {
this.timer = undefined
this.timerAt = null
const now = this.now()
for (const [key, lease] of this.leases) {
if (lease.until <= now) {
this.leases.delete(key)
}
}
this.schedule()
this.wake?.()
},
Math.max(1, next - this.now())
)
// Read untyped: this module also compiles into the phone's tests, where a timer is a number.
const timer: unknown = this.timer
if (
typeof timer === 'object' &&
timer &&
'unref' in timer &&
typeof timer.unref === 'function'
) {
timer.unref()
}
}
}
function leaseKey(key: EditLeaseKey): string {
return JSON.stringify([key.callerKey, key.editId])
}
/** A hand-off or a return in between re-scopes the card even when its text is unchanged. */
function leaseMatchesRow(lease: EditLease, row: QueuedMessageRow): boolean {
return (
isUnsettledQueuedMessage(row) &&
row.fingerprint === lease.fingerprint &&
row.state === lease.state &&
row.consumedAs === lease.consumedAs
)
}
@@ -16,7 +16,7 @@ import {
liveTestJournalRows,
updateTestJournalRowJson
} from './journal-host-database-test-support'
import { QueuedMessageNotConsumableError } from './journal-queued-messages'
import { QueuedMessageNotConsumableError } from './queued-message-consume-error'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import { parseJournalRow } from './journal-row-schema'
import type { AgentSessionJournal } from './journal-store'
@@ -256,19 +256,20 @@ export function queuePauseHolding(
}
/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a
* returned card or one a pause holds comes first. The queue never reorders, so a newer card never
* overtakes one a pause holds; a card held on its own is passed over. The drain's pick and its
* consume both read this. */
export function nextSendableQueuedCard<T extends QueueCard>(
* returned card, one a pause holds, or one being edited (`editHeld`) comes first. The queue never
* reorders, so a newer card never overtakes either; a card held on its own is passed over. The
* drain's pick and its consume both read this. */
export function nextSendableQueuedCard<T extends QueueCard & { messageId: string }>(
pauses: readonly DerivedQueuePause[],
cards: readonly T[]
cards: readonly T[],
editHeld: ReadonlySet<string> = new Set()
): T | null {
for (const card of cards) {
if (card.state === 'returned' || queuePauseHolding(pauses, card)) {
return null
}
if (card.state === 'waiting' && card.holdReason === null) {
return card
return editHeld.has(card.messageId) ? null : card
}
}
return null
@@ -37,6 +37,21 @@ export function queuedMessageSettlementOwed(
)
}
/** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running
* marks it (`AgentSessionJournal.markQueueReopen`). */
export function queuedMessagesAwaitReopenMark(
rows: readonly QueuedMessageRow[],
submissions: Submissions
): boolean {
return rows.some((row) => {
if (row.state === 'waiting') {
return true
}
const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined
return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown')
})
}
/** Applies each owed settlement, and withdraws each waiting draft an applied echo proves
* delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */
export function settleOwedQueuedMessages(
@@ -16,11 +16,8 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure-
import Database from '../../sqlite/sync-database'
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
import { journalDatabasePath } from './journal-host-database'
import {
JournalQueuedMessages,
QUEUED_MESSAGE_REPLAY_WINDOW_MS,
QueuedMessageNotConsumableError
} from './journal-queued-messages'
import { JournalQueuedMessages, QUEUED_MESSAGE_REPLAY_WINDOW_MS } from './journal-queued-messages'
import { QueuedMessageNotConsumableError } from './queued-message-consume-error'
import type { AgentSessionJournal } from './journal-store'
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
import {
@@ -319,6 +316,31 @@ describe('consume', () => {
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
})
it('a consume carrying text the card no longer holds loses: an edit since the read never sends the old text', async () => {
const journal = await open()
await queueDraft(journal, 'draft-1', 'old')
const read = journal.queuedMessages.get('draft-1')!
await journal.queuedMessages.update({
messageId: 'draft-1',
expectedBodyFingerprint: read.fingerprint,
text: 'new'
})
await expect(
journal.appendSubmission(
{
clientMessageId: 'sub-draft-1',
payloadFingerprint: read.fingerprint,
body: read.body,
fence: 0,
handoverRecorded: true
},
{ messageId: 'draft-1', expect: 'waiting', settledByOp: null }
)
).rejects.toBeInstanceOf(QueuedMessageNotConsumableError)
expect(journal.submissions()).toHaveLength(0)
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
})
it('a failed submission insert rolls the draft transition back', async () => {
const journal = await open()
await queueDraft(journal, 'draft-1')
@@ -155,6 +155,8 @@ export function consumeQueuedMessageInTransaction(
sessionId: string
messageId: string
expect: 'waiting' | 'returned'
/** The body being sent: a card edited since it was read is not consumed with its old text. */
fingerprint: string
/** The fresh submission id; never the draft's own id. */
consumedAs: string
settledByOp: string | null
@@ -171,7 +173,7 @@ export function consumeQueuedMessageInTransaction(
`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 = ?`
WHERE session_id = ? AND message_id = ? AND state = ? AND fingerprint = ?`
)
.run(
input.now,
@@ -180,7 +182,8 @@ export function consumeQueuedMessageInTransaction(
input.hostInstance ?? null,
input.sessionId,
input.messageId,
input.expect
input.expect,
input.fingerprint
)
return Number(changed.changes ?? 0) === 1
}
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import Database from '../../sqlite/sync-database'
import { ensureQueuedMessagesTable } from './queued-message-schema'
import { getQueuedMessage, insertQueuedMessage } from './queued-message-table'
import { updateQueuedMessageText } from './queued-message-text-update'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
import { MAX_PROMPT_BYTES } from '../../../shared/rpc-contract/structured-agent-session-params'
let db: Database.Database
const sessionId = 'folder-session'
const initial: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'base' }]
}
beforeEach(() => {
db = new Database(':memory:')
ensureQueuedMessagesTable(db)
})
afterEach(() => db.close())
function insert(body = initial, messageId = 'card') {
return insertQueuedMessage(db, {
sessionId,
messageId,
body,
fingerprint: agentSessionSendBodyFingerprint(sessionId, body),
hostInstance: 'remote-host',
queuedAt: { epoch: 'epoch', sequence: 4 },
now: 1,
holdReason: 'send_failed'
})
}
function update(text: string, fingerprint = agentSessionSendBodyFingerprint(sessionId, initial)) {
return updateQueuedMessageText(db, sessionId, {
messageId: 'card',
expectedBodyFingerprint: fingerprint,
text
})
}
describe('atomic queued text update', () => {
it('preserves identity, cursor, order, sender, kind, role, send mode and attachment order', () => {
const body: AgentJournalMessageItem = {
...initial,
sentAs: 'goal',
from: { kind: 'agent', senders: [], orchestration: null },
blocks: [
{ type: 'image-ref', path: 'before.png' },
{ type: 'text', text: 'one' },
{ type: 'image-ref', url: 'https://example.com/image.png' },
{ type: 'text', text: 'two' }
]
}
const before = insert(body)
expect(update('edited', before.fingerprint).status).toBe('updated')
const after = getQueuedMessage(db, sessionId, 'card')!
expect(after).toEqual({
...before,
body: {
...body,
blocks: [body.blocks[0]!, { type: 'text', text: 'edited' }, body.blocks[2]!]
},
fingerprint: after.fingerprint
})
expect(after.fingerprint).toBe(agentSessionSendBodyFingerprint(sessionId, after.body))
})
it('saving the same text keeps several text blocks as they are', () => {
const body: AgentJournalMessageItem = {
...initial,
blocks: [
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' }
]
}
const before = insert(body)
expect(update('one\ntwo', before.fingerprint).status).toBe('unchanged')
expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before)
})
it('retains returned-card refusal and failure hold rather than requeueing', () => {
insert()
db.prepare(
"UPDATE queued_messages SET state = 'returned', returned_reason = 'refused', consumed_as = 'submission' WHERE message_id = 'card'"
).run()
const before = getQueuedMessage(db, sessionId, 'card')!
expect(update('edit').status).toBe('updated')
expect(getQueuedMessage(db, sessionId, 'card')).toMatchObject({
state: 'returned',
returnedReason: before.returnedReason,
consumedAs: before.consumedAs,
holdReason: before.holdReason,
position: before.position
})
})
it('checks the resulting body before the expected base, making lost-answer retry safe', () => {
insert()
expect(update('edit').status).toBe('updated')
expect(update('edit').status).toBe('unchanged')
expect(update('other').status).toBe('changed')
})
it('accepts semantic A/B/A and preserves the latest host-owned sender', () => {
insert()
const base = agentSessionSendBodyFingerprint(sessionId, initial)
update('B')
const second = getQueuedMessage(db, sessionId, 'card')!
update('base', second.fingerprint)
const from: NonNullable<AgentJournalMessageItem['from']> = {
kind: 'agent',
senders: [],
orchestration: null
}
const latestBody = { ...initial, from }
db.prepare("UPDATE queued_messages SET body_json = ? WHERE message_id = 'card'").run(
JSON.stringify(latestBody)
)
expect(agentSessionSendBodyFingerprint(sessionId, latestBody)).toBe(base)
expect(update('C', base).status).toBe('updated')
expect(getQueuedMessage(db, sessionId, 'card')?.body.from).toEqual(from)
})
it('refuses empty, oversized, command and unsupported bodies without modifying them', () => {
insert()
expect(update(' ').status).toBe('not-editable')
expect(update('é'.repeat(MAX_PROMPT_BYTES)).status).toBe('not-editable')
db.prepare('DELETE FROM queued_messages').run()
const command = insert({ ...initial, command: { name: 'compact' } })
expect(update('edit', command.fingerprint).status).toBe('not-editable')
expect(getQueuedMessage(db, sessionId, 'card')).toEqual(command)
db.prepare('DELETE FROM queued_messages').run()
const unsupported = insert({
...initial,
blocks: [{ type: 'tool-result', callId: 'tool', output: 'retained' }]
})
expect(update('edit', unsupported.fingerprint).status).toBe('not-editable')
expect(getQueuedMessage(db, sessionId, 'card')).toEqual(unsupported)
})
it('allows empty text with a retained attachment, and inserts text before one with none', () => {
const before = insert({
...initial,
blocks: [
{ type: 'text', text: 'caption' },
{ type: 'image-ref', path: 'file.png' }
]
})
expect(update('', before.fingerprint).status).toBe('updated')
expect(getQueuedMessage(db, sessionId, 'card')?.body.blocks).toEqual([
{ type: 'text', text: '' },
{ type: 'image-ref', path: 'file.png' }
])
const imageOnly = insert(
{ ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] },
'image'
)
expect(
updateQueuedMessageText(db, sessionId, {
messageId: 'image',
expectedBodyFingerprint: imageOnly.fingerprint,
text: 'added'
}).status
).toBe('updated')
expect(getQueuedMessage(db, sessionId, 'image')?.body.blocks).toEqual([
{ type: 'text', text: 'added' },
{ type: 'image-ref', path: 'file.png' }
])
})
it('a failed transaction rolls back body and fingerprint together', () => {
const before = insert()
db.exec('BEGIN IMMEDIATE')
update('edit')
db.exec('ROLLBACK')
expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before)
})
it('dispatch, withdrawal and a pruned tombstone all answer gone', () => {
insert()
db.prepare("UPDATE queued_messages SET state = 'dispatched' WHERE message_id = 'card'").run()
expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'dispatched' })
db.prepare("UPDATE queued_messages SET state = 'withdrawn' WHERE message_id = 'card'").run()
expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'withdrawn' })
db.prepare('DELETE FROM queued_messages').run()
expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'missing' })
})
})
@@ -0,0 +1,66 @@
import type Database from '../../sqlite/sync-database'
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { AgentSessionQueuedMessageUpdateResult } from '../../../shared/agent-session-wire'
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
import { queuedMessageWithEditedText } from '../../../shared/queued-message-text-edit'
import {
getQueuedMessage,
isUnsettledQueuedMessage,
type QueuedMessageRow
} from './queued-message-table'
export type QueuedMessageTextUpdate = {
messageId: string
expectedBodyFingerprint: string
text: string
}
/** Compare-and-set on the body fingerprint. The resulting body is checked first, so a retried
* Save whose first answer was lost reads `unchanged`, and one a newer edit superseded reads
* `changed`: a retry never overwrites newer text. */
export function queuedMessageTextUpdateDecision(
row: QueuedMessageRow | null,
sessionId: string,
input: QueuedMessageTextUpdate
):
| AgentSessionQueuedMessageUpdateResult
| { status: 'ready'; body: AgentJournalMessageItem; fingerprint: string } {
const { messageId } = input
if (!row || !isUnsettledQueuedMessage(row)) {
const disposition = !row ? 'missing' : row.state === 'dispatched' ? 'dispatched' : 'withdrawn'
return { status: 'gone', messageId, disposition }
}
const body = queuedMessageWithEditedText(row.body, input.text)
if (!body) {
return { status: 'not-editable', messageId }
}
const fingerprint = agentSessionSendBodyFingerprint(sessionId, body)
if (row.fingerprint === fingerprint) {
return { status: 'unchanged', messageId, fingerprint }
}
if (row.fingerprint !== input.expectedBodyFingerprint) {
return { status: 'changed', messageId }
}
return { status: 'ready', body, fingerprint }
}
/** Inside the journal's draft transaction; only body and fingerprint change, so the card keeps
* its id, position, state, holds and hand-off link. */
export function updateQueuedMessageText(
db: Database.Database,
sessionId: string,
input: QueuedMessageTextUpdate
): AgentSessionQueuedMessageUpdateResult {
const decision = queuedMessageTextUpdateDecision(
getQueuedMessage(db, sessionId, input.messageId),
sessionId,
input
)
if (decision.status !== 'ready') {
return decision
}
db.prepare(
'UPDATE queued_messages SET body_json = ?, fingerprint = ? WHERE session_id = ? AND message_id = ?'
).run(JSON.stringify(decision.body), decision.fingerprint, sessionId, input.messageId)
return { status: 'updated', messageId: input.messageId, fingerprint: decision.fingerprint }
}
@@ -64,7 +64,7 @@ export class StructuredAgentSessionHost {
},
deliverSettleEdge: (id, journal) => this.conversationDelivery.afterSettleEdge(id, journal),
logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger),
onOpened: (sessionId) => this.queued.drain.schedule(sessionId),
onOpened: (sessionId) => this.queued.onOpened(sessionId),
now: () => this.now()
})
private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () =>
@@ -296,6 +296,8 @@ export class StructuredAgentSessionHost {
queuedMessageSend = this.queued.queuedMessageSend
queuedMessageDelete = this.queued.queuedMessageDelete
queuedMessagesResume = this.queued.queuedMessagesResume
queuedMessageUpdate = this.queued.queuedMessageUpdate
queuedMessageEditHold = this.queued.queuedMessageEditHold
waitForSendSettlement = this.clientDelivery.waitForSendSettlement
@@ -0,0 +1,335 @@
// Editing a queued card in place, through the real host, journal and drain: Save keeps the card's
// identity, an edit lease stops automatic delivery at the card (and so at everything behind it),
// and no lease outlives its deadline, its card, or its conversation.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createQueuedMessageTestRig,
eventually,
QUEUED_RIG_CALLER,
type QueuedMessageTestRig
} from './structured-agent-session-queued-message-rig.test-fixture'
import {
HOST_TEST_SESSION,
hostTestMessage,
hostTestOperationId
} from './structured-agent-session-host-test-data'
import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation'
import {
readQueuePublication,
structuredQueueSendGate
} from './structured-agent-session-queued-publication'
import { shouldQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages'
import { QUEUED_MESSAGES_PUBLISHED_MAX_BYTES } from './structured-agent-session-queued-published-bytes'
import { QUEUED_MESSAGE_EDIT_LEASE_MS } from '../agent-session-journal/queued-message-edit-leases'
let rig: QueuedMessageTestRig
beforeEach(async () => {
rig = await createQueuedMessageTestRig()
})
afterEach(() => rig.dispose())
function journal() {
const value = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
if (!value) {
throw new Error('missing test conversation')
}
return value
}
async function queue(text: string, options?: { internal: true }) {
const result = await rig.send(text, 'queue-if-active', options).result
if (!result.ok || !('queued' in result.value)) {
throw new Error('not queued')
}
return result.value.queued.messageId
}
function fingerprint(id: string) {
const card = journal().queuedMessages.get(id)
if (!card) {
throw new Error('missing card')
}
return card.fingerprint
}
function update(messageId: string, expected: string, text: string, operationId?: string) {
const fields = { messageId, expectedBodyFingerprint: expected, text }
return rig.host.queuedMessageUpdate(QUEUED_RIG_CALLER, {
envelope: rig.envelope(
fields,
'agentSession.queuedMessageUpdate',
operationId ?? hostTestOperationId()
),
...fields
})
}
function hold(
messageId: string,
action: 'acquire' | 'renew' | 'release' = 'acquire',
editId = 'edit-1',
caller = QUEUED_RIG_CALLER
) {
const common = { sessionId: HOST_TEST_SESSION, messageId, editId }
return rig.host.queuedMessageEditHold(
caller,
action === 'acquire'
? { ...common, action, expectedBodyFingerprint: fingerprint(messageId) }
: { ...common, action }
)
}
function publication() {
return readQueuePublication(journal(), structuredQueueSendGate(rig.store, HOST_TEST_SESSION))
}
function published(id: string) {
return publication().queuedMessages.find((message) => message.messageId === id)
}
function held() {
return [...journal().queuedMessages.editLeases.heldIds()]
}
describe('saving a queued card in place', () => {
it('keeps id, position and sender; text that reads like a command stays text', async () => {
await rig.workingSend()
const from = { kind: 'agent' as const, senders: [], orchestration: null }
const queued = await rig.send('original', 'queue-if-active', { internal: true, from }).result
if (!queued.ok || !('queued' in queued.value)) {
throw new Error('not queued')
}
const id = queued.value.queued.messageId
const before = journal().queuedMessages.get(id)!
expect(await update(id, before.fingerprint, '/compact')).toMatchObject({
ok: true,
value: { status: 'updated' }
})
const body = { ...before.body, blocks: [{ type: 'text' as const, text: '/compact' }] }
expect(journal().queuedMessages.get(id)).toEqual({
...before,
body,
fingerprint: agentSessionSendBodyFingerprint(HOST_TEST_SESSION, body)
})
expect(published(id)?.body).toEqual(body)
})
it('a resent Save reads the card as it stands and never overwrites newer text', async () => {
await rig.workingSend()
const id = await queue('base')
const base = fingerprint(id)
const operation = hostTestOperationId()
expect(await update(id, base, 'mine', operation)).toMatchObject({
value: { status: 'updated' }
})
expect(await update(id, base, 'mine', operation)).toMatchObject({
value: { status: 'unchanged' }
})
expect(await update(id, fingerprint(id), 'newer')).toMatchObject({
value: { status: 'updated' }
})
expect(await update(id, base, 'mine', operation)).toMatchObject({
value: { status: 'changed' }
})
expect(await update(id, base, 'another draft')).toMatchObject({
value: { status: 'changed' }
})
expect(journal().queuedMessages.get(id)?.body.blocks).toEqual([{ type: 'text', text: 'newer' }])
})
it('after Send or Delete elsewhere, Save answers gone and recreates nothing', async () => {
await rig.workingSend()
const deleted = await queue('deleted')
const sent = await queue('sent')
const bases = [fingerprint(deleted), fingerprint(sent)] as const
await rig.deleteQueued(deleted)
await rig.sendNow(sent)
expect(await update(deleted, bases[0], 'edit')).toMatchObject({
value: { status: 'gone', disposition: 'withdrawn' }
})
expect(await update(sent, bases[1], 'edit')).toMatchObject({
value: { status: 'gone', disposition: 'dispatched' }
})
expect(await rig.drafts()).toEqual([])
})
it('counts the edited card once against the queue size bound', async () => {
await rig.workingSend()
const big = await queue('x'.repeat(QUEUED_MESSAGES_PUBLISHED_MAX_BYTES - 120 * 1024))
const small = await queue('small')
expect(await update(small, fingerprint(small), 'y'.repeat(200 * 1024))).toMatchObject({
ok: false,
refusal: { details: { reason: 'queueTooLarge' } }
})
expect(journal().queuedMessages.get(small)?.body.blocks).toEqual([
{ type: 'text', text: 'small' }
])
expect(await update(big, fingerprint(big), 'z'.repeat(250 * 1024))).toMatchObject({
ok: true,
value: { status: 'updated' }
})
})
it('a command card is not editable', async () => {
await rig.workingSend()
const body = { ...hostTestMessage('/compact'), command: { name: 'compact' } }
const command = await journal().queuedMessages.insert({
messageId: 'command-card',
body,
fingerprint: agentSessionSendBodyFingerprint(HOST_TEST_SESSION, body),
hostInstance: 'host'
})
expect(await hold(command.messageId)).toEqual({ status: 'not-editable' })
expect(await update(command.messageId, command.fingerprint, 'text')).toMatchObject({
value: { status: 'not-editable' }
})
})
})
describe('edit leases and automatic delivery', () => {
it('earlier cards deliver; the edited card and those behind it wait; a new send still queues', async () => {
const work = await rig.workingSend()
const first = await queue('first')
const edited = await queue('edited')
const last = await queue('last')
expect(await hold(edited)).toMatchObject({ status: 'held', leaseDurationMs: 120_000 })
expect(held()).toEqual([edited])
expect(published(edited)?.paused).toBeUndefined()
await rig.settleAccepted(work, 'work-done')
await eventually(async () => expect(await rig.handoff(first)).toBeDefined())
await rig.settleAccepted(await rig.handoffId(first), 'first-done')
expect(publication().nextQueuedMessageId).toBeNull()
expect(journal().queuedMessages.get(edited)?.state).toBe('waiting')
expect(journal().queuedMessages.get(last)?.state).toBe('waiting')
expect(
shouldQueueStructuredAgentSessionSend({
journal: journal(),
...structuredQueueSendGate(rig.store, HOST_TEST_SESSION)()
})
).toBe(true)
// An explicit Send still overrides, and spends the lease with the card.
expect(await rig.sendNow(edited)).toMatchObject({ ok: true })
expect(journal().queuedMessages.editLeases.heldIds().size).toBe(0)
})
it('Save then release lets the edited card drain with its new text', async () => {
const work = await rig.workingSend()
const id = await queue('before')
await hold(id)
await rig.settleAccepted(work, 'work-done')
expect(await update(id, fingerprint(id), 'after')).toMatchObject({
value: { status: 'updated' }
})
expect(held()).toEqual([])
await hold(id, 'release')
await eventually(async () => expect(await rig.handoff(id)).toBeDefined())
const submissions = (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions
expect(submissions.at(-1)?.payloadFingerprint).toBe(
agentSessionSendBodyFingerprint(HOST_TEST_SESSION, {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'after' }]
})
)
})
it("one editor's release keeps another's hold; a renewal re-sends nothing and is not activity", async () => {
await rig.workingSend()
const id = await queue('base')
await hold(id)
await hold(id, 'acquire', 'edit-1', { callerKey: 'phone' })
const before = publication()
const touch = vi.spyOn(rig.host.collaboratorsForTests().sessions, 'touch')
expect(await hold(id, 'renew')).toMatchObject({ status: 'held' })
// An open editor does not keep the agent's process from its idle stop.
expect(touch).not.toHaveBeenCalled()
expect(publication()).toBe(before)
expect(await hold(id, 'release')).toEqual({ status: 'released' })
expect(held()).toEqual([id])
await hold(id, 'release', 'edit-1', { callerKey: 'phone' })
expect(held()).toEqual([])
})
it('acquiring after delivery won answers gone; Save without a lease still works', async () => {
await rig.workingSend()
const id = await queue('base')
expect(await update(id, fingerprint(id), 'saved')).toMatchObject({
value: { status: 'updated' }
})
await rig.sendNow(id)
expect(
await rig.host.queuedMessageEditHold(QUEUED_RIG_CALLER, {
sessionId: HOST_TEST_SESSION,
messageId: id,
editId: 'late',
action: 'acquire',
expectedBodyFingerprint: 'f'.repeat(64)
})
).toEqual({ status: 'gone' })
})
it('a lapsed lease wakes delivery with no journal activity, even when publishing fails', async () => {
const work = await rig.workingSend()
const id = await queue('edited')
let clock = 0
const time = vi.spyOn(performance, 'now').mockImplementation(() => clock)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
await hold(id)
await rig.settleAccepted(work, 'work-done')
await vi.advanceTimersByTimeAsync(0)
expect(publication().nextQueuedMessageId).toBeNull()
const publish = vi
.spyOn(rig.host.collaboratorsForTests().subscribers, 'publish')
.mockImplementationOnce(() => {
throw new Error('publication failed')
})
clock = QUEUED_MESSAGE_EDIT_LEASE_MS
await vi.advanceTimersByTimeAsync(QUEUED_MESSAGE_EDIT_LEASE_MS)
publish.mockRestore()
vi.useRealTimers()
await eventually(async () => expect(await rig.handoff(id)).toBeDefined())
} finally {
vi.useRealTimers()
time.mockRestore()
}
})
it('Stop and Resume leave the edit hold alone and never publish it as a pause', async () => {
await rig.workingSend()
const id = await queue('base')
await hold(id)
await rig.stop()
await rig.resume()
expect(held()).toEqual([id])
expect(published(id)?.paused).toBeUndefined()
expect(publication().nextQueuedMessageId).toBeNull()
})
it('an edit open across /clear keeps its hold and saves into the same card', async () => {
const working = await rig.workingSend()
const id = await queue('before clear')
await hold(id)
await rig.stop()
await rig.settleAccepted(working, 'answer')
const fields = { command: 'clear' as const }
expect(
await rig.host.conversationCommand(QUEUED_RIG_CALLER, {
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
...fields
})
).toMatchObject({ ok: true })
expect(held()).toEqual([id])
expect(await update(id, fingerprint(id), 'after clear')).toMatchObject({
value: { status: 'updated' }
})
expect(published(id)?.body.blocks).toEqual([{ type: 'text', text: 'after clear' }])
expect(publication().queuePause).toBeNull()
expect(journal().queuedMessages.get(id)?.state).toBe('waiting')
})
it('release never opens a closed conversation; closing drops every lease', async () => {
await rig.workingSend()
const id = await queue('base')
await hold(id)
const leases = journal().queuedMessages.editLeases
await rig.host.close(HOST_TEST_SESSION, 'evict')
expect(leases.heldIds([]).size).toBe(0)
expect(await hold(id, 'release')).toEqual({ status: 'released' })
expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)).toBeUndefined()
})
})
@@ -0,0 +1,117 @@
// Editing a queued card in place: Save is a compare-and-set on the card's body fingerprint, and
// an edit lease keeps automatic delivery from sending the card while someone types. The card keeps
// its id, position, sender and attachments; only its text changes.
import type {
AgentSessionMutationEnvelope,
AgentSessionMutationResult,
AgentSessionQueuedMessageEditHoldParams,
AgentSessionQueuedMessageEditHoldResult,
AgentSessionQueuedMessageUpdateResult
} from '../../../shared/agent-session-wire'
import { queuedMessageEditableText } from '../../../shared/queued-message-text-edit'
import { queuedMessageTextUpdateDecision } from '../agent-session-journal/queued-message-text-update'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context'
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import { mutateQueued } from './structured-agent-session-queued-mutations'
import { queuedMessagesPublishedBytesRefusal } from './structured-agent-session-queued-published-bytes'
/** Save. Editing sends nothing, so it pays no send admission, like Delete. A resend under the
* same id simply runs again: the compare-and-set answers it from the card as it stands. */
export function updateQueuedStructuredAgentMessage(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
params: {
envelope: AgentSessionMutationEnvelope
messageId: string
expectedBodyFingerprint: string
text: string
}
): Promise<AgentSessionMutationResult<AgentSessionQueuedMessageUpdateResult>> {
const { envelope, ...fields } = params
const plan: MutationPlan<AgentSessionQueuedMessageUpdateResult> = {
method: 'agentSession.queuedMessageUpdate',
fields,
conversationWrite: true,
run: async (ctx) => {
const queued = ctx.journal.queuedMessages
const decision = queuedMessageTextUpdateDecision(
queued.get(fields.messageId),
ctx.sessionId,
fields
)
if (decision.status !== 'ready') {
return { ok: true, value: decision }
}
// Editing an agent's card is still the person's action, so it may use their reserve.
const refusal = queuedMessagesPublishedBytesRefusal(
ctx.journal,
decision.body,
true,
fields.messageId
)
return refusal ? { ok: false, refusal } : { ok: true, value: await queued.update(fields) }
},
replay: () => null,
rerunWhenReplayMissing: () => true
}
return mutateQueued(context, caller, envelope, plan)
}
/** Acquire, renew and release an edit lease. Ephemeral: no ledger row, no agent started, and a
* release never opens a conversation (a closed one already dropped its leases). */
export function holdQueuedStructuredAgentMessageEdit(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
params: AgentSessionQueuedMessageEditHoldParams
): Promise<AgentSessionQueuedMessageEditHoldResult> {
const { sessionId, messageId } = params
const key = { callerKey: caller.callerKey, editId: params.editId }
return context.serialize(sessionId, async () => {
if (params.action === 'release') {
const journal = context.sessions.get(sessionId)?.journal
journal?.queuedMessages.editLeases.release(key, messageId)
if (journal) {
republish(context, sessionId, journal)
}
return { status: 'released' }
}
const session = await context.openConversation(sessionId)
if (!session) {
return { status: 'gone' }
}
const queued = session.journal.queuedMessages
if (params.action !== 'acquire') {
// Holds nothing new, so nothing to publish (a lapsed lease it prunes wakes the queue itself);
// and a publish is idle-sweep activity, which an editor left open must not renew forever.
return queued.editLeases.renew(key, messageId)
}
const row = queued.get(messageId)
const result: AgentSessionQueuedMessageEditHoldResult =
row && queuedMessageEditableText(row.body) === null
? { status: 'not-editable' }
: queued.editLeases.acquire(key, { messageId, fingerprint: params.expectedBodyFingerprint })
republish(context, sessionId, session.journal)
return result
})
}
/** Leases live outside the journal, so no commit announces them: publish the next card to send
* and wake the drain here. A failed publish is logged; the lease still stands. */
export function republish(
context: StructuredAgentSessionMutationContext,
sessionId: string,
journal: Parameters<StructuredAgentSessionMutationContext['publish']>[1]
): void {
try {
context.publish(sessionId, journal)
} catch (error) {
context.deps.logger.warn('publishing a queued edit hold failed', {
scope: 'queued-edit-hold',
sessionId,
error
})
}
context.wakeQueuedDrain?.(sessionId)
}
@@ -22,7 +22,7 @@ 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 type { AgentSessionTurnContext } from './structured-agent-session-turns'
import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages'
import { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-consume-error'
import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import {
@@ -57,16 +57,19 @@ export function pendingPromptExists(journal: Pick<AgentSessionJournal, 'visitIte
/** Waiting, not held on its own, and not positioned behind a returned card or a
* card the queue's pause holds: the queue never reorders. The admission rule
* (§accept) and the drain's selection both read it. */
* (§accept) and the drain's selection both read it; only the drain's (`automatic`) stops at a
* card being edited, so an edited card still counts as backlog a new send queues behind. */
function oldestActionableQueuedMessage(
journal: Pick<AgentSessionJournal, 'queuedMessages'>
journal: Pick<AgentSessionJournal, 'queuedMessages'>,
automatic: boolean
): 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')) {
return null
}
return nextSendableQueuedCard(structuredQueuePauses(journal), rows)
const edited = automatic ? journal.queuedMessages.editLeases.heldIds(rows) : undefined
return nextSendableQueuedCard(structuredQueuePauses(journal), rows, edited)
}
/**
@@ -125,7 +128,7 @@ export function nextStructuredQueuedMessage(input: {
record: AgentSessionRecord | null
fence: number
}): QueuedMessageRow | null {
const next = oldestActionableQueuedMessage(input.journal)
const next = oldestActionableQueuedMessage(input.journal, true)
const { journal, fence } = input
// The gate's cheap `working` first: publication asks on every streamed frame, and the gate's
// prompt check walks the whole fold.
@@ -160,7 +163,7 @@ export function shouldQueueStructuredAgentSessionSend(input: {
if (hold !== null) {
return true
}
return oldestActionableQueuedMessage(input.journal) !== null
return oldestActionableQueuedMessage(input.journal, false) !== null
}
/**
@@ -289,7 +292,7 @@ export class StructuredAgentSessionQueuedMessageDrain {
try {
if (
!journal.queuedMessages.settlementOwed() &&
(oldestActionableQueuedMessage(journal) === null ||
(oldestActionableQueuedMessage(journal, true) === null ||
isStructuredAgentSessionMainAgentWorking(
journal.activeTurnId(),
journal.submissions(),
@@ -15,7 +15,7 @@ import type {
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 { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-consume-error'
import {
isJournalWrittenByNewerOrca,
journalOpenRefusal
@@ -72,7 +72,7 @@ export async function withdrawQueuedMessagesForOperation(
/** Draft actions run like any mutation: admitted on the session's lane, the
* conversation opened for the write. */
function mutateQueued<TValue>(
export function mutateQueued<TValue>(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
envelope: AgentSessionMutationEnvelope,
@@ -20,14 +20,16 @@ export const QUEUED_MESSAGES_PUBLISHED_MAX_BYTES =
export const QUEUED_MESSAGES_PERSON_RESERVE_BYTES = MAX_PROMPT_BYTES + 1024
/** Refused readably rather than trimmed, when the published cards would outgrow their frame room.
* Another agent's card must also leave the person's reserve free. */
* Another agent's card must also leave the person's reserve free. `replacing` is an edit's own
* card, whose old body the new one takes the place of. */
export function queuedMessagesPublishedBytesRefusal(
journal: AgentSessionJournal,
body: AgentJournalMessageItem,
fromPerson: boolean
fromPerson: boolean,
replacing?: string
): AgentSessionWireRefusal | null {
const bytes = unsettledQueuedMessages(journal).reduce(
(sum, row) => sum + publishedBodyBytes(row.body),
(sum, row) => (row.messageId === replacing ? sum : sum + publishedBodyBytes(row.body)),
publishedBodyBytes(body)
)
const limit = fromPerson
@@ -1,5 +1,5 @@
// 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
// host's mutation context and exposes the draft actions (Send, Delete, Resume, Edit), so the host
// class stays a description of its surface.
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
@@ -15,6 +15,11 @@ import {
sendQueuedStructuredAgentMessage
} from './structured-agent-session-queued-mutations'
import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger'
import {
holdQueuedStructuredAgentMessageEdit,
republish,
updateQueuedStructuredAgentMessage
} from './structured-agent-session-queued-edit'
/** `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,
@@ -37,6 +42,34 @@ export function wireStructuredAgentSessionQueuedMessages(
})
return {
drain,
/** A conversation (re)opened: its edit leases wake the queue when a deadline passes. */
onOpened: (sessionId: string) => {
const journal = sessions.get(sessionId)?.journal
journal?.queuedMessages.editLeases.onDeadline(() => {
void context()
.serialize(sessionId, async () => {
if (sessions.get(sessionId)?.journal === journal) {
republish(context(), sessionId, journal)
}
})
.catch((error: unknown) =>
context().deps.logger.warn('expiring a queued edit hold failed', {
scope: 'queued-edit-hold',
sessionId,
error
})
)
})
drain.schedule(sessionId)
},
queuedMessageUpdate: (
caller: StructuredAgentSessionCaller,
params: Parameters<typeof updateQueuedStructuredAgentMessage>[2]
) => updateQueuedStructuredAgentMessage(context(), caller, params),
queuedMessageEditHold: (
caller: StructuredAgentSessionCaller,
params: Parameters<typeof holdQueuedStructuredAgentMessageEdit>[2]
) => holdQueuedStructuredAgentMessageEdit(context(), caller, params),
/** Every journal publish: turn, submission, prompt, command and Stop
* settlements are all commits, and each re-derives the drain's gates. */
onJournalActivity: (sessionId: string) => {
@@ -156,6 +156,8 @@ describe('mobile RPC allowlist', () => {
'agentSession.cancel',
'agentSession.queuedMessageSend',
'agentSession.queuedMessageDelete',
'agentSession.queuedMessageUpdate',
'agentSession.queuedMessageEditHold',
'agentSession.queuedMessagesResume',
'agentSession.close',
'agentSession.respondToApproval',
@@ -0,0 +1,88 @@
// The two in-place edit methods on the wire: registered and advertised apart from the dark
// mid-turn queue, the caller taken from the authenticated transport, strict params, and a lease
// release that cleanup admission always lets through.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
RUNTIME_CAPABILITIES
} from '../../../../shared/protocol-version'
import {
call,
clearStructuredHostStub,
envelope,
hostCalls,
installStructuredHostStub,
SESSION,
STRUCTURED_CLIENT
} from './structured-agent-session-rpc.test-fixture'
import {
QueuedMessageActionParams,
QueuedMessageEditHoldParams,
QueuedMessageUpdateParams
} from './structured-agent-session-schemas'
const update = {
envelope: envelope(),
messageId: 'card',
expectedBodyFingerprint: 'a'.repeat(64),
text: 'updated'
}
const acquire = {
sessionId: SESSION,
messageId: 'card',
editId: 'edit',
action: 'acquire',
expectedBodyFingerprint: 'a'.repeat(64)
}
beforeEach(() => {
installStructuredHostStub()
hostCalls.queuedMessageUpdate = vi.fn(async () => ({ ok: true, value: { status: 'updated' } }))
hostCalls.queuedMessageEditHold = vi.fn(async () => ({ status: 'held' }))
})
afterEach(clearStructuredHostStub)
describe('queued edit methods', () => {
it('the host advertises editing on its own, with mid-turn queueing still dark', () => {
expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY)
expect(RUNTIME_CAPABILITIES).not.toContain(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY)
})
it.each([
['agentSession.queuedMessageUpdate', update, 'queuedMessageUpdate'],
['agentSession.queuedMessageEditHold', acquire, 'queuedMessageEditHold']
] as const)('%s reaches the host as the authenticated caller', async (method, params, name) => {
expect(await call(method, params, STRUCTURED_CLIENT)).toMatchObject({ ok: true })
expect(hostCalls[name]).toHaveBeenCalledWith({ callerKey: 'trusted-local:runtime' }, params)
})
it('a release passes cleanup admission even for an audience that cannot start this agent', async () => {
hostCalls.sessionAgent = vi.fn(() => 'pi')
const release = { sessionId: SESSION, messageId: 'card', editId: 'edit', action: 'release' }
expect(
await call('agentSession.queuedMessageEditHold', acquire, STRUCTURED_CLIENT)
).toMatchObject({ ok: false })
expect(
await call('agentSession.queuedMessageEditHold', release, STRUCTURED_CLIENT)
).toMatchObject({ ok: true })
})
it('strict params refuse a forged caller, oversized text and unknown fields', () => {
expect(QueuedMessageUpdateParams.safeParse(update).success).toBe(true)
expect(
QueuedMessageUpdateParams.safeParse({ ...update, text: 'é'.repeat(256 * 1024) }).success
).toBe(false)
expect(QueuedMessageUpdateParams.safeParse({ ...update, sentAs: 'goal' }).success).toBe(false)
expect(QueuedMessageEditHoldParams.safeParse(acquire).success).toBe(true)
expect(
QueuedMessageEditHoldParams.safeParse({ ...acquire, callerKey: 'another-client' }).success
).toBe(false)
expect(QueuedMessageEditHoldParams.safeParse({ ...acquire, action: 'renew' }).success).toBe(
false
)
expect(
QueuedMessageActionParams.safeParse({ envelope: envelope(), messageId: 'card' }).success
).toBe(true)
})
})
@@ -1,18 +1,41 @@
// The queued-message actions: Send-now and Delete on one card, and Resume on a
// paused queue. Not gated on agent-session.queued-messages.v1: a host without it
// still publishes the cards it kept unsent. A host older than the queue lacks them.
// The queued-message actions: Send-now, Delete and in-place Edit on one card, and Resume on a
// paused queue. Not gated on agent-session.queued-messages.v1: a host without it still publishes
// the cards it kept unsent. A host older than the queue, or than edit-v1, lacks them.
import { defineMethod } from '../core'
import {
requireStructuredSessionHost as requireSessionHost,
requireStructuredCleanupHost,
structuredCallerFor as callerFor
} from './structured-agent-session-gate'
import {
QueuedMessageActionParams,
QueuedMessageUpdateParams,
QueuedMessageEditHoldParams,
QueuedMessagesResumeParams
} from './structured-agent-session-schemas'
export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [
defineMethod({
name: 'agentSession.queuedMessageUpdate',
permission: 'workspace',
params: QueuedMessageUpdateParams,
handler: async (params, ctx) =>
requireSessionHost(ctx, params.envelope.sessionId).queuedMessageUpdate(callerFor(ctx), params)
}),
defineMethod({
name: 'agentSession.queuedMessageEditHold',
permission: 'workspace',
params: QueuedMessageEditHoldParams,
handler: async (params, ctx) => {
// Releasing a lease is cleanup: no admission condition may strand it until its deadline.
const host =
params.action === 'release'
? requireStructuredCleanupHost(ctx)
: requireSessionHost(ctx, params.sessionId)
return host.queuedMessageEditHold(callerFor(ctx), params)
}
}),
defineMethod({
name: 'agentSession.queuedMessageSend',
permission: 'workspace',
@@ -34,3 +34,7 @@ export {
UnsubscribeParams
} from '../../../../shared/rpc-contract/structured-agent-session-params'
export { ContinueInterruptedParams } from '../../../../shared/rpc-contract/structured-agent-session-continue-params'
export {
QueuedMessageUpdateParams,
QueuedMessageEditHoldParams
} from '../../../../shared/rpc-contract/structured-agent-session-queued-edit-params'
@@ -175,7 +175,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(34)
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(36)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -8,6 +8,8 @@ export const MOBILE_AGENT_SESSION_RPC_METHODS = [
'agentSession.cancel',
'agentSession.queuedMessageSend',
'agentSession.queuedMessageDelete',
'agentSession.queuedMessageUpdate',
'agentSession.queuedMessageEditHold',
'agentSession.queuedMessagesResume',
'agentSession.close',
'agentSession.respondToApproval',
@@ -0,0 +1,27 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { Button } from '@/components/ui/button'
import { ButtonKeyHint } from './ButtonKeyHint'
afterEach(cleanup)
describe('ButtonKeyHint', () => {
it('shows the modifier before the Enter glyph without joining the button name', () => {
render(
<Button>
Create worktree
<ButtonKeyHint modifierLabel="⌘" />
</Button>
)
const button = screen.getByRole('button', { name: 'Create worktree' })
const badge = button.querySelector('[aria-hidden="true"]')
expect(badge?.textContent).toBe('⌘')
expect(badge?.querySelector('svg')).toBeTruthy()
})
it('shows only the Enter glyph when plain Enter submits', () => {
const { container } = render(<ButtonKeyHint />)
expect(container.firstElementChild?.textContent).toBe('')
expect(container.querySelector('svg')).toBeTruthy()
})
})
@@ -0,0 +1,21 @@
import React from 'react'
import { CornerDownLeft } from 'lucide-react'
/** The Enter-key badge that trails a filled primary button's label ("Create worktree ⌘↵").
* Decorative: the button's name is its label, not the glyphs. */
export function ButtonKeyHint({
modifierLabel
}: {
/** The platform modifier shown before ↵ (e.g. "⌘" or "Ctrl"); omit when plain Enter submits. */
modifierLabel?: string
}): React.JSX.Element {
return (
<span
aria-hidden
className="ml-1 inline-flex items-center gap-0.5 rounded border border-white/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80"
>
{modifierLabel ? <span>{modifierLabel}</span> : null}
<CornerDownLeft className="size-3" />
</span>
)
}
@@ -35,6 +35,8 @@ import { useNativeChatClippedLine } from './use-native-chat-clipped-line'
import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice'
import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
/** The visible caption under the text; the default waiting hold needs none. */
export function queuedMessageCardCaption(
@@ -138,6 +140,8 @@ export function NativeChatQueuedMessageCard({
onSteer,
onDelete,
onEdit,
editor,
editDisabled = false,
onTurnOffQueueing
}: {
card: QueuedMessageCard
@@ -151,7 +155,12 @@ export function NativeChatQueuedMessageCard({
steerHeld?: boolean
onSteer: () => void
onDelete: () => void
onEdit: () => void
/** Absent when the host cannot edit in place: the card then offers no Edit at all. */
onEdit?: () => void
/** This card is being edited here: the editor replaces its text and its actions. */
editor?: QueuedMessageInlineEditor
/** Another card in this pane is being edited. */
editDisabled?: boolean
/** Absent when the host does not queue sends, so there is nothing to turn off. */
onTurnOffQueueing?: () => void
}): React.JSX.Element {
@@ -164,6 +173,21 @@ export function NativeChatQueuedMessageCard({
const [expanded, setExpanded] = useState(false)
const [clipped, measureLine] = useNativeChatClippedLine(false)
const textId = useId()
const sender = card.from ? (
<NativeChatAgentMessageSenders from={card.from} chatWorktreeId={chatWorktreeId} queued />
) : null
if (editor) {
// The editor's own header stands in for the icon, the actions and the caption.
return (
<li
data-queued-message-id={card.messageId}
data-queued-message-state={card.state}
className="px-2.5 py-1.5"
>
<NativeChatQueuedMessageEditor editor={editor} sender={sender} />
</li>
)
}
return (
<li
data-queued-message-id={card.messageId}
@@ -177,13 +201,7 @@ export function NativeChatQueuedMessageCard({
<ListEnd className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
)}
<div className="min-w-0 flex-1">
{card.from ? (
<NativeChatAgentMessageSenders
from={card.from}
chatWorktreeId={chatWorktreeId}
queued
/>
) : null}
{sender}
{expanded ? null : (
<p ref={measureLine} className="truncate text-sm" title={card.text}>
{card.text}
@@ -256,38 +274,43 @@ export function NativeChatQueuedMessageCard({
{translate('components.native-chat.queuedMessages.delete', 'Delete')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'components.native-chat.queuedMessages.moreActions',
'More actions'
)}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* A command's text is not a draft: edited, it would become a message. */}
{card.command ? null : (
<DropdownMenuItem onSelect={onEdit}>
<Pencil />
{translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
</DropdownMenuItem>
)}
{onTurnOffQueueing ? (
<DropdownMenuItem onSelect={onTurnOffQueueing}>
{translate(
'components.native-chat.queuedMessages.turnOffQueueing',
'Turn off queueing'
{(!card.command && onEdit) || onTurnOffQueueing ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'components.native-chat.queuedMessages.moreActions',
'More actions'
)}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* A command's text is not a draft: edited, it would become a message. */}
{card.command || !onEdit ? null : (
<DropdownMenuItem disabled={editDisabled} onSelect={onEdit}>
<Pencil />
{translate(
'components.native-chat.queuedMessages.editMessage',
'Edit message'
)}
</DropdownMenuItem>
)}
{onTurnOffQueueing ? (
<DropdownMenuItem onSelect={onTurnOffQueueing}>
{translate(
'components.native-chat.queuedMessages.turnOffQueueing',
'Turn off queueing'
)}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
)}
</div>
@@ -0,0 +1,192 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatQueuedMessageEditor } from './NativeChatQueuedMessageEditor'
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => values?.[name] ?? '')
}))
afterEach(cleanup)
function editor(): QueuedMessageInlineEditor {
return {
messageId: 'mail',
text: 'full message',
acquiring: false,
saving: false,
canSave: true,
change: vi.fn(),
save: vi.fn(),
cancel: vi.fn()
}
}
function view(
edit: QueuedMessageInlineEditor,
prompt: 'question' | 'approval' | null,
answer = vi.fn(),
promptCancel = vi.fn()
) {
return (
<div data-native-chat-root="true">
<ul>
<li>
<NativeChatQueuedMessageEditor editor={edit} />
</li>
</ul>
{prompt === 'approval' ? (
<NativeChatApprovalCard
approval={{
title: 'Allow command?',
options: [{ label: 'Allow', send: 'allow' }]
}}
onChoose={answer}
onCancel={promptCancel}
shouldFocus
/>
) : null}
{prompt === 'question' ? (
<NativeChatQuestionCard
prompt={{
questions: [
{
question: 'Which?',
options: [{ label: 'One' }],
multiSelect: false
}
]
}}
onAnswer={answer}
onCancel={promptCancel}
shouldFocus
/>
) : null}
</div>
)
}
describe('queued card textarea', () => {
it.each(['question', 'approval'] as const)(
'stays mounted and focused when a %s opens, its keys apart from the prompt',
(prompt) => {
const edit = editor()
const answer = vi.fn()
const promptCancel = vi.fn()
const { rerender } = render(view(edit, null, answer, promptCancel))
const field = screen.getByRole('textbox', { name: 'Edit message' })
expect(document.activeElement).toBe(field)
rerender(view(edit, prompt, answer, promptCancel))
expect(document.activeElement).toBe(field)
fireEvent.keyDown(field, { key: 'Enter' })
expect(edit.save).toHaveBeenCalledOnce()
expect(answer).not.toHaveBeenCalled()
fireEvent.keyDown(field, { key: 'Escape' })
expect(edit.cancel).toHaveBeenCalledOnce()
expect(promptCancel).not.toHaveBeenCalled()
}
)
it.each(['question', 'approval'] as const)(
'focuses and saves with a %s already open',
(prompt) => {
const edit = editor()
render(view(edit, prompt))
const field = screen.getByRole('textbox', { name: 'Edit message' })
expect(document.activeElement).toBe(field)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(edit.save).toHaveBeenCalledOnce()
}
)
it('Enter and the platform modifier save, Shift+Enter is a newline and handled keys do not bubble', () => {
const edit = editor()
const parent = vi.fn()
render(
<div onKeyDown={parent}>
<NativeChatQueuedMessageEditor editor={edit} />
</div>
)
const field = screen.getByRole('textbox')
fireEvent.keyDown(field, { key: 'Enter' })
expect(edit.save).toHaveBeenCalledOnce()
expect(parent).not.toHaveBeenCalled()
fireEvent.keyDown(field, { key: 'Enter', shiftKey: true })
expect(edit.save).toHaveBeenCalledOnce()
for (const [platform, modifier] of [
['Macintosh', { metaKey: true }],
['Linux', { ctrlKey: true }],
['Windows', { ctrlKey: true }]
] as const) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform)
fireEvent.keyDown(field, { key: 'Enter', ...modifier })
}
expect(edit.save).toHaveBeenCalledTimes(4)
vi.restoreAllMocks()
})
it('IME confirmation and Escape never commit/cancel or reach the prompt', () => {
const edit = editor()
const parent = vi.fn()
render(
<div onKeyDown={parent}>
<NativeChatQueuedMessageEditor editor={edit} />
</div>
)
const field = screen.getByRole('textbox')
fireEvent.compositionStart(field)
fireEvent.keyDown(field, { key: 'Enter', isComposing: true })
fireEvent.keyDown(field, { key: 'Escape', isComposing: true })
fireEvent.compositionEnd(field)
fireEvent.keyDown(field, { key: 'Enter', keyCode: 229 })
expect(edit.save).not.toHaveBeenCalled()
expect(edit.cancel).not.toHaveBeenCalled()
expect(parent).not.toHaveBeenCalled()
})
it('acquisition seeds a read-only field with the X always usable', () => {
const edit = { ...editor(), acquiring: true }
render(<NativeChatQueuedMessageEditor editor={edit} />)
expect(screen.getByRole('textbox').getAttribute('readonly')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Cancel editing' }))
expect(edit.cancel).toHaveBeenCalledOnce()
})
it('the header X cancels and saves nothing; Save is the only footer action', () => {
const edit = editor()
render(<NativeChatQueuedMessageEditor editor={edit} />)
expect(screen.getByText('Editing message')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Cancel editing' }))
expect(edit.cancel).toHaveBeenCalledOnce()
expect(edit.save).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
expect(
screen
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label') ?? button.textContent)
).toEqual(['Cancel editing', 'Save'])
})
it.each([
{ state: 'saving', edit: { saving: true } },
{ state: 'unchanged', edit: { canSave: false } }
])('Save is disabled while $state and enabled otherwise', ({ edit: overrides }) => {
const edit = { ...editor(), ...overrides }
const { rerender } = render(<NativeChatQueuedMessageEditor editor={edit} />)
const save = screen.getByRole('button', { name: 'Save' })
expect(save.hasAttribute('disabled')).toBe(true)
fireEvent.click(save)
expect(edit.save).not.toHaveBeenCalled()
rerender(<NativeChatQueuedMessageEditor editor={{ ...edit, saving: false, canSave: true }} />)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(edit.save).toHaveBeenCalledOnce()
})
it('Save carries a decorative Enter badge and no key hint text', () => {
for (const platform of ['Macintosh', 'Windows']) {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform)
const { container, unmount } = render(<NativeChatQueuedMessageEditor editor={editor()} />)
const save = screen.getByRole('button', { name: 'Save' })
const badge = save.querySelector('[aria-hidden="true"]')
expect(badge?.querySelector('svg')).toBeTruthy()
expect(badge?.textContent).toBe('')
expect(container.textContent).not.toMatch(/Shift|⇧|new line/)
unmount()
}
vi.restoreAllMocks()
})
})
@@ -0,0 +1,109 @@
import { useLayoutEffect, useRef, type ReactNode } from 'react'
import { Pencil, X } from 'lucide-react'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { ButtonKeyHint } from '@/components/ButtonKeyHint'
import { isMacPlatform } from './native-chat-shortcut'
import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit'
/** The inline editor that replaces a queued card's row while it is edited. It lives in the card,
* not the composer, so it stays usable while a question or approval holds the composer's slot. */
export function NativeChatQueuedMessageEditor({
editor,
sender
}: {
editor: QueuedMessageInlineEditor
/** The card's "From <name>" line, kept beside the label. */
sender?: ReactNode
}): React.JSX.Element {
const field = useRef<HTMLTextAreaElement>(null)
useLayoutEffect(() => {
if (editor.acquiring) {
return
}
const textarea = field.current
textarea?.focus({ preventScroll: true })
const list = textarea?.closest('ul')
const row = textarea?.closest('li')
if (list && row) {
const rowBounds = row.getBoundingClientRect()
const listBounds = list.getBoundingClientRect()
if (rowBounds.top < listBounds.top) {
list.scrollTop += rowBounds.top - listBounds.top
} else if (rowBounds.bottom > listBounds.bottom) {
list.scrollTop += rowBounds.bottom - listBounds.bottom
}
}
}, [editor.acquiring])
return (
<div data-queued-message-editor>
<div className="flex min-h-7 items-center gap-1.5 text-xs text-muted-foreground">
<Pencil className="size-3.5 shrink-0" aria-hidden />
<span className="shrink-0">
{translate('components.native-chat.queuedMessages.editingMessage', 'Editing message')}
</span>
{sender ? (
<>
<span aria-hidden>·</span>
<div className="min-w-0">{sender}</div>
</>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="ml-auto"
aria-label={translate(
'components.native-chat.queuedMessages.cancelEditing',
'Cancel editing'
)}
onClick={editor.cancel}
>
<X className="size-3.5" />
</Button>
</div>
<Textarea
ref={field}
aria-label={translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
className="max-h-32 resize-none"
rows={2}
value={editor.text}
readOnly={editor.acquiring || editor.saving}
onChange={(event) => editor.change(event.currentTarget.value)}
// Enter or Cmd/Ctrl+Enter saves, Shift+Enter is a newline, Escape cancels; an IME's own
// Enter and Escape never get here (`ImeTextarea`). Handled keys stay out of the prompt.
onKeyDown={(event) => {
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
editor.cancel()
} else if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
(mod || (!event.metaKey && !event.ctrlKey))
) {
event.preventDefault()
event.stopPropagation()
if (editor.canSave && !editor.acquiring && !editor.saving) {
editor.save()
}
}
}}
/>
<div className="mt-1.5 flex justify-end">
<Button
type="button"
size="xs"
disabled={editor.acquiring || editor.saving || !editor.canSave}
onClick={editor.save}
>
{translate('components.native-chat.queuedMessages.save', 'Save')}
<ButtonKeyHint />
</Button>
</div>
</div>
)
}
@@ -0,0 +1,191 @@
// @vitest-environment happy-dom
// A card edited in place inside the list: the editor replaces its text and actions, a host that
// cannot edit offers no Edit, and focus leaves a closing editor for the question or the composer.
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
// A card's sender line opens and names agents through modules this test does not exercise.
vi.mock('@/lib/open-agent-message-sender', () => ({
openAgentMessageSender: vi.fn()
}))
vi.mock('@/runtime/structured-conversation-name', () => ({
useStructuredChatTabConversationName: () => null
}))
vi.mock('../../store', () => {
const state = { updateSettings: vi.fn() }
const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state)
useAppStore.getState = () => state
return { useAppStore }
})
import { TooltipProvider } from '@/components/ui/tooltip'
import type { QueuedMessageCard } from './structured-agent-session-queued-cards'
import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList'
import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages'
function renderList(owner: StructuredAgentSessionQueuedMessagesController) {
return render(
<TooltipProvider delayDuration={0}>
<NativeChatQueuedMessageList chatWorktreeId={null} controller={owner} />
</TooltipProvider>
)
}
function card(overrides: Partial<QueuedMessageCard> & { messageId: string }): QueuedMessageCard {
return {
position: 1,
text: `text of ${overrides.messageId}`,
state: 'waiting',
hold: 'turn',
...overrides
}
}
function controller(
cards: QueuedMessageCard[],
pause: { reason: string } | null,
queueCapable: boolean,
edits: Pick<StructuredAgentSessionQueuedMessagesController, 'editCapable' | 'editor'>
): StructuredAgentSessionQueuedMessagesController {
return {
cards,
queueCapable,
...edits,
pause,
resume: vi.fn(async () => false),
resuming: false,
steer: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
edit: vi.fn(async () => {}),
steerNewest: vi.fn(() => false),
queueResume: undefined,
queueHold: undefined
}
}
afterEach(() => cleanup())
describe('NativeChatQueuedMessageList inline edit', () => {
it('a host that cannot edit in place offers no Edit, and an empty menu is not shown', async () => {
const owner = controller([card({ messageId: 'kept', hold: 'paused' })], null, false, {
editCapable: false,
editor: undefined
})
renderList(owner)
expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull()
expect(screen.getByRole('button', { name: 'Delete' })).toBeTruthy()
})
it('the card being edited shows its editor in place of its text and actions; others cannot open one', async () => {
const editor = {
messageId: 'draft-1',
text: 'editing',
acquiring: false,
saving: false,
canSave: true,
change: vi.fn(),
save: vi.fn(),
cancel: vi.fn()
}
const owner = controller(
[card({ messageId: 'draft-1' }), card({ messageId: 'draft-2', position: 2 })],
null,
true,
{ editCapable: true, editor }
)
renderList(owner)
const [edited, other] = screen.getAllByRole('listitem')
expect(within(edited!).getByRole('textbox', { name: 'Edit message' })).toBeTruthy()
expect(within(edited!).queryByRole('button', { name: 'Delete' })).toBeNull()
expect(within(edited!).queryByText('text of draft-1')).toBeNull()
fireEvent.pointerDown(within(other!).getByRole('button', { name: 'More actions' }))
const item = await screen.findByRole('menuitem', { name: 'Edit message' })
expect(item.getAttribute('aria-disabled')).toBe('true')
})
it('an edited card shows the editing header with its sender and hides its caption until editing ends', () => {
const editor = {
messageId: 'mail',
text: 'editing',
acquiring: false,
saving: false,
canSave: true,
change: vi.fn(),
save: vi.fn(),
cancel: vi.fn()
}
const cards = [
card({
messageId: 'mail',
hold: 'awaiting-answer',
from: { kind: 'agent', senders: [], orchestration: null }
})
]
const { rerender } = renderList(controller(cards, null, true, { editCapable: true, editor }))
const row = screen.getByRole('listitem')
expect(within(row).getByText('Editing message')).toBeTruthy()
expect(within(row).getByText('From')).toBeTruthy()
expect(within(row).queryByText('Waiting for your answer')).toBeNull()
fireEvent.click(within(row).getByRole('button', { name: 'Cancel editing' }))
expect(editor.cancel).toHaveBeenCalledOnce()
rerender(
<TooltipProvider delayDuration={0}>
<NativeChatQueuedMessageList
chatWorktreeId={null}
controller={controller(cards, null, true, {
editCapable: true,
editor: undefined
})}
/>
</TooltipProvider>
)
expect(screen.getByText('Waiting for your answer')).toBeTruthy()
expect(screen.queryByText('Editing message')).toBeNull()
})
it.each([
{ prompt: true, expected: 'the question' },
{ prompt: false, expected: 'the composer' }
])('a closing edit hands focus to $expected', async ({ prompt }) => {
const focusComposer = vi.fn()
const editor = {
messageId: 'draft-1',
text: 'editing',
acquiring: false,
saving: false,
canSave: true,
change: vi.fn(),
save: vi.fn(),
cancel: vi.fn()
}
const cards = [card({ messageId: 'draft-1' })]
const pane = (owner: ReturnType<typeof controller>) => (
<div data-native-chat-root="true">
<TooltipProvider delayDuration={0}>
<NativeChatQueuedMessageList
chatWorktreeId={null}
controller={owner}
focusComposer={focusComposer}
/>
</TooltipProvider>
{/* The composer's slot, held by a question that wants focus. */}
{prompt ? <div tabIndex={-1} data-native-chat-prompt-card-focus="" /> : null}
</div>
)
const { container, rerender } = render(
pane(controller(cards, null, true, { editCapable: true, editor }))
)
screen.getByRole('textbox', { name: 'Edit message' }).focus()
rerender(pane(controller(cards, null, true, { editCapable: true, editor: undefined })))
const question = container.querySelector<HTMLElement>('[data-native-chat-prompt-card-focus]')
if (prompt) {
await waitFor(() => expect(document.activeElement).toBe(question))
expect(focusComposer).not.toHaveBeenCalled()
} else {
await waitFor(() => expect(focusComposer).toHaveBeenCalledOnce())
}
})
})
@@ -78,6 +78,8 @@ function controller(
return {
cards,
queueCapable,
editCapable: true,
editor: undefined,
pause,
resume: vi.fn(async () => false),
resuming: false,
@@ -121,7 +123,13 @@ function renderHeldQueue(
isWorking: false,
composerScopeKey: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read.
mutate: mutate as StructuredAgentSessionMutate
mutate: mutate as StructuredAgentSessionMutate,
editTransport: {
target: { kind: 'local' },
sessionId: 'session',
capable: false,
write: async () => ({ kind: 'dropped' })
}
})
return <NativeChatQueuedMessageList chatWorktreeId={null} controller={owner} />
}
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef } from 'react'
import { Pause, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAppStore } from '../../store'
@@ -6,6 +6,8 @@ import { translate } from '@/i18n/i18n'
import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard'
import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure'
import { NATIVE_CHAT_ROOT_SELECTOR } from '@/lib/native-chat-paste-request'
import { focusNativeChatPromptCard } from './use-native-chat-prompt-card-focus'
/**
* Host-held drafts stacked between the transcript and the composer — never in
@@ -27,10 +29,11 @@ export function NativeChatQueuedMessageList({
statedFailures?: readonly AgentSessionFailureFact[]
/** The chat reads Stopping: no card steers into the turn a Stop is ending. */
steerHeld?: boolean
/** Where focus goes once Steer, Edit or Delete takes the focused card away. */
/** Where focus goes once Steer, Delete or a closing edit takes the focused card away. */
focusComposer?: () => void
}): React.JSX.Element {
const updateSettings = useAppStore((store) => store.updateSettings)
const regionRef = useRef<HTMLDivElement>(null)
const queueRef = useRef<HTMLDivElement>(null)
const { cards, pause } = controller
const newest = cards.at(-1)
@@ -53,17 +56,29 @@ export function NativeChatQueuedMessageList({
? () => void updateSettings({ nativeChatQueueFollowUps: false })
: undefined
// Only when focus was on the queue (a card, or Resume) — never pull it from wherever the user
// moved on to.
// moved on to. A question or approval holding the composer's slot takes it instead.
const refocusAfter = (action: Promise<unknown>): void => {
void action.then(() => {
const active = document.activeElement
if (!active || active === document.body || queueRef.current?.contains(active)) {
focusComposer?.()
const root = regionRef.current?.closest(NATIVE_CHAT_ROOT_SELECTOR)
if (!root || !focusNativeChatPromptCard(root, null)) {
focusComposer?.()
}
}
})
}
const editingId = controller.editor?.messageId
const wasEditing = useRef(editingId)
useEffect(() => {
const closed = wasEditing.current !== undefined && editingId === undefined
wasEditing.current = editingId
if (closed) {
refocusAfter(Promise.resolve())
}
})
return (
<div aria-live="polite">
<div ref={regionRef} aria-live="polite">
{cards.length > 0 ? (
<div ref={queueRef} className="mx-auto w-full max-w-(--chat-content-max-width) px-4 py-1">
{/* One box: the pause row, when shown, is its first row, and each card a row below it. */}
@@ -95,7 +110,13 @@ export function NativeChatQueuedMessageList({
steerHeld={steerHeld}
onSteer={() => refocusAfter(controller.steer(card.messageId))}
onDelete={() => refocusAfter(controller.remove(card.messageId))}
onEdit={() => refocusAfter(controller.edit(card.messageId))}
onEdit={
controller.editCapable ? () => void controller.edit(card.messageId) : undefined
}
editor={
controller.editor?.messageId === card.messageId ? controller.editor : undefined
}
editDisabled={controller.editor !== undefined}
onTurnOffQueueing={turnOffQueueing}
/>
))}
@@ -397,6 +397,8 @@ it('reveals a structured prompt answer at the press, before the host accepts it'
respond: async () => host.promise,
queuedMessages: {
queueCapable: true,
editCapable: false,
editor: undefined,
cards: [],
pause: null,
resuming: false,
@@ -0,0 +1,36 @@
// What a queued edit tells the person when its card changed or left under it, or refused a Save.
import { translate } from '@/i18n/i18n'
/** A question or approval holds the chat box's place, so the text there is out of sight until then. */
export function queuedEditNotice(
key: 'editChanged' | 'editChangedPrompt' | 'editFailed' | 'editGone' | 'editGonePrompt'
): string {
switch (key) {
case 'editChanged':
return translate(
'components.native-chat.queuedMessages.editChanged',
'This message changed while you were editing. Your edit is in the chat box.'
)
case 'editChangedPrompt':
return translate(
'components.native-chat.queuedMessages.editChangedPrompt',
'This message changed while you were editing. Your edit will be in the chat box after you answer the agent.'
)
case 'editGone':
return translate(
'components.native-chat.queuedMessages.editGone',
'This message was already sent or removed. Your edit is in the chat box.'
)
case 'editGonePrompt':
return translate(
'components.native-chat.queuedMessages.editGonePrompt',
'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.'
)
case 'editFailed':
return translate(
'components.native-chat.queuedMessages.editFailed',
"This message can't be edited."
)
}
}
@@ -0,0 +1,95 @@
// One inline editor's lease on the execution host: acquired when the editor opens, renewed while
// it stays open, released when it closes. The host expires it on its own, so a renewal or release
// that fails is only logged; nothing here ever blocks typing, Save or Cancel.
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import type {
AgentSessionQueuedMessageEditHoldParams,
AgentSessionQueuedMessageEditHoldResult
} from '../../../../shared/agent-session-wire'
/** A quarter of the host's two-minute lease, so one lost renewal never lets it lapse. */
export const QUEUED_EDIT_LEASE_RENEW_MS = 30_000
export type QueuedEditLease = {
/** The host's first answer; a transport failure rejects it. */
acquired: Promise<AgentSessionQueuedMessageEditHoldResult>
/** Stops renewing and releases, after any request still on its way. */
end: () => void
}
function logLeaseFailure(error: unknown): void {
console.warn('[queued-message-edit] edit hold request failed', error)
}
export function startQueuedEditLease(input: {
target: RuntimeClientTarget
sessionId: string
messageId: string
editId: string
expectedBodyFingerprint: string
}): QueuedEditLease {
const { target, sessionId, messageId, editId, expectedBodyFingerprint } = input
let ended = false
let timer: ReturnType<typeof setTimeout> | undefined
const call = (
action: AgentSessionQueuedMessageEditHoldParams['action']
): Promise<AgentSessionQueuedMessageEditHoldResult> =>
callStructuredAgentSession<AgentSessionQueuedMessageEditHoldResult>(
target,
'agentSession.queuedMessageEditHold',
{
sessionId,
messageId,
editId,
action,
...(action === 'acquire' ? { expectedBodyFingerprint } : {})
}
)
const acquired = call('acquire')
// Requests go one at a time, so a release never overtakes the acquire it releases.
let chain: Promise<unknown> = acquired.catch(() => undefined)
let held = false
const send = (action: 'acquire' | 'renew'): void => {
chain = chain
.then(() => (ended ? undefined : call(action)))
.then(keepAlive, (error: unknown) => {
logLeaseFailure(error)
keepAlive(undefined)
})
}
const keepAlive = (answer: AgentSessionQueuedMessageEditHoldResult | undefined): void => {
if (ended || !answer) {
// A failed request is tried again on the next beat; the host's deadline covers the gap.
if (!ended) {
timer = setTimeout(() => send(held ? 'renew' : 'acquire'), QUEUED_EDIT_LEASE_RENEW_MS)
}
return
}
if (answer.status === 'expired') {
// A lapsed lease (a host restart, a reopened chat) is taken again at once for the same text.
held = false
send('acquire')
} else if (answer.status === 'held') {
held = true
timer = setTimeout(() => send('renew'), QUEUED_EDIT_LEASE_RENEW_MS)
}
// `changed`, `gone` and `not-editable` are final: the card moved on, nothing is left to hold.
}
void acquired.then(keepAlive, (error: unknown) => {
logLeaseFailure(error)
keepAlive(undefined)
})
return {
acquired,
end: () => {
if (ended) {
return
}
ended = true
clearTimeout(timer)
chain = chain.then(() => call('release')).catch(logLeaseFailure)
}
}
}
@@ -40,12 +40,13 @@ export function useNativeChatPromptCardFocus(
useLayoutEffect(() => {
const card = cardRef.current
const active = document.activeElement
// The pane's find bar is a surface of its own: a card arriving mid-query does not take its keys.
// The pane's find bar and an open queued-message editor are surfaces of their own: a card
// arriving mid-query or mid-edit does not take their keys.
if (
shouldFocus &&
isInNativeChatPaneOf(card, active) &&
!card?.contains(active) &&
!active?.closest('[data-native-chat-find-bar]')
!active?.closest('[data-native-chat-find-bar], [data-queued-message-editor]')
) {
card?.focus()
}
@@ -0,0 +1,503 @@
// @vitest-environment happy-dom
// The inline editor's lifecycle against a scripted host: what it acquires and saves, and that
// typing is never lost — a card that leaves mid-edit puts dirty text in the chat box, and lease
// bookkeeping failing never gates typing, Save or Cancel.
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import type {
AgentSessionQueuedMessage,
AgentSessionQueuedMessageEditHoldParams,
AgentSessionQueuedMessageEditHoldResult,
AgentSessionQueuedMessageUpdateResult
} from '../../../../shared/agent-session-wire'
import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation'
import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit'
import { projectQueuedMessageCards } from './structured-agent-session-queued-cards'
import {
clearNativeChatDraftCacheForTests,
readNativeChatDraftCache,
writeNativeChatDraftCache
} from './native-chat-draft-cache'
import {
createMemoryNativeChatComposerDraftStorage,
setNativeChatComposerDraftStorageForTests
} from './native-chat-composer-draft-storage'
import type {
StructuredAgentSessionWrite,
StructuredAgentSessionWriteOutcome
} from './use-structured-agent-session-mutate'
const HELD: AgentSessionQueuedMessageEditHoldResult = {
status: 'held',
fingerprint: 'f'.repeat(64),
leaseDurationMs: 120_000,
remainingMs: 120_000
}
const rpc = vi.hoisted(() =>
vi.fn(
async (
_target: unknown,
_method: string,
params: AgentSessionQueuedMessageEditHoldParams
): Promise<AgentSessionQueuedMessageEditHoldResult> =>
params.action === 'release' ? { status: 'released' } : HELD
)
)
vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc }))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
const SESSION = 'folder-session'
const SCOPE = 'pane-draft'
const message: AgentSessionQueuedMessage = {
messageId: 'card',
position: 2,
state: 'waiting',
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'original' }],
from: { kind: 'agent', senders: [], orchestration: null }
}
}
const BASE = agentSessionSendBodyFingerprint(SESSION, message.body)
type Outcome = StructuredAgentSessionWriteOutcome<AgentSessionQueuedMessageUpdateResult>
let answer: () => Promise<Outcome>
const writeSpy = vi.fn(async (..._args: unknown[]) => answer())
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness answers only queuedMessageUpdate, with that method's scripted outcome.
const write = writeSpy as StructuredAgentSessionWrite
type Props = {
messages: AgentSessionQueuedMessage[] | null
sessionId?: string
submissions?: AgentJournalSubmission[]
promptOpen?: boolean
}
function harness(capable = true, initial: Props = { messages: [message] }) {
return renderHook(
(props: Props) => {
const submissions = props.submissions ?? []
return useStructuredAgentSessionQueuedEdit({
transport: {
target: { kind: 'environment', environmentId: 'remote-host' },
sessionId: props.sessionId ?? SESSION,
capable,
write
},
messages: props.messages,
cards: projectQueuedMessageCards(props.messages, submissions, { hasPendingPrompt: true }),
submissions,
composerScopeKey: SCOPE,
promptOpen: props.promptOpen ?? false
})
},
{ initialProps: initial }
)
}
type Hook = ReturnType<typeof harness>
async function begin(hook: Hook) {
await act(() => hook.result.current.begin('card'))
}
function holdCalls(action: string) {
return rpc.mock.calls.filter((call) => call[2].action === action)
}
function done(value: AgentSessionQueuedMessageUpdateResult): () => Promise<Outcome> {
return async () => ({ kind: 'done', value })
}
function withText(text: string): AgentSessionQueuedMessage {
return { ...message, body: { ...message.body, blocks: [{ type: 'text', text }] } }
}
function submittedAs(text: string): AgentJournalSubmission {
return {
clientMessageId: 'submission',
queuedMessageId: 'card',
payloadFingerprint: agentSessionSendBodyFingerprint(SESSION, withText(text).body),
fence: 3,
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}
beforeEach(() => {
rpc.mockClear()
writeSpy.mockClear()
vi.mocked(toast.error).mockClear()
clearNativeChatDraftCacheForTests()
setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage())
answer = done({ status: 'updated', messageId: 'card', fingerprint: 'a'.repeat(64) })
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
describe('inline queued editor', () => {
it('holds the card on its host, saves against the full body, and leaves the chat box alone', async () => {
writeNativeChatDraftCache(SCOPE, 'existing composer')
const hook = harness()
await begin(hook)
expect(rpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'remote-host' },
'agentSession.queuedMessageEditHold',
expect.objectContaining({
sessionId: SESSION,
action: 'acquire',
expectedBodyFingerprint: BASE
})
)
expect(hook.result.current.editor).toMatchObject({ text: 'original', acquiring: false })
act(() => hook.result.current.editor?.change('edited'))
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
expect(writeSpy).toHaveBeenCalledWith(
'agentSession.queuedMessageUpdate',
'agentSession.queuedMessageUpdate',
{ messageId: 'card', expectedBodyFingerprint: BASE, text: 'edited' }
)
await waitFor(() => expect(holdCalls('release')).toHaveLength(1))
expect(holdCalls('release')[0]?.[2].editId).toBe(holdCalls('acquire')[0]?.[2].editId)
expect(readNativeChatDraftCache(SCOPE)).toBe('existing composer')
expect(toast.error).not.toHaveBeenCalled()
})
it('a host that cannot edit in place gets no editor and no request', async () => {
const hook = harness(false)
await begin(hook)
expect(hook.result.current.editor).toBeUndefined()
expect(rpc).not.toHaveBeenCalled()
})
it('opens one editor per pane', async () => {
const second = { ...message, messageId: 'second', position: 3 }
const hook = harness(true, { messages: [message, second] })
await begin(hook)
await act(() => hook.result.current.begin('second'))
expect(hook.result.current.editor?.messageId).toBe('card')
expect(holdCalls('acquire')).toHaveLength(1)
})
it('Save with the text unchanged writes nothing', async () => {
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.save())
expect(hook.result.current.editor).toBeUndefined()
expect(writeSpy).not.toHaveBeenCalled()
})
it('Cancel during a slow acquire closes at once and releases that edit after it lands', async () => {
let finish: (value: AgentSessionQueuedMessageEditHoldResult) => void = () => {}
rpc.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const hook = harness()
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = hook.result.current.begin('card')
})
expect(hook.result.current.editor?.acquiring).toBe(true)
act(() => hook.result.current.editor?.cancel())
expect(hook.result.current.editor).toBeUndefined()
expect(holdCalls('release')).toHaveLength(0)
await act(async () => {
finish(HELD)
await pending
})
await waitFor(() => expect(holdCalls('release')).toHaveLength(1))
expect(writeSpy).not.toHaveBeenCalled()
})
it('a card sent elsewhere moves dirty typing into the chat box, after its draft, with one toast', async () => {
writeNativeChatDraftCache(SCOPE, 'existing')
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('unsaved'))
hook.rerender({ messages: [] })
expect(hook.result.current.editor).toBeUndefined()
expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nunsaved')
expect(toast.error).toHaveBeenCalledTimes(1)
hook.rerender({ messages: [] })
expect(toast.error).toHaveBeenCalledTimes(1)
})
it('a clean edit whose card leaves just closes; an unloaded list proves nothing', async () => {
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('typing'))
hook.rerender({ messages: null })
expect(hook.result.current.editor?.text).toBe('typing')
act(() => hook.result.current.editor?.change('original'))
hook.rerender({ messages: [] })
expect(hook.result.current.editor).toBeUndefined()
expect(readNativeChatDraftCache(SCOPE)).toBe('')
expect(toast.error).not.toHaveBeenCalled()
})
it('changed puts the typing in the chat box and reopens on the newer text once it shows', async () => {
writeNativeChatDraftCache(SCOPE, 'existing')
answer = done({ status: 'changed', messageId: 'card' })
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('local'))
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nlocal')
expect(toast.error).toHaveBeenCalledExactlyOnceWith(
'This message changed while you were editing. Your edit is in the chat box.'
)
// The newer text has not reached this pane yet: nothing opens on the old one.
hook.rerender({ messages: [message] })
expect(hook.result.current.editor).toBeUndefined()
const remote = withText('remote')
hook.rerender({ messages: [remote] })
await waitFor(() => expect(hook.result.current.editor).toMatchObject({ text: 'remote' }))
expect(holdCalls('acquire')[1]?.[2]).toMatchObject({
expectedBodyFingerprint: agentSessionSendBodyFingerprint(SESSION, remote.body)
})
expect(writeSpy).toHaveBeenCalledOnce()
expect(toast.error).toHaveBeenCalledOnce()
})
it('while a question holds the chat box, the notice says when the edit will be there', async () => {
answer = done({ status: 'gone', messageId: 'card', disposition: 'dispatched' })
const hook = harness(true, { messages: [message], promptOpen: true })
await begin(hook)
act(() => hook.result.current.editor?.change('unsaved'))
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
expect(toast.error).toHaveBeenCalledExactlyOnceWith(
'This message was already sent or removed. Your edit will be in the chat box after you answer the agent.'
)
expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
})
it('a refused Save says why once and keeps the editor', async () => {
answer = async () => ({
kind: 'not-done',
notice: 'Too much text is waiting in the queue.',
failure: { kind: 'failed' }
})
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('long'))
act(() => hook.result.current.editor?.save())
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('Too much text is waiting in the queue.')
)
expect(hook.result.current.editor).toMatchObject({ text: 'long', saving: false })
})
it('Save answering gone puts the typing in the chat box; another chat keeps it there quietly', async () => {
answer = done({ status: 'gone', messageId: 'card', disposition: 'dispatched' })
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('unsaved'))
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor).toBeUndefined())
expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
expect(toast.error).toHaveBeenCalledOnce()
await begin(hook)
act(() => hook.result.current.editor?.change('another draft'))
hook.rerender({ messages: [message], sessionId: 'another-session' })
expect(hook.result.current.editor).toBeUndefined()
expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved\n\nanother draft')
expect(toast.error).toHaveBeenCalledOnce()
await waitFor(() => expect(holdCalls('release')).toHaveLength(2))
})
it('closing the pane keeps unsaved typing in the chat box, quietly; clean edits leave nothing', async () => {
const dirty = harness()
await begin(dirty)
act(() => dirty.result.current.editor?.change('unsaved'))
dirty.unmount()
expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
const clean = harness()
await begin(clean)
clean.unmount()
expect(readNativeChatDraftCache(SCOPE)).toBe('unsaved')
expect(toast.error).not.toHaveBeenCalled()
await waitFor(() => expect(holdCalls('release')).toHaveLength(2))
})
it('a reload, which unmounts nothing, keeps unsaved typing in the chat box once', async () => {
writeNativeChatDraftCache(SCOPE, 'existing')
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('unsaved'))
act(() => {
window.dispatchEvent(new Event('pagehide'))
})
expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nunsaved')
hook.unmount()
expect(readNativeChatDraftCache(SCOPE)).toBe('existing\n\nunsaved')
expect(toast.error).not.toHaveBeenCalled()
await waitFor(() => expect(holdCalls('release')).toHaveLength(1))
})
describe('a Save whose answer this pane cannot read reads the published card', () => {
const dropped: () => Promise<Outcome> = async () => ({ kind: 'dropped' })
function deferred() {
let finish: (outcome: Outcome) => void = () => {}
answer = () =>
new Promise((resolve) => {
finish = resolve
})
return (outcome: Outcome) => act(async () => finish(outcome))
}
it('the card already shows the saved text: the editor closes, quietly', async () => {
const finish = deferred()
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('saved'))
act(() => hook.result.current.editor?.save())
hook.rerender({ messages: [withText('saved')] })
await finish({ kind: 'dropped' })
expect(hook.result.current.editor).toBeUndefined()
expect(toast.error).not.toHaveBeenCalled()
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it('a lost answer for a Save that landed says nothing either', async () => {
const finish = deferred()
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('saved'))
act(() => hook.result.current.editor?.save())
hook.rerender({ messages: [withText('saved')] })
await finish({ kind: 'not-done', notice: 'lost', failure: { kind: 'unconfirmed' } })
expect(hook.result.current.editor).toBeUndefined()
expect(toast.error).not.toHaveBeenCalled()
})
it('the card still shows other text: the editor stays as it was, quietly', async () => {
answer = dropped
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('typed'))
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor?.saving).toBe(false))
expect(hook.result.current.editor).toMatchObject({ text: 'typed' })
expect(toast.error).not.toHaveBeenCalled()
})
it('an answer that beats its own publication closes once the card shows the saved text, before it drains', async () => {
answer = dropped
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('saved text'))
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor?.saving).toBe(false))
expect(hook.result.current.editor).toMatchObject({ text: 'saved text' })
hook.rerender({ messages: [withText('saved text')] })
expect(hook.result.current.editor).toBeUndefined()
hook.rerender({ messages: [], submissions: [submittedAs('saved text')] })
expect(toast.error).not.toHaveBeenCalled()
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it('a card that drains with exactly the typed text, unseen, closes quietly too', async () => {
answer = dropped
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('saved text'))
act(() => hook.result.current.editor?.save())
await waitFor(() => expect(hook.result.current.editor?.saving).toBe(false))
hook.rerender({ messages: [], submissions: [submittedAs('saved text')] })
expect(hook.result.current.editor).toBeUndefined()
expect(toast.error).not.toHaveBeenCalled()
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it('the card left with its old text (sent elsewhere first): the typing goes to the chat box', async () => {
const finish = deferred()
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('typed'))
act(() => hook.result.current.editor?.save())
hook.rerender({ messages: [] })
expect(hook.result.current.editor?.saving).toBe(true)
await finish({ kind: 'dropped' })
expect(hook.result.current.editor).toBeUndefined()
expect(readNativeChatDraftCache(SCOPE)).toBe('typed')
expect(toast.error).toHaveBeenCalledOnce()
})
})
it('a Save that lands and then drains reads as success, even with its answer lost', async () => {
let finish: (outcome: Outcome) => void = () => {}
answer = () =>
new Promise((resolve) => {
finish = resolve
})
const hook = harness()
await begin(hook)
act(() => hook.result.current.editor?.change('saved text'))
act(() => hook.result.current.editor?.save())
hook.rerender({ messages: [], submissions: [submittedAs('saved text')] })
expect(hook.result.current.editor?.saving).toBe(true)
await act(async () =>
finish({ kind: 'not-done', notice: 'lost', failure: { kind: 'unconfirmed' } })
)
expect(hook.result.current.editor).toBeUndefined()
expect(toast.error).not.toHaveBeenCalled()
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it('a failed acquire, renewal or release never gates typing, Save or Cancel, and says nothing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.useFakeTimers()
rpc.mockRejectedValueOnce(new Error('disconnected'))
const hook = harness()
await act(async () => {
await hook.result.current.begin('card')
})
expect(hook.result.current.editor?.acquiring).toBe(false)
act(() => hook.result.current.editor?.change('still typing'))
// The lease keeps trying on its own beat: acquire again, then renew.
await act(() => vi.advanceTimersByTimeAsync(30_000))
expect(holdCalls('acquire')).toHaveLength(2)
rpc.mockRejectedValueOnce(new Error('renewal failed'))
await act(() => vi.advanceTimersByTimeAsync(30_000))
expect(holdCalls('renew')).toHaveLength(1)
rpc.mockRejectedValueOnce(new Error('release failed'))
act(() => hook.result.current.editor?.cancel())
await act(() => vi.advanceTimersByTimeAsync(0))
expect(hook.result.current.editor).toBeUndefined()
expect(holdCalls('release')).toHaveLength(1)
// Ended: no more beats.
await act(() => vi.advanceTimersByTimeAsync(120_000))
expect(rpc).toHaveBeenCalledTimes(4)
expect(toast.error).not.toHaveBeenCalled()
warn.mockRestore()
})
it('a lapsed lease is taken again at once for the same text', async () => {
vi.useFakeTimers()
const hook = harness()
await begin(hook)
rpc.mockResolvedValueOnce({ status: 'expired' })
await act(() => vi.advanceTimersByTimeAsync(30_000))
expect(holdCalls('acquire')).toHaveLength(2)
expect(holdCalls('acquire')[1]?.[2]).toMatchObject({ expectedBodyFingerprint: BASE })
act(() => hook.result.current.editor?.cancel())
})
it('a card edited or sent before the hold lands closes the editor quietly', async () => {
rpc.mockResolvedValueOnce({ status: 'changed' })
const hook = harness()
await begin(hook)
expect(hook.result.current.editor).toBeUndefined()
expect(toast.error).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,332 @@
// Editing one queued card in place, inside the card itself, so it works while a question or an
// approval holds the composer's slot. The host owns the card; this pane holds only the typing,
// an edit lease that keeps automatic delivery off the card, and Save's compare-and-set.
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability'
import type {
AgentSessionQueuedMessage,
AgentSessionQueuedMessageUpdateResult
} from '../../../../shared/agent-session-wire'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation'
import {
queuedMessageEditableText,
queuedMessageWithEditedText
} from '../../../../shared/queued-message-text-edit'
import { appendNativeChatDraftCache } from './native-chat-draft-cache'
import { queuedEditNotice } from './queued-message-edit-notices'
import { structuredSessionOperationId } from './structured-agent-session-operation-id'
import {
startQueuedEditLease,
type QueuedEditLease
} from './structured-agent-session-queued-edit-lease'
import type { QueuedMessageCard } from './structured-agent-session-queued-cards'
import type { StructuredAgentSessionWrite } from './use-structured-agent-session-mutate'
export type QueuedEditTransport = {
target: RuntimeClientTarget
sessionId: string
/** The host advertises `agent-session.queued-message-edit.v1`; without it there is no Edit. */
capable: boolean
write: StructuredAgentSessionWrite
}
export type QueuedMessageInlineEditor = {
messageId: string
text: string
/** The host has not answered the lease yet: the field shows the text, read-only. */
acquiring: boolean
saving: boolean
canSave: boolean
change: (text: string) => void
save: () => void
cancel: () => void
}
type Edit = {
scope: string
/** The chat box this edit's typing falls back to; the one it started in, never another. */
draftKey: string
messageId: string
body: AgentSessionQueuedMessage['body']
originalText: string
text: string
/** The body Save expects to replace; fixed for the edit, so a newer text is never overwritten. */
baseFingerprint: string
acquiring: boolean
saving: boolean
lease: QueuedEditLease
}
export function useStructuredAgentSessionQueuedEdit(args: {
transport: QueuedEditTransport
/** The host's published cards, for each card's full body. */
messages: readonly AgentSessionQueuedMessage[] | null
/** The cards this pane shows; a card that leaves them has left the queue. */
cards: readonly QueuedMessageCard[]
submissions: readonly AgentJournalSubmission[]
composerScopeKey: string | undefined
/** A question or approval holds the chat box's place. */
promptOpen: boolean
}) {
const { transport, messages, cards, submissions, composerScopeKey, promptOpen } = args
const { target, sessionId, capable, write } = transport
const scope = `${structuredAgentSessionHostKey(target)}:${sessionId}:${composerScopeKey ?? ''}`
const active = useRef<Edit | null>(null)
const [shown, setShown] = useState<Edit | null>(null)
/** Save answered `changed`: the editor reopens on the newer text once this pane shows it. */
const reopen = useRef<{ scope: string; messageId: string; stale: string } | null>(null)
const latest = useRef({ messages, cards, submissions, promptOpen })
useEffect(() => {
latest.current = { messages, cards, submissions, promptOpen }
}, [messages, cards, submissions, promptOpen])
const render = useCallback((edit: Edit | null) => {
active.current = edit
setShown(edit ? { ...edit } : null)
}, [])
const close = useCallback(() => {
const edit = active.current
render(null)
edit?.lease.end()
}, [render])
/** The card left the queue under the editor: typing is never thrown away, it goes to the
* chat box, after whatever draft is already there. */
const gone = useCallback(
(edit: Edit) => {
if (active.current !== edit) {
return
}
if (edit.text !== edit.originalText) {
appendNativeChatDraftCache(edit.draftKey, edit.text)
toast.error(queuedEditNotice(latest.current.promptOpen ? 'editGonePrompt' : 'editGone'))
}
close()
},
[close]
)
// Another chat, host or composer, the pane going away, or a reload (which unmounts nothing):
// unsaved typing goes quietly to the chat box of the chat it was typed in.
useEffect(() => {
const handOff = (): void => {
const edit = active.current
active.current = null
reopen.current = null
if (edit && edit.text !== edit.originalText) {
appendNativeChatDraftCache(edit.draftKey, edit.text)
}
edit?.lease.end()
}
window.addEventListener('pagehide', handOff)
return () => {
window.removeEventListener('pagehide', handOff)
handOff()
}
}, [scope])
const begin = useCallback(
async (messageId: string): Promise<void> => {
// One editor per pane: another card's Edit waits for this one's Save or Cancel.
reopen.current = null
if (active.current || !capable || !composerScopeKey) {
return
}
const message = messages?.find((entry) => entry.messageId === messageId)
const text = message ? queuedMessageEditableText(message.body) : null
if (!message || text === null) {
return
}
const baseFingerprint = agentSessionSendBodyFingerprint(sessionId, message.body)
const lease = startQueuedEditLease({
target,
sessionId,
messageId,
editId: structuredSessionOperationId(),
expectedBodyFingerprint: baseFingerprint
})
const edit: Edit = {
scope,
draftKey: composerScopeKey,
messageId,
body: message.body,
originalText: text,
text,
baseFingerprint,
acquiring: true,
saving: false,
lease
}
render(edit)
const answer = await lease.acquired.catch(() => null)
if (!isOpen(active, edit)) {
return
}
if (answer?.status === 'gone' || answer?.status === 'changed') {
// Sent, removed or edited elsewhere before the edit began: the card shows what happened.
close()
} else if (answer?.status === 'not-editable') {
toast.error(queuedEditNotice('editFailed'))
close()
} else {
// An unanswered lease never gates typing: the lease keeps trying, and Save checks the text.
edit.acquiring = false
render(edit)
}
},
[capable, close, composerScopeKey, messages, render, scope, sessionId, target]
)
const change = useCallback(
(text: string) => {
const edit = active.current
if (edit && !edit.acquiring && !edit.saving) {
edit.text = text
render(edit)
}
},
[render]
)
const save = useCallback(async () => {
const edit = active.current
if (!edit || edit.acquiring || edit.saving) {
return
}
if (edit.text === edit.originalText) {
close()
return
}
const body = queuedMessageWithEditedText(edit.body, edit.text)
if (!body) {
return
}
edit.saving = true
render(edit)
const result = await write<AgentSessionQueuedMessageUpdateResult>(
'agentSession.queuedMessageUpdate',
'agentSession.queuedMessageUpdate',
{ messageId: edit.messageId, expectedBodyFingerprint: edit.baseFingerprint, text: edit.text }
)
if (!isOpen(active, edit)) {
return
}
edit.saving = false
if (result.kind === 'done') {
const { status } = result.value
if (status === 'updated' || status === 'unchanged') {
close()
} else if (status === 'gone') {
gone(edit)
} else if (status === 'changed') {
// Someone else's edit landed first: the typing goes to the chat box, and the editor
// opens again on the newer text.
appendNativeChatDraftCache(edit.draftKey, edit.text)
toast.error(
queuedEditNotice(latest.current.promptOpen ? 'editChangedPrompt' : 'editChanged')
)
reopen.current = { scope, messageId: edit.messageId, stale: edit.baseFingerprint }
close()
} else {
toast.error(queuedEditNotice('editFailed'))
render(edit)
}
return
}
// No answer this pane can read (a lost reply, or one dropped for a moved runtime fence, which
// a text edit does not depend on): the published card says whether this Save landed.
const listed = latest.current.messages?.find((entry) => entry.messageId === edit.messageId)
const desired = agentSessionSendBodyFingerprint(sessionId, body)
if (latest.current.messages === null) {
render(edit)
} else if (!listed || !onScreen(latest.current.cards, edit.messageId)) {
// The card has left: it went out with this Save's text, or before the Save could land.
if (sentWith(latest.current.submissions, edit.messageId, desired)) {
close()
} else {
gone(edit)
}
} else if (agentSessionSendBodyFingerprint(sessionId, listed.body) === desired) {
close()
} else {
// A refusal says why; an unread answer leaves the editor as it was for Save again.
if (result.kind === 'not-done') {
toast.error(result.notice)
}
render(edit)
}
}, [close, gone, render, scope, sessionId, write])
// Sent or removed elsewhere, or a lapsed lease lost the race to delivery. While a Save is out,
// its answer decides instead: its own text going out right after it lands is a success.
// An unloaded list (`null`) proves nothing: a stream that went quiet did not send the card.
useEffect(() => {
const edit = active.current
if (edit && !edit.saving && messages !== null) {
const typed = queuedMessageWithEditedText(edit.body, edit.text)
const desired = typed ? agentSessionSendBodyFingerprint(sessionId, typed) : null
const listed = messages.find((entry) => entry.messageId === edit.messageId)
if (!listed || !onScreen(cards, edit.messageId)) {
if (desired !== null && sentWith(submissions, edit.messageId, desired)) {
close()
} else {
gone(edit)
}
} else if (
desired !== edit.baseFingerprint &&
agentSessionSendBodyFingerprint(sessionId, listed.body) === desired
) {
// The card already holds what is typed, as after a Save whose answer was lost.
close()
}
}
const pending = reopen.current
if (!pending || edit || messages === null) {
return
}
const listed = messages.find((entry) => entry.messageId === pending.messageId)
if (pending.scope !== scope || !listed || !onScreen(cards, pending.messageId)) {
reopen.current = null
} else if (agentSessionSendBodyFingerprint(sessionId, listed.body) !== pending.stale) {
void begin(pending.messageId)
}
}, [begin, cards, close, gone, messages, scope, sessionId, shown, submissions])
const editor: QueuedMessageInlineEditor | undefined =
shown && shown.scope === scope
? {
messageId: shown.messageId,
text: shown.text,
acquiring: shown.acquiring,
saving: shown.saving,
canSave: queuedMessageWithEditedText(shown.body, shown.text) !== null,
change,
save: () => void save(),
cancel: close
}
: undefined
return { editor, begin, capable }
}
/** A read of the ref, not a narrowing: an await may have closed or replaced the edit. */
function isOpen(active: { current: Edit | null }, edit: Edit): boolean {
return active.current === edit
}
/** The card went out holding exactly this body. */
function sentWith(
submissions: readonly AgentJournalSubmission[],
messageId: string,
fingerprint: string
): boolean {
return submissions.some(
(entry) => entry.queuedMessageId === messageId && entry.payloadFingerprint === fingerprint
)
}
function onScreen(cards: readonly QueuedMessageCard[], messageId: string): boolean {
return cards.some((card) => card.messageId === messageId)
}
@@ -13,6 +13,14 @@ import type {
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { NativeChatQueueSendConfirmDialog } from './NativeChatQueueSendConfirmDialog'
import type { QueuedEditTransport } from './use-structured-agent-session-queued-edit'
const NO_EDIT: QueuedEditTransport = {
target: { kind: 'local' },
sessionId: 'session',
capable: false,
write: async () => ({ kind: 'dropped' })
}
const STOPPED = { reason: 'stopped' } as const
@@ -51,7 +59,8 @@ function renderController(
isWorking: options.isWorking === true,
composerScopeKey: undefined,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape.
mutate: mutate as unknown as StructuredAgentSessionMutate
mutate: mutate as unknown as StructuredAgentSessionMutate,
editTransport: NO_EDIT
})
)
return { ...view, mutate }
@@ -30,6 +30,14 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-
import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal'
import { useNativeChatMessageListHandle } from './use-native-chat-reveal-latest'
import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll'
import type { QueuedEditTransport } from './use-structured-agent-session-queued-edit'
const NO_EDIT: QueuedEditTransport = {
target: { kind: 'local' },
sessionId: 'session',
capable: false,
write: async () => ({ kind: 'dropped' })
}
const RESUMED = {
ok: true,
@@ -72,7 +80,8 @@ function renderController(initialProps: ControllerInput = {}) {
hasPendingPrompt: input.hasPendingPrompt ?? false,
isWorking: input.isWorking ?? false,
composerScopeKey: undefined,
mutate
mutate,
editTransport: NO_EDIT
})
},
{ initialProps }
@@ -286,7 +295,8 @@ function useQueue(sessionId = 'session-1', fence: number | null = 1) {
hasPendingPrompt: false,
isWorking: false,
composerScopeKey: undefined,
mutate
mutate,
editTransport: NO_EDIT
})
}
@@ -1,8 +1,7 @@
// @vitest-environment happy-dom
// The queued-message controller: which RPC each card action issues, and Edit's
// copy-first order — the card's shown text is in the composer before the Delete
// goes out, so no RPC outcome can lose it.
// The queued-message controller: which RPC each card action issues, and that a host which cannot
// edit in place gets no Edit at all, never the old copy-and-delete.
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -11,17 +10,15 @@ import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { toast } from 'sonner'
import {
clearNativeChatDraftCacheForTests,
readNativeChatDraftCache
} from './native-chat-draft-cache'
import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages'
import {
createMemoryNativeChatComposerDraftStorage,
setNativeChatComposerDraftStorageForTests
} from './native-chat-composer-draft-storage'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import type {
StructuredAgentSessionMutate,
StructuredAgentSessionWrite
} from './use-structured-agent-session-mutate'
type MutateCall = [string, string, Record<string, unknown>]
@@ -34,6 +31,7 @@ function draft(id: string, position: number): AgentSessionQueuedMessage {
}
const SCOPE = 'tab-1:pane-scope'
const write: StructuredAgentSessionWrite = vi.fn(async () => ({ kind: 'dropped' as const }))
function createHarness(
overrides: {
@@ -58,7 +56,8 @@ function createHarness(
isWorking: false,
composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that.
mutate: mutate as StructuredAgentSessionMutate
mutate: mutate as StructuredAgentSessionMutate,
editTransport: { target: { kind: 'local' }, sessionId: 'session', capable: false, write }
})
)
return { ...rendered, mutate, mutateCalls }
@@ -110,74 +109,14 @@ describe('queued message actions', () => {
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it('Edit puts the shown text in the composer BEFORE the Delete goes out, then deletes', async () => {
let draftWhenDeleteArrived: string | null = null
const harness = createHarness({
mutateResult: (call) => {
draftWhenDeleteArrived = readNativeChatDraftCache(SCOPE)
return { deleted: true, messageId: call[2].messageId }
}
})
await act(() => harness.result.current.edit('draft-1'))
expect(harness.mutateCalls).toEqual([
[
'agentSession.queuedMessageDelete',
'agentSession.queuedMessageDelete',
{ messageId: 'draft-1' }
]
])
// Copy-first: the composer already held the text when the RPC was issued.
expect(draftWhenDeleteArrived).toBe('text of draft-1')
expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
})
it('the text survives a failed Delete: the card stays and the composer keeps the copy', async () => {
// mutate answers null for a refused or lost write; the card remains on the host's list.
it('a host that cannot edit in place offers no Edit and never copies or deletes', async () => {
const harness = createHarness()
await act(() => harness.result.current.edit('draft-1'))
expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
expect(harness.result.current.cards.map((card) => card.messageId)).toEqual([
'draft-1',
'draft-2'
])
})
it('an Edit whose draft was already dispatched says so, and the copy stays', async () => {
const harness = createHarness({
mutateResult: (call) => ({
deleted: false,
messageId: call[2].messageId,
disposition: 'dispatched'
})
})
await act(() => harness.result.current.edit('draft-1'))
expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
expect(toast.error).toHaveBeenCalledWith('Already sent — your text is still in the composer.')
})
it('Edit deletes the card only once storage holds the text, and keeps it when storage refuses', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = createMemoryNativeChatComposerDraftStorage()
storage.refuseWrites = true
setNativeChatComposerDraftStorageForTests(storage)
try {
const harness = createHarness()
await act(() => harness.result.current.edit('draft-1'))
expect(readNativeChatDraftCache(SCOPE)).toBe('text of draft-1')
expect(harness.mutate).not.toHaveBeenCalled()
storage.refuseWrites = false
await act(() => harness.result.current.edit('draft-2'))
expect(harness.mutate).toHaveBeenCalledTimes(1)
} finally {
warn.mockRestore()
}
})
it('Edit with no composer to hold the text deletes nothing', async () => {
const harness = createHarness({ composerScopeKey: undefined })
await act(() => harness.result.current.edit('draft-1'))
expect(harness.result.current.editCapable).toBe(false)
expect(harness.result.current.editor).toBeUndefined()
expect(harness.mutate).not.toHaveBeenCalled()
expect(write).not.toHaveBeenCalled()
expect(readNativeChatDraftCache(SCOPE)).toBe('')
})
it('a second press on a card while its action is in flight sends nothing more', async () => {
@@ -1,9 +1,7 @@
// Host-held drafts as this pane acts on them: the card list, Send-now (Steer),
// Delete, Edit, the Cmd/Ctrl+Enter steer chord, Resume of a held queue, and
// Delete, in-place Edit, the Cmd/Ctrl+Enter steer chord, Resume of a held queue, and
// clearing it before a new message.
// Everything durable lives on the host, and no draft text ever travels back over
// the wire: Edit copies the text the card already shows into the composer,
// locally, before deleting the draft, so no RPC outcome can lose it.
// Everything durable lives on the host; an open editor holds only its own typing.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
@@ -16,9 +14,12 @@ import type {
AgentSessionQueuePause,
AgentSessionSendResult
} from '../../../../shared/agent-session-wire'
import { appendNativeChatDraftCache } from './native-chat-draft-cache'
import {
useStructuredAgentSessionQueuedEdit,
type QueuedEditTransport,
type QueuedMessageInlineEditor
} from './use-structured-agent-session-queued-edit'
import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends'
import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store'
import {
newestSteerableQueuedMessageCard,
projectQueuedMessageCards,
@@ -46,7 +47,11 @@ export type StructuredAgentSessionQueuedMessagesController = {
/** Send-now into the running turn; the transcript shows it at delivery position. */
steer: (messageId: string) => Promise<void>
remove: (messageId: string) => Promise<void>
/** Copy the card's shown text into the composer, then delete the draft. */
/** The host edits cards in place; without it a card offers no Edit. */
editCapable: boolean
/** The card being edited in this pane, at most one. */
editor: QueuedMessageInlineEditor | undefined
/** Opens the card's inline editor. */
edit: (messageId: string) => Promise<void>
/** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */
steerNewest: () => boolean
@@ -85,6 +90,7 @@ export function useStructuredAgentSessionQueuedMessages(args: {
sending?: readonly StructuredAgentSessionPendingSend[]
composerScopeKey: string | undefined
mutate: StructuredAgentSessionMutate
editTransport: QueuedEditTransport
}): StructuredAgentSessionQueuedMessagesController {
const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args
const { isWorking, queuePause } = args
@@ -169,38 +175,14 @@ export function useStructuredAgentSessionQueuedMessages(args: {
[removeCard]
)
const edit = useCallback(
(messageId: string): Promise<void> =>
actOnce(messageId, async () => {
// The text is copied FIRST, from the card this pane already shows — a local move,
// never a wire payload. Without a composer to hold it, deleting would destroy it,
// so the draft then stays a card.
const card = cardsRef.current.find((entry) => entry.messageId === messageId)
if (!card || card.command || !composerScopeKey) {
return
}
appendNativeChatDraftCache(composerScopeKey, card.text)
// The card goes only once storage holds the draft; a refused save keeps it.
if (!(await nativeChatComposerDraftWriteSettled(composerScopeKey))) {
return
}
const result = await mutate<AgentSessionQueuedMessageDeleteResult>(
'agentSession.queuedMessageDelete',
'agentSession.queuedMessageDelete',
{ messageId }
)
if (result && !result.deleted && result.disposition === 'dispatched') {
toast.error(
translate(
'components.native-chat.queuedMessages.editAlreadySent',
'Already sent — your text is still in the composer.'
)
)
}
// A failed Delete leaves the card: the text shows in both places, visibly, never lost.
}),
[actOnce, composerScopeKey, mutate]
)
const editing = useStructuredAgentSessionQueuedEdit({
transport: args.editTransport,
messages: queuedMessages,
cards,
submissions,
composerScopeKey,
promptOpen: hasPendingPrompt
})
const steerNewest = useCallback((): boolean => {
if (!enabled) {
@@ -265,7 +247,9 @@ export function useStructuredAgentSessionQueuedMessages(args: {
resuming,
steer,
remove,
edit,
edit: editing.begin,
editCapable: editing.capable,
editor: editing.editor,
steerNewest,
queueResume,
queueHold
@@ -10,6 +10,7 @@ import { useStructuredLegacyLaunchStop } from '@/lib/structured-agent-session-le
import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured-agent-session-client'
import {
useStructuredAgentSessionHostQueuesCommands,
useStructuredAgentSessionHostEditsQueuedMessages,
useStructuredAgentSessionHostQueuesMessagesState
} from '@/runtime/structured-agent-session-host-capability'
import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control'
@@ -211,6 +212,11 @@ export function useStructuredAgentSession(args: {
transcriptPending,
transportState.submissions
)
const editCapable = useStructuredAgentSessionHostEditsQueuedMessages(target)
const editTransport = useMemo(
() => ({ target, sessionId, capable: editCapable, write }),
[editCapable, sessionId, target, write]
)
const queuedController = useStructuredAgentSessionQueuedMessages({
// Its published list, pause and submissions; the rest is named below.
...transportState,
@@ -220,7 +226,8 @@ export function useStructuredAgentSession(args: {
// Hidden from the transcript, a queue send on its way reads as sending among the cards.
sending: pending,
composerScopeKey,
mutate
mutate,
editTransport
})
return {
epoch: state.epoch,
@@ -19,6 +19,8 @@ it('reveals before launch Retry, but waits for a successful queue Resume', async
steer: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
edit: vi.fn(async () => {}),
editCapable: false,
editor: undefined,
steerNewest: vi.fn(() => false),
queueResume: undefined,
queueHold: undefined
@@ -1,9 +1,10 @@
import React from 'react'
import { CornerDownLeft, LoaderCircle } from 'lucide-react'
import { LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SwitchIndicator } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { ButtonKeyHint } from '@/components/ButtonKeyHint'
import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props'
type NewWorkspaceComposerFooterProps = Pick<
@@ -78,10 +79,7 @@ export function NewWorkspaceComposerFooter({
>
{creating ? <LoaderCircle className="size-4 animate-spin" /> : null}
{primaryActionLabel}
<span className="ml-1 inline-flex items-center gap-0.5 rounded border border-white/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-current/80">
<span>{submitShortcutModifierLabel}</span>
<CornerDownLeft className="size-3" />
</span>
<ButtonKeyHint modifierLabel={submitShortcutModifierLabel} />
</Button>
</div>
</>
+9 -2
View File
@@ -18667,7 +18667,6 @@
"turnOffQueueing": "Turn off queueing",
"listLabel": "Queued messages",
"alreadySent": "This message was already sent.",
"editAlreadySent": "Already sent — your text is still in the composer.",
"withdrawnHold": "Stopped before it was sent",
"pausedSendFailed": "Couldn't send — press Send to retry.",
"paused": "Paused",
@@ -18681,7 +18680,15 @@
"sendConfirmClear": "Clear queue",
"sendConfirmSend": "Send message",
"fromSender": "From",
"pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes."
"pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes.",
"save": "Save",
"editingMessage": "Editing message",
"cancelEditing": "Cancel editing",
"editChanged": "This message changed while you were editing. Your edit is in the chat box.",
"editChangedPrompt": "This message changed while you were editing. Your edit will be in the chat box after you answer the agent.",
"editFailed": "This message can't be edited.",
"editGone": "This message was already sent or removed. Your edit is in the chat box.",
"editGonePrompt": "This message was already sent or removed. Your edit will be in the chat box after you answer the agent."
},
"thought": "Thought",
"thoughtForDuration": "Thought for {{duration}}",
+9 -2
View File
@@ -18250,7 +18250,6 @@
},
"queuedMessages": {
"alreadySent": "Este mensaje ya se envió.",
"editAlreadySent": "Ya se envió — tu texto sigue en el cuadro de mensaje.",
"listLabel": "Mensajes en cola",
"behindReturnedHold": "En espera: un mensaje anterior necesita atención",
"awaitingAnswerHold": "Esperando tu respuesta",
@@ -18277,7 +18276,15 @@
"sendConfirmClear": "Vaciar cola",
"sendConfirmSend": "Enviar mensaje",
"fromSender": "De",
"pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar."
"pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar.",
"save": "Guardar",
"editingMessage": "Editando mensaje",
"cancelEditing": "Cancelar edición",
"editChanged": "Este mensaje cambió mientras lo editabas. Tu edición está en el cuadro de chat.",
"editChangedPrompt": "Este mensaje cambió mientras lo editabas. Tu edición estará en el cuadro de chat cuando respondas al agente.",
"editFailed": "Este mensaje no se puede editar.",
"editGone": "Este mensaje ya se envió o se eliminó. Tu edición está en el cuadro de chat.",
"editGonePrompt": "Este mensaje ya se envió o se eliminó. Tu edición estará en el cuadro de chat cuando respondas al agente."
},
"thought": "Pensó",
"thoughtForDuration": "Pensó durante {{duration}}",
+9 -2
View File
@@ -18489,7 +18489,6 @@
},
"queuedMessages": {
"alreadySent": "Ce message a déjà été envoyé.",
"editAlreadySent": "Déjà envoyé — votre texte reste dans la zone de saisie.",
"listLabel": "Messages en file d'attente",
"behindReturnedHold": "En attente — un message précédent nécessite votre attention",
"awaitingAnswerHold": "En attente de votre réponse",
@@ -18516,7 +18515,15 @@
"sendConfirmClear": "Vider la file d'attente",
"sendConfirmSend": "Envoyer le message",
"fromSender": "De",
"pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer."
"pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer.",
"save": "Enregistrer",
"editingMessage": "Modification du message",
"cancelEditing": "Annuler la modification",
"editChanged": "Ce message a changé pendant que vous le modifiiez. Votre modification se trouve dans le champ de discussion.",
"editChangedPrompt": "Ce message a changé pendant que vous le modifiiez. Votre modification sera dans le champ de discussion une fois que vous aurez répondu à l'agent.",
"editFailed": "Ce message ne peut pas être modifié.",
"editGone": "Ce message a déjà été envoyé ou supprimé. Votre modification se trouve dans le champ de discussion.",
"editGonePrompt": "Ce message a déjà été envoyé ou supprimé. Votre modification sera dans le champ de discussion une fois que vous aurez répondu à l'agent."
},
"thought": "A réfléchi",
"thoughtForDuration": "A réfléchi pendant {{duration}}",
+9 -2
View File
@@ -18422,7 +18422,6 @@
},
"queuedMessages": {
"alreadySent": "このメッセージはすでに送信されています。",
"editAlreadySent": "すでに送信されています — テキストは入力欄に残っています。",
"listLabel": "キュー内のメッセージ",
"behindReturnedHold": "待機中 — 前のメッセージの対応が必要です",
"awaitingAnswerHold": "回答を待っています",
@@ -18449,7 +18448,15 @@
"sendConfirmClear": "キューを消去",
"sendConfirmSend": "メッセージを送信",
"fromSender": "送信元:",
"pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。"
"pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。",
"save": "保存",
"editingMessage": "メッセージを編集中",
"cancelEditing": "編集をキャンセル",
"editChanged": "編集中にこのメッセージが変更されました。編集したテキストはチャット入力欄にあります。",
"editChangedPrompt": "編集中にこのメッセージが変更されました。Agent に回答すると、編集したテキストがチャット入力欄に表示されます。",
"editFailed": "このメッセージは編集できません。",
"editGone": "このメッセージはすでに送信または削除されています。編集したテキストはチャット入力欄にあります。",
"editGonePrompt": "このメッセージはすでに送信または削除されています。Agent に回答すると、編集したテキストがチャット入力欄に表示されます。"
},
"thought": "考えました",
"thoughtForDuration": "{{duration}} 考えました",
+9 -2
View File
@@ -18422,7 +18422,6 @@
},
"queuedMessages": {
"alreadySent": "이 메시지는 이미 전송되었습니다.",
"editAlreadySent": "이미 전송되었습니다 — 텍스트는 입력창에 남아 있습니다.",
"listLabel": "대기 중인 메시지",
"behindReturnedHold": "대기 중 — 앞의 메시지에 확인이 필요합니다",
"awaitingAnswerHold": "답변을 기다리는 중",
@@ -18449,7 +18448,15 @@
"sendConfirmClear": "대기열 지우기",
"sendConfirmSend": "메시지 보내기",
"fromSender": "보낸 사람:",
"pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요."
"pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요.",
"save": "저장",
"editingMessage": "메시지 편집 중",
"cancelEditing": "편집 취소",
"editChanged": "편집하는 동안 이 메시지가 변경되었습니다. 편집한 내용은 채팅 입력란에 있습니다.",
"editChangedPrompt": "편집하는 동안 이 메시지가 변경되었습니다. 에이전트에 답하면 편집한 내용이 채팅 입력란에 표시됩니다.",
"editFailed": "이 메시지는 편집할 수 없습니다.",
"editGone": "이 메시지는 이미 전송되었거나 삭제되었습니다. 편집한 내용은 채팅 입력란에 있습니다.",
"editGonePrompt": "이 메시지는 이미 전송되었거나 삭제되었습니다. 에이전트에 답하면 편집한 내용이 채팅 입력란에 표시됩니다."
},
"thought": "생각함",
"thoughtForDuration": "{{duration}} 동안 생각함",
+9 -2
View File
@@ -18387,7 +18387,6 @@
},
"queuedMessages": {
"alreadySent": "此消息已发送。",
"editAlreadySent": "已发送 — 你的文本仍在输入框中。",
"listLabel": "排队的消息",
"behindReturnedHold": "等待中 — 前面有一条消息需要处理",
"awaitingAnswerHold": "等待你的回答",
@@ -18414,7 +18413,15 @@
"sendConfirmClear": "清空队列",
"sendConfirmSend": "发送消息",
"fromSender": "来自",
"pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。"
"pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。",
"save": "保存",
"editingMessage": "正在编辑消息",
"cancelEditing": "取消编辑",
"editChanged": "你编辑时此消息已更改。你编辑的内容已放入聊天输入框。",
"editChangedPrompt": "你编辑时此消息已更改。回答智能体后,你编辑的内容会出现在聊天输入框中。",
"editFailed": "此消息无法编辑。",
"editGone": "此消息已发送或移除。你编辑的内容已放入聊天输入框。",
"editGonePrompt": "此消息已发送或移除。回答智能体后,你编辑的内容会出现在聊天输入框中。"
},
"thought": "已思考",
"thoughtForDuration": "思考了 {{duration}}",
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import {
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
AGENT_SESSION_REWIND_RECOVERY_CAPABILITY,
type RuntimeCapability
@@ -127,3 +128,14 @@ export function useStructuredAgentSessionHostQueuesCommands(target: RuntimeClien
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY
)
}
/** Whether the host edits a queued card in place and holds it from delivery while edited. Until
* it says so, and against an older host, a card offers no Edit. */
export function useStructuredAgentSessionHostEditsQueuedMessages(
target: RuntimeClientTarget
): boolean {
return useStructuredAgentSessionHostCapability(
target,
AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY
)
}
@@ -59,3 +59,23 @@ export type AgentSessionQueuedMessageDeleteResult =
/** `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' }
/** `agentSession.queuedMessageUpdate` (`agent-session.queued-message-edit.v1`). `unchanged`: the
* card already reads as asked, which is also how a resent Save whose answer was lost succeeds.
* `changed`: someone else's edit landed first; nothing was overwritten. */
export type AgentSessionQueuedMessageUpdateResult =
| { status: 'updated' | 'unchanged'; messageId: string; fingerprint: string }
| { status: 'changed' | 'not-editable'; messageId: string }
| { status: 'gone'; messageId: string; disposition: 'dispatched' | 'withdrawn' | 'missing' }
/** `agentSession.queuedMessageEditHold`: one editor's lease, keyed by the authenticated caller and
* `editId`. It expires on its own; renewing extends it, and release frees only this editor's. */
export type AgentSessionQueuedMessageEditHoldParams = {
sessionId: string
messageId: string
editId: string
} & ({ action: 'acquire'; expectedBodyFingerprint: string } | { action: 'renew' | 'release' })
export type AgentSessionQueuedMessageEditHoldResult =
| { status: 'held'; fingerprint: string; leaseDurationMs: number; remainingMs: number }
| { status: 'released' | 'expired' | 'changed' | 'gone' | 'not-editable' }
+7
View File
@@ -253,6 +253,12 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY =
// gates the rollout.
export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY =
'agent-session.queued-messages.v1' as const
// Why: a host advertising this edits a queued card in place (`agentSession.queuedMessageUpdate`)
// and holds it from automatic delivery while edited (`agentSession.queuedMessageEditHold`). Apart
// from queued-messages.v1: kept and agent-mail cards are editable with mid-turn queueing still off.
// A client offers Edit only when the host advertises it; there is no copy-and-delete fallback.
export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY =
'agent-session.queued-message-edit.v1' as const
// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects
// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued
// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too:
@@ -392,6 +398,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY,
AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY,
...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES,
...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES,
+44
View File
@@ -0,0 +1,44 @@
import type { AgentJournalMessageItem } from './agent-session-journal-types'
import { MAX_PROMPT_BYTES } from './rpc-contract/structured-agent-session-params'
/** The text an in-place edit starts from, or null when the card is not editable: a command is
* not a draft, and a block this edit cannot carry over unchanged refuses rather than drops. */
export function queuedMessageEditableText(body: AgentJournalMessageItem): string | null {
if (body.command || body.blocks.some((b) => b.type !== 'text' && b.type !== 'image-ref')) {
return null
}
return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n')
}
/** The body with its text replaced by one block at the first text block's place; attachments,
* sender, kind and send mode stay. Null when the result would not be a sendable draft. */
export function queuedMessageWithEditedText(
body: AgentJournalMessageItem,
text: string
): AgentJournalMessageItem | null {
if (
queuedMessageEditableText(body) === null ||
new TextEncoder().encode(text).length > MAX_PROMPT_BYTES ||
(!text.trim() && !body.blocks.some((block) => block.type === 'image-ref'))
) {
return null
}
// Unchanged text keeps its blocks, so a Save that changes nothing is a no-op.
if (text === queuedMessageEditableText(body)) {
return body
}
const blocks: AgentJournalMessageItem['blocks'] = []
let placed = false
for (const block of body.blocks) {
if (block.type !== 'text') {
blocks.push(block)
} else if (!placed) {
blocks.push({ type: 'text', text })
placed = true
}
}
if (!placed) {
blocks.unshift({ type: 'text', text })
}
return { ...body, blocks }
}
+6
View File
@@ -518,6 +518,10 @@ import {
ThreadGoalParams,
UnsubscribeParams
} from './structured-agent-session-params'
import {
QueuedMessageEditHoldParams,
QueuedMessageUpdateParams
} from './structured-agent-session-queued-edit-params'
import { TerminalAdoptOrphans } from './terminal-orphan-params'
import { TerminalQuickCommandsUpdate } from './terminal-quick-command-params'
import {
@@ -629,7 +633,9 @@ export const RPC_PARAMS_BY_METHOD = {
'agentSession.modelCatalog': ModelCatalogParams,
'agentSession.options': OptionsParams,
'agentSession.queuedMessageDelete': QueuedMessageActionParams,
'agentSession.queuedMessageEditHold': QueuedMessageEditHoldParams,
'agentSession.queuedMessageSend': QueuedMessageActionParams,
'agentSession.queuedMessageUpdate': QueuedMessageUpdateParams,
'agentSession.queuedMessagesResume': QueuedMessagesResumeParams,
'agentSession.readVisual': ReadVisualParams,
'agentSession.release': HoldParams,
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { Identifier, SessionId } from './structured-agent-session-identifiers'
import { MutationEnvelope, MAX_PROMPT_BYTES } from './structured-agent-session-params'
export const QueuedMessageUpdateParams = z
.object({
envelope: MutationEnvelope,
messageId: Identifier('Invalid queued message id'),
expectedBodyFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
text: z.string().refine((text) => new TextEncoder().encode(text).length <= MAX_PROMPT_BYTES)
})
.strict()
const QueuedMessageEditIdentity = z.object({
sessionId: SessionId,
messageId: Identifier('Invalid queued message id'),
editId: Identifier('Invalid edit id')
})
export const QueuedMessageEditHoldParams = z.discriminatedUnion('action', [
QueuedMessageEditIdentity.extend({
action: z.literal('acquire'),
expectedBodyFingerprint: z.string().regex(/^[0-9a-f]{64}$/)
}).strict(),
QueuedMessageEditIdentity.extend({ action: z.literal('renew') }).strict(),
QueuedMessageEditIdentity.extend({ action: z.literal('release') }).strict()
])
@@ -59,6 +59,13 @@ export function structuredHostStub(
queuedMessageSend: vi.fn(async () => ({ ok: true, replayed: false })),
queuedMessageDelete: vi.fn(async () => ({ ok: true, replayed: false })),
queuedMessagesResume: vi.fn(async () => ({ ok: true, replayed: false })),
queuedMessageUpdate: vi.fn(async () => ({ ok: true, replayed: false })),
queuedMessageEditHold: vi.fn(async () => ({
status: 'held' as const,
fingerprint: 'a'.repeat(64),
leaseDurationMs: 120_000,
remainingMs: 120_000
})),
rewind: vi.fn(async () => ({
ok: true,
replayed: false,
@@ -59,6 +59,18 @@ export const STRUCTURED_CALLS: {
hostMethod: 'queuedMessagesResume',
result: { ok: true, replayed: false }
},
// Editing a queued card in place. Clients call these only on a host advertising
// `agent-session.queued-message-edit.v1`; an older host answers `method_not_found`.
{
method: 'agentSession.queuedMessageUpdate',
hostMethod: 'queuedMessageUpdate',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.queuedMessageEditHold',
hostMethod: 'queuedMessageEditHold',
result: { status: 'held' }
},
{
method: REWIND_METHOD,
hostMethod: 'rewind',
@@ -151,6 +151,18 @@ export function paramsFor(method: string): unknown {
}
case 'agentSession.queuedMessagesResume':
return { envelope: envelope({ method, fields: {}, fence }) }
case 'agentSession.queuedMessageUpdate': {
const fields = { messageId: 'queued-1', expectedBodyFingerprint: 'a'.repeat(64), text: 'hi' }
return { envelope: envelope({ method, fields, fence }), ...fields }
}
case 'agentSession.queuedMessageEditHold':
return {
sessionId: SESSION,
messageId: 'queued-1',
editId: 'edit-1',
action: 'acquire',
expectedBodyFingerprint: 'a'.repeat(64)
}
case 'agentSession.respondToApproval':
case 'agentSession.respondToQuestion': {
const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }