Files
orca/src/main/native-chat/agent-session-wire/structured-conversation-command.ts
T
Brennan Benson 4735ecefac fix(native-chat): a /compact sent while the agent works waits in line instead of being refused (#25704)
* fix(native-chat): a /compact sent while the agent works waits in line instead of being refused

* test(native-chat): build the admission test's submissions without a type assertion

* fix(native-chat): a /clear refused while the agent works says so plainly; a /compact behind an unsent message waits for it quietly

* test(native-chat): name the admission test's cases for the sends they model

* fix(native-chat): a /compact refused while the agent works reads plainly too; a command card offers no Edit

* test(native-chat): a refused /compact reads the plain sentence

* fix(native-chat): a refused command card is spent and said once; a command card offers Send while the agent is idle; /compact behind an unsent message is not taken

* test(native-chat): keep the queued receipt's narrowing inside the wait

* fix(native-chat): spend a command card only when its own turn says why; refuse a queued command without a hand-off, with its reason; a queue send on its way reads as sending

* fix(native-chat): a recorded send is no sending card; queue lists land at once; a spent card's wait says to run it again; only /compact or /clear give way to returned text

* refactor(native-chat): derive the sending cards inside the queued-messages hook

* fix(native-chat): keep protocol-version and the agent-session params under the line limit; the send capabilities get their own module

* fix(native-chat): with the agent idle, a command behind this window's own unsent message says it is still being sent

* fix(native-chat): the queued-commands capability stays a plain constant beside the others; main's send capabilities back to its text

* test(native-chat): keep the rewind suite under its line limit after main's growth

* fix(native-chat): one import per module in the command send; a rewind on its way holds commands, tested

* test(native-chat): type the rewind module mock with a type import

* test(native-chat): a command card cut short by a restart waits or is kept like any card; kept-card captions name only controls shown

* fix(native-chat): a held command card, which the queue skips, no longer forces later sends to queue

* fix(native-chat): a command's refusal line goes once what it waits on is gone

A refusal that names something the chat shows (the agent working, a pending prompt, background
tasks, an unsent message) is said only while that still holds, derived each render, and dropped
once it ends. Other composer errors are unchanged.

* fix(mobile): a command's refusal banner goes once what it waits on is gone

The phone's banner keeps a refusal naming the agent working or a pending prompt only while the
chat still shows it, dropped each render once it ends. Background tasks, which the phone can't
see, and generic errors behave as before. The host-reason mapping is shared with desktop.

* fix(native-chat): a refusal behind an earlier message names whether it is being sent or offers Retry

Each line now goes when its own state ends: a send that fails stops being sent, and a resend
stops offering Retry.

* test(native-chat): a Stop behind a typed /compact keeps the taken-back message on screen; the in-doubt case says so

* fix(native-chat): a command refused while the queue is about to send reads working, as the pane does

* Update held command fixture for the merged queue model

* Restart one host at a time in the recovery ordering test

* fix: preserve person queue reserve for chat commands
2026-10-08 13:32:20 -07:00

188 lines
7.8 KiB
TypeScript

import { randomBytes } from 'node:crypto'
import type {
AgentSessionConversationCommand,
AgentSessionConversationCommandResult
} from '../../../shared/agent-session-conversation-command'
import type {
AgentSessionMutationEnvelope,
AgentSessionMutationResult
} from '../../../shared/agent-session-wire'
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import { sendPreparation } from './structured-agent-session-send-preparation'
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
import {
committedClearOfCaller,
conversationCommandBlocked
} from './structured-conversation-command-admission'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure'
import {
agentSessionFailureWords,
type AgentSessionFailureWordsContext
} from '../../../shared/agent-session-failure-words'
import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
/** A command's `error` is the sentence its row shows. */
export function conversationCommandFailure(
failure: AgentSessionFailureFact | undefined,
context: AgentSessionFailureWordsContext = {}
) {
if (!failure) {
return {}
}
const words = agentSessionFailureWords(failure, { ...context, surface: 'row' })
return { error: words.text, failure: words.failure }
}
export type ConversationCommandParams = {
envelope: AgentSessionMutationEnvelope
command: AgentSessionConversationCommand
/** /compact only: wait as a card while the agent works, as a queued send does. */
delivery?: 'queue-if-active'
/** Host-local, set by the client-facing command RPC as for an ordinary send. */
userSend?: true
}
export type ConversationReplacement = {
sourceSessionId: string
sessionId: string
workspaceId: string
agent: StructuredAgentId
}
const clearFingerprintOf = (sessionId: string) =>
computeAgentSessionPayloadFingerprint({
method: 'agentSession.conversationCommand',
sessionId,
fields: { command: 'clear' }
})
/** This caller's committed /clear, for a /clear it presses again on the conversation that one
* cleared. Answered before admission, which would refuse it as cleared. */
async function answerFromCommittedClear(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
{ envelope, command }: ConversationCommandParams
): Promise<AgentSessionMutationResult<AgentSessionConversationCommandResult> | null> {
const { store } = context.deps
const record = store.getRecord(envelope.sessionId)
const committed =
command === 'clear' && envelope.payloadFingerprint === clearFingerprintOf(envelope.sessionId)
? committedClearOfCaller(record, caller.callerKey, store.getSessionTabId(envelope.sessionId))
: null
const session =
committed && (await context.openConversation(envelope.sessionId).catch(() => null))
return record && committed && session
? {
ok: true,
replayed: true,
fence: record.lease.runtimeFence,
cursor: session.journal.cursor(),
value: committed
}
: null
}
/**
* `/clear`: one write that points this conversation at a new, at-rest one and moves its tab there.
* The new conversation's first send starts its agent.
*/
export function runStructuredConversationCommand(
context: StructuredAgentSessionMutationContext,
caller: StructuredAgentSessionCaller,
params: ConversationCommandParams
): Promise<AgentSessionMutationResult<AgentSessionConversationCommandResult>> {
const { envelope, command } = params
const { sessionId, clientOperationId } = envelope
const store = context.deps.store
const matching = () => {
const record = store.getRecord(sessionId)?.conversationCommand
return record?.operationId === clientOperationId && record.callerKey === caller.callerKey
? record
: null
}
return context.serialize(sessionId, async () => {
const committed = await answerFromCommittedClear(context, caller, params)
if (committed) {
return committed
}
return admitAndRunAgentSessionMutation({
store,
adapter: context.deps.adapter,
agents: context.deps.agents,
logger: context.deps.logger,
callerKey: caller.callerKey,
envelope,
// Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing.
prepareSession: sendPreparation(context, envelope, { refusesInRun: true }),
journal: () => context.sessions.get(sessionId)?.journal,
publish: (journal) => context.publish(sessionId, journal),
now: context.now,
plan: {
method: 'agentSession.conversationCommand',
fields: { command },
// Written to the conversation, not the agent, so whoever owns the agent does not matter.
conversationWrite: true,
recoverUnknownFromDurableState: true,
settledOutcome: (value) => ({ status: 'succeeded', sessionId, conversationCommand: value }),
replay: (_ctx, outcome) => {
if (outcome.status === 'succeeded' && outcome.conversationCommand) {
return outcome.conversationCommand
}
const prior = matching()
return prior?.phase === 'committed' ? prior : null
},
// The commit is the only write, so a clear with no committed answer changed nothing.
rerunWhenReplayMissing: () => true,
run: async (ctx) => {
const record = store.getRecord(sessionId)!
const blocked = conversationCommandBlocked(
ctx,
record,
context.readChildWork(sessionId),
context.sessions.get(sessionId)?.child ? undefined : 'at-rest'
)
if (blocked) {
return { ok: false, refusal: blocked }
}
// Stopped before the marker, so nothing the old agent does can land after the clear. The
// stop releases the lease, which moves its fence: the marker is written at the new one.
// A /clear replaces this chat: the user closing it.
await context.stopAgent(sessionId, { cause: 'user-close' })
const fence = store.getRecord(sessionId)!.lease.runtimeFence
const completed = {
command,
runtimeFence: fence,
operationId: clientOperationId,
callerKey: caller.callerKey,
// Only has to be new: the marker is what points at it, and a same-id resend replays it.
replacementSessionId: `clear-${randomBytes(20).toString('hex')}`,
phase: 'committed' as const,
state: 'completed' as const
}
await store.commitConversationClear({
sessionId,
fence,
command: completed,
claimKeyId: context.deps.claimKeyId,
now: context.now()
})
// Carry the source's drafts to the replacement, the same for every client version:
// the cards stay visible where the user now is, and no text rides the wire.
// Bookkeeping — a failure is reported and never fails the clear.
await carryQueuedMessagesToClearReplacement(ctx, {
replacementSessionId: completed.replacementSessionId,
// Opened under its own lock, as every open is.
openReplacementJournal: async () =>
(await context.conversation(completed.replacementSessionId)).journal,
callerKey: caller.callerKey,
operationId: clientOperationId
})
return { ok: true, value: completed }
}
}
})
})
}