From c2e6ebbf0b67e93241d58db6290d2292e95f5fdc Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:37:31 +0000 Subject: [PATCH] feat(cli): read, message and wait on agent chats with orca chat list/read/send/wait (#40836) * feat(cli): read and message agent chats with orca chat list/read/send Carried over from #33115 (squashed onto current main; conflicts with chat rename resolved). * feat(cli): wait for a chat to settle, read text from stdin, refuse paired hosts chat send --wait and chat wait poll the history's working flag until the chat is idle or waits on a question, keeping a queued message pending until it runs. chat read is one history fetch and returns a cursor. Unattached and paired-host refusals say what they mean. * fix(cli): settle a held queued message, skip subagent rows, resolve chat list --worktree * fix(cli): show chat errors, keep waiting through queue handoffs, return the whole settled turn * fix(cli): settle any queued message the host will not send, show grouped questions and only answerable prompts * test(cli): drive chat tests through a real client and fake timers * fix(cli): describe chat --session, --wait and --state in command help * fix(cli): report chat wait in chat list's states, reject unknown --state, flag truncated reads * docs(cli): say chat wait settles on the chat's own turn; no truncated flag on held * fix(cli): chat rename help asks for a session id, not a handle --------- Co-authored-by: YangNuoCheng <42427439+YangNuoCheng@users.noreply.github.com> --- docs/site/content/docs/cli/reference.mdx | 15 ++ skill-guides/orca-cli.md | 30 +++- src/cli/bundled-skill-guides.ts | 30 +++- src/cli/chat-history.ts | 168 ++++++++++++++++++++ src/cli/chat-sessions.ts | 175 ++++++++++++++++++++ src/cli/command-scoped-flag-help.ts | 15 ++ src/cli/flag-help-text.ts | 1 + src/cli/handler-group-manifest.ts | 2 +- src/cli/handlers/chat.test.ts | 176 ++++++++++++++++++++ src/cli/handlers/chat.ts | 194 +++++++++++++++++++---- src/cli/root-help-text-primary.ts | 4 + src/cli/specs/chat.ts | 52 ++++++ 12 files changed, 829 insertions(+), 33 deletions(-) create mode 100644 src/cli/chat-history.ts create mode 100644 src/cli/chat-sessions.ts diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index 12cfa96f2b0..fb1011130dc 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -233,6 +233,21 @@ orca chat rename --name "Migrate auth" Renames a chat tab, the same as renaming it by hand. `--session` takes the `sessionId` that `orca agent launch` prints. Run inside a chat, it defaults to that chat; with `--environment` it is required. +Agents that run as chats are read and messaged with `chat` commands. `--session` takes the session id from `chat list` or the handle `agent launch` returns. + +```bash +orca chat list --json +orca chat list --state working --json +orca chat read --session --limit 10 --json +orca chat send --session --text "Run the tests, then summarize failures" --json +orca chat send --session --text "" --wait --timeout-ms 600000 --json +orca chat wait --session --timeout-ms 600000 --json +``` + +`chat read` returns the newest messages, tool calls, errors, approvals and questions; an approval or question waiting for an answer has `pending: true`. `chat send` sends the text as your message. While the agent is working, hosts with a message queue hold it until the turn ends and report `delivery: "queued"`. A chat that restarted since it was read refuses the send. `--text-file -` reads the message from stdin. + +`chat send --wait` and `chat wait` return once the chat's own turn settles, in the words `chat list` uses: `settled: "done"`, or `settled: "blocked"` when an approval or question needs an answer. Background agents the chat started can still show as `working` or `waiting` in `chat list`. They print the entries since the last user message. A wait past `--timeout-ms` returns `settled: "timeout"`, and a message held in a stopped or failed queue returns `settled: "held"`; both exit 1. `chat read --json` returns a `cursor` for the newest row read, and `truncated: true` when older rows it needed were past the 200-row read. `chat list --state` takes `working`, `blocked`, `waiting` or `done`. Paired hosts (`--environment`, `--pairing-code`) can list chats but not read, send or wait yet. + ## Files ```bash diff --git a/skill-guides/orca-cli.md b/skill-guides/orca-cli.md index fcedd851a73..cb89e0c74e5 100644 --- a/skill-guides/orca-cli.md +++ b/skill-guides/orca-cli.md @@ -28,9 +28,9 @@ Prefer `--json` for agent-driven calls. If the CLI is missing, say so explicitly A full handoff transfers ownership to another agent or worktree, then the original agent stops. Treat requests phrased as "hand off", "handoff", "handover", "give this to another agent", "give this to another worktree", "another agent", or "another worktree" as full handoffs unless the user expects the result back ("have another agent review this and report") or explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use decision gates, or manage ask/reply. -A handoff is done when the new worktree id and agent handle have been reported and the prompt was delivered: the launch's `prompt.outcome` is `journaled` or `handed-to-terminal`, or, for an agent that was already running, the send receipt reported `accepted: true`. Do not wait for the receiving agent to finish. +A handoff is done when the new worktree id and agent handle have been reported and the prompt was delivered: the launch's `prompt.outcome` is `journaled` or `handed-to-terminal`, or, for an agent that was already running, the `terminal send` receipt reported `accepted: true` or `chat send` reported `delivery` `submitted` or `queued`. Do not wait for the receiving agent to finish. -Do not use `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Deliver the prompt with the agent launch, or with terminal commands for an agent that is already running. +Do not use `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Deliver the prompt with the agent launch, or with `terminal send` or `chat send` for an agent that is already running. For local work in the caller’s project, omit `--repo` and `--project`. Pass one to target another project or when Orca asks you to disambiguate; do not pick a repo from a list for unambiguous same-project work. The project comes from the caller’s directory, not the project visible in the app. Paired-server calls require an explicit server-side target. @@ -62,6 +62,14 @@ Existing-terminal handoff: ORCA terminal send --terminal --text "" --enter --json ``` +Existing-chat handoff, for an agent that runs as a chat (`outcome.kind` `structured`): + +```text +ORCA chat send --session --text "" --json +ORCA chat send --session --text "" --wait --timeout-ms 600000 --json +ORCA chat wait --session --timeout-ms 600000 --json +``` + ## Starting agents `worktree create --agent` (new checkout) and `agent launch` (existing worktree) are how to start an agent. They go through Orca's agent launch, the same one the app's new-agent tab uses: the user's Chat | CLI choice for that agent decides whether it opens as a chat or a terminal, and the result reports which (`outcome.kind`) and why (`receipt.detail`). When a chat is not possible, such as for an agent without chat support, the launch starts a terminal and the receipt says so. `--surface chat|terminal` picks one for that launch instead of the setting; pass it only when the user asked for a chat or a terminal. `--surface chat` is refused when Orca can establish before starting anything that the agent cannot open as a chat. If support cannot be checked, the launch can fall back to a terminal. If chat becomes unavailable only after a new workspace is created, Orca keeps that workspace, its setup and default tabs, then opens a terminal; the receipt explains the fallback. `terminal create` makes a terminal for shells and commands. @@ -273,6 +281,24 @@ Terminal rules: - For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`. - `--direction vertical` splits left/right. `--direction horizontal` splits top/bottom. +## Chats + +Agents launched as chats are read and messaged with `chat`, not `terminal`. `chat list` names each chat's workspace, agent, state and last reply; `--session` takes the `session` it reports or the `handle` a launch returned. + +```text +ORCA chat list --json +ORCA chat list --state working --json +ORCA chat read --session --limit 10 --json +ORCA chat send --session --text "" --json +``` + +- `chat read` returns messages, tool calls, errors, and approvals or questions; `pending: true` marks one waiting for the user. +- `chat send` sends the text as the user's message. While the agent works, a host with a message queue holds it until the turn ends and reports `delivery: "queued"`. +- A refused send was not delivered. Read the chat again before resending. +- `chat send --wait` (or `chat wait`) returns when the chat settles, using `chat list` states: `settled: "done"`, or `"blocked"` on an approval or question, with the entries since the last user message; `settled: "timeout"` or `"held"` (queued message stopped or failed) exits 1. Pass `--timeout-ms`. Long or quoted text goes through `--text-file -` on stdin. +- Paired hosts can `chat list` only; read, send and wait run on the host machine. +- When relaying for a user from outside Orca (for example a voice or messaging assistant on the same machine), report from `chat list` and `chat read`, repeat an instruction back and get the user's confirmation before `chat send`, and send the user's words without adding instructions. + ## Artifacts diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index f9f46a79606..79d85aeda9d 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -256,9 +256,9 @@ const ORCA_CLI_MARKDOWN = [ "", "A full handoff transfers ownership to another agent or worktree, then the original agent stops. Treat requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs unless the user expects the result back (\"have another agent review this and report\") or explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use decision gates, or manage ask/reply.", "", - "A handoff is done when the new worktree id and agent handle have been reported and the prompt was delivered: the launch's `prompt.outcome` is `journaled` or `handed-to-terminal`, or, for an agent that was already running, the send receipt reported `accepted: true`. Do not wait for the receiving agent to finish.", + "A handoff is done when the new worktree id and agent handle have been reported and the prompt was delivered: the launch's `prompt.outcome` is `journaled` or `handed-to-terminal`, or, for an agent that was already running, the `terminal send` receipt reported `accepted: true` or `chat send` reported `delivery` `submitted` or `queued`. Do not wait for the receiving agent to finish.", "", - "Do not use `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Deliver the prompt with the agent launch, or with terminal commands for an agent that is already running.", + "Do not use `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Deliver the prompt with the agent launch, or with `terminal send` or `chat send` for an agent that is already running.", "", "For local work in the caller’s project, omit `--repo` and `--project`. Pass one to target another project or when Orca asks you to disambiguate; do not pick a repo from a list for unambiguous same-project work. The project comes from the caller’s directory, not the project visible in the app. Paired-server calls require an explicit server-side target.", "", @@ -290,6 +290,14 @@ const ORCA_CLI_MARKDOWN = [ "ORCA terminal send --terminal --text \"\" --enter --json", "```", "", + "Existing-chat handoff, for an agent that runs as a chat (`outcome.kind` `structured`):", + "", + "```text", + "ORCA chat send --session --text \"\" --json", + "ORCA chat send --session --text \"\" --wait --timeout-ms 600000 --json", + "ORCA chat wait --session --timeout-ms 600000 --json", + "```", + "", "## Starting agents", "", "`worktree create --agent` (new checkout) and `agent launch` (existing worktree) are how to start an agent. They go through Orca's agent launch, the same one the app's new-agent tab uses: the user's Chat | CLI choice for that agent decides whether it opens as a chat or a terminal, and the result reports which (`outcome.kind`) and why (`receipt.detail`). When a chat is not possible, such as for an agent without chat support, the launch starts a terminal and the receipt says so. `--surface chat|terminal` picks one for that launch instead of the setting; pass it only when the user asked for a chat or a terminal. `--surface chat` is refused when Orca can establish before starting anything that the agent cannot open as a chat. If support cannot be checked, the launch can fall back to a terminal. If chat becomes unavailable only after a new workspace is created, Orca keeps that workspace, its setup and default tabs, then opens a terminal; the receipt explains the fallback. `terminal create` makes a terminal for shells and commands.", @@ -501,6 +509,24 @@ const ORCA_CLI_MARKDOWN = [ "- For long output, use cursor reads. After a limited tail preview, page from `oldestCursor`; after a cursor read, continue with `nextCursor` while `limited` is true and `nextCursor !== latestCursor`.", "- `--direction vertical` splits left/right. `--direction horizontal` splits top/bottom.", "", + "## Chats", + "", + "Agents launched as chats are read and messaged with `chat`, not `terminal`. `chat list` names each chat's workspace, agent, state and last reply; `--session` takes the `session` it reports or the `handle` a launch returned.", + "", + "```text", + "ORCA chat list --json", + "ORCA chat list --state working --json", + "ORCA chat read --session --limit 10 --json", + "ORCA chat send --session --text \"\" --json", + "```", + "", + "- `chat read` returns messages, tool calls, errors, and approvals or questions; `pending: true` marks one waiting for the user.", + "- `chat send` sends the text as the user's message. While the agent works, a host with a message queue holds it until the turn ends and reports `delivery: \"queued\"`.", + "- A refused send was not delivered. Read the chat again before resending.", + "- `chat send --wait` (or `chat wait`) returns when the chat settles, using `chat list` states: `settled: \"done\"`, or `\"blocked\"` on an approval or question, with the entries since the last user message; `settled: \"timeout\"` or `\"held\"` (queued message stopped or failed) exits 1. Pass `--timeout-ms`. Long or quoted text goes through `--text-file -` on stdin.", + "- Paired hosts can `chat list` only; read, send and wait run on the host machine.", + "- When relaying for a user from outside Orca (for example a voice or messaging assistant on the same machine), report from `chat list` and `chat read`, repeat an instruction back and get the user's confirmation before `chat send`, and send the user's words without adding instructions.", + "", "", "", "## Artifacts", diff --git a/src/cli/chat-history.ts b/src/cli/chat-history.ts new file mode 100644 index 00000000000..db4c70730cd --- /dev/null +++ b/src/cli/chat-history.ts @@ -0,0 +1,168 @@ +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult +} from '../shared/agent-session/agent-session-wire' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../shared/agent-session/agent-session-wire' +import { + AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + isUnattachedAgentSessionReadRefusal +} from '../shared/agent-session/failures/structured-agent-session-read-refusal' +import { structuredAgentSessionQueueSendsNext } from '../shared/agent-session/view/structured-agent-session-live-turn' +import { chatTranscript, formatChatRead, type ChatTranscriptEntry } from './chat-sessions' +import type { RuntimeClient, RuntimeRpcSuccess } from './runtime-client' +import { RuntimeClientError } from './runtime-client' + +export const DEFAULT_READ_LIMIT = 20 +const WAIT_POLL_MS = 1000 + +export type ChatTranscript = { + session: string + entries: ChatTranscriptEntry[] + /** Newest journal row read, so a later `--since` can continue from it. */ + cursor: AgentSessionHistoryPage['window']['newest'] + /** Older rows exist that this 200-row read did not reach, and they were needed. */ + truncated: boolean +} +/** The chat's own turn in `chat list` words (structuredAgentSessionLeadState), plus two wait-only outcomes. */ +export type ChatSettled = 'done' | 'blocked' | 'held' | 'timeout' + +export async function readHistory( + client: RuntimeClient, + session: string, + limit: number +): Promise> { + return client + .call('agentSession.history', { + sessionId: session, + direction: 'tail', + limit + }) + .catch((error: unknown) => { + throw explainChatRefusal(error, session, 'read') + }) +} + +export async function readTranscript( + client: RuntimeClient, + session: string, + limit?: number +): Promise> { + // Status frames and turn markers share the journal, so read the most rows a page allows. + const response = await readHistory(client, session, AGENT_SESSION_HISTORY_MAX_LIMIT) + const { page } = response.result + const all = chatTranscript(page.items, page.actionablePromptIds) + const entries = limit === undefined ? all : all.slice(-limit) + const truncated = page.hasOlder && (limit === undefined || all.length < limit) + return { ...response, result: { session, entries, cursor: page.window.newest, truncated } } +} + +export function requireWorkingFlag(page: AgentSessionHistoryPage): void { + if (page.working === undefined) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca host does not report when a chat is working, so it cannot wait. Update Orca on the host.' + ) + } +} + +// Settled means no running turn or unanswered send, or a question/approval waiting for the user. +export async function waitForChat( + client: RuntimeClient, + session: string, + timeoutMs: number | undefined, + queuedMessageId?: string +): Promise { + const deadline = timeoutMs === undefined ? Infinity : Date.now() + timeoutMs + for (;;) { + const { page } = (await readHistory(client, session, 1)).result + requireWorkingFlag(page) + if ((page.actionablePromptIds?.length ?? 0) > 0) { + return 'blocked' + } + // Between turns the host names its next automatic send while working is briefly false. + const sendsNext = structuredAgentSessionQueueSendsNext( + page.nextQueuedMessageId, + page.working === true + ) + if (!page.working && !sendsNext) { + // Still queued with nothing running or about to send: stopped, failed or held across a reopen. + const held = page.queuedMessages?.some((m) => m.messageId === queuedMessageId) === true + return held ? 'held' : 'done' + } + if (Date.now() >= deadline) { + return 'timeout' + } + await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)) + } +} + +// After a wait, the turn's entries: everything after the last user message. +export async function readSettledTurn( + client: RuntimeClient, + session: string, + settled: ChatSettled +): Promise> { + if (settled === 'timeout' || settled === 'held') { + // Chained commands such as `send --wait && ...` need a failing exit when the chat did not settle. + process.exitCode = 1 + } + const response = await readTranscript(client, session) + const { entries, truncated } = response.result + const lastUser = entries.findLastIndex((e) => e.kind === 'message' && e.role === 'user') + // A held message never ran, so the last turn on the page is not its reply. + const turn = settled === 'held' ? [] : entries.slice(lastUser + 1) + return { + ...response, + result: { + ...response.result, + entries: turn, + truncated: settled !== 'held' && truncated && lastUser === -1, + settled + } + } +} + +export function formatSettled(result: ChatTranscript & { settled: ChatSettled }): string { + const status = + result.settled === 'timeout' + ? 'Still working when the wait timed out.' + : result.settled === 'held' + ? 'Your message is held in the queue (the chat was stopped or the send failed). Resume it in Orca.' + : result.settled === 'blocked' + ? 'Blocked: waiting for you to answer an approval or question.' + : 'Done.' + return result.entries.length === 0 ? status : `${formatChatRead(result)}\n${status}` +} + +// The host answers with bare wire codes; say what they mean for this command. +export function explainChatRefusal( + error: unknown, + sessionId: string, + action: 'rename' | 'read' | 'send to' +): unknown { + if (!(error instanceof RuntimeClientError)) { + return error + } + if (error.message === 'agent_session_identity_required') { + return new RuntimeClientError( + 'agent_session_identity_required', + `No chat with session id ${sessionId} on this host.` + ) + } + if (isUnattachedAgentSessionReadRefusal(error)) { + return new RuntimeClientError( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + `No open chat with session id ${sessionId} on this host. Find it with \`orca chat list\`.` + ) + } + if ( + error.message === 'structured_agent_session_unsupported' || + error.code === 'method_not_found' + ) { + return new RuntimeClientError( + 'incompatible_runtime', + `This Orca host cannot ${action} chats from the CLI. Update Orca on that host, or check that it has chats turned on.` + ) + } + return error +} diff --git a/src/cli/chat-sessions.ts b/src/cli/chat-sessions.ts new file mode 100644 index 00000000000..e88453284f6 --- /dev/null +++ b/src/cli/chat-sessions.ts @@ -0,0 +1,175 @@ +import { agentSessionPromptQuestions } from '../shared/agent-session/agent-session-question-answer' +import { isActionableStructuredAgentSessionPrompt } from '../shared/agent-session/view/structured-agent-session-live-turn' +import { isRootAgentJournalItem } from '../shared/agent-session/journal/agent-session-journal-producer' +import type { AgentJournalRenderItem } from '../shared/agent-session/journal/agent-session-journal-types' +import type { + RuntimeWorktreeAgentRow, + RuntimeWorktreePsSummary +} from '../shared/runtime-worktree-contracts' +import { structuredAgentSessionTabId } from '../shared/agent-session/view/structured-agent-session-projection' +import { RuntimeClientError } from './runtime-client' + +const CHAT_HANDLE_PREFIX = structuredAgentSessionTabId('') + +export type ChatSessionEntry = { + session: string + handle: string + worktreeId: string + worktree: string + agentType: string | null + state: RuntimeWorktreeAgentRow['state'] + title: string | null + lastAssistantMessage: string | null + updatedAt: number +} + +export type ChatTranscriptEntry = + | { kind: 'message'; role: string; text: string } + | { kind: 'tool'; name: string; state: string } + | { kind: 'approval' | 'question'; text: string; pending: boolean } + | { kind: 'error'; text: string } + +/** The session id a chat is addressed by, from its id, its handle, or a pane key that names it. */ +export function parseChatSessionSelector(value: string): string { + const withoutHandle = value.startsWith(CHAT_HANDLE_PREFIX) + ? value.slice(CHAT_HANDLE_PREFIX.length) + : value + // A pane key is the chat's handle and its leaf: ":". + const session = withoutHandle.split(':')[0]?.trim() ?? '' + if (session.length === 0) { + throw new RuntimeClientError( + 'invalid_argument', + '--session needs a chat session id or handle; run `orca chat list` to find one.' + ) + } + return session +} + +/** The top-level chats in a worktree summary. Terminal agents and subagents are left out: + * terminals are read with `orca terminal`, and a subagent is reached through its parent chat. */ +export function listChatSessions( + worktrees: readonly RuntimeWorktreePsSummary[] +): ChatSessionEntry[] { + return worktrees.flatMap((worktree) => + worktree.agents + .filter( + (agent) => agent.parentPaneKey === null && agent.paneKey.startsWith(CHAT_HANDLE_PREFIX) + ) + .map((agent) => { + const session = parseChatSessionSelector(agent.paneKey) + return { + session, + handle: structuredAgentSessionTabId(session), + worktreeId: worktree.worktreeId, + worktree: worktree.displayName, + agentType: agent.agentType, + state: agent.state, + title: agent.displayName ?? agent.taskTitle, + lastAssistantMessage: agent.lastAssistantMessage, + updatedAt: agent.updatedAt + } + }) + ) +} + +function messageText(item: AgentJournalRenderItem): string { + if (item.body.kind !== 'message') { + return '' + } + return item.body.blocks + .map((block) => + block.type === 'text' ? block.text : block.type === 'image-ref' ? '[image]' : '' + ) + .filter((text) => text.length > 0) + .join('\n') + .trim() +} + +/** The conversation as a reader follows it: messages, the tools the agent ran, and the + * approvals and questions it raised. Provider status frames and turn markers are left out. */ +export function chatTranscript( + items: readonly AgentJournalRenderItem[], + actionablePromptIds?: readonly string[] +): ChatTranscriptEntry[] { + // An ended agent's unanswered prompts stay pending in the journal but can no longer be answered. + const waiting = (item: AgentJournalRenderItem, state: string): boolean => + state === 'pending' && + isActionableStructuredAgentSessionPrompt(item.itemId, actionablePromptIds) + const entries: ChatTranscriptEntry[] = [] + for (const item of items) { + // Subagents share the journal; their rows belong to their own section, not the chat's turn. + if (!isRootAgentJournalItem(item)) { + continue + } + const body = item.body + if (body.kind === 'message') { + const text = messageText(item) + // Reasoning is the model's scratch work, folded away in the app too. + if (text.length > 0 && body.role !== 'reasoning') { + entries.push({ kind: 'message', role: body.role, text }) + } + } else if (body.kind === 'status' && body.tone === 'error') { + // Start failures and dead agents land as error status rows; without them a wait reads "Done.". + entries.push({ kind: 'error', text: String(body.text) }) + } else if (body.kind === 'tool-call') { + entries.push({ kind: 'tool', name: body.name, state: body.state }) + } else if (body.kind === 'approval') { + entries.push({ + kind: 'approval', + text: body.title, + pending: waiting(item, body.resolution.state) + }) + } else if (body.kind === 'question') { + entries.push({ + kind: 'question', + // Grouped prompts keep their real questions in `questions`; `question` is then a heading. + text: agentSessionPromptQuestions(body) + .map((q) => q.question) + .join('\n'), + pending: waiting(item, body.resolution.state) + }) + } + } + return entries +} + +export function formatChatList(result: { chats: ChatSessionEntry[] }): string { + if (result.chats.length === 0) { + return 'No chats.' + } + return result.chats + .map((chat) => + [ + `${chat.worktree} · ${chat.agentType ?? 'agent'} · ${chat.state}`, + ` session: ${chat.session}`, + ...(chat.title ? [` title: ${chat.title}`] : []), + ...(chat.lastAssistantMessage + ? [` last reply: ${chat.lastAssistantMessage.replace(/\s+/g, ' ').slice(0, 200)}`] + : []) + ].join('\n') + ) + .join('\n\n') +} + +export function formatChatRead(result: { + session: string + entries: ChatTranscriptEntry[] +}): string { + if (result.entries.length === 0) { + return `No messages in ${result.session}.` + } + return result.entries + .map((entry) => { + if (entry.kind === 'message') { + return `${entry.role}: ${entry.text}` + } + if (entry.kind === 'error') { + return `[error] ${entry.text}` + } + if (entry.kind === 'tool') { + return `[tool ${entry.name}: ${entry.state}]` + } + return `[${entry.kind}${entry.pending ? ', waiting for you' : ''}] ${entry.text}` + }) + .join('\n') +} diff --git a/src/cli/command-scoped-flag-help.ts b/src/cli/command-scoped-flag-help.ts index fc5c6c56006..92e83f0a753 100644 --- a/src/cli/command-scoped-flag-help.ts +++ b/src/cli/command-scoped-flag-help.ts @@ -2,7 +2,22 @@ const FILE_OPEN_FOCUS_HELP = "--focus Bring the user to the file (switches Orca's window to its worktree)" +const CHAT_SESSION_HELP = + '--session Chat session id or handle from chat list or agent launch' + const COMMAND_SCOPED_FLAG_HELP: Record> = { + 'chat rename': { + session: '--session Chat session id from chat list or agent launch' + }, + 'chat list': { + state: '--state Only chats in this state: working, blocked, waiting or done' + }, + 'chat read': { session: CHAT_SESSION_HELP }, + 'chat send': { + session: CHAT_SESSION_HELP, + wait: '--wait Wait until the chat finishes or needs your answer' + }, + 'chat wait': { session: CHAT_SESSION_HELP }, 'extension reload': { 'extension-id': '--extension-id Installed unpacked extension ID; also accepted positionally', diff --git a/src/cli/flag-help-text.ts b/src/cli/flag-help-text.ts index 108cf43bf8f..1e283dd828f 100644 --- a/src/cli/flag-help-text.ts +++ b/src/cli/flag-help-text.ts @@ -36,6 +36,7 @@ export const FLAG_HELP_TEXT: Record = { interrupt: '--interrupt Send as an interrupt-style input when supported', 'keep-open': '--keep-open Keep the worker open after it finishes', 'spec-file': '--spec-file Read the spec from a file or stdin', + 'text-file': '--text-file Read the text from a file or stdin', id: '--id Identifier for a target item or permission', issue: '--issue Linked GitHub issue number', 'gitlab-issue': diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 9cc19bf9f86..31606226998 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -207,7 +207,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ }, { name: 'chat', - keys: ['chat rename'], + keys: ['chat rename', 'chat list', 'chat read', 'chat send', 'chat wait'], load: async () => (await import('./handlers/chat.js')).CHAT_HANDLERS }, { diff --git a/src/cli/handlers/chat.test.ts b/src/cli/handlers/chat.test.ts index 50005f098b6..177a839ad13 100644 --- a/src/cli/handlers/chat.test.ts +++ b/src/cli/handlers/chat.test.ts @@ -1,7 +1,183 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { SendParams } from '../../shared/rpc-contract/structured-agent-session-params' +import { AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../shared/agent-session/agent-session-runtime-capabilities' import { RuntimeClient } from '../runtime-client' import { CHAT_HANDLERS } from './chat' +const SESSION = 'codex_8eb631ab_59c5_41e4_8672_01cfe0a6fd5f' + +function ok(result: unknown) { + return { id: 'req', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function historyPage( + items: unknown[], + fence: number | undefined = 3, + pageOverrides: Record = {} +) { + return ok({ + ok: true, + page: { + sessionId: SESSION, + epoch: 'e', + fence, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: { epoch: 'e', sequence: 9 }, + nextCursor: { epoch: 'e', sequence: 9 } + }, + ...pageOverrides + } + }) +} + +function item(body: unknown) { + return { itemId: 'i', revision: 1, sequence: 1, observedAt: 1, body } +} + +function fakeClient( + responses: unknown[], + capabilities: string[] = [AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY] +): RuntimeClient & { call: ReturnType } { + const client = new RuntimeClient('/tmp/orca-chat-test', 60_000, null, null) + const call = vi.fn() + for (const response of responses) { + call.mockResolvedValueOnce(response) + } + vi.spyOn(client, 'call').mockImplementation(call) + vi.spyOn(client, 'getCliStatus').mockResolvedValue({ + id: 'status', + ok: true, + result: { + app: { running: true, pid: 123 }, + runtime: { state: 'ready', reachable: true, runtimeId: 'runtime-1', capabilities }, + graph: { state: 'ready' } + }, + _meta: { runtimeId: 'runtime-1' } + }) + return Object.assign(client, { call }) +} + +async function run( + command: string, + flags: Record, + client: RuntimeClient +) { + const logs: string[] = [] + const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => logs.push(line)) + try { + await CHAT_HANDLERS[command]!({ + flags: new Map(Object.entries(flags)), + client, + cwd: '/tmp', + json: true + }) + } finally { + spy.mockRestore() + } + return JSON.parse(logs.join('\n')).result +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('chat send', () => { + it('sends a host-valid message fenced to the chat it read, queued while the agent works', async () => { + const client = fakeClient([ + historyPage([], 7), + ok({ + ok: true, + replayed: false, + fence: 7, + cursor: { epoch: 'e', sequence: 2 }, + value: { + clientMessageId: 'm-1', + queued: { messageId: 'q-1', position: 1, state: 'waiting' } + } + }) + ]) + const result = await run('chat send', { session: SESSION, text: 'Run the tests' }, client) + const params = client.call.mock.calls[1]![1] + expect(client.call.mock.calls[1]![0]).toBe('agentSession.send') + // The host validates the same schema, so a malformed envelope or body fails here. + expect(SendParams.safeParse(params).success).toBe(true) + expect(params).toMatchObject({ + envelope: { sessionId: SESSION, expectedRuntimeFence: 7 }, + delivery: 'queue-if-active', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Run the tests' }] } + }) + expect(result).toEqual({ + session: SESSION, + delivery: 'queued', + clientMessageId: 'm-1', + queuePosition: 1 + }) + }) + + it('reports a host refusal as an error instead of a sent message', async () => { + const client = fakeClient([ + historyPage([], 2), + ok({ + ok: false, + refusal: { code: 'agent_session_fence_mismatch', message: 'The chat restarted.' } + }) + ]) + await expect(run('chat send', { session: SESSION, text: 'hi' }, client)).rejects.toMatchObject({ + code: 'agent_session_fence_mismatch' + }) + }) + + it('with --wait, waits for its queued message to run and returns that turn', async () => { + vi.useFakeTimers() + const client = fakeClient([ + historyPage([], 7, { working: true }), + ok({ + ok: true, + replayed: false, + fence: 7, + cursor: { epoch: 'e', sequence: 2 }, + value: { + clientMessageId: 'm-1', + queued: { messageId: 'q-1', position: 1, state: 'waiting' } + } + }), + // The earlier turn ended, but the queued message has not been handed to the agent yet. + historyPage([], 7, { + working: false, + queuedMessages: [{ messageId: 'q-1' }], + nextQueuedMessageId: 'q-1' + }), + historyPage([], 7, { working: true, queuedMessages: [] }), + historyPage([], 7, { working: false, queuedMessages: [], actionablePromptIds: [] }), + historyPage([ + item({ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Earlier' }] }), + item({ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Old reply' }] }), + item({ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Run the tests' }] }), + item({ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'All pass' }] }) + ]) + ]) + const pending = run( + 'chat send', + { session: SESSION, text: 'Run the tests', wait: true }, + client + ) + await vi.advanceTimersByTimeAsync(5_000) + const result = await pending + expect(client.call).toHaveBeenCalledTimes(6) + expect(result).toMatchObject({ + delivery: 'queued', + settled: 'done', + entries: [{ kind: 'message', role: 'assistant', text: 'All pass' }] + }) + }) +}) + function chatClient(isRemote: boolean): { client: RuntimeClient; call: ReturnType } { const client = new RuntimeClient('/tmp/orca-chat-test', 60_000, null, null) vi.spyOn(client, 'isRemote', 'get').mockReturnValue(isRemote) diff --git a/src/cli/handlers/chat.ts b/src/cli/handlers/chat.ts index f9303026a8d..51d6a8dbc0f 100644 --- a/src/cli/handlers/chat.ts +++ b/src/cli/handlers/chat.ts @@ -1,11 +1,64 @@ -import type { CommandHandler } from '../dispatch' -import { getPresentStringFlag, getRequiredStringFlag } from '../flags' -import { printResult } from '../format' -import { RuntimeClientError } from '../runtime-client' +import { randomUUID } from 'node:crypto' +import type { + AgentSessionMutationResult, + AgentSessionSendResult +} from '../../shared/agent-session/agent-session-wire' +import { AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY } from '../../shared/agent-session/agent-session-runtime-capabilities' +import type { + RuntimeWorktreePsResult, + RuntimeWorktreeRecord +} from '../../shared/runtime-worktree-contracts' +import { createStructuredAgentSessionOperationId } from '../../shared/agent-session/lifecycle/structured-agent-session-mutation' +import { + structuredAgentSessionMessageSendMutation, + structuredAgentSessionSendBody +} from '../../shared/agent-session/requests/structured-agent-session-send-mutation' +import { AGENT_STATUS_STATES } from '../../shared/agent-status/contracts/agent-status-types' import { readInjectedAgentSessionId } from '../../shared/agent-session/lifecycle/agent-session-caller-env' +import { + DEFAULT_READ_LIMIT, + explainChatRefusal, + formatSettled, + readHistory, + readSettledTurn, + readTranscript, + requireWorkingFlag, + waitForChat +} from '../chat-history' +import { + formatChatList, + formatChatRead, + listChatSessions, + parseChatSessionSelector +} from '../chat-sessions' +import { readTextOrFileFlag } from '../command-body-input' +import type { CommandHandler } from '../dispatch' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getPresentStringFlag, + getRequiredStringFlag +} from '../flags' +import { printResult } from '../format' +import { getOptionalWorktreeSelector } from '../selectors' +import type { RuntimeClient } from '../runtime-client' +import { RuntimeClientError } from '../runtime-client' + +const MAX_READ_LIMIT = 100 type ChatRenameResult = { name: string | null; applied: boolean } +// Paired CLIs lack the host's chat read/send capability; refuse before the host does with a bare code. +function readSession(flags: Map, client: RuntimeClient): string { + if (client.isRemote) { + throw new RuntimeClientError( + 'runtime_unsupported', + 'Reading and messaging chats on a paired host is not supported yet. Run this on the host machine.' + ) + } + return parseChatSessionSelector(getRequiredStringFlag(flags, 'session')) +} + export const CHAT_HANDLERS: Record = { 'chat rename': async ({ flags, client, json }) => { const name = getRequiredStringFlag(flags, 'name') @@ -24,34 +77,119 @@ export const CHAT_HANDLERS: Record = { const result = await client .call('agentSession.rename', { sessionId, name }) .catch((error: unknown) => { - throw explainRenameRefusal(error, sessionId) + throw explainChatRefusal(error, sessionId, 'rename') }) if (!result.result.applied) { throw new RuntimeClientError('invalid_argument', 'Not renamed: the name has no visible text.') } printResult(result, json, (value) => `Renamed chat to "${value.name}".`) + }, + 'chat list': async ({ flags, client, cwd, json }) => { + const state = getOptionalStringFlag(flags, 'state') + if (state !== undefined && !AGENT_STATUS_STATES.some((known) => known === state)) { + throw new RuntimeClientError( + 'invalid_argument', + `--state must be one of: ${AGENT_STATUS_STATES.join(', ')}.` + ) + } + const selector = await getOptionalWorktreeSelector(flags, 'worktree', cwd, client) + const shown = selector + ? ( + await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { + worktree: selector + }) + ).result.worktree + : undefined + const result = await client.call('worktree.ps', {}) + // A local and an SSH copy can share an id; the host tells them apart when both name one. + const worktrees = result.result.worktrees.filter( + (w) => + !shown || + (w.worktreeId === shown.id && + (w.hostId === undefined || shown.hostId === undefined || w.hostId === shown.hostId)) + ) + const chats = listChatSessions(worktrees).filter((chat) => !state || chat.state === state) + printResult({ ...result, result: { chats } }, json, formatChatList) + }, + 'chat read': async ({ flags, client, json }) => { + const session = readSession(flags, client) + const limit = getOptionalPositiveIntegerFlag(flags, 'limit') ?? DEFAULT_READ_LIMIT + if (limit > MAX_READ_LIMIT) { + throw new RuntimeClientError('invalid_argument', `--limit must be at most ${MAX_READ_LIMIT}.`) + } + printResult(await readTranscript(client, session, limit), json, formatChatRead) + }, + 'chat wait': async ({ flags, client, json }) => { + const session = readSession(flags, client) + const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms') + const settled = await waitForChat(client, session, timeoutMs) + printResult(await readSettledTurn(client, session, settled), json, formatSettled) + }, + 'chat send': async ({ flags, client, cwd, json }) => { + const session = readSession(flags, client) + const text = (await readTextOrFileFlag(flags, cwd, 'text', true)) ?? '' + if (text.trim().length === 0) { + throw new RuntimeClientError('invalid_argument', 'The message must not be empty.') + } + const wait = flags.has('wait') + const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms') + const status = await client.getCliStatus() + if (!status.result.runtime.reachable) { + throw new RuntimeClientError( + 'runtime_unavailable', + 'Orca could not reach the execution host, so nothing was sent. Retry once it is reachable.' + ) + } + // A working chat holds a queued message until its turn ends; older hosts get an ordinary send. + const queue = + status.result.runtime.capabilities?.includes( + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ) === true + // The send is fenced to the session's current runtime, so a restarted chat refuses it. + const { page } = (await readHistory(client, session, 1)).result + if (wait) { + requireWorkingFlag(page) + } + const { fence } = page + if (fence === undefined) { + throw new RuntimeClientError( + 'agent_session_not_running', + `Chat ${session} has no running session on this host, so nothing was sent. Open it in Orca and retry.` + ) + } + const mutation = structuredAgentSessionMessageSendMutation({ + sessionId: session, + clientOperationId: createStructuredAgentSessionOperationId(randomUUID), + expectedRuntimeFence: fence, + body: structuredAgentSessionSendBody(text, []), + ...(queue ? { delivery: 'queue-if-active' as const } : {}) + }) + const result = await client.call>( + 'agentSession.send', + mutation + ) + if (!result.result.ok) { + const { code, message } = result.result.refusal + throw explainChatRefusal(new RuntimeClientError(code, message), session, 'send to') + } + const value = result.result.value + const sent = { + session, + delivery: 'queued' in value ? ('queued' as const) : ('submitted' as const), + clientMessageId: value.clientMessageId, + ...('queued' in value ? { queuePosition: value.queued.position } : {}) + } + if (!wait) { + printResult({ ...result, result: sent }, json, (r) => + r.delivery === 'queued' + ? `Queued for ${r.session}; it is sent when the current turn ends.` + : `Sent to ${r.session}.` + ) + return + } + const queuedId = 'queued' in value ? value.queued.messageId : undefined + const settled = await waitForChat(client, session, timeoutMs, queuedId) + const turn = await readSettledTurn(client, session, settled) + printResult({ ...turn, result: { ...sent, ...turn.result } }, json, formatSettled) } } - -// The host answers with bare wire codes; say what they mean for this command. -function explainRenameRefusal(error: unknown, sessionId: string): unknown { - if (!(error instanceof RuntimeClientError)) { - return error - } - if (error.message === 'agent_session_identity_required') { - return new RuntimeClientError( - 'agent_session_identity_required', - `No chat with session id ${sessionId} on this host.` - ) - } - if ( - error.message === 'structured_agent_session_unsupported' || - error.code === 'method_not_found' - ) { - return new RuntimeClientError( - 'incompatible_runtime', - 'This Orca host cannot rename chats from the CLI. Update Orca on that host, or check that it has chats turned on.' - ) - } - return error -} diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index 0b0415336b9..502443d3d69 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -121,6 +121,10 @@ export const ROOT_HELP_TEXT_PRIMARY = [ ' agent launch Start an agent in a worktree, as a chat or terminal per your settings', '', 'Chats:', + ' chat list List agent chats with their workspace, state and last reply', + ' chat read Read the latest messages of an agent chat', + ' chat send Send a message to an agent chat', + ' chat wait Wait until an agent chat finishes or needs your answer', ' chat rename Rename a chat tab', '', 'Files:', diff --git a/src/cli/specs/chat.ts b/src/cli/specs/chat.ts index 28fb41556dd..ae73e34d6ed 100644 --- a/src/cli/specs/chat.ts +++ b/src/cli/specs/chat.ts @@ -15,5 +15,57 @@ export const CHAT_COMMAND_SPECS: CommandSpec[] = [ 'orca chat rename --name "Migrate auth to OAuth"', 'orca chat rename --session --name "Release notes" --json' ] + }, + { + path: ['chat', 'list'], + summary: 'List agent chats with their workspace, state and last reply', + usage: 'orca chat list [--worktree ] [--state ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'state'], + notes: [ + 'Lists the top-level chats Orca shows as agent tabs. Terminal agents are read with `orca terminal`.', + '--state keeps chats in one state: working, blocked, waiting or done.' + ], + examples: ['orca chat list --json', 'orca chat list --state working'] + }, + { + path: ['chat', 'read'], + summary: 'Read the latest messages of an agent chat', + usage: 'orca chat read --session [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'session', 'limit'], + notes: [ + '--session takes the session id or handle from `orca chat list` or `orca agent launch`.', + '--limit is the number of entries shown (messages, tool calls, approvals and questions); the default is 20 and the maximum 100.', + 'Approvals and questions waiting for an answer are marked "waiting for you".' + ], + examples: ['orca chat read --session codex_1234 --limit 10'] + }, + { + path: ['chat', 'send'], + summary: 'Send a message to an agent chat', + usage: + 'orca chat send --session (--text | --text-file ) [--wait] [--timeout-ms ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'session', 'text', 'text-file', 'wait', 'timeout-ms'], + notes: [ + 'Sends the text as your message, as if typed in the chat. --text-file - reads it from stdin.', + 'While the agent is working, hosts with a message queue hold the message until the turn ends; the result says "queued".', + '--wait returns once the chat finishes or waits for your answer, with what it did since your message. It exits 1 when the wait times out or the message is held in a stopped queue.', + 'A chat that restarted since it was read refuses the send; read it again and resend.' + ], + examples: [ + 'orca chat send --session codex_1234 --text "Run the tests, then summarize failures"', + 'orca chat send --session codex_1234 --text "Run the tests" --wait --timeout-ms 600000 --json', + 'cat notes.md | orca chat send --session codex_1234 --text-file -' + ] + }, + { + path: ['chat', 'wait'], + summary: 'Wait until an agent chat finishes or needs your answer', + usage: 'orca chat wait --session [--timeout-ms ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'session', 'timeout-ms'], + notes: [ + 'Settles on the chat\'s own turn, in `chat list` words: "done", or "blocked" on an approval or question. Background agents it started can still show as working or waiting in `chat list`. "held" and "timeout" exit 1.', + 'Prints what the chat did since the last user message. A wait that times out exits 1.' + ], + examples: ['orca chat wait --session codex_1234 --timeout-ms 600000 --json'] } ]