mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* 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.
207 lines
7.9 KiB
TypeScript
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' })]) }
|
|
}
|
|
]
|