mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
+10
-7
@@ -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(),
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+5
-3
@@ -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>
|
||||
)
|
||||
}
|
||||
+191
@@ -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()
|
||||
}
|
||||
|
||||
+503
@@ -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)
|
||||
}
|
||||
+10
-1
@@ -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 }
|
||||
|
||||
+12
-2
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+14
-75
@@ -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 () => {
|
||||
|
||||
+24
-40
@@ -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,
|
||||
|
||||
+2
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} 考えました",
|
||||
|
||||
@@ -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}} 동안 생각함",
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user