mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* 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
188 lines
7.8 KiB
TypeScript
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 }
|
|
}
|
|
}
|
|
})
|
|
})
|
|
}
|