Files
orca/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts
T
Brennan Benson 59631b8b26 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.
2026-10-09 17:31:28 -07:00

207 lines
7.9 KiB
TypeScript

import { expect } from 'vitest'
export const SESSION = 'session-alpha'
export const WORKSPACE = 'workspace-1'
export const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
export const NOW = 1_800_000_000_000
export const ATTENTION_READ = {
sessionId: SESSION,
observedCursor: { epoch: 'attention-epoch', sequence: 7 }
} as const
export const REWIND_METHOD = 'agentSession.rewind'
export const CONVERSATION_OUTLINE_METHOD = 'agentSession.conversationOutline'
export const STATUS_FEED_METHOD = 'agentSession.subscribeStatus'
export const TURN_COMPLETION_FEED_METHOD = 'agentSession.subscribeTurnCompletions'
/** Every method the structured surface publishes: the host method it must reach,
* and the result it must hand back. A gate that hides one method and leaks
* another is the bug; so is a method that is registered and answers with an
* error, which is why `result` is declared per method rather than inferred from
* "did not say method_not_found". `result` is omitted only where the method
* legitimately answers with no reply at all. */
export const STRUCTURED_CALLS: {
method: string
hostMethod: string | null
result?: Record<string, unknown>
}[] = [
{ method: 'agentSession.createSupport', hostMethod: null, result: { supported: true } },
{
method: 'agentSession.create',
hostMethod: 'attach',
result: { ok: true, replayed: false, value: { sessionId: SESSION } }
},
{
method: 'agentSession.ensure',
hostMethod: 'attach',
result: { ok: true, replayed: false, value: { sessionId: SESSION } }
},
{
method: 'agentSession.conversationCommand',
hostMethod: 'conversationCommand',
result: { ok: true, value: { command: 'compact', state: 'completed' } }
},
{ method: 'agentSession.send', hostMethod: 'send', result: { ok: true, replayed: false } },
{ method: 'agentSession.cancel', hostMethod: 'cancel', result: { ok: true, replayed: false } },
// Draft mutations for mid-turn queueing. Methods exist ahead of the
// capability's advertisement; only capability-gated clients ever call them.
{
method: 'agentSession.queuedMessageSend',
hostMethod: 'queuedMessageSend',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.queuedMessageDelete',
hostMethod: 'queuedMessageDelete',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.queuedMessagesResume',
hostMethod: 'queuedMessagesResume',
result: { ok: true, replayed: false }
},
// 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',
result: { ok: true, replayed: false, value: { itemId: 'item-1', epoch: 'rewound-epoch' } }
},
{ method: 'agentSession.close', hostMethod: 'close', result: { ok: true } },
{
method: 'agentSession.respondToApproval',
hostMethod: 'respondToPrompt',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.respondToQuestion',
hostMethod: 'respondToPrompt',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.setOption',
hostMethod: 'setOption',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.threadGoal',
hostMethod: 'changeThreadGoal',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.handoffStatus',
hostMethod: 'handoffStatus',
result: { owner: 'native' }
},
{
method: 'agentSession.options',
hostMethod: 'readOptions',
result: { current: { model: 'gpt-live' } }
},
{
method: 'agentSession.modelCatalog',
hostMethod: 'modelCatalog',
result: { origin: 'unknown' }
},
{
method: 'agentSession.commands',
hostMethod: 'readCommands',
result: { commands: [{ name: 'clear', kind: 'command' }] }
},
{
method: 'agentSession.reveal',
hostMethod: 'revealSession',
result: { ok: true, sessionId: SESSION, workspaceId: WORKSPACE, agent: 'codex', readable: true }
},
// A chat's visual, read from the host's own record and state directory. A bare addition: an older
// host answers `method_not_found` and the client shows the visual as unavailable. The stub host
// holds no record, so the typed refusal is the declared answer.
{
method: 'agentSession.readVisual',
hostMethod: null,
result: { ok: false, error: 'session_not_found' }
},
// A no-op on a host that starts an agent only for work; it still builds the host.
{ method: 'agentSession.hold', hostMethod: null, result: { held: true } },
// The restart-resume surface. Bare additions, not capability-negotiated: an RPC method's
// absence is explicit (`method_not_found`), which the old-dispatcher case below asserts, so a
// newer client learns it during negotiation instead of by being met with silence.
{
method: 'agentSession.restartResumable',
hostMethod: 'restartResumableList',
result: { sessions: [] }
},
{
method: 'agentSession.restartResumableDismiss',
hostMethod: 'restartResumableDismiss',
result: { dismissed: 0 }
},
// Reattaching alone is nothing now, so this answers that nothing was resumed.
{ method: 'agentSession.restartResume', hostMethod: null, result: { results: [] } },
{
method: 'agentSession.restartContinue',
hostMethod: 'restartContinueAll',
result: { resumed: [], continued: [] }
},
// Continue on a reply an Orca stop cut off. Clients call it only on a host advertising it.
{
method: 'agentSession.continueInterrupted',
hostMethod: 'continueInterrupted',
result: { sessionId: SESSION, outcome: 'superseded' }
},
{ method: 'agentSession.release', hostMethod: null, result: { released: true } },
{
method: 'agentSession.history',
hostMethod: 'history',
result: { ok: true, page: { items: [] } }
},
{
method: CONVERSATION_OUTLINE_METHOD,
hostMethod: 'journalSnapshot',
result: { sessionId: SESSION, entries: [], omittedEntries: 0 }
},
// A subscription that opens with nothing to say answers with no reply at all,
// so reaching the host is the only signal that the gate opened.
{ method: 'agentSession.subscribe', hostMethod: 'subscribe' },
// The status feed opens with a snapshot of every session, so its first reply is the contract.
{
method: STATUS_FEED_METHOD,
hostMethod: 'subscribeStatus',
result: { type: 'snapshot', sessions: [] }
},
// Opens with nothing for the same reason `agentSession.subscribe` does, and unlike the status
// feed above: a completion is an edge that has already passed, not state a late subscriber
// needs. Reaching the host is the only signal that the gate opened.
{
method: TURN_COMPLETION_FEED_METHOD,
hostMethod: 'subscribeTurnCompletions'
},
// Reading a chat retires the phone alerts its host pushed, through the runtime's own store, so
// its reply is the only signal that the gate opened.
{
method: 'agentSession.acknowledgeAttention',
hostMethod: null,
result: { acknowledged: true }
},
// Teardown runs through the runtime's subscription registry rather than the
// host, so its reply is the only signal that the gate opened.
{ method: 'agentSession.unsubscribe', hostMethod: null, result: { unsubscribed: true } },
// The host's registered agents, each with its declared capability record. Builds before
// #25845 read them from the installed host; later ones from the registrations that host is
// built from, so they answer without installing it. Either way Codex is listed.
{
method: 'agentSession.agents',
hostMethod: null,
result: { agents: expect.arrayContaining([expect.objectContaining({ agent: 'codex' })]) }
}
]